@rig-c/core 2.59.0 → 2.61.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.
@@ -38,6 +38,7 @@ import { FOLDS_FILE, foldLine, foldsFileOf, foldsOf, foldsText, setupFaultLine }
38
38
  import { AngleRangeError, angleRangeLine, type AngleRangeReading, angleRangesOf, type BonePair, readBonePairs } from '../anglerange.ts';
39
39
  import { outsideLine, outsideViewport, pinnedRecord, readViewportBox, viewportOfFramesFile, ViewportError } from '../viewportpin.ts';
40
40
  import { PASS_THROUGH_FILE, passFrameLine, type PassPair, passSummaryLine, passThroughFileOf, passThroughOf, passThroughText, PassThroughError, readPassPairs } from '../passthrough.ts';
41
+ import { readSeamPairs, SEAM_FILE, seamFileOf, seamLine, SeamError, type SeamPair, seamsOf, seamText } from '../seam.ts';
41
42
  import { environmentCollisions, environmentFrame, EnvironmentError, environmentResponse, readEnvironmentSchedule, type EnvironmentStep } from '../environment.ts';
42
43
  import { estimatePose, poseLines, type PoseOptions } from '../pose.ts';
43
44
  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';
@@ -45,9 +46,10 @@ import { type CompileResult } from '../types.ts';
45
46
  import { attachmentRegionJoins } from '../region_joins.ts';
46
47
  import { cmdRepack } from './repack.ts';
47
48
  import { GUIDE_FILE, GUIDE_RULE_TABLES_ELSEWHERE, guideAnswer, refusalLine } from '../guide.ts';
48
- import { ATLAS_ABSENT, COMMANDS, type CommandRun, modelTextBeside, resolveBuild, type BuildGate, runBuild, PACKAGE_ROOT, DEFAULT_CHAINFIT_OUT, DEFAULT_POSE_OUT, DEFAULT_SKILLS_DIR, ExplainError, GuideError, meshBudget, meshDepthNote, meshFit, meshInfluenceNote, PAGE_GRID_UNLOCATED, readAnimationFlag, readJsonFile, readPoserFlag, readSkeletonText, readVersion, resolveCut, resolveDrawable, runCheck, SkillsInstallError, STAGELESS_FRAMING, UsageError, writeJson, documentStageBeside } from './shared.ts';
49
+ import { ATLAS_ABSENT, COMMANDS, type CommandRun, DelegateError, modelTextBeside, resolveBuild, type BuildGate, runBuild, PACKAGE_ROOT, DEFAULT_CHAINFIT_OUT, DEFAULT_POSE_OUT, DEFAULT_SKILLS_DIR, ExplainError, GuideError, meshBudget, meshDepthNote, meshFit, meshInfluenceNote, PAGE_GRID_UNLOCATED, readAnimationFlag, readJsonFile, readPoserFlag, readSkeletonText, readVersion, resolveCut, resolveDrawable, runCheck, SkillsInstallError, STAGELESS_FRAMING, UsageError, writeJson, documentStageBeside } from './shared.ts';
49
50
  import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
50
51
  import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
52
+ import { spawnSync } from 'node:child_process';
51
53
 
52
54
 
53
55
  const MESH_KIND_NOTES: Record<CompileResult['meshes'][number]['kind'], string> = {
@@ -561,6 +563,23 @@ function readPassThroughFlag(raw: string | undefined, facts: SkeletonFacts): Pas
561
563
  }
562
564
  }
563
565
 
