spine-rigc 2.54.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.
@@ -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,9 +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';
38
+ import { PASS_THROUGH_FILE, passFrameLine, type PassPair, passSummaryLine, passThroughFileOf, passThroughOf, passThroughText, PassThroughError, readPassPairs } from '../passthrough.ts';
35
39
  import { environmentCollisions, environmentFrame, EnvironmentError, environmentResponse, readEnvironmentSchedule, type EnvironmentStep } from '../environment.ts';
36
40
  import { estimatePose, poseLines, type PoseOptions } from '../pose.ts';
37
- 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';
38
42
  import { type CompileResult } from '../types.ts';
39
43
  import { attachmentRegionJoins } from '../region_joins.ts';
40
44
  import { cmdRepack } from './repack.ts';
@@ -539,6 +543,75 @@ function environmentRefusal(given: string, steps: readonly EnvironmentStep[], sk
539
543
  return null;
540
544
  }
541
545
 
546
+ /**
547
+ * `--pass-through <cloth>:<leg>[,…]` read against the skeleton's slots (issue
548
+ * #1413, `../passthrough.ts`), or refused as a usage error before anything is
549
+ * posed: a malformed list, an empty name, a slot the skeleton does not have, a
550
+ * pair named twice — each by its entry. `undefined` without the flag.
551
+ */
552
+ function readPassThroughFlag(raw: string | undefined, facts: SkeletonFacts): PassPair[] | undefined {
553
+ if (raw === undefined) return undefined;
554
+ try {
555
+ return readPassPairs(raw, facts.slots.map((slot) => slot.name));
556
+ } catch (err) {
557
+ if (err instanceof PassThroughError) throw new UsageError(err.message);
558
+ throw err;
559
+ }
560
+ }
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
+
542
615
  /**
543
616
  * render — the frame series, drawn by the same rasteriser `check` measures with.
544
617
  *
@@ -557,6 +630,8 @@ export function cmdRender(flags: Record<string, string>): void {
557
630
  // Read before anything is read off the candidate, so a malformed schedule is refused before a line is printed.
558
631
  const environment = readEnvironmentFlag(flags);
559
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);
560
635
  const outRoot = resolve(flags.out ?? 'render');
561
636
  const geometry = flags.geometry !== undefined;
562
637
  // The fold reading (issue #1383) is its own flag rather than a part of --geometry, so a caller that asked for
@@ -580,6 +655,8 @@ export function cmdRender(flags: Record<string, string>): void {
580
655
  }
581
656
  }
582
657
  }
658
+ // The pass-through reading (issue #1413) is its own flag for --folds' reason: without it, every file is the bytes it was.
659
+ const passThrough = flags['pass-through'];
583
660
  // The same refusal for the same reason: the reading is of every mesh the pose shows, whatever a picture leaves out.
584
661
  if (folds) {
585
662
  for (const flag of ['slot', 'hide'] as const) {
@@ -593,6 +670,19 @@ export function cmdRender(flags: Record<string, string>): void {
593
670
  }
594
671
  }
595
672
 
673
+ // And for the pass-through reading: it reads the two slots as the frame draws them, and a subset changes which draw.
674
+ if (passThrough !== undefined) {
675
+ for (const flag of ['slot', 'hide'] as const) {
676
+ if (flags[flag] !== undefined) {
677
+ throw new UsageError(
678
+ `--pass-through takes no --${flag}: it reads the cloth and the leg as the whole rig draws them, which a subset of the ` +
679
+ `drawn slots would change. Run \`rigc render --pass-through\` on the whole rig for the reading, and --${flag} ${flags[flag]} ` +
680
+ 'without --pass-through for the pictures — both land on the same grid',
681
+ );
682
+ }
683
+ }
684
+ }
685
+
596
686
  console.log('rigc render');
597
687
  console.log(` .. skeleton ${skeletonPath}`);
598
688
  console.log(` .. atlas ${atlasPath}${atlasText === null ? ` — not there (${ATLAS_ABSENT})` : ''}`);
@@ -610,6 +700,9 @@ export function cmdRender(flags: Record<string, string>): void {
610
700
  const only = readAnimationFlag(flags, [...facts.animations]);
611
701
  const skin = readSkinFlag(flags, [...facts.skins]);
612
702
  const subset = readSlotSubsetFlags(flags, facts, skin);
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);
613
706
  // One object, so the framing and the frames cannot be posed under two
614
707
  // different skins — which would frame one shot with another shot's box.
615
708
  // Not annotated `PoseOptions`: that name is `src/pose.ts`'s in this file, and
@@ -653,14 +746,22 @@ export function cmdRender(flags: Record<string, string>): void {
653
746
  // sits at one point — every drawn bone unposed by the skin, or collapsed — as
654
747
  // an `UnframeablePoseError` (issue #997); neither reaches this. The roster is what
655
748
  // lets the second name the skins that pose a bone, whichever poser draws.
656
- const viewport = framingViewport(poser, maxSide, pose, roster);
657
- if (!viewport) {
749
+ const fitted = framingViewport(poser, maxSide, pose, roster);
750
+ if (!fitted) {
658
751
  throw new UsageError(
659
752
  `${skeletonPath} posed no drawable attachment in any animation or in its setup pose${
660
753
  skin === undefined ? '' : ` under skin ${JSON.stringify(skin)}`
661
754
  } — there is nothing to draw`,
662
755
  );
663
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;
664
765
 
665
766
  // `sampleAll` covers the skeleton with no animation at all, which files its one
666
767
  // setup-pose frame under the reserved name. Narrowing to one animation reuses
@@ -669,7 +770,10 @@ export function cmdRender(flags: Record<string, string>): void {
669
770
  // `--geometry` rides on the SAME call (issue #864): the bones and the whole
670
771
  // attachments are read off the skeleton at the step that drew each frame, so
671
772
  // the export's grid is this frame set's by construction.
672
- const sampling = geometry ? { ...pose, bones: true, geometry: true } : folds ? { ...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 } : {}) };
673
777
  const sampled: Map<string, Frame[]> =
674
778
  only === undefined
675
779
  ? sampleAll(poser, fps, sampling)
@@ -694,9 +798,50 @@ export function cmdRender(flags: Record<string, string>): void {
694
798
  foldReadings.set(name, foldsFileOf(animation, fps, skin, foldsOf(poser.rest(skin, shown), read)));
695
799
  }
696
800
  }
697
- return { viewport, sampled, geometryTexts, foldReadings };
801
+ // The pass-through reading, off the very pieces the frames are drawn from, in their draw order — one per set, every
802
+ // pair the flag names. A pair whose slot draws on no frame of any set drawn is refused rather than read as 0.
803
+ const passReadings = new Map<string, ReturnType<typeof passThroughFileOf>>();
804
+ if (passPairs !== undefined) {
805
+ const draws = new Set<string>();
806
+ for (const frames of sampled.values()) for (const frame of frames) for (const piece of frame.pieces) draws.add(piece.slot);
807
+ for (const pair of passPairs) {
808
+ for (const slot of [pair.cloth, pair.leg]) {
809
+ if (!draws.has(slot)) {
810
+ throw new UsageError(
811
+ `--pass-through: slot ${JSON.stringify(slot)} draws on no frame of [${[...sampled.keys()].join(', ')}]${skin === undefined ? '' : ` under skin ${JSON.stringify(skin)}`} ` +
812
+ '— it shows no region or mesh there, so it has no drawn outline to read',
813
+ );
814
+ }
815
+ }
816
+ }
817
+ for (const [name, frames] of sampled) {
818
+ const animation = name === SETUP_POSE_DIR && facts.animations.length === 0 ? null : name;
819
+ const shown = frames.map((frame) => frame.attachments ?? []);
820
+ const read = frames.map((frame) => ({ index: frame.index, time: frame.time, pieces: frame.pieces, attachments: frame.attachments ?? [] }));
821
+ try {
822
+ passReadings.set(name, passThroughFileOf(animation, fps, skin, passThroughOf(passPairs, poser.rest(skin, shown), read, pages)));
823
+ } catch (err) {
824
+ if (err instanceof PassThroughError) throw new UsageError(`--pass-through, ${JSON.stringify(name)}: ${err.message}`);
825
+ throw err;
826
+ }
827
+ }
828
+ }
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 };
698
843
  });
699
- const { viewport, sampled, geometryTexts, foldReadings } = posed.value;
844
+ const { viewport, fitted, sampled, geometryTexts, foldReadings, passReadings, angleReadings } = posed.value;
700
845
  console.log(` .. poser ${posed.note}`);
701
846
  if (environment !== undefined) {
702
847
  const response = environmentResponse(skeletonText);
@@ -707,6 +852,17 @@ export function cmdRender(flags: Record<string, string>): void {
707
852
  }
708
853
  console.log(` .. ${viewport.width}x${viewport.height}px at ${fps} fps, ${sampled.size} set(s) -> ${outRoot}`);
709
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
+ }
710
866
 
711
867
  mkdirSync(outRoot, { recursive: true });
712
868
  const sets: FrameSet[] = [];
@@ -715,7 +871,7 @@ export function cmdRender(flags: Record<string, string>): void {
715
871
  for (const [name, frames] of sampled) {
716
872
  // Same naming as a reference render: the protocol rate says nothing, any
717
873
  // other rate says itself, so two rates of one animation sit side by side.
718
- const dirName = fps === PROTOCOL_FPS ? name : `${name}@${fps}fps`;
874
+ const dirName = frameSetDir(name, fps);
719
875
  const dir = join(outRoot, dirName);
720
876
  // Cleared rather than written over: a shorter animation would otherwise leave
721
877
  // the tail of a longer previous run on disk, and stale frames in a frame set
@@ -733,6 +889,8 @@ export function cmdRender(flags: Record<string, string>): void {
733
889
  if (geometryOut !== undefined) writeFileSync(join(dir, GEOMETRY_FILE), geometryOut);
734
890
  const foldReading = foldReadings.get(name);
735
891
  if (foldReading !== undefined) writeFileSync(join(dir, FOLDS_FILE), foldsText(foldReading));
892
+ const passReading = passReadings.get(name);
893
+ if (passReading !== undefined) writeFileSync(join(dir, PASS_THROUGH_FILE), passThroughText(passReading));
736
894
  const duration = frames[frames.length - 1].time;
737
895
  sets.push({
738
896
  dir: dirName,
@@ -746,7 +904,9 @@ export function cmdRender(flags: Record<string, string>): void {
746
904
  const how = frames.length === 1 ? 'a single pose' : `${duration.toFixed(3)}s`;
747
905
  const folding = foldReading?.meshes.filter((m) => m.worst !== null).length ?? 0;
748
906
  const foldNote = foldReading === undefined ? '' : ` + ${FOLDS_FILE} (${folding} of ${foldReading.meshes.length} mesh(es) fold)`;
749
- const extras = `${sheet ? ` + ${SHEET_FILE}` : ''}${geometryOut === undefined ? '' : ` + ${GEOMETRY_FILE}`}${foldNote}`;
907
+ const passing = passReading?.pairs.filter((p) => p.frames.length > 0).length ?? 0;
908
+ const passNote = passReading === undefined ? '' : ` + ${PASS_THROUGH_FILE} (${passing} of ${passReading.pairs.length} pair(s) pass through)`;
909
+ const extras = `${sheet ? ` + ${SHEET_FILE}` : ''}${geometryOut === undefined ? '' : ` + ${GEOMETRY_FILE}`}${foldNote}${passNote}`;
750
910
  console.log(` .. ${name.padEnd(16)} ${frames.length} frame(s), ${how}${extras} -> ${dir}`);
751
911
  for (const mesh of foldReading?.meshes ?? []) {
752
912
  const key = `${mesh.slot}\u0000${mesh.attachment}`;
@@ -756,6 +916,13 @@ export function cmdRender(flags: Record<string, string>): void {
756
916
  const line = foldLine(foldReading?.animation ?? null, fps, mesh);
757
917
  if (line !== null) console.log(` .. folds ${line}`);
758
918
  }
919
+ for (const pair of passReading?.pairs ?? []) {
920
+ console.log(` .. pass ${passSummaryLine(passReading?.animation ?? null, fps, pair)}`);
921
+ for (const frame of pair.frames) console.log(` .. pass ${passFrameLine(passReading?.animation ?? null, pair, frame)}`);
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
+ }
759
926
  }
760
927
 
761
928
  // The sidecar is what makes this a frame SET rather than a pile of pictures:
@@ -773,6 +940,8 @@ export function cmdRender(flags: Record<string, string>): void {
773
940
  ...subsetFields(subset),
774
941
  // Written only when a schedule was given (issue #1416): a render without one says nothing and stays the bytes it was.
775
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) }),
776
945
  background: BACKGROUND,
777
946
  // The one spelling `geometry.json` repeats, so the two files cannot state
778
947
  // two boxes for one grid.
@@ -826,12 +995,12 @@ export function cmdInspect(flags: Record<string, string>): void {
826
995
  let frame: InspectFrame = { kind: 'shot' };
827
996
  if (flags.zoom !== undefined) frame = { kind: 'zoom', ...readZoom(flags.zoom) };
828
997
  if (flags.viewport !== undefined) {
829
- const parts = flags.viewport.split(',').map((s) => Number(s.trim()));
830
- if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n))) {
831
- 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;
832
1003
  }
833
- if (parts[2] <= 0 || parts[3] <= 0) throw new UsageError('--viewport width and height must be positive');
834
- frame = { kind: 'viewport', x: parts[0], y: parts[1], width: parts[2], height: parts[3] };
835
1004
  }
836
1005
  const target = resolve(flags.out ?? DEFAULT_INSPECT_OUT);
837
1006
  const out = existsSync(target) && statSync(target).isDirectory() ? join(target, DEFAULT_INSPECT_OUT) : target;
@@ -1107,6 +1276,11 @@ function refuseUnposableArt(result: CompileResult, opts: CompileOptions): void {
1107
1276
  export function cmdExplain(flags: Record<string, string>): void {
1108
1277
  // `--poser` is refused by its spelling before anything compiles (issue #1019): it chooses the deform survey's reader and poser.
1109
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}`);
1110
1284
  const { label, opts } = resolveCut(flags);
1111
1285
  console.log(`rigc explain ${label}`);
1112
1286
  // See the identical pair of lines in `cmdBuild` for why both paths are named
@@ -1303,6 +1477,24 @@ export function cmdExplain(flags: Record<string, string>): void {
1303
1477
  console.log(` transform ${gen.kind} ${gen.stated}`);
1304
1478
  console.log(` ${gen.formula}`);
1305
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
+ }
1306
1498
  console.log(
1307
1499
  ` ${gen.vertexCount} vertices, largest offset ${gen.maxOffset}px at vertex ${gen.maxOffsetVertex}`,
1308
1500
  );
@@ -1367,6 +1559,36 @@ export function cmdExplain(flags: Record<string, string>): void {
1367
1559
  for (const ph of result.physics) {
1368
1560
  console.log(` ${ph.name.padEnd(12)} bone=${ph.bone.padEnd(14)} components=[${ph.components.join(', ')}] mix=${ph.mix} drivesMesh=${ph.drivesMesh}`);
1369
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
+ }
1370
1592
  }
1371
1593
 
1372
1594
  // Path constraints, with the curve each one follows MEASURED — its length and
package/src/cli/shared.ts CHANGED
@@ -38,6 +38,8 @@ import { checkAgainstFrames, type CheckOptions, CheckPlates, type CheckReport }
38
38
  import { compile, CompileError, droppedStateReason, headerBoundsOf, type CompileOptions } from '../compile.ts';
39
39
  import { depthStepLevels, type FoldLimit, type TurnCeiling } from '../depth.ts';
40
40
  import { FOLDS_FILE } from '../folds.ts';
41
+ import { PASS_THROUGH_FILE, PASS_THROUGH_RUN } from '../passthrough.ts';
42
+ import { readViewportBox, ViewportError } from '../viewportpin.ts';
41
43
  import { parseJsonWithPosition } from '../json-position.ts';
42
44
  import { RUNG_IDS } from '../ladder.ts';
43
45
  import { MODEL_DOCUMENT_FILE, MODEL_DOCUMENT_SPEC, modelDocument, spineFileSha256 } from '../model.ts';
@@ -674,12 +676,12 @@ function readCheckFlags(
674
676
  out.fps = fps;
675
677
  }
676
678
  if (flags.viewport !== undefined) {
677
- const parts = flags.viewport.split(',').map((s) => Number(s.trim()));
678
- if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n))) {
679
- 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;
680
684
  }
681
- if (parts[2] <= 0 || parts[3] <= 0) throw new UsageError('--viewport width and height must be positive');
682
- out.viewport = { x: parts[0], y: parts[1], width: parts[2], height: parts[3] };
683
685
  }
684
686
  if (flags.as !== undefined) out.as = flags.as;
685
687
  // The name is not checked against the candidate here: `check` owns that
@@ -1013,6 +1015,24 @@ const FLAG_MEANINGS: Record<string, string> = {
1013
1015
  'with where they are (the world box of their posed vertices) and how big (their setup-pose area, and its share of the mesh\'s). ' +
1014
1016
  'One line per mesh that folds; a triangle degenerate or inverted at the setup pose is said once and never counted. ' +
1015
1017
  'A reading, not a gate. Not with --slot/--hide: it reads every mesh the pose shows',
1018
+ 'pass-through':
1019
+ `also read where a cloth slot passes through a leg slot, for each <cloth>:<leg> pair, and write ${PASS_THROUGH_FILE} into ` +
1020
+ 'each frame directory: the frames where the cloth draws over the leg and its drawn side lies inside the leg\'s drawn ' +
1021
+ `outline by ${PASS_THROUGH_RUN} steps or more — rows read in the cloth's setup pose from its hem's corner up, the run stepped in ` +
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. ' +
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. ' +
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',
1016
1036
  environment:
1017
1037
  'the world the rig\'s physics answers, in time: a file { "spec": "rigc-environment/1", "steps": [{ "t": <seconds>, ' +
1018
1038
  '"wind"?: [x, y], "gravity"?: [x, y] }, …] }, x right and y down, each step holding from its t until the next and set ' +
@@ -1027,6 +1047,12 @@ const FLAG_MEANINGS: Record<string, string> = {
1027
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. ' +
1028
1048
  "`explain`: which reads and poses the DEFORM block's survey — `core` (the model document the compile writes, spine-core untouched) or " +
1029
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',
1030
1056
  'texture-from':
1031
1057
  "also measure this run through this atlas's texels, keeping the candidate's own geometry, and report how much " +
1032
1058
  'of the MAE is texture resampling rather than the rig — pass the atlas the reference frames were rendered ' +
@@ -1202,6 +1228,9 @@ const FLAG_VALUES: Record<string, string> = {
1202
1228
  'texture-from': '<path>',
1203
1229
  poser: 'core|spine',
1204
1230
  environment: '<schedule.json>',
1231
+ 'pass-through': '<cloth>:<leg>[,<cloth>:<leg>…]',
1232
+ 'angle-range': '<bone>:<relative-to>[,…]',
1233
+ 'viewport-from': '<frames.json|dir>',
1205
1234
  reference: '<dir|skeleton.json>',
1206
1235
  'reference-atlas': '<path>',
1207
1236
  bones: `<correspondence.json|${IDENTITY_CORRESPONDENCE}>`,
@@ -1216,6 +1245,7 @@ const FLAG_VALUES: Record<string, string> = {
1216
1245
  scale: '<min,max>',
1217
1246
  rotation: '<min,max>',
1218
1247
  'max-residual': '<0..1>',
1248
+ 'lever-fold': '<fraction>',
1219
1249
  anchor: '<pose.json>',
1220
1250
  hinge: '<min,max>',
1221
1251
  stretch: '<ratio>',
@@ -1467,8 +1497,9 @@ export const COMMANDS: CommandDoc[] = [
1467
1497
  'rigc explain --rig <path> --motion <path> --out <dir> [--manifest <path>] [--images <dir>] [--poser core|spine] (it never gates, and writes nothing)',
1468
1498
  'rigc explain … --atlas-in <skeleton.atlas> (resolve the parts against a pack somebody already made, as build does)',
1469
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)',
1470
1501
  ],
1471
- 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'],
1472
1503
  notes: [
1473
1504
  'this line said "the same arguments as build, minus --profile" and was false in both',
1474
1505
  'directions (issue #697): --atlas-in was not listed here, so the one flag that lets this',
@@ -1608,11 +1639,25 @@ export const COMMANDS: CommandDoc[] = [
1608
1639
  'rigc render --candidate <dir | skeleton.json> [--animation <name>] [--skin <name>] [--fps 12] [--max 256] [--geometry] [--folds] [--poser core|spine] [--out render/]',
1609
1640
  'rigc render … --slot <name[,name…]> | --hide <name[,name…]> (a subset of the slots, on the whole rig\'s grid)',
1610
1641
  'rigc render … --environment <schedule.json> (the wind and gravity the physics answers, in time)',
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)',
1611
1645
  ],
1612
- flags: ['candidate', 'atlas', 'animation', 'skin', 'slot', 'hide', 'fps', 'max', 'geometry', 'folds', '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'],
1613
1647
  overrides: {
1614
1648
  out: { value: '<dir>', meaning: 'directory to write the frame series into (default `render/`)' },
1615
- 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
+ },
1616
1661
  },
1617
1662
  },
1618
1663
  {