@rig-c/pose 0.8.0 → 0.9.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/src/retarget.ts CHANGED
@@ -64,11 +64,13 @@ import { screenToSpineDegrees } from 'rig-c/transform';
64
64
  import type { Body18 } from './joints.ts';
65
65
  import { isBody18, VIRTUAL } from './joints.ts';
66
66
  import type { Chain, JointMap, Rest, RigSlots } from './map.ts';
67
- import { aimAngle, rigPoint } from './map.ts';
67
+ import { aimAngle, descends, rigPoint } from './map.ts';
68
68
  import type { Joint } from './pose.ts';
69
- import { Problems } from './problems.ts';
69
+ import { Problems, Refusal } from './problems.ts';
70
70
  import type { ArtRange, SlotTrack, Switch, SwitchOptions } from './switch.ts';
71
71
  import { clampArt, coveredChains, measureArt, overflow, RANGE_SLACK, runsOf, selectSwitches } from './switch.ts';
72
+ import type { Feet, LegScale, PathPose, Posing, Side } from './legs.ts';
73
+ import { isLegSegment, legScaleOf, legsOf, pathFrom, plantHip, posePath, rootOf, SIDES, supportsOf, tipOf } from './legs.ts';
72
74
 
73
75
  const DEG = 180 / Math.PI;
74
76
 
@@ -85,6 +87,10 @@ export interface RetargetOptions extends SwitchOptions {
85
87
  flip: number | 'off';
86
88
  /** A segment shorter in the picture than this fraction of its median length has its angle held there (`holdShort`); 'off' reads every frame. */
87
89
  short: number | 'off';
90
+ /** Leg segments scaled along their x axis by picture length over straight length, clamped to [min, max] (`legs.ts`); 'off' (the default) scales nothing. */
91
+ legScale?: [number, number] | 'off';
92
+ /** The hip derived from the supporting foot (`legs.ts`); false (the default) moves it by the pose's travel. */
93
+ plantFeet?: boolean;
88
94
  }
89
95
 
