spine-rigc 2.55.0 → 2.57.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-rigc",
3
- "version": "2.55.0",
3
+ "version": "2.57.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": {
@@ -100,7 +100,10 @@ row for each.
100
100
  parts were not kept, and writes only after every region and the skeleton are
101
101
  shown unchanged (`--accept-skeleton-differences` writes a rebuild whose skeleton
102
102
  differs, every difference printed; `--stage-box <slot>` reads a build's stage box)
103
- — AUTHORING §0.4.
103
+ — AUTHORING §0.4. A mesh refused because its triangles are several pieces that
104
+ share no vertex: `rigc split --rig … --motion … --slot <slot> --out <dir>` writes
105
+ both specs back with one mesh per piece and every deform key renumbered — build
106
+ that — AUTHORING §3.4.11.
104
107
  7. Every finished unit ends with `rigc preview --candidate <out>`, and the report
105
108
  names the `.html` it wrote. The hand-off to a person is part of the work.
106
109
 
@@ -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';
@@ -30,12 +31,16 @@ import { findingLine, ingest, type IngestFinding, IngestSpecRefused, type Ingest
30
31
  import { PARSER_DEFAULTS, parserReading } from '../keyorder.ts';
31
32
  import { MODEL_DOCUMENT_FILE, modelDocument } from '../model.ts';
32
33
  import { parseMotionSpec } from '../motion.ts';
34
+ import { splitMesh, SplitError } from '../meshsplit.ts';
35
+ import { parseRigSpec } from '../rig.ts';
33
36
  import { type InspectCompare, type InspectFrame, InspectError, inspectCandidate, INSPECT_DEFAULT_FPS, legendText, readBonePatterns, readShow, readTimes, readZoom } from '../inspect.ts';
34
37
  import { FOLDS_FILE, foldLine, foldsFileOf, foldsOf, foldsText, setupFaultLine } from '../folds.ts';
38
+ import { AngleRangeError, angleRangeLine, type AngleRangeReading, angleRangesOf, type BonePair, readBonePairs } from '../anglerange.ts';
39
+ import { outsideLine, outsideViewport, pinnedRecord, readViewportBox, viewportOfFramesFile, ViewportError } from '../viewportpin.ts';
35
40
  import { PASS_THROUGH_FILE, passFrameLine, type PassPair, passSummaryLine, passThroughFileOf, passThroughOf, passThroughText, PassThroughError, readPassPairs } from '../passthrough.ts';
36
41
  import { environmentCollisions, environmentFrame, EnvironmentError, environmentResponse, readEnvironmentSchedule, type EnvironmentStep } from '../environment.ts';
37
42
  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';
43
+ 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
44
  import { type CompileResult } from '../types.ts';
40
45
  import { attachmentRegionJoins } from '../region_joins.ts';
41
46
  import { cmdRepack } from './repack.ts';
@@ -556,6 +561,59 @@ function readPassThroughFlag(raw: string | undefined, facts: SkeletonFacts): Pas
556
561
  }
557
562
  }
558
563
 
564
+ /**
565
+ * `--angle-range`'s pairs, read against the skeleton's bones (issue #1414,
566
+ * `../anglerange.ts`), or refused as a usage error before anything is posed: a
567
+ * malformed list, an empty name, a bone the skeleton does not have, a bone read
568
+ * against itself, a pair named twice — each by its entry. `undefined` without the flag.
569
+ */
570
+ function readAngleRangeFlag(raw: string | undefined, facts: SkeletonFacts): BonePair[] | undefined {
571
+ if (raw === undefined) return undefined;
572
+ try {
573
+ return readBonePairs(raw, facts.bones.map((bone) => bone.name));
574
+ } catch (err) {
575
+ if (err instanceof AngleRangeError) throw new UsageError(err.message);
576
+ throw err;
577
+ }
578
+ }
579
+
580
+ /** A box `render` was given rather than fitting one (issue #1414): the box flag's numbers, or an earlier render's grid. */
581
+ type GivenViewport =
582
+ | { by: '--viewport'; box: { x: number; y: number; width: number; height: number } }
583
+ | { by: '--viewport-from'; path: string; viewport: Viewport };
584
+
585
+ /**
586
+ * `--viewport` and `--viewport-from`, read before anything is read off the
587
+ * candidate (`../viewportpin.ts`): the two together are refused, and so is
588
+ * `--max` beside `--viewport-from`, whose file already fixes the pixel size; a
589
+ * malformed box, a path with no `frames.json` and a file whose grid is not one
590
+ * are refused by name. `undefined` without either flag.
591
+ */
592
+ function readGivenViewport(flags: Record<string, string>): GivenViewport | undefined {
593
+ const box = flags.viewport;
594
+ const from = flags['viewport-from'];
595
+ if (box !== undefined && from !== undefined) {
596
+ throw new UsageError('--viewport and --viewport-from both give the box the frames are of; give one of them');
597
+ }
598
+ try {
599
+ if (box !== undefined) return { by: '--viewport', box: readViewportBox(box) };
600
+ if (from === undefined) return undefined;
601
+ if (flags.max !== undefined) {
602
+ throw new UsageError(
603
+ `--viewport-from takes no --max: the frames.json it reads fixes the pixel size as well as the box, so --max ${flags.max} ` +
604
+ 'would put this render on another grid. Give --viewport <x,y,w,h> with --max for a box at another size',
605
+ );
606
+ }
607
+ const given = resolve(from);
608
+ const path = existsSync(given) && statSync(given).isDirectory() ? join(given, FRAMES_SIDECAR) : given;
609
+ 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`);
610
+ return { by: '--viewport-from', path, viewport: viewportOfFramesFile(readJsonFile(path), path) };
611
+ } catch (err) {
612
+ if (err instanceof ViewportError) throw new UsageError(err.message);
613
+ throw err;
614
+ }
615
+ }
616
+
559
617
  /**
560
618
  * render — the frame series, drawn by the same rasteriser `check` measures with.
561
619
  *
@@ -574,6 +632,8 @@ export function cmdRender(flags: Record<string, string>): void {
574
632
  // Read before anything is read off the candidate, so a malformed schedule is refused before a line is printed.
575
633
  const environment = readEnvironmentFlag(flags);
576
634
  const maxSide = readPositiveNumber(flags, 'max', 256, 16);
635
+ // A box given rather than fitted (issue #1414), read before a line is printed, so a malformed one is refused first.
636
+ const given = readGivenViewport(flags);
577
637
  const outRoot = resolve(flags.out ?? 'render');
578
638
  const geometry = flags.geometry !== undefined;
579
639
  // The fold reading (issue #1383) is its own flag rather than a part of --geometry, so a caller that asked for
@@ -643,6 +703,8 @@ export function cmdRender(flags: Record<string, string>): void {
643
703
  const skin = readSkinFlag(flags, [...facts.skins]);
644
704
  const subset = readSlotSubsetFlags(flags, facts, skin);
645
705
  const passPairs = readPassThroughFlag(passThrough, facts);
706
+ // The angle reading (issue #1414) reads bones, which a subset of the drawn slots does not change, so it takes --slot/--hide.
707
+ const anglePairs = readAngleRangeFlag(flags['angle-range'], facts);
646
708
  // One object, so the framing and the frames cannot be posed under two
647
709
  // different skins — which would frame one shot with another shot's box.
648
710
  // Not annotated `PoseOptions`: that name is `src/pose.ts`'s in this file, and
@@ -686,14 +748,22 @@ export function cmdRender(flags: Record<string, string>): void {
686
748
  // sits at one point — every drawn bone unposed by the skin, or collapsed — as
687
749
  // an `UnframeablePoseError` (issue #997); neither reaches this. The roster is what
688
750
  // lets the second name the skins that pose a bone, whichever poser draws.
689
- const viewport = framingViewport(poser, maxSide, pose, roster);
690
- if (!viewport) {
751
+ const fitted = framingViewport(poser, maxSide, pose, roster);
752
+ if (!fitted) {
691
753
  throw new UsageError(
692
754
  `${skeletonPath} posed no drawable attachment in any animation or in its setup pose${
693
755
  skin === undefined ? '' : ` under skin ${JSON.stringify(skin)}`
694
756
  } — there is nothing to draw`,
695
757
  );
696
758
  }
759
+ // ⭐ A given box replaces the fitted one, and the framing still runs: it is what refuses a pose with nothing to draw,
760
+ // one that is not finite and one with no extent, and a box given over such a pose would draw it in silence.
761
+ const viewport =
762
+ given === undefined
763
+ ? fitted
764
+ : given.by === '--viewport'
765
+ ? viewportFor(given.box.x, given.box.y, given.box.x + given.box.width, given.box.y + given.box.height, maxSide)
766
+ : given.viewport;
697
767
 
698
768
  // `sampleAll` covers the skeleton with no animation at all, which files its one
699
769
  // setup-pose frame under the reserved name. Narrowing to one animation reuses
@@ -702,7 +772,10 @@ export function cmdRender(flags: Record<string, string>): void {
702
772
  // `--geometry` rides on the SAME call (issue #864): the bones and the whole
703
773
  // attachments are read off the skeleton at the step that drew each frame, so
704
774
  // 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;
775
+ // The bones ride along for --geometry and --angle-range, the whole attachments for --geometry and the two readings off them.
776
+ const wantsBones = geometry || anglePairs !== undefined;
777
+ const wantsGeometry = geometry || folds || passPairs !== undefined;
778
+ const sampling = !wantsBones && !wantsGeometry ? pose : { ...pose, ...(wantsBones ? { bones: true } : {}), ...(wantsGeometry ? { geometry: true } : {}) };
706
779
  const sampled: Map<string, Frame[]> =
707
780
  only === undefined
708
781
  ? sampleAll(poser, fps, sampling)
@@ -755,9 +828,22 @@ export function cmdRender(flags: Record<string, string>): void {
755
828
  }
756
829
  }
757
830
  }
758
- return { viewport, sampled, geometryTexts, foldReadings, passReadings };
831
+ // The angle reading, off the very bones the frames were posed with — one per set, every pair the flag names.
832
+ const angleReadings = new Map<string, AngleRangeReading[]>();
833
+ if (anglePairs !== undefined) {
834
+ for (const [name, frames] of sampled) {
835
+ const read = frames.map((frame) => ({ index: frame.index, time: frame.time, bones: frame.bones ?? [] }));
836
+ try {
837
+ angleReadings.set(name, angleRangesOf(anglePairs, read, JSON.stringify(name)));
838
+ } catch (err) {
839
+ if (err instanceof AngleRangeError) throw new UsageError(err.message);
840
+ throw err;
841
+ }
842
+ }
843
+ }
844
+ return { viewport, fitted, sampled, geometryTexts, foldReadings, passReadings, angleReadings };
759
845
  });
760
- const { viewport, sampled, geometryTexts, foldReadings, passReadings } = posed.value;
846
+ const { viewport, fitted, sampled, geometryTexts, foldReadings, passReadings, angleReadings } = posed.value;
761
847
  console.log(` .. poser ${posed.note}`);
762
848
  if (environment !== undefined) {
763
849
  const response = environmentResponse(skeletonText);
@@ -768,6 +854,17 @@ export function cmdRender(flags: Record<string, string>): void {
768
854
  }
769
855
  console.log(` .. ${viewport.width}x${viewport.height}px at ${fps} fps, ${sampled.size} set(s) -> ${outRoot}`);
770
856
  if (!facts.declaresStage) console.log(` .. ${STAGELESS_FRAMING.render}`);
857
+ // A given box (issue #1414): said with the box it replaced, and every slot it leaves part of out, by set and frame.
858
+ const outside = given === undefined ? [] : outsideViewport(sampled, viewport);
859
+ if (given !== undefined) {
860
+ const f = (n: number): string => String(Number(n.toFixed(4)));
861
+ const box = (v: Viewport): string => `world x ${f(v.minX)}..${f(v.maxX)}, y ${f(v.minY)}..${f(v.maxY)}`;
862
+ console.log(
863
+ ` .. viewport given by ${given.by}${given.by === '--viewport-from' ? ` ${given.path}` : ''}: ${box(viewport)} at ${f(viewport.scale)} px per unit, y up ` +
864
+ `(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`}`,
865
+ );
866
+ for (const reading of outside) console.log(` .. outside ${outsideLine(reading, fps)}`);
867
+ }
771
868
 
772
869
  mkdirSync(outRoot, { recursive: true });
773
870
  const sets: FrameSet[] = [];
@@ -776,7 +873,7 @@ export function cmdRender(flags: Record<string, string>): void {
776
873
  for (const [name, frames] of sampled) {
777
874
  // Same naming as a reference render: the protocol rate says nothing, any
778
875
  // other rate says itself, so two rates of one animation sit side by side.
779
- const dirName = fps === PROTOCOL_FPS ? name : `${name}@${fps}fps`;
876
+ const dirName = frameSetDir(name, fps);
780
877
  const dir = join(outRoot, dirName);
781
878
  // Cleared rather than written over: a shorter animation would otherwise leave
782
879
  // the tail of a longer previous run on disk, and stale frames in a frame set
@@ -825,6 +922,9 @@ export function cmdRender(flags: Record<string, string>): void {
825
922
  console.log(` .. pass ${passSummaryLine(passReading?.animation ?? null, fps, pair)}`);
826
923
  for (const frame of pair.frames) console.log(` .. pass ${passFrameLine(passReading?.animation ?? null, pair, frame)}`);
827
924
  }
925
+ for (const reading of angleReadings.get(name) ?? []) {
926
+ console.log(` .. angle ${angleRangeLine(name === SETUP_POSE_DIR && facts.animations.length === 0 ? null : name, fps, reading)}`);
927
+ }
828
928
  }
829
929
 
830
930
  // The sidecar is what makes this a frame SET rather than a pile of pictures:
@@ -842,6 +942,8 @@ export function cmdRender(flags: Record<string, string>): void {
842
942
  ...subsetFields(subset),
843
943
  // Written only when a schedule was given (issue #1416): a render without one says nothing and stays the bytes it was.
844
944
  ...(environment === undefined ? {} : { environment: environment.steps }),
945
+ // Written only when the box was given (issue #1414): a fitted render says nothing and stays the bytes it was.
946
+ ...(given === undefined ? {} : { pinned: pinnedRecord(given.by, outside) }),
845
947
  background: BACKGROUND,
846
948
  // The one spelling `geometry.json` repeats, so the two files cannot state
847
949
  // two boxes for one grid.
@@ -895,12 +997,12 @@ export function cmdInspect(flags: Record<string, string>): void {
895
997
  let frame: InspectFrame = { kind: 'shot' };
896
998
  if (flags.zoom !== undefined) frame = { kind: 'zoom', ...readZoom(flags.zoom) };
897
999
  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');
1000
+ try {
1001
+ frame = { kind: 'viewport', ...readViewportBox(flags.viewport) };
1002
+ } catch (err) {
1003
+ if (err instanceof ViewportError) throw new UsageError(err.message);
1004
+ throw err;
901
1005
  }
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
1006
  }
905
1007
  const target = resolve(flags.out ?? DEFAULT_INSPECT_OUT);
906
1008
  const out = existsSync(target) && statSync(target).isDirectory() ? join(target, DEFAULT_INSPECT_OUT) : target;
@@ -1176,6 +1278,11 @@ function refuseUnposableArt(result: CompileResult, opts: CompileOptions): void {
1176
1278
  export function cmdExplain(flags: Record<string, string>): void {
1177
1279
  // `--poser` is refused by its spelling before anything compiles (issue #1019): it chooses the deform survey's reader and poser.
1178
1280
  const poser = readPoserFlag(flags);
1281
+ // `--lever-fold` is refused by its value before anything compiles, too (issue #1427): the fraction is the caller's and
1282
+ // has no default, so a value that is not one is a usage error rather than a reading taken at a guess.
1283
+ const leverFold = flags['lever-fold'] === undefined ? null : Number(flags['lever-fold']);
1284
+ const leverWhy = leverFold === null ? null : leverFractionWhy(leverFold);
1285
+ if (leverWhy !== null) throw new UsageError(`--lever-fold ${JSON.stringify(flags['lever-fold'])}: ${leverWhy}`);
1179
1286
  const { label, opts } = resolveCut(flags);
1180
1287
  console.log(`rigc explain ${label}`);
1181
1288
  // See the identical pair of lines in `cmdBuild` for why both paths are named
@@ -1372,6 +1479,24 @@ export function cmdExplain(flags: Record<string, string>): void {
1372
1479
  console.log(` transform ${gen.kind} ${gen.stated}`);
1373
1480
  console.log(` ${gen.formula}`);
1374
1481
  for (const line of gen.derived) console.log(` ${line}`);
1482
+ // The envelope's weights, one per vertex, because they are half
1483
+ // of every number below (issue #1413): each offset is
1484
+ // round(w × the un-enveloped offset), and a reader holding these
1485
+ // and the model reproduces the run without a second evaluation.
1486
+ if (gen.envelope !== undefined) {
1487
+ const env = gen.envelope;
1488
+ console.log(` envelope ${env.stated}`);
1489
+ console.log(` ${env.formula}`);
1490
+ console.log(
1491
+ ` each offset below is round(w × the un-enveloped offset): ${env.zero} vertex(es) at w 0, ` +
1492
+ `${env.full} at 1, ${gen.vertexCount - env.zero - env.full} between`,
1493
+ );
1494
+ for (let v = 0; v < env.weights.length; v += 8) {
1495
+ const ws: string[] = [];
1496
+ for (let k = v; k < Math.min(v + 8, env.weights.length); k++) ws.push(`v${String(k).padStart(3)} ${env.weights[k]}`);
1497
+ console.log(` w ${ws.join(' ')}`);
1498
+ }
1499
+ }
1375
1500
  console.log(
1376
1501
  ` ${gen.vertexCount} vertices, largest offset ${gen.maxOffset}px at vertex ${gen.maxOffsetVertex}`,
1377
1502
  );
@@ -1436,6 +1561,36 @@ export function cmdExplain(flags: Record<string, string>): void {
1436
1561
  for (const ph of result.physics) {
1437
1562
  console.log(` ${ph.name.padEnd(12)} bone=${ph.bone.padEnd(14)} components=[${ph.components.join(', ')}] mix=${ph.mix} drivesMesh=${ph.drivesMesh}`);
1438
1563
  }
1564
+ // The whirl reading (issue #1423): how far each constraint's bone turns in one step, walked by the core from the
1565
+ // setup pose — `src/whirl.ts` says why that walk and not render's. It decides nothing, so it sits in the report
1566
+ // rather than in the gate, and explain's exit is the same whatever it prints.
1567
+ console.log(`\n${WHIRL_HEADER}`);
1568
+ let reading: WhirlAnimation[] | null = null;
1569
+ try {
1570
+ reading = whirlReading(readModel(modelDocument(result.model, result.skeletonText, result.atlasText), `the whirl reading of ${label}`));
1571
+ } catch (err) {
1572
+ if (!(err instanceof CoreInputError)) throw err;
1573
+ console.log(` .. turn not read — the core refused the model document: ${err.message}`);
1574
+ }
1575
+ if (reading !== null && reading.length === 0) console.log(' .. turn not read — the skeleton declares no animation to walk');
1576
+ for (const line of whirlLines(reading ?? [])) console.log(` .. turn ${line}`);
1577
+ // The lever reading (issue #1427): on the same walk, the steps a scaleX drive's inertia lever divides by a collapsed
1578
+ // divisor, and the steps its scale factor jumps. Only when asked for, because its fraction has no default —
1579
+ // and said so when not asked, rather than left out in silence.
1580
+ if (leverFold === null) {
1581
+ 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');
1582
+ } else {
1583
+ console.log(`\n${leverHeader(leverFold)}`);
1584
+ let levers: LeverReading | null = null;
1585
+ try {
1586
+ levers = leverReading(readModel(modelDocument(result.model, result.skeletonText, result.atlasText), `the lever reading of ${label}`), leverFold);
1587
+ } catch (err) {
1588
+ if (!(err instanceof CoreInputError)) throw err;
1589
+ console.log(` .. lever not read — the core refused the model document: ${err.message}`);
1590
+ }
1591
+ 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');
1592
+ for (const line of levers === null ? [] : leverLines(levers)) console.log(` .. lever ${line}`);
1593
+ }
1439
1594
  }
1440
1595
 
1441
1596
  // Path constraints, with the curve each one follows MEASURED — its length and
@@ -1947,6 +2102,55 @@ export const MODEL_AND_TEXT_GATE: BuildGate = {
1947
2102
  look: (outDir) => `rigc: look at it: rigc render --candidate ${outDir}`,
1948
2103
  };
1949
2104
 
2105
+ /**
2106
+ * split — one authored mesh's pieces, proposed as one mesh each (issue #1414).
2107
+ *
2108
+ * Reads the two spec files and nothing else: a mesh of several pieces does not
2109
+ * compile, so there is no build to read. Writes the proposal — both specs and
2110
+ * `split.json` — into an `--out` that is absent or empty, which is the whole of
2111
+ * how it never overwrites anything: there is no in-place flag, because the
2112
+ * specs it read are the only statement of what the proposal must reproduce,
2113
+ * and a proposal nobody has built yet is not something to put over them.
2114
+ */
2115
+ function cmdSplit(flags: Record<string, string>): void {
2116
+ for (const flag of ['rig', 'motion', 'slot', 'out'] as const) {
2117
+ if (flags[flag] === undefined) throw new UsageError(`split needs --${flag}`);
2118
+ }
2119
+ const rigPath = resolve(flags.rig);
2120
+ const motionPath = resolve(flags.motion);
2121
+ const outDir = resolve(flags.out);
2122
+ for (const [flag, path] of [['rig', rigPath], ['motion', motionPath]] as const) {
2123
+ if (!existsSync(path) || !statSync(path).isFile()) throw new SplitError(`--${flag} ${path}: no file there`, 2);
2124
+ }
2125
+ if (existsSync(outDir) && (!statSync(outDir).isDirectory() || readdirSync(outDir).length > 0)) {
2126
+ throw new SplitError(
2127
+ `--out ${outDir} ${statSync(outDir).isDirectory() ? `holds ${readdirSync(outDir).length} entr${readdirSync(outDir).length === 1 ? 'y' : 'ies'}` : 'is a file'}; ` +
2128
+ 'the proposal is written into an absent or empty directory only, so it never lands on top of a file — the specs it read included',
2129
+ 2,
2130
+ );
2131
+ }
2132
+ const rig = parseRigSpec(readJsonFile(rigPath), rigPath);
2133
+ const motion = parseMotionSpec(readJsonFile(motionPath), motionPath);
2134
+ // The rig spec's `images` is relative to the rig file, and the proposal is written somewhere else: restate it from
2135
+ // there, so every `image` resolves to the file it resolved to before.
2136
+ const imagesDir = resolve(dirname(rigPath), rig.images ?? '.');
2137
+ const images = relativeImagesPath(outDir, imagesDir);
2138
+ console.log(`rigc split ${rigPath}`);
2139
+ console.log(` .. motion ${motionPath}`);
2140
+ const result = splitMesh({ rig, motion, slot: flags.slot, ...(flags.attachment === undefined ? {} : { attachment: flags.attachment }), ...(flags.skin === undefined ? {} : { skin: flags.skin }), images });
2141
+ if (result.kind === 'whole') {
2142
+ console.log(`rigc split: ${result.line}`);
2143
+ return;
2144
+ }
2145
+ for (const line of result.lines) console.log(` .. ${line}`);
2146
+ console.log(` .. images ${images} (the rig spec's own, restated from ${outDir} — it resolves to ${imagesDir})`);
2147
+ mkdirSync(outDir, { recursive: true });
2148
+ writeJson(join(outDir, 'rig.json'), result.rig);
2149
+ writeJson(join(outDir, 'motion.json'), result.motion);
2150
+ writeJson(join(outDir, 'split.json'), result.record);
2151
+ console.log(`rigc: build it with rigc build --rig ${join(outDir, 'rig.json')} --motion ${join(outDir, 'motion.json')} --out <dir>`);
2152
+ }
2153
+
1950
2154
  /**
1951
2155
  * The bodies the second entry runs under a name whose full-entry body is the
1952
2156
  * runtime's (`runtime.core` in `COMMANDS`, issue #1060) — registered by
@@ -1968,6 +2172,7 @@ export const CORE_ENTRY_RUNS: Readonly<Record<string, CommandRun>> = {
1968
2172
  export const CORE_COMMAND_RUNS: Readonly<Record<string, CommandRun>> = {
1969
2173
  explain: ({ flags }) => cmdExplain(flags),
1970
2174
  ingest: ({ flags, positional }) => cmdIngest(flags, positional),
2175
+ split: ({ flags }) => cmdSplit(flags),
1971
2176
  diff: ({ flags, lists, positional }) => cmdDiff(flags, lists, positional),
1972
2177
  check: ({ flags }) => cmdCheck(flags),
1973
2178
  render: ({ flags }) => cmdRender(flags),