566
+ /**
567
+ * `--seam <a>:<b>[,…]` read against the skeleton's slots (issue #1456,
568
+ * `../seam.ts`), or refused as a usage error before anything is posed: a
569
+ * malformed list, an empty name, a slot the skeleton does not have, a slot paired
570
+ * with itself, a pair named twice in either order — each by its entry.
571
+ * `undefined` without the flag.
572
+ */
573
+ function readSeamFlag(raw: string | undefined, facts: SkeletonFacts): SeamPair[] | undefined {
574
+ if (raw === undefined) return undefined;
575
+ try {
576
+ return readSeamPairs(raw, facts.slots.map((slot) => slot.name));
577
+ } catch (err) {
578
+ if (err instanceof SeamError) throw new UsageError(err.message);
579
+ throw err;
580
+ }
581
+ }
582
+
564
583
  /**
565
584
  * `--angle-range`'s pairs, read against the skeleton's bones (issue #1414,
566
585
  * `../anglerange.ts`), or refused as a usage error before anything is posed: a
@@ -685,6 +704,21 @@ export function cmdRender(flags: Record<string, string>): void {
685
704
  }
686
705
  }
687
706
 
707
+ // The seam reading (issue #1456) is its own flag for --folds' reason, and refused beside a subset for --pass-through's:
708
+ // it reads the two slots as the whole rig draws them.
709
+ const seam = flags.seam;
710
+ if (seam !== undefined) {
711
+ for (const flag of ['slot', 'hide'] as const) {
712
+ if (flags[flag] !== undefined) {
713
+ throw new UsageError(
714
+ `--seam takes no --${flag}: it reads the two slots as the whole rig draws them, which a subset of the drawn slots ` +
715
+ `would change. Run \`rigc render --seam\` on the whole rig for the reading, and --${flag} ${flags[flag]} ` +
716
+ 'without --seam for the pictures — both land on the same grid',
717
+ );
718
+ }
719
+ }
720
+ }
721
+
688
722
  console.log('rigc render');
689
723
  console.log(` .. skeleton ${skeletonPath}`);
690
724
  console.log(` .. atlas ${atlasPath}${atlasText === null ? ` — not there (${ATLAS_ABSENT})` : ''}`);
@@ -702,7 +736,10 @@ export function cmdRender(flags: Record<string, string>): void {
702
736
  const only = readAnimationFlag(flags, [...facts.animations]);
703
737
  const skin = readSkinFlag(flags, [...facts.skins]);
704
738
  const subset = readSlotSubsetFlags(flags, facts, skin);
739
+ // The physics constraints the skeleton declares, off the Spine file both posers read (issue #1467).
740
+ const physicsDeclared = environmentResponse(skeletonText).declared;
705
741
  const passPairs = readPassThroughFlag(passThrough, facts);
742
+ const seamPairs = readSeamFlag(seam, facts);
706
743
  // The angle reading (issue #1414) reads bones, which a subset of the drawn slots does not change, so it takes --slot/--hide.
707
744
  const anglePairs = readAngleRangeFlag(flags['angle-range'], facts);
708
745
  // One object, so the framing and the frames cannot be posed under two
@@ -774,8 +811,15 @@ export function cmdRender(flags: Record<string, string>): void {
774
811
  // the export's grid is this frame set's by construction.
775
812
  // The bones ride along for --geometry and --angle-range, the whole attachments for --geometry and the two readings off them.
776
813
  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 } : {}) };
814
+ const wantsGeometry = geometry || folds || passPairs !== undefined || seamPairs !== undefined;
815
+ // The physics constraints each frame's step stepped ride along whenever the rig declares one (issue #1467): the
816
+ // `physics:` line and frames.json's `physics` field are read off them.
817
+ const sampling = {
818
+ ...pose,
819
+ ...(wantsBones ? { bones: true } : {}),
820
+ ...(wantsGeometry ? { geometry: true } : {}),
821
+ ...(physicsDeclared.length > 0 ? { physics: true } : {}),
822
+ };
779
823
  const sampled: Map<string, Frame[]> =
780
824
  only === undefined
781
825
  ? sampleAll(poser, fps, sampling)
@@ -828,6 +872,34 @@ export function cmdRender(flags: Record<string, string>): void {
828
872
  }
829
873
  }
830
874
  }
875
+ // The seam reading, off the very pieces the frames are drawn from — one per set, every pair the flag names. A pair
876
+ // whose slot draws on no frame of any set drawn is refused, as --pass-through refuses it, rather than read as a SKIP.
877
+ const seamReadings = new Map<string, ReturnType<typeof seamFileOf>>();
878
+ if (seamPairs !== undefined) {
879
+ const draws = new Set<string>();
880
+ for (const frames of sampled.values()) for (const frame of frames) for (const piece of frame.pieces) draws.add(piece.slot);
881
+ for (const pair of seamPairs) {
882
+ for (const slot of [pair.a, pair.b]) {
883
+ if (!draws.has(slot)) {
884
+ throw new UsageError(
885
+ `--seam: slot ${JSON.stringify(slot)} draws on no frame of [${[...sampled.keys()].join(', ')}]${skin === undefined ? '' : ` under skin ${JSON.stringify(skin)}`} ` +
886
+ '— it shows no region or mesh there, so it has no drawing to read a seam of',
887
+ );
888
+ }
889
+ }
890
+ }
891
+ for (const [name, frames] of sampled) {
892
+ const animation = name === SETUP_POSE_DIR && facts.animations.length === 0 ? null : name;
893
+ const shown = frames.map((frame) => frame.attachments ?? []);
894
+ const read = frames.map((frame) => ({ index: frame.index, time: frame.time, pieces: frame.pieces, attachments: frame.attachments ?? [] }));
895
+ try {
896
+ seamReadings.set(name, seamFileOf(animation, fps, skin, seamsOf(seamPairs, poser.rest(skin, shown), read, pages)));
897
+ } catch (err) {
898
+ if (err instanceof SeamError) throw new UsageError(`--seam, ${JSON.stringify(name)}: ${err.message}`);
899
+ throw err;
900
+ }
901
+ }
902
+ }
831
903
  // The angle reading, off the very bones the frames were posed with — one per set, every pair the flag names.
832
904
  const angleReadings = new Map<string, AngleRangeReading[]>();
833
905
  if (anglePairs !== undefined) {
@@ -841,9 +913,9 @@ export function cmdRender(flags: Record<string, string>): void {
841
913
  }
842
914
  }
843
915
  }
844
- return { viewport, fitted, sampled, geometryTexts, foldReadings, passReadings, angleReadings };
916
+ return { viewport, fitted, sampled, geometryTexts, foldReadings, passReadings, seamReadings, angleReadings };
845
917
  });
846
- const { viewport, fitted, sampled, geometryTexts, foldReadings, passReadings, angleReadings } = posed.value;
918
+ const { viewport, fitted, sampled, geometryTexts, foldReadings, passReadings, seamReadings, angleReadings } = posed.value;
847
919
  console.log(` .. poser ${posed.note}`);
848
920
  if (environment !== undefined) {
849
921
  const response = environmentResponse(skeletonText);
@@ -853,6 +925,8 @@ export function cmdRender(flags: Record<string, string>): void {
853
925
  );
854
926
  }
855
927
  console.log(` .. ${viewport.width}x${viewport.height}px at ${fps} fps, ${sampled.size} set(s) -> ${outRoot}`);
928
+ // Whether physics ran (issue #1467): said once for a rig with nothing to step, and per set below for one with.
929
+ if (physicsDeclared.length === 0) console.log(` .. ${PHYSICS_NONE_LINE}`);
856
930
  if (!facts.declaresStage) console.log(` .. ${STAGELESS_FRAMING.render}`);
857
931
  // A given box (issue #1414): said with the box it replaced, and every slot it leaves part of out, by set and frame.
858
932
  const outside = given === undefined ? [] : outsideViewport(sampled, viewport);
@@ -893,7 +967,10 @@ export function cmdRender(flags: Record<string, string>): void {
893
967
  if (foldReading !== undefined) writeFileSync(join(dir, FOLDS_FILE), foldsText(foldReading));
894
968
  const passReading = passReadings.get(name);
895
969
  if (passReading !== undefined) writeFileSync(join(dir, PASS_THROUGH_FILE), passThroughText(passReading));
970
+ const seamReading = seamReadings.get(name);
971
+ if (seamReading !== undefined) writeFileSync(join(dir, SEAM_FILE), seamText(seamReading));
896
972
  const duration = frames[frames.length - 1].time;
973
+ const physics = physicsDeclared.length === 0 ? undefined : physicsSteppedOf(frames, fps);
897
974
  sets.push({
898
975
  dir: dirName,
899
976
  animation: name === SETUP_POSE_DIR && facts.animations.length === 0 ? null : name,
@@ -902,14 +979,18 @@ export function cmdRender(flags: Record<string, string>): void {
902
979
  written: frames.length,
903
980
  stride: 1,
904
981
  duration,
982
+ ...(physics === undefined ? {} : { physics: { stepped: physics.stepped.length, fps } }),
905
983
  });
906
984
  const how = frames.length === 1 ? 'a single pose' : `${duration.toFixed(3)}s`;
907
985
  const folding = foldReading?.meshes.filter((m) => m.worst !== null).length ?? 0;
908
986
  const foldNote = foldReading === undefined ? '' : ` + ${FOLDS_FILE} (${folding} of ${foldReading.meshes.length} mesh(es) fold)`;
909
987
  const passing = passReading?.pairs.filter((p) => p.frames.length > 0).length ?? 0;
910
988
  const passNote = passReading === undefined ? '' : ` + ${PASS_THROUGH_FILE} (${passing} of ${passReading.pairs.length} pair(s) pass through)`;
911
- const extras = `${sheet ? ` + ${SHEET_FILE}` : ''}${geometryOut === undefined ? '' : ` + ${GEOMETRY_FILE}`}${foldNote}${passNote}`;
989
+ const opening = seamReading?.pairs.filter((p) => p.opens.length > 0).length ?? 0;
990
+ const seamNote = seamReading === undefined ? '' : ` + ${SEAM_FILE} (${opening} of ${seamReading.pairs.length} pair(s) open)`;
991
+ const extras = `${sheet ? ` + ${SHEET_FILE}` : ''}${geometryOut === undefined ? '' : ` + ${GEOMETRY_FILE}`}${foldNote}${passNote}${seamNote}`;
912
992
  console.log(` .. ${name.padEnd(16)} ${frames.length} frame(s), ${how}${extras} -> ${dir}`);
993
+ if (physics !== undefined) console.log(` .. ${physicsLine(physics.stepped, physicsDeclared, fps, frames.length)}`);
913
994
  for (const mesh of foldReading?.meshes ?? []) {
914
995
  const key = `${mesh.slot}\u0000${mesh.attachment}`;
915
996
  const fault = faultsSaid.has(key) ? null : setupFaultLine(mesh);
@@ -922,6 +1003,7 @@ export function cmdRender(flags: Record<string, string>): void {
922
1003
  console.log(` .. pass ${passSummaryLine(passReading?.animation ?? null, fps, pair)}`);
923
1004
  for (const frame of pair.frames) console.log(` .. pass ${passFrameLine(passReading?.animation ?? null, pair, frame)}`);
924
1005
  }
1006
+ for (const pair of seamReading?.pairs ?? []) console.log(` .. seam ${seamLine(seamReading?.animation ?? null, fps, pair)}`);
925
1007
  for (const reading of angleReadings.get(name) ?? []) {
926
1008
  console.log(` .. angle ${angleRangeLine(name === SETUP_POSE_DIR && facts.animations.length === 0 ? null : name, fps, reading)}`);
927
1009
  }
@@ -954,6 +1036,28 @@ export function cmdRender(flags: Record<string, string>): void {
954
1036
  console.log(`rigc: wrote ${join(outRoot, FRAMES_SIDECAR)}`);
955
1037
  }
956
1038
 
1039
+ /** `render`'s line for a rig that declares no physics constraint (issue #1467): nothing was stepped because nothing could be. */
1040
+ export const PHYSICS_NONE_LINE = 'physics: none in this rig — it declares no physics constraint, so no frame steps one';
1041
+
1042
+ /** The physics constraints any frame of a set stepped (issue #1467), by name in code-unit order. */
1043
+ export function physicsSteppedOf(frames: readonly Frame[], fps: number): { stepped: string[]; fps: number } {
1044
+ const names = new Set<string>();
1045
+ for (const frame of frames) for (const name of frame.physics ?? []) names.add(name);
1046
+ return { stepped: [...names].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)), fps };
1047
+ }
1048
+
1049
+ /**
1050
+ * `render`'s `physics:` line for one set (issue #1467): how many constraints were stepped at the set's rate, and — when
1051
+ * fewer than the rig declares — the ones that took no step, by name, with why a constraint takes none.
1052
+ */
1053
+ export function physicsLine(stepped: readonly string[], declared: readonly string[], fps: number, frames: number): string {
1054
+ const head = `physics: ${stepped.length} constraint(s) stepped at ${fps} fps`;
1055
+ const idle = declared.filter((name) => !stepped.includes(name));
1056
+ if (idle.length === 0) return head;
1057
+ const why = frames === 1 ? 'a single pose takes no step' : 'inactive, or a mix of 0, on every frame';
1058
+ return `${head} — ${idle.length} of the ${declared.length} declared took none: ${idle.map((n) => JSON.stringify(n)).join(', ')} (${why})`;
1059
+ }
1060
+
957
1061
  /** Where `inspect` writes when `--out` is not given, and the name it takes inside a directory `--out` names. */