90
96
  export interface Hold {
@@ -170,7 +176,7 @@ export interface MotionKey {
170
176
  }
171
177
  export interface MotionTrack {
172
178
  bone: string;
173
- property: 'rotate' | 'translate';
179
+ property: 'rotate' | 'translate' | 'scalex';
174
180
  keys: MotionKey[];
175
181
  }
176
182
  export interface Animation {
@@ -251,6 +257,29 @@ export interface Retarget {
251
257
  range: RangeDemand[];
252
258
  /** Every run a bone inside a span turned from its drawn angle past its range. */
253
259
  artRange: ArtRange[];
260
+ /** With --leg-scale: each leg segment's scale, straight length and clamped runs. */
261
+ legScale: LegScale[];
262
+ /** Per scaled bone, the scale its x axis must reach on each frame (relative to setup); the scalex key of a bone under a scaled bone differs from it. */
263
+ scales: Map<string, number[]>;
264
+ /** The supports and jumps read off the reference ankles, when the map has both legs (whether or not --plant-feet used them). */
265
+ feet: Feet | null;
266
+ /** Whether the hip was derived from the supporting foot. */
267
+ planted: boolean;
268
+ /** Each ankle's world position on every frame, as the keys put it, when legs are scaled or feet planted. */
269
+ ankles: { bones: Record<Side, string>; at: Record<Side, Pt[]> } | null;
270
+ /** The translated bone's origin against its thigh roots' midpoint, at setup. */
271
+ pivot: Pivot | null;
272
+ }
273
+
274
+ /** The hip's pivot against the thigh roots, at setup (rig-c/pose#47). */
275
+ export interface Pivot {
276
+ bone: string;
277
+ roots: [string, string];
278
+ /** Hip origin − the roots' midpoint, world units. */
279
+ offset: Pt;
280
+ distance: number;
281
+ /** The distance in hip widths (the roots' distance apart). */
282
+ hipWidths: number;
254
283
  }
255
284
 
256
285
  type Pt = [number, number];
@@ -358,6 +387,24 @@ const r3 = (v: number) => {
358
387
  const r = Math.round(v * 1000) / 1000;
359
388
  return Object.is(r, -0) ? 0 : r;
360
389
  };
390
+ /** A scale key: to 1e-6, so its rounding moves an ankle 500 units out by at most 0.00025. */
391
+ const r6 = (v: number) => {
392
+ const r = Math.round(v * 1e6) / 1e6;
393
+ return Object.is(r, -0) ? 0 : r;
394
+ };
395
+
396
+ /** The translated bone's origin against the midpoint of the two legs' first bones, at setup. */
397
+ function pivotOf(rest: Rest, bone: string | null, legs: ReturnType<typeof legsOf> | null): Pivot | null {
398
+ if (bone === null || legs === null || !('legs' in legs)) return null;
399
+ const roots: [string, string] = [legs.legs.r.chain.bones[0]!, legs.legs.l.chain.bones[0]!];
400
+ const a = rest.get(roots[0])!;
401
+ const b = rest.get(roots[1])!;
402
+ const h = rest.get(bone)!;
403
+ const offset: Pt = [h.worldX - (a.worldX + b.worldX) / 2, h.worldY - (a.worldY + b.worldY) / 2];
404
+ const width = Math.hypot(b.worldX - a.worldX, b.worldY - a.worldY);
405
+ const distance = Math.hypot(offset[0], offset[1]);
406
+ return { bone, roots, offset, distance, hipWidths: width > 1e-9 ? distance / width : NaN };
407
+ }
361
408
 
362
409
  /**
363
410
  * Retarget. Refuses (every reason at once) when a point the map needs is never
@@ -493,30 +540,17 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
493
540
  const s = rigLen / poseLen;
494
541
  scale = { factor: s, pose: poseLen, rig: rigLen };
495
542
  const P = series.get(t.joint)!;
496
- const bone = rest.get(t.bone)!;
497
- const parent = bone.parent === null ? null : rest.get(bone.parent)!;
498
- const keys: MotionKey[] = [];
499
- const at: Pt[] = [];
500
543
  for (let i = 0; i < n; i++) {
501
544
  const wx = (P[i]![0] - P[0]![0]) * s;
502
545
  const wy = -(P[i]![1] - P[0]![1]) * s;
503
- let lx = wx;
504
- let ly = wy;
505
- if (parent !== null) {
506
- const det = parent.a * parent.d - parent.b * parent.c;
507
- lx = (parent.d * wx - parent.b * wy) / det;
508
- ly = (-parent.c * wx + parent.a * wy) / det;
509
- }
510
- keys.push({ t: keyTimes[i]!, v: [r3(lx), r3(ly)] });
511
- at.push([bone.worldX + wx, bone.worldY + wy]);
512
546
  travel.push([wx, wy]);
513
547
  }
514
- translateTrack = { bone: t.bone, property: 'translate', keys };
515
- origin = { bone: t.bone, at };
516
548
  }
517
549
  }
518
550
  }
519
551
  p.throwIfAny();
552
+ /** The pose's own travel of the hip, world, before --plant-feet replaces it. */
553
+ const poseTravel: Pt[] = travel.map((v) => [v[0], v[1]]);
520
554
 
521
555
  // Each mapped bone's target world angle per frame, reaches last.
522
556
  const targets = new Map<string, number[]>();
@@ -595,6 +629,111 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
595
629
  }
596
630
  p.throwIfAny();
597
631
 
632
+ // The legs (#47): each leg segment's scale, the supports and jumps, and with
633
+ // --plant-feet the hip derived from the supporting foot — before the reaches,
634
+ // which are aimed from where the hip is.
635
+ const legScaleOn = opts.legScale !== undefined && opts.legScale !== 'off';
636
+ const plantOn = opts.plantFeet === true;
637
+ const legScale: LegScale[] = [];
638
+ const scales = new Map<string, number[]>();
639
+ let feet: Feet | null = null;
640
+ let legs: ReturnType<typeof legsOf> | null = null;
641
+ let legPath: string[] | null = null;
642
+ let legTop: string | null = null;
643
+ const restTurn = (b: string, i: number): number => targets.get(b)![i]! - restOf.get(b)!;
644
+ const aimX = new Map<string, boolean>();
645
+ for (const c of map.chains) c.bones.forEach((b, k) => aimX.set(b, c.aim[k] === 'x'));
646
+ {
647
+ const why = plantOn ? '--plant-feet' : legScaleOn ? '--leg-scale' : 'reading the feet';
648
+ legs = legsOf(map, why);
649
+ if ('problems' in legs) {
650
+ if (plantOn || legScaleOn) for (const x of legs.problems) p.add('LEGS_MAP', x);
651
+ } else {
652
+ const L = legs.legs;
653
+ const top = t !== null && SIDES.every((s) => L[s].ankleBone === t.bone || descends(rest, L[s].ankleBone, t.bone)) ? t.bone : rootOf(rest, L.r.ankleBone);
654
+ if (plantOn && (t === null || top !== t.bone)) p.add('LEGS_MAP', t === null ? '--plant-feet places the hip, and the map has no "translate"' : `--plant-feet places the translated bone "${t.bone}", and the legs' last bones (${L.r.ankleBone}, ${L.l.ankleBone}) do not both descend from it`);
655
+ const path = pathFrom(rest, top, [L.r.ankleBone, L.l.ankleBone]);
656
+ if (path === null || rootOf(rest, L.l.ankleBone) !== rootOf(rest, L.r.ankleBone)) {
657
+ if (plantOn || legScaleOn) p.add('LEGS_MAP', `the legs' last bones ${L.r.ankleBone} and ${L.l.ankleBone} do not hang from one bone`);
658
+ } else {
659
+ legPath = path;
660
+ legTop = top;
661
+ }
662
+ const hipOf = (s: Side) => series.get(`${s}_hip`);
663
+ const ankleOf = (s: Side) => series.get(`${s}_ankle`);
664
+ const hr = hipOf('r'), hl = hipOf('l'), ar = ankleOf('r'), al = ankleOf('l');
665
+ if (hr !== undefined && hl !== undefined && ar !== undefined && al !== undefined) feet = supportsOf({ r: ar, l: al }, { r: hr, l: hl }, keyTimes, secondsOf);
666
+ else if (plantOn) p.add('LEGS_MAP', `--plant-feet reads both hips and both ankles from the pose; the map's chains use ${['r_hip', 'l_hip', 'r_ankle', 'l_ankle'].filter((j) => !series.has(j)).join(', ')} nowhere`);
667
+ }
668
+ }
669
+ if (legScaleOn) {
670
+ const limits = opts.legScale as [number, number];
671
+ for (const c of map.chains) {
672
+ if (c.reach) continue;
673
+ c.bones.forEach((b, k) => {
674
+ const joints: [string, string] = [c.joints[k]!, c.joints[k + 1]!];
675
+ if (!isLegSegment(joints[0], joints[1])) return;
676
+ if (c.aim[k] !== 'x') {
677
+ p.add('LEGS_MAP', `--leg-scale scales chain "${c.name}" bone "${b}" (${joints[0]} → ${joints[1]}) along its own x axis, and the map aims it ${JSON.stringify(c.aim[k])}; a leg segment it scales must be aimed "x"`);
678
+ return;
679
+ }
680
+ const from = series.get(joints[0])!;
681
+ const to = series.get(joints[1])!;
682
+ const ls = legScaleOf(from.map((f, i) => Math.hypot(to[i]![0] - f[0], to[i]![1] - f[1])), limits);
683
+ if (!(ls.straight > 1e-6)) {
684
+ p.add('LEGS_MAP', `--leg-scale: chain "${c.name}" bone "${b}"'s segment ${joints[0]} → ${joints[1]} has a straight length (95th percentile) of ${ls.straight}px; there is nothing to scale against`);
685
+ return;
686
+ }
687
+ legScale.push({ chain: c.name, bone: b, joints, ...ls });
688
+ scales.set(b, ls.scale);
689
+ });
690
+ }
691
+ if (legScale.length === 0 && !p.any) p.add('LEGS_MAP', '--leg-scale scales the leg segments (two of one side\'s hip, knee and ankle), and the map aims none');
692
+ }
693
+ p.throwIfAny();
694
+ /** Every bone on the legs' path posed on frame i, by the targets as they stand. */
695
+ const poseLegs = (i: number): PathPose => {
696
+ const r = posePath(rest, legPath!, (b): Posing | null => (targets.has(b) ? { turn: restTurn(b, i), target: targets.get(b)![i]!, aimX: aimX.get(b)! } : null), (b) => scales.get(b)?.[i] ?? null);
697
+ if (typeof r === 'string') throw new Refusal([{ code: 'LEGS_MAP', text: r }]);
698
+ return r;
699
+ };
700
+ if (plantOn) {
701
+ // The plant is derived from the targets before the range clamp, so nothing after may turn a bone on the legs' path.
702
+ const onPath = new Set(legPath!);
703
+ for (const c of map.chains) for (const b of Object.keys(c.range)) if (onPath.has(b)) p.add('LEGS_RANGE', `--plant-feet places the hip through chain "${c.name}" bone "${b}", which has a "range"; a clamp after the plant would move the planted foot — drop the range or --plant-feet`);
704
+ for (const a of map.alternates) {
705
+ for (const b of [...Object.keys(a.turn), ...Object.keys(a.offsets)]) if (onPath.has(b)) p.add('LEGS_RANGE', `--plant-feet places the hip through bone "${b}", which set "${a.name}" turns or offsets; that would move the planted foot — drop it from the set or --plant-feet`);
706
+ }
707
+ p.throwIfAny();
708
+ const offsets = Array.from({ length: n }, (_, i) => {
709
+ const lp = poseLegs(i);
710
+ return { r: tipOf(rest, lp, legs !== null && 'legs' in legs ? legs.legs.r.ankleBone : ''), l: tipOf(rest, lp, legs !== null && 'legs' in legs ? legs.legs.l.ankleBone : '') };
711
+ });
712
+ const tb = rest.get(t!.bone)!;
713
+ const hip = plantHip(feet!, (i, s) => offsets[i]![s], (i) => poseTravel[i]!, [tb.worldX, tb.worldY]);
714
+ for (let i = 0; i < n; i++) travel[i] = [hip[i]![0] - tb.worldX, hip[i]![1] - tb.worldY];
715
+ }
716
+ if (t !== null && scale !== null) {
717
+ const bone = rest.get(t.bone)!;
718
+ const parent = bone.parent === null ? null : rest.get(bone.parent)!;
719
+ const keys: MotionKey[] = [];
720
+ const at: Pt[] = [];
721
+ for (let i = 0; i < n; i++) {
722
+ const [wx, wy] = travel[i]!;
723
+ let lx = wx;
724
+ let ly = wy;
725
+ if (parent !== null) {
726
+ const det = parent.a * parent.d - parent.b * parent.c;
727
+ lx = (parent.d * wx - parent.b * wy) / det;
728
+ ly = (-parent.c * wx + parent.a * wy) / det;
729
+ }
730
+ keys.push({ t: keyTimes[i]!, v: [r3(lx), r3(ly)] });
731
+ at.push([bone.worldX + wx, bone.worldY + wy]);
732
+ }
733
+ translateTrack = { bone: t.bone, property: 'translate', keys };
734
+ origin = { bone: t.bone, at };
735
+ }
736
+
598
737
  // Reaches: from the bone's posed origin at the pose's end point, anchored at
599
738
  // the translated bone. A reach above another is aimed first.
600
739
  const depth = (b: string): number => {
@@ -685,6 +824,25 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
685
824
  const artRange = measureArt(sw.switches, map, targets, endOf, keyTimes, RANGE_GAP);
686
825
  clampArt(sw.switches, map, targets);
687
826
 
827
+ // The legs on their final targets: a bone under a scaled bone is keyed through
828
+ // its parent's posed matrix (legs.ts), and each ankle's world position is read.
829
+ const legPoses: PathPose[] | null = legPath !== null && (legScaleOn || plantOn) ? Array.from({ length: n }, (_, i) => poseLegs(i)) : null;
830
+ let ankles: Retarget['ankles'] = null;
831
+ if (legPoses !== null && legs !== null && 'legs' in legs) {
832
+ const L = legs.legs;
833
+ const topRest = rest.get(legTop!)!;
834
+ const at = { r: [] as Pt[], l: [] as Pt[] };
835
+ legPoses.forEach((lp, i) => {
836
+ const o: Pt = origin !== null && origin.bone === legTop ? origin.at[i]! : [topRest.worldX, topRest.worldY];
837
+ for (const sd of SIDES) {
838
+ const tip = tipOf(rest, lp, L[sd].ankleBone);
839
+ at[sd].push([o[0] + tip[0], o[1] + tip[1]]);
840
+ }
841
+ });
842
+ ankles = { bones: { r: L.r.ankleBone, l: L.l.ankleBone }, at };
843
+ }
844
+ const solvedKey = (b: string, i: number) => legPoses?.[i]!.solved.get(b);
845
+
688
846
  // Keys: the bone's turn less the turn of the nearest mapped bone above it.
689
847
  const tracks: Array<MotionTrack | SlotTrack> = [];
690
848
  for (const c of map.chains) {
@@ -699,6 +857,22 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
699
857
  keys.push({ t: keyTimes[i]!, v: [r3(v)] });
700
858
  }
701
859
  tracks.push({ bone: b, property: 'rotate', keys });
860
+ // A bone under a scaled bone: the rotate key that lands it through its parent's scale (legs.ts).
861
+ if (solvedKey(b, 0) !== undefined) {
862
+ let last = 0;
863
+ keys.forEach((k, i) => {
864
+ const raw = solvedKey(b, i)!.rotate;
865
+ last = i === 0 ? wrap(raw) : last + wrap(raw - last);
866
+ k.v = [r3(last)];
867
+ });
868
+ }
869
+ }
870
+ }
871
+ // Scale keys: a scaled leg segment's own scale, or the scale that lands a bone under one at its asked length.
872
+ for (const c of map.chains) {
873
+ for (const b of c.bones) {
874
+ if (!scales.has(b) && solvedKey(b, 0) === undefined) continue;
875
+ tracks.push({ bone: b, property: 'scalex', keys: Array.from({ length: n }, (_, i) => ({ t: keyTimes[i]!, v: [r6(solvedKey(b, i)?.scale ?? scales.get(b)?.[i] ?? 1)] })) });
702
876
  }
703
877
  }