958
1062
  export const DEFAULT_INSPECT_OUT = 'inspect.png';
959
1063
 
@@ -1283,7 +1387,7 @@ export function cmdExplain(flags: Record<string, string>): void {
1283
1387
  const leverFold = flags['lever-fold'] === undefined ? null : Number(flags['lever-fold']);
1284
1388
  const leverWhy = leverFold === null ? null : leverFractionWhy(leverFold);
1285
1389
  if (leverWhy !== null) throw new UsageError(`--lever-fold ${JSON.stringify(flags['lever-fold'])}: ${leverWhy}`);
1286
- const { label, opts } = resolveCut(flags);
1390
+ const { label, opts } = resolveCut(flags, 'explain');
1287
1391
  console.log(`rigc explain ${label}`);
1288
1392
  // See the identical pair of lines in `cmdBuild` for why both paths are named
1289
1393
  // here rather than only the one the header's `label` happens to carry.
@@ -2103,6 +2207,72 @@ export const MODEL_AND_TEXT_GATE: BuildGate = {
2103
2207
  look: (outDir) => `rigc: look at it: rigc render --candidate ${outDir}`,
2104
2208
  };
2105
2209
 
2210
+ /**
2211
+ * Where a delegated package's `package.json` resolves, or `null` (issue #1457).
2212
+ *
2213
+ * ⚖️ Node's `node_modules` lookup — `<dir>/node_modules/<package>` for the
2214
+ * directory and then each of its ancestors, nearest first — from two places in
2215
+ * this order, the first answer winning:
2216
+ *
2217
+ * 1. the working directory — the project `npm i -D <package>` installed it
2218
+ * into, which is what the refusal tells a caller to run, so a project's
2219
+ * own install is the one its command line reaches;
2220
+ * 2. this package's root — where an install of rig-c sits beside it in the
2221
+ * same `node_modules` and the command was run from somewhere else.
2222
+ *
2223
+ * 🚨 Walked here rather than asked of the runtime's resolver: Bun's resolves a
2224
+ * package it cannot find, from a directory with no `node_modules` above it, by
2225
+ * installing it on demand into its global cache — measured, a directory with
2226
+ * none resolved `@rig-c/review` there — so "not installed" would never be
2227
+ * refused, and asking would reach the network. `NODE_PATH` and the global
2228
+ * folders are not read: what the refusal tells a caller to run never puts a
2229
+ * package there. The bin is read off the `package.json` found, not resolved.
2230
+ */
2231
+ function resolveDelegate(name: string): string | null {
2232
+ for (const from of [process.cwd(), PACKAGE_ROOT]) {
2233
+ for (let dir = resolve(from); ; dir = dirname(dir)) {
2234
+ const manifest = join(dir, 'node_modules', name, 'package.json');
2235
+ if (existsSync(manifest)) return manifest;
2236
+ if (dirname(dir) === dir) break;
2237
+ }
2238
+ }
2239
+ return null;
2240
+ }
2241
+
2242
+ /**
2243
+ * A delegating command's body (`CommandDoc.delegate`, issue #1457): resolve the
2244
+ * package's bin and run it under the Bun running this — the bin is a Bun script
2245
+ * — with every argument untouched and stdio inherited, then exit with its code,
2246
+ * or die by its signal the way `bin/rigc.cjs` does, so a caller reads what it
2247
+ * would have read running the bin itself. Not installed, it is refused by name
2248
+ * with the one command that installs it, exit 2, and nothing else is printed.
2249
+ */
2250
+ function cmdDelegate(command: string, raw: readonly string[]): void {
2251
+ const delegate = COMMANDS.find((c) => c.name === command)?.delegate;
2252
+ if (delegate === undefined) throw new Error(`internal: ${command} names no delegate`);
2253
+ const manifest = resolveDelegate(delegate.package);
2254
+ if (manifest === null) throw new DelegateError(`${delegate.package} is not installed — npm i -D ${delegate.package}`, 2);
2255
+ const bins = (readJsonFile(manifest) as { bin?: unknown }).bin;
2256
+ const relativeBin = typeof bins === 'object' && bins !== null ? (bins as Record<string, unknown>)[delegate.bin] : undefined;
2257
+ const bin = typeof relativeBin === 'string' ? join(dirname(manifest), relativeBin) : null;
2258
+ if (bin === null || !existsSync(bin)) {
2259
+ throw new DelegateError(
2260
+ `${delegate.package} at ${dirname(manifest)} ${bin === null ? `declares no "${delegate.bin}" bin` : `declares the "${delegate.bin}" bin ${String(relativeBin)}, which is not there`} — npm i -D ${delegate.package}`,
2261
+ 2,
2262
+ );
2263
+ }
2264
+ const ran = spawnSync(process.execPath, [bin, ...raw], { stdio: 'inherit' });
2265
+ // ⚠️ The exit and the signal are read before `error`: Bun 1.2.0 — the declared minimum — sets `error` on every
2266
+ // child that exits non-zero, so reading it first refused review's own exit 7 as "could not run". `error` is a
2267
+ // launch that failed only where the child left neither a status nor a signal.
2268
+ if (typeof ran.status === 'number') process.exit(ran.status);
2269
+ if (ran.signal !== null) {
2270
+ process.kill(process.pid, ran.signal);
2271
+ return;
2272
+ }
2273
+ throw new DelegateError(`could not run ${bin}: ${ran.error?.message ?? 'it left no exit status and no signal'}`, 1);
2274
+ }
2275
+
2106
2276
  /**
2107
2277
  * split — one authored mesh's pieces, proposed as one mesh each (issue #1414).
2108
2278
  *
@@ -2182,4 +2352,5 @@ export const CORE_COMMAND_RUNS: Readonly<Record<string, CommandRun>> = {
2182
2352
  chainfit: ({ flags }) => cmdChainFit(flags),
2183
2353
  guide: ({ flags, positional }) => cmdGuide(flags, positional),
2184
2354
  skills: ({ flags, positional }) => cmdSkills(flags, positional),
2355
+ review: ({ raw }) => cmdDelegate('review', raw),
2185
2356
  };
package/src/cli/shared.ts CHANGED
@@ -38,7 +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';
41
+ import { DRAWN_ALPHA, PASS_THROUGH_FILE, PASS_THROUGH_RUN } from '../passthrough.ts';
42
+ import { SEAM_FILE } from '../seam.ts';
42
43
  import { readViewportBox, ViewportError } from '../viewportpin.ts';
43
44
  import { parseJsonWithPosition } from '../json-position.ts';
44
45
  import { RUNG_IDS } from '../ladder.ts';
@@ -93,6 +94,19 @@ export type CutTable = Record<string, CutEntry>;
93
94
 
94
95
  export class UsageError extends Error {}
95
96
 
97
+ /**
98
+ * A usage error that is one required flag missing from a spelled-out cut —
99
+ * printed as its one line and exit 2, without the usage block under it (issue
100
+ * #1466). The catalogue under `--out is required` buried the only line that
101
+ * said what to add, which is `PoseError`'s reason for printing alone. Still a
102
+ * `UsageError`, so every reader that asks whether an invocation was refused
103
+ * reads it as one.
104
+ */
105
+ export class MissingFlagError extends UsageError {}
106
+
107
+ /** The `<value>` each flag of a spelled-out cut takes, for the line that names a missing one. */
108
+ const CUT_FLAG_VALUES = { rig: '<rig.json>', motion: '<motion.json>', out: '<dir>' } as const;
109
+
96
110
  /**
97
111
  * `explain` refusing a pair it cannot pose — a usage error in kind, printed
98
112
  * without the usage block for `PoseError`'s and `IngestError`'s reason: the
@@ -263,7 +277,7 @@ function entryToOptions(dir: string, name: string, entry: CutEntry): CompileOpti
263
277
  * Resolve the cut a command was pointed at, either spelled out on the command
264
278
  * line or looked up by name in a cuts.json.
265
279
  */
266
- export function resolveCut(flags: Record<string, string>): { label: string; opts: CompileOptions } {
280
+ export function resolveCut(flags: Record<string, string>, command: 'build' | 'explain' | 'validate'): { label: string; opts: CompileOptions } {
267
281
  const explicit =
268
282
  flags.rig !== undefined || flags.manifest !== undefined || flags.motion !== undefined || flags.out !== undefined;
269
283
  if (explicit) {
@@ -271,7 +285,7 @@ export function resolveCut(flags: Record<string, string>): { label: string; opts
271
285
  throw new UsageError('--rig/--motion/--out and --cut/--cuts are two ways to say the same thing; pick one');
272
286
  }
273
287
  for (const key of ['rig', 'motion', 'out'] as const) {
274
- if (flags[key] === undefined) throw new UsageError(`--${key} is required when the cut is spelled out`);
288
+ if (flags[key] === undefined) throw new MissingFlagError(`${command} needs --${key} ${CUT_FLAG_VALUES[key]} (or --cut <name> --cuts <cuts.json>)`);
275
289
  }
276
290
  const opts: CompileOptions = {
277
291
  rigPath: resolve(flags.rig),
@@ -955,6 +969,21 @@ export class SkillsInstallError extends Error {}
955
969
  /** `guide` asked for a rule or a section the guide does not have (issue #1397): a refusal by `GUIDE_RULE_KNOWN` or `GUIDE_SECTION_KNOWN`, whose message is the whole line. Exit 1. */
956
970
  export class GuideError extends Error {}
957
971
 
972
+ /**
973
+ * A delegating command (`CommandDoc.delegate`, issue #1457) that could not hand
974
+ * over: the package it delegates to does not resolve, or resolves without the
975
+ * bin it names. Nothing ran, so the exit is `status` (2) and the message is the
976
+ * whole of what is printed after `rigc <command>: `.
977
+ */
978
+ export class DelegateError extends Error {
979
+ constructor(
980
+ message: string,
981
+ readonly status: number,
982
+ ) {
983
+ super(message);
984
+ }
985
+ }
986
+
958
987
  // ---------------------------------------------------------------------------
959
988
  // usage / per-command help
960
989
  // ---------------------------------------------------------------------------
@@ -1023,6 +1052,13 @@ const FLAG_MEANINGS: Record<string, string> = {
1023
1052
  '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. ' +
1024
1053
  'The order is the declaration: the first name must draw over the second for a frame to be read. A reading, not a gate. ' +
1025
1054
  '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',
1055
+ seam:
1056
+ `also read, for each <slot>:<slot> pair, where the two drawings stop meeting, and write ${SEAM_FILE} into each frame directory: ` +
1057
+ 'per pair, the frames with a gap — background inside the hull of the band the two drawings share at the setup pose, carried to the frame by both — ' +
1058
+ 'and the frames with a ghost — the band of one drawing showing where, from its partner\'s point of view, nothing was drawn at the setup pose — each with ' +
1059
+ `its cell count, area and world box. Cells are one texel of the coarser drawing, drawn at ${DRAWN_ALPHA} of 255 or more. One line per pair; a pair whose ` +
1060
+ 'drawings share no cell at the setup pose is a SKIP. A reading, not a gate: the exit status never moves. An unknown slot, a slot with itself, ' +
1061
+ 'a slot that draws on no frame, a malformed list and a pair named twice in either order are refused by name. Not with --slot/--hide',
1026
1062
  'angle-range':
1027
1063
  '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 ' +
1028
1064
  '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 ' +
@@ -1234,6 +1270,7 @@ const FLAG_VALUES: Record<string, string> = {
1234
1270
  poser: 'core|spine',
1235
1271
  environment: '<schedule.json>',
1236
1272
  'pass-through': '<cloth>:<leg>[,<cloth>:<leg>…]',
1273
+ seam: '<slot>:<slot>[,<slot>:<slot>…]',
1237
1274
  'angle-range': '<bone>:<relative-to>[,…]',
1238
1275
  'viewport-from': '<frames.json|dir>',
1239
1276
  reference: '<dir|skeleton.json>',
@@ -1365,6 +1402,15 @@ interface CommandDoc {
1365
1402
  * `diff`) can be moved to another side in one place.
1366
1403
  */
1367
1404
  spineFormat: boolean;
1405
+ /**
1406
+ * A command whose body is another package's bin (issue #1457): the dispatch
1407
+ * hands it every argument after the name untouched — nothing is parsed as a
1408
+ * flag, `--help` included, so its page is the delegate's own — and the
1409
+ * delegate's exit code is the command's. `flags` is empty for that reason:
1410
+ * rigc documents no flag of a program it does not own. A control that reads
1411
+ * a command's flag table off `--help` reads it off the commands without this.
1412
+ */
1413
+ delegate?: { package: string; bin: string };
1368
1414
  }
1369
1415
 
1370
1416
  export const COMMANDS: CommandDoc[] = [
@@ -1686,10 +1732,11 @@ export const COMMANDS: CommandDoc[] = [
1686
1732
  'rigc render … --slot <name[,name…]> | --hide <name[,name…]> (a subset of the slots, on the whole rig\'s grid)',
1687
1733
  'rigc render … --environment <schedule.json> (the wind and gravity the physics answers, in time)',
1688
1734
  'rigc render … --pass-through <cloth>:<leg>[,…] (the frames where a cloth drawn over a leg passes through it)',
1735
+ 'rigc render … --seam <slot>:<slot>[,…] (the frames where two slots that meet open a gap or show a ghost)',
1689
1736
  'rigc render … --angle-range <bone>:<relative-to>[,…] (how far a bone turns against another, per animation)',
1690
1737
  'rigc render … --viewport <x,y,w,h> | --viewport-from <frames.json> (a box given, not fitted: two renders on one pixel grid)',
1691
1738
  ],
1692
- flags: ['candidate', 'atlas', 'animation', 'skin', 'slot', 'hide', 'fps', 'max', 'viewport', 'viewport-from', 'geometry', 'folds', 'pass-through', 'angle-range', 'environment', 'poser', 'out'],
1739
+ flags: ['candidate', 'atlas', 'animation', 'skin', 'slot', 'hide', 'fps', 'max', 'viewport', 'viewport-from', 'geometry', 'folds', 'pass-through', 'seam', 'angle-range', 'environment', 'poser', 'out'],
1693
1740
  overrides: {
1694
1741
  out: { value: '<dir>', meaning: 'directory to write the frame series into (default `render/`)' },
1695
1742
  fps: {
@@ -1891,6 +1938,16 @@ export const COMMANDS: CommandDoc[] = [
1891
1938
  '"Install it into your agent").',
1892
1939
  ],
1893
1940
  },
1941
+ {
1942
+ name: 'review',
1943
+ runtime: false,
1944
+ spineFormat: false,
1945
+ delegate: { package: '@rig-c/review', bin: 'rig-review' },
1946
+ usage: [
1947
+ "rigc review <dir> [...] (@rig-c/review's rig-review, installed beside: every argument handed over untouched, its exit code returned — `rigc review open <dir>` prints a loopback URL to give a person, `rigc review --help` is rig-review's own usage)",
1948
+ ],
1949
+ flags: [],
1950
+ },
1894
1951
  ];
1895
1952
 
1896
1953
  /** Every command's name, in the order the usage lists them — what the full entry dispatches over. */
@@ -2247,7 +2304,7 @@ function pageRectangle(region: AtlasRegion): string {
2247
2304
  * alone (`./core_commands.ts`). Moved here unchanged from `cmdBuild`.
2248
2305
  */
2249
2306
  export function readBuildInvocation(flags: Record<string, string>): { label: string; opts: CompileOptions; profile: AssertionProfile; packing: boolean } {
2250
- const { label, opts } = resolveCut(flags);
2307
+ const { label, opts } = resolveCut(flags, 'build');
2251
2308
  const profile = readProfile(flags);
2252
2309
  const packing = flags.pack !== undefined;
2253
2310
  // Two combinations are refused rather than silently resolved, because in each
@@ -2731,6 +2788,8 @@ export interface CommandArgs {
2731
2788
  flags: Record<string, string>;
2732
2789
  lists: Record<string, string[]>;
2733
2790
  positional: string[];
2791
+ /** Every argument after the command's name, as given — what a delegating command (`CommandDoc.delegate`) hands over. */
2792
+ raw: readonly string[];
2734
2793
  }
2735
2794
 
2736
2795
  /** One command's body, as an entry registers it. */
@@ -2931,13 +2990,18 @@ export function runCli(entry: CliEntry, argv: readonly string[]): void {
2931
2990
  if (elsewhere !== undefined) throw commandNeedsRuntime(elsewhere, known, rest);
2932
2991
  throw new UsageError(`unknown command: ${command}`);
2933
2992
  }
2993
+ // A delegating command reads nothing here: not a flag, not `--help` (issue #1457).
2994
+ if (docs.find((doc) => doc.name === command)?.delegate !== undefined) {
2995
+ entry.runs[command]({ flags: {}, lists: {}, positional: [], raw: rest });
2996
+ return;
2997
+ }
2934
2998
 
2935
2999
  const { flags, lists, positional } = parseArgs([...rest], REPEATABLE_FLAGS[command]);
2936
3000
  if (flags.help !== undefined) {
2937
3001
  console.log(commandHelp(command, docs));
2938
3002
  process.exit(0);
2939
3003
  }
2940
- entry.runs[command]({ flags, lists, positional });
3004
+ entry.runs[command]({ flags, lists, positional, raw: rest });
2941
3005
  } catch (err) {
2942
3006
  refuse(err, command, USAGE, entry);
2943
3007
  }
@@ -2950,6 +3014,10 @@ export function runCli(entry: CliEntry, argv: readonly string[]): void {
2950
3014
  * (`CliEntry.refusals`). Anything else is not a refusal and is thrown on.
2951
3015
  */
2952
3016
  function refuse(err: unknown, command: string | undefined, USAGE: string, entry: CliEntry): never {
3017
+ if (err instanceof MissingFlagError) {
3018
+ console.error(`rigc: ${err.message}`);
3019
+ process.exit(2);
3020
+ }
2953
3021
  if (err instanceof UsageError) {
2954
3022
  console.error(`rigc: ${err.message}\n\n${USAGE}`);
2955
3023
  process.exit(2);
@@ -3107,5 +3175,11 @@ function refuse(err: unknown, command: string | undefined, USAGE: string, entry:
3107
3175
  console.error(err.message);
3108
3176
  process.exit(1);
3109
3177
  }
3178
+ // A delegate that could not be handed the run (issue #1457): one line naming the package and what to run, nothing else —
3179
+ // the usage under it would bury the one command that fixes it.
3180
+ if (err instanceof DelegateError) {
3181
+ console.error(`rigc ${command}: ${err.message}`);
3182
+ process.exit(err.status);
3183
+ }
3110
3184
  throw err;
3111
3185
  }
@@ -76,7 +76,7 @@ export function cmdValidate(flags: Record<string, string>, positional: string[])
76
76
  // a directory alone cannot supply — and the report says which it had.
77
77
  const named = flags.cut !== undefined || flags.rig !== undefined;
78
78
  const profile = readProfile(flags);
79
- const derivedOpts = named ? resolveCut(flags).opts : null;
79
+ const derivedOpts = named ? resolveCut(flags, 'validate').opts : null;
80
80
  // Both files there before the first line, from the one statement of a build (issue #1046): a directory without them
81
81
  // read the skeleton unguarded and died on an ENOENT and a stack.
82
82
  const { skeletonPath, atlasPath } = spinePairOf(resolveBuild(derivedOpts ? derivedOpts.outDir : (positional[0] ?? '.'), flags.atlas));
package/src/compile.ts CHANGED
@@ -18,7 +18,7 @@
18
18
  * the two emits byte for byte (assertion A18).
19
19
  */
20
20
  import { basename, dirname, join, relative, resolve } from 'node:path';
21
- import { existsSync, readFileSync } from 'node:fs';
21
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
22
22
  import { readPngHeader, readPngInfo } from './png.ts';
23
23
  import {
24
24
  DEPTH_TONE_IDENTITY,
@@ -1649,8 +1649,62 @@ function easingCurve(
1649
1649
  // inputs
1650
1650
  // ---------------------------------------------------------------------------
1651
1651
 
1652
- function readJson<T>(path: string): T {
1652
+ /**
1653
+ * Which input a JSON path was given as, for the refusal a directory gets: the
1654
+ * flag that named it, the file it wants, and whether a file's contents read as
1655
+ * one (`null` where no parser exists here — the manifest is cast, not parsed).
1656
+ */
1657
+ interface JsonInputRole {
1658
+ flag: '--rig' | '--motion' | '--manifest';
1659
+ wants: string;
1660
+ reads: ((raw: unknown, where: string) => unknown) | null;
1661
+ }
1662
+
1663
+ const RIG_INPUT: JsonInputRole = { flag: '--rig', wants: 'the rig spec file', reads: parseRigSpec };
1664
+ const MOTION_INPUT: JsonInputRole = { flag: '--motion', wants: 'the motion spec file', reads: parseMotionSpec };
1665
+ const MANIFEST_INPUT: JsonInputRole = { flag: '--manifest', wants: 'the cut manifest file', reads: null };
1666
+
1667
+ /** The most `.json` names a directory refusal lists before it says how many there are. */
1668
+ const LISTED_JSON_NAMES = 8;
1669
+
1670
+ /**
1671
+ * The refusal for an input flag that names a directory (issue #1466): what the
1672
+ * flag wants, and what the directory holds — the one file there that reads as
1673
+ * that input, named by its path, or every `.json` there when that is not
1674
+ * exactly one. Nothing is accepted on the strength of it: the caller is told
1675
+ * the path, and the compile still reads only what it was given. The names are
1676
+ * sorted, so the sentence is the same on every machine.
1677
+ */
1678
+ function directoryInputRefusal(dir: string, role: JsonInputRole): string {
1679
+ const head = `${role.flag} names a directory; it wants ${role.wants} —`;
1680
+ const names = readdirSync(dir)
1681
+ .filter((name) => name.endsWith('.json') && statSync(join(dir, name)).isFile())
1682
+ .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
1683
+ if (names.length === 0) return `${head} ${dir} holds no .json file`;
1684
+ const reading = names.filter((name) => {
1685
+ if (role.reads === null) return true;
1686
+ const file = join(dir, name);
1687
+ try {
1688
+ role.reads(parseJsonWithPosition(readFileSync(file, 'utf8')), file);
1689
+ return true;
1690
+ } catch {
1691
+ return false;
1692
+ }
1693
+ });
1694
+ if (reading.length === 1) return `${head} ${join(dir, reading[0])} exists there`;
1695
+ const listed = (list: string[]): string =>
1696
+ list.slice(0, LISTED_JSON_NAMES).join(', ') + (list.length > LISTED_JSON_NAMES ? `, … ${list.length} in all` : '');
1697
+ if (reading.length === 0) {
1698
+ return `${head} ${dir} holds ${names.length} .json file(s) and none reads as one: ${listed(names)}`;
1699
+ }
1700
+ return `${head} ${reading.length} .json files in ${dir} read as one, so name it: ${listed(reading)}`;
1701
+ }
1702
+
1703
+ function readJson<T>(path: string, role: JsonInputRole): T {
1653
1704
  let text: string;
1705
+ if (existsSync(path) && statSync(path).isDirectory()) {
1706
+ throw new CompileError(directoryInputRefusal(path, role), 'CE_COMPILE_JSON_READ_FAILED');
1707
+ }
1654
1708
  try {
1655
1709
  text = readFileSync(path, 'utf8');
1656
1710
  } catch (err) {
@@ -2309,11 +2363,11 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2309
2363
  const manifestPath = opts.manifestPath === undefined ? null : resolve(opts.manifestPath);
2310
2364
  const manifestDir = manifestPath === null ? null : dirname(manifestPath);
2311
2365
 
2312
- const rig = parseRigSpec(readJson<unknown>(rigPath), rigPath);
2366
+ const rig = parseRigSpec(readJson<unknown>(rigPath, RIG_INPUT), rigPath);
2313
2367
  // Parsed, not cast, since issue #307 — `src/motion.ts` states what it proves
2314
2368
  // and which refusals it deliberately leaves in this file.
2315
- const motion = parseMotionSpec(readJson<unknown>(motionPath), motionPath);
2316
- const manifest = manifestPath === null ? null : readJson<FaceManifest>(manifestPath);
2369
+ const motion = parseMotionSpec(readJson<unknown>(motionPath, MOTION_INPUT), motionPath);
2370
+ const manifest = manifestPath === null ? null : readJson<FaceManifest>(manifestPath, MANIFEST_INPUT);
2317
2371
  // The manifest is the record of what was measured, and its numbers become
2318
2372
  // bone positions and the stage: a `crop.h` of 1e309 built as `"height": null`
2319
2373
  // before issue #881. The rig spec's walk is `parseRigSpec`'s; this is the
@@ -238,7 +238,7 @@ export function texelSize(piece: Piece, vertices: ArrayLike<number>, page: Plate
238
238
  }
239
239
 
240
240
  /** The cells `piece` draws at half opacity or more over `viewport`, row major, 1 where drawn. */
241
- function drawnCells(piece: Piece, page: Plate, viewport: Viewport): Uint8Array {
241
+ export function drawnCells(piece: Piece, page: Plate, viewport: Viewport): Uint8Array {
242
242
  const cells = new Uint8Array(viewport.width * viewport.height);
243
243
  rasterisePiece(page, piece, projector(viewport), viewport, (px, py, _r, _g, _b, a) => {
244
244
  if (a >= DRAWN_ALPHA) cells[py * viewport.width + px] = 1;
@@ -251,7 +251,7 @@ function drawnCells(piece: Piece, page: Plate, viewport: Viewport): Uint8Array {
251
251
  * renderer's own traversal over a one-cell grid centred on the point, so the
252
252
  * test is the frame's pixel test and nothing re-derived beside it.
253
253
  */
254
- function drawsAt(piece: Piece, page: Plate, x: number, y: number, cell: number): boolean {
254
+ export function drawsAt(piece: Piece, page: Plate, x: number, y: number, cell: number): boolean {
255
255
  const viewport: Viewport = { minX: x - cell / 2, minY: y - cell / 2, maxX: x + cell / 2, maxY: y + cell / 2, scale: 1 / cell, width: 1, height: 1 };
256
256
  let drawn = false;
257
257
  rasterisePiece(page, piece, projector(viewport), viewport, (_px, _py, _r, _g, _b, a) => {
@@ -427,7 +427,7 @@ function runsAt(
427
427
  const restKey = (slot: string, attachment: string): string => `${slot}\u0000${attachment}`;
428
428
 
429
429
  /** Whether two vertex lists are the same numbers — a piece drawn from its attachment whole, not cut by a clip. */
430
- function sameVertices(a: ArrayLike<number>, b: ArrayLike<number>): boolean {
430
+ export function sameVertices(a: ArrayLike<number>, b: ArrayLike<number>): boolean {
431
431
  if (a.length !== b.length) return false;
432
432
  for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
433
433
  return true;
package/src/render.ts CHANGED
@@ -549,9 +549,25 @@ function spinePosed(skeleton: Skeleton): Posed {
549
549
  pieces: (draw) => drawPieces(skeleton, draw),
550
550
  bones: () => boneSnapshots(skeleton),
551
551
  attachments: () => attachmentsOf(skeleton),
552
+ physics: () => steppedPhysicsOf(skeleton),
552
553
  };
553
554
  }
554
555
 
556
+ /**
557
+ * `Posed.physics` on spine-core (issue #1467): every physics constraint whose
558
+ * `lastTime` reads the skeleton's `time` after a step that moved it — which
559
+ * `PhysicsConstraint.update` sets under `Physics.update` and `Physics.reset`,
560
+ * and which an inactive constraint, a mix of 0 or `Physics.none` leaves where
561
+ * it was. The setup pose and frame 0 sit at time 0 and step none.
562
+ */
563
+ function steppedPhysicsOf(skeleton: Skeleton): string[] {
564
+ if (!(skeleton.time > 0)) return [];
565
+ return skeleton.physics
566
+ .filter((c) => c.lastTime === skeleton.time)
567
+ .map((c) => c.data.name)
568
+ .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
569
+ }
570
+
555
571
  /**
556
572
  * A fresh skeleton under `skin`, stepped into its setup pose — the one recipe
557
573
  * `sampleSetupPose` and the geometry export's `rest` table both pose from, so