704
878
  if (translateTrack !== null) tracks.push(translateTrack);
@@ -708,6 +882,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
708
882
  animation: { duration: keyTimes[n - 1]!, tracks },
709
883
  times: keyTimes, targets, origin, holds, bends, short, endOn, flips, scale,
710
884
  switches: sw.switches, shown: sw.shown, distances: sw.distances, turns, turnsUnread, range, artRange,
885
+ legScale, scales, feet, planted: plantOn, ankles, pivot: pivotOf(rest, t?.bone ?? null, legs),
711
886
  };
712
887
  }
713
888
 
package/src/rigc.ts CHANGED
@@ -96,11 +96,11 @@ export interface Gate {
96
96
  }
97
97
 
98
98
  /** Build `rig` with `motion` under rigc's gate into `dir/build`; nothing is read from a red build. */
99
- export function gate(rig: string, motion: Record<string, unknown>, dir: string, profile: string): Gate {
99
+ export function gate(rig: string, motion: Record<string, unknown>, dir: string, profile: string, images?: string): Gate {
100
100
  mkdirSync(dir, { recursive: true });
101
101
  const motionPath = join(dir, 'motion.json');
102
102
  writeFileSync(motionPath, `${JSON.stringify(motion, null, 2)}\n`);
103
- return buildInto(rig, motionPath, join(dir, 'build'), profile, join(dir, 'build-report.json'));
103
+ return buildInto(rig, motionPath, join(dir, 'build'), profile, join(dir, 'build-report.json'), images);
104
104
  }
105
105
 
106
106
  /**
@@ -112,8 +112,8 @@ export function gate(rig: string, motion: Record<string, unknown>, dir: string,
112
112
  * never copied out, which would carry a path climbing from the scratch
113
113
  * directory to the filesystem root. --copy-images puts the pages beside it.
114
114
  */
115
- export function buildInto(rig: string, motionPath: string, out: string, profile: string, report?: string): Gate {
116
- const run = runRigc(['build', '--rig', rig, '--motion', motionPath, '--out', out, '--profile', profile, '--copy-images', ...(report === undefined ? [] : ['--report', report])]);
115
+ export function buildInto(rig: string, motionPath: string, out: string, profile: string, report?: string, images?: string): Gate {
116
+ const run = runRigc(['build', '--rig', rig, '--motion', motionPath, '--out', out, '--profile', profile, '--copy-images', ...(images === undefined ? [] : ['--images', images]), ...(report === undefined ? [] : ['--report', report])]);
117
117
  const lines = run.out.split('\n');
118
118
  return {
119
119
  run,
@@ -196,3 +196,12 @@ export function folds(build: string, animation: string, dir: string, fps: number
196
196
  frames: setDir,
197
197
  };
198
198
  }
199
+
200
+ /** rig-c's version and the entry its launcher picked (`rigc --version`), so a run names the compiler that gated it. */
201
+ export function rigcVersion(): { version: string; entry: string } {
202
+ const r = runRigc(['--version']);
203
+ const lines = r.out.split('\n').map((l) => l.trim()).filter((l) => l !== '');
204
+ const version = lines.find((l) => /^\d+\.\d+\.\d+/.test(l)) ?? lines[0] ?? 'unknown';
205
+ const entry = lines.find((l) => l.startsWith('entry:'))?.slice('entry:'.length).trim() ?? 'not stated';
206
+ return { version, entry };
207
+ }
package/src/run.ts CHANGED
@@ -7,13 +7,15 @@
7
7
  import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
8
8
  import { tmpdir } from 'node:os';
9
9
  import { dirname, join, resolve } from 'node:path';
10
- import { readMap, resolveMap } from './map.ts';
10
+ import { readMap, resolveMap, type JointMap } from './map.ts';
11
11
  import { convertCoco17, personTrack, readPoseSequence } from './pose.ts';
12
12
  import { lineOf, Problems, Refusal, type Problem } from './problems.ts';
13
13
  import { bendLine, endOnLine, flipLine, holdLine, rangeLine, retarget, shortLine, turnLine, type RetargetOptions } from './retarget.ts';
14
- import { attachmentBones, baseMotionOf, buildInto, folds, gate, readBuild, resolveRig, restOf, slotsOf } from './rigc.ts';
14
+ import { attachmentBones, baseMotionOf, buildInto, rigcVersion, folds, gate, readBuild, resolveRig, restOf, slotsOf } from './rigc.ts';
15
15
  import { artRangeLine, chainsMoving, switchLine, uncoveredFolds } from './switch.ts';
16
- import { landed, measureLanding, measurePosture, poseFrames, postureLines, ANGLE_TOLERANCE, ORIGIN_TOLERANCE } from './verify.ts';
16
+ import { landed, measureFeet, measureLanding, measurePosture, measureShear, poseFrames, postureLines } from './verify.ts';
17
+ import { legsOf } from './legs.ts';
18
+ import type { LegScale } from './legs.ts';
17
19
 
18
20
  export interface RetargetArgs extends RetargetOptions {
19
21
  rig: string;
@@ -24,6 +26,8 @@ export interface RetargetArgs extends RetargetOptions {
24
26
  coco: boolean;
25
27
  person?: string;
26
28
  baseMotion?: string;
29
+ /** Passed to rigc build as --images: the directory the rig spec's images resolve in, when rig.json's own `images` does not point there (a copied rig). */
30
+ images?: string;
27
31
  profile: 'spine' | 'spine-html';
28
32
  /** Keep the gated build here. */
29
33
  build?: string;
@@ -72,17 +76,19 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
72
76
  const scratch = mkdtempSync(join(tmpdir(), 'rig-pose-'));
73
77
  try {
74
78
  // The rig as it stands, through the gate, so the rest pose is rig-c's reading of a green build.
75
- const g0 = gate(input.rig, base, join(scratch, 'base'), a.profile);
79
+ const g0 = gate(input.rig, base, join(scratch, 'base'), a.profile, a.images);
76
80
  if (!g0.green) {
77
81
  refuse([{ code: 'RIG_GATE_BASE', text: `${input.rig} with ${input.motion ?? 'no motion spec'} is red under rigc build --profile ${a.profile} before any animation is added (exit ${g0.run.status})` }, ...g0.fails.map((f) => ({ code: 'RIGC', text: f }))]);
78
82
  }
79
83
  const doc0 = readBuild(g0.build);
84
+ const rigc = rigcVersion();
80
85
  const rest = restOf(doc0);
81
86
  const slots = slotsOf(doc0);
82
87
  resolveMap(map, rest, a.map, slots);
83
88
 
84
89
  const r = retarget(rest, map, frames, times, a, slots);
85
- say(`rig-pose retarget: ${seq.frames.length} frame(s), ${r.animation.duration.toFixed(3)}s, onto "${input.name}" as "${a.name}" — ${r.animation.tracks.length} track(s); min-score ${a.minScore}, smooth ${a.smooth}, flip ${a.flip === 'off' ? 'off' : `${a.flip}°`}, short ${a.short}, bends ${a.bends} (tolerance ${a.bendTolerance}°)`);
90
+ say(`rig-pose retarget: ${seq.frames.length} frame(s), ${r.animation.duration.toFixed(3)}s, onto "${input.name}" as "${a.name}" — ${r.animation.tracks.length} track(s); min-score ${a.minScore}, smooth ${a.smooth}, flip ${a.flip === 'off' ? 'off' : `${a.flip}°`}, short ${a.short}, bends ${a.bends} (tolerance ${a.bendTolerance}°)${a.legScale !== undefined && a.legScale !== 'off' ? `, leg-scale ${a.legScale[0]}–${a.legScale[1]}` : ''}${a.plantFeet === true ? ', plant-feet' : ''}`);
91
+ say(` RIGC rig-c ${rigc.version} (${rigc.entry}) gates and poses this run`);
86
92
  if (r.scale !== null) say(` SCALE ${r.scale.factor.toFixed(5)} rig units per picture px (rig ${r.scale.rig.toFixed(2)} / pose median ${r.scale.pose.toFixed(2)})`);
87
93
  for (const h of r.holds) say(` HOLD ${holdLine(h, r.times)}`);
88
94
  if (a.short !== 'off') {
@@ -96,12 +102,23 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
96
102
  if (map.alternates.length > 0) {
97
103
  if (r.switches.length === 0) say(` SWITCH none — no set within ${a.switchTolerance}° of the pose for ${a.switchMin}s`);
98
104
  for (const s of r.switches) say(` SWITCH ${switchLine(s, r.times)}`);
105
+ for (const l of windowLines(map, r.switches, r.distances, r.times, a.switchTolerance)) say(` FRAMES ${l}`);
99
106
  }
100
107
  for (const e of r.range) say(` RANGE ${rangeLine(e, r.times)}`);
101
108
  for (const e of r.artRange) say(` ART-RANGE ${artRangeLine(e, r.times)}`);
109
+ for (const l of r.legScale) say(` LEG-SCALE ${legScaleLine(l, r.times)}`);
110
+ if (r.pivot !== null) say(` PIVOT bone "${r.pivot.bone}"'s origin is ${r.pivot.distance.toFixed(2)} units (${r.pivot.hipWidths.toFixed(3)} hip widths) from its thigh roots' midpoint (${r.pivot.roots.join(' + ')}): dx ${r.pivot.offset[0].toFixed(2)}, dy ${r.pivot.offset[1].toFixed(2)}, at setup — the pivot every hip turn swings the legs about`);
111
+ if (r.feet !== null) {
112
+ const f = r.feet;
113
+ const span = (x: [number, number]) => (x[0] === x[1] ? `${x[0]}` : `${x[0]}–${x[1]}`);
114
+ say(` FEET ${r.planted ? 'planted' : 'read, not planted (no --plant-feet)'}: leg length (median hip → ankle) r ${f.legLength.r.toFixed(1)}px, l ${f.legLength.l.toFixed(1)}px; ${f.supports.length} support span(s), ${f.changes.filter((c) => c.from !== null).length} change(s) of support, ${f.jumps.length} jump(s), ${f.steps.length} step(s)`);
115
+ say(` FEET supports: ${f.supports.map((x) => `${x.side} ${span(x.frames)}`).join(', ') || 'none'}`);
116
+ say(` FEET jumps (both ankles and mid_hip > 0.05 leg lengths above their rolling floor, ≥ 2 frames): ${f.jumps.map(span).join(', ') || 'none'}`);
117
+ say(` FEET steps (the supporting ankle > 0.03 leg lengths a frame; re-planted after 2 frames < 0.01): ${f.steps.map((x) => `${x.side} ${span(x.frames)} → ${x.to === null ? 'the pose\'s travel' : `${x.to} supports`}, ${x.replant === null ? 'never re-planted' : `re-planted at ${x.replant}`}`).join('; ') || 'none'}`);
118
+ }
102
119
 
103
120
  const motion = { ...base, animations: { ...animations, [a.name]: r.animation } };
104
- const g1 = gate(input.rig, motion, join(scratch, 'with'), a.profile);
121
+ const g1 = gate(input.rig, motion, join(scratch, 'with'), a.profile, a.images);
105
122
  if (!g1.green) {
106
123
  refuse([{ code: 'RIG_GATE', text: `rigc build --profile ${a.profile} is red with "${a.name}" in (exit ${g1.run.status}); nothing written` }, ...g1.fails.map((f) => ({ code: 'RIGC', text: f }))]);
107
124
  }
@@ -112,16 +129,30 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
112
129
  const landings = measureLanding(doc, a.name, map, r, posed);
113
130
  const p = new Problems();
114
131
  for (const l of landings) {
115
- const unit = l.kind === 'angle' ? '°' : ' units';
116
- const tol = l.kind === 'angle' ? ANGLE_TOLERANCE : ORIGIN_TOLERANCE;
117
- const line = `bone "${l.bone}" ${l.kind === 'angle' ? 'aim' : 'origin'} — worst |reached − asked| ${l.worst.toPrecision(3)}${unit} at frame ${l.frame} (t=${r.times[l.frame]!.toFixed(3)}s); the tolerance is ${tol}${unit}`;
132
+ const unit = l.kind === 'angle' ? '°' : l.kind === 'scale' ? ' of its setup length' : ' units';
133
+ const what = l.kind === 'angle' ? 'aim' : l.kind === 'scale' ? 'scale' : l.kind === 'foot' ? 'ankle' : 'origin';
134
+ const tol = l.kind === 'foot' ? Number(l.tolerance.toPrecision(3)) : l.tolerance;
135
+ const line = `bone "${l.bone}" ${what} — worst |reached − asked| ${l.worst.toPrecision(3)}${unit} at frame ${l.frame} (t=${r.times[l.frame]!.toFixed(3)}s); the tolerance is ${tol}${unit}`;
118
136
  if (landed(l)) say(` LAND ${line}`);
119
137
  else p.add('POSE_LANDS', `${line} — the rig does not turn the way the key formula assumes (a constraint, a scaled or sheared parent, a non-normal inherit mode)`);
120
138
  }
121
139
  p.throwIfAny();
122
140
 
123
- const posture = measurePosture(posed, new Map(doc.bones.map((b) => [b.name, b.length ?? 0])), map, r, frames, a.minScore);
141
+ const lengths = new Map(doc.bones.map((b) => [b.name, b.length ?? 0]));
142
+ const posture = measurePosture(posed, lengths, map, r, frames, a.minScore);
124
143
  if (posture !== null) for (const l of postureLines(posture)) say(` POSTURE ${l}`);
144
+ const legBones = legBonesOf(map);
145
+ const feetReading = legBones === null ? null : measureFeet(posed, lengths, r, legBones);
146
+ if (feetReading !== null) {
147
+ const f = feetReading;
148
+ say(` FEET the posed build, outside jumps: the lowest ankle's vertical range ${f.lowest.range.toFixed(2)} units = ${f.lowest.legs.toFixed(4)} leg lengths (rig leg r ${f.legLength.r.toFixed(1)}, l ${f.legLength.l.toFixed(1)} units) over ${f.lowest.frames} frames`);
149
+ const worst = (k: 'verticalLegs' | 'horizontalLegs') => f.plants.reduce<(typeof f.plants)[number] | null>((m, x) => (m === null || x[k] > m[k] ? x : m), null);
150
+ const wv = worst('verticalLegs');
151
+ const wh = worst('horizontalLegs');
152
+ if (wv !== null && wh !== null) say(` FEET the supporting ankle over each support span: worst vertical range ${wv.vertical.toFixed(2)} units = ${wv.verticalLegs.toFixed(4)} leg lengths (${wv.side} ${wv.frames[0]}–${wv.frames[1]}), worst horizontal drift ${wh.horizontal.toFixed(2)} units = ${wh.horizontalLegs.toFixed(4)} leg lengths (${wh.side} ${wh.frames[0]}–${wh.frames[1]})`);
153
+ }
154
+ const shear = r.scales.size === 0 ? [] : measureShear(doc, posed, r.scales.keys());
155
+ for (const x of shear) say(` SHEAR bone "${x.bone}": art sheared up to ${x.shear.toFixed(2)}° (frame ${x.frame}); world x axis ${x.x[0].toFixed(3)} … ${x.x[1].toFixed(3)}, y axis ${x.y[0].toFixed(3)} … ${x.y[1].toFixed(3)} of setup — a scaled bone, or one under it; a reading`);
125
156
 
126
157
  const dts = r.times.slice(1).map((t, i) => t - r.times[i]!).sort((x, y) => x - y);
127
158
  const fps = dts.length === 0 ? 12 : Math.max(1, Math.min(60, Math.round(1 / dts[dts.length >> 1]!)));
@@ -139,7 +170,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
139
170
  if (a.build !== undefined) {
140
171
  // Built again where it will live, not copied: rigc's page paths are relative to the build (#18).
141
172
  rmSync(a.build, { recursive: true, force: true });
142
- const kept = buildInto(input.rig, join(scratch, 'with', 'motion.json'), resolve(a.build), a.profile);
173
+ const kept = buildInto(input.rig, join(scratch, 'with', 'motion.json'), resolve(a.build), a.profile, undefined, a.images);
143
174
  if (!kept.green) refuse([{ code: 'RIG_GATE', text: `rigc build into --build ${a.build} is red where the same build was green in scratch (exit ${kept.run.status}); nothing written` }, ...kept.fails.map((x) => ({ code: 'RIGC', text: x }))]);
144
175
  }
145
176
  writeFileSync(a.out, `${JSON.stringify(motion, null, 2)}\n`);
@@ -150,11 +181,12 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
150
181
  if (a.report !== undefined) {
151
182
  writeFileSync(a.report, `${JSON.stringify({
152
183
  spec: 'rig-pose-report/1',
184
+ rigc,
153
185
  rig: input.name,
154
186
  animation: a.name,
155
187
  frames: seq.frames.length,
156
188
  duration: r.animation.duration,
157
- options: { minScore: a.minScore, smooth: a.smooth, flip: a.flip, short: a.short, bends: a.bends, bendTolerance: a.bendTolerance, switchTolerance: a.switchTolerance, switchMin: a.switchMin, switchAt: a.switchAt, profile: a.profile },
189
+ options: { minScore: a.minScore, smooth: a.smooth, flip: a.flip, short: a.short, bends: a.bends, bendTolerance: a.bendTolerance, switchTolerance: a.switchTolerance, switchMin: a.switchMin, switchAt: a.switchAt, profile: a.profile, legScale: a.legScale ?? 'off', plantFeet: a.plantFeet === true },
158
190
  scale: r.scale,
159
191
  holds: r.holds,
160
192
  short: r.short,
@@ -170,6 +202,10 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
170
202
  turns: r.turns,
171
203
  range: r.range,
172
204
  artRange: r.artRange,
205
+ legScale: r.legScale.map(({ scale: _scale, ...rest }) => rest),
206
+ pivot: r.pivot,
207
+ feet: r.feet === null ? null : { planted: r.planted, legLength: r.feet.legLength, supports: r.feet.supports, jumps: r.feet.jumps, steps: r.feet.steps, changes: r.feet.changes, reading: feetReading },
208
+ shear,
173
209
  }, null, 2)}\n`);
174
210
  }
175
211
  say(` wrote ${a.out} — animations: ${Object.keys(motion.animations).join(', ')}`);
@@ -195,3 +231,52 @@ function spans(ts: number[], step: number): string {
195
231
  }
196
232
 
197
233
  export { lineOf };
234
+
235
+ /**
236
+ * For each set with `frames` windows: every stretch of a window the set did not
237
+ * show on, and why — the pose there was past the tolerance (with the largest
238
+ * distance), or within it but the run was dropped (shorter than --switch-min, or
239
+ * taken by another set's span). A window never forces a set; this says so.
240
+ */
241
+ function windowLines(map: JointMap, switches: ReadonlyArray<{ set: string; frames: [number, number] }>, distances: ReadonlyMap<string, readonly number[]>, times: readonly number[], tolerance: number): string[] {
242
+ const out: string[] = [];
243
+ for (const set of map.alternates) {
244
+ if (set.frames === null) continue;
245
+ const shown = new Set<number>();
246
+ for (const sw of switches) if (sw.set === set.name) for (let i = sw.frames[0]; i <= sw.frames[1]; i++) shown.add(i);
247
+ const d = distances.get(set.name)!;
248
+ for (const [first, last] of set.frames) {
249
+ const lastIn = Math.min(last, times.length - 1);
250
+ const parts: string[] = [];
251
+ for (let i = first; i <= lastIn;) {
252
+ if (shown.has(i)) { i++; continue; }
253
+ let j = i;
254
+ while (j + 1 <= lastIn && !shown.has(j + 1)) j++;
255
+ const ds = d.slice(i, j + 1);
256
+ const worst = Math.max(...ds);
257
+ const over = ds.filter((x) => x > tolerance).length;
258
+ const span = i === j ? `frame ${i}` : `frames ${i}–${j}`;
259
+ parts.push(over === ds.length ? `${span} outside the tolerance (up to ${worst.toFixed(1)}° > ${tolerance}°)` : over === 0 ? `${span} within the tolerance but not shown (a run shorter than --switch-min, or another set's span)` : `${span} partly outside the tolerance (up to ${worst.toFixed(1)}°)`);
260
+ i = j + 1;
261
+ }
262
+ const head = `set "${set.name}" window ${first}–${last}`;
263
+ out.push(parts.length === 0 ? `${head}: shown on every frame` : `${head}: ${parts.join('; ')} — a window restricts a set, it never forces one`);
264
+ }
265
+ }
266
+ return out;
267
+ }
268
+
269
+ /** Each leg's first bone (its root, the thigh) and last bone (whose tip is the ankle), when the map has both. */
270
+ function legBonesOf(map: JointMap): { r: { root: string; ankle: string }; l: { root: string; ankle: string } } | null {
271
+ const l = legsOf(map, 'reading the feet');
272
+ if ('problems' in l) return null;
273
+ return { r: { root: l.legs.r.chain.bones[0]!, ankle: l.legs.r.ankleBone }, l: { root: l.legs.l.chain.bones[0]!, ankle: l.legs.l.ankleBone } };
274
+ }
275
+
276
+ /** A LEG-SCALE line: the segment, its straight length, its ratio's spread, and the frames clamped. */
277
+ function legScaleLine(l: LegScale, times: readonly number[]): string {
278
+ const span = (x: [number, number]) => (x[0] === x[1] ? `${x[0]}` : `${x[0]}–${x[1]}`);
279
+ const count = (rs: Array<[number, number]>) => rs.reduce((k, x) => k + x[1] - x[0] + 1, 0);
280
+ const clamp = (rs: Array<[number, number]>, at: number, how: string) => (rs.length === 0 ? `none ${how} ${at}` : `${count(rs)} frame(s) ${how} ${at}, clamped (frames ${rs.map(span).join(', ')})`);
281
+ return `chain "${l.chain}" bone "${l.bone}" (${l.joints[0]} → ${l.joints[1]}): straight length ${l.straight.toFixed(1)}px (95th percentile of ${times.length} frames); picture length ÷ straight ${l.ratio.min.toFixed(3)} … ${l.ratio.max.toFixed(3)} (median ${l.ratio.median.toFixed(3)}); scalex keyed in [${l.limits[0]}, ${l.limits[1]}]: ${clamp(l.low, l.limits[0], 'under')}; ${clamp(l.high, l.limits[1], 'over')}`;
282
+ }