@rig-c/pose 0.8.1 → 0.10.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/run.ts CHANGED
@@ -9,11 +9,14 @@ import { tmpdir } from 'node:os';
9
9
  import { dirname, join, resolve } from 'node:path';
10
10
  import { readMap, resolveMap, type JointMap } from './map.ts';
11
11
  import { convertCoco17, personTrack, readPoseSequence } from './pose.ts';
12
- import { lineOf, Problems, Refusal, type Problem } from './problems.ts';
12
+ import { isObject, lineOf, parseJson, Problems, Refusal, type Problem } from './problems.ts';
13
+ import { boneIdentity, droppedLine, keptOutLine, mergeProblems, readFragment, readIdle, targetOf, trackLabel, type IdleReading, type RigcTrack, type Target } from './idle.ts';
13
14
  import { bendLine, endOnLine, flipLine, holdLine, rangeLine, retarget, shortLine, turnLine, type RetargetOptions } from './retarget.ts';
14
15
  import { attachmentBones, baseMotionOf, buildInto, rigcVersion, folds, gate, readBuild, resolveRig, restOf, slotsOf } from './rigc.ts';
15
16
  import { artRangeLine, chainsMoving, switchLine, uncoveredFolds } from './switch.ts';
16
- import { landed, measureLanding, measurePosture, poseFrames, postureLines, ANGLE_TOLERANCE, ORIGIN_TOLERANCE } from './verify.ts';
17
+ import { landed, measureFeet, measureLanding, measurePosture, measureShear, poseFrames, postureLines } from './verify.ts';
18
+ import { legsOf } from './legs.ts';
19
+ import type { LegScale } from './legs.ts';
17
20
 
18
21
  export interface RetargetArgs extends RetargetOptions {
19
22
  rig: string;
@@ -33,6 +36,12 @@ export interface RetargetArgs extends RetargetOptions {
33
36
  render?: string;
34
37
  /** Write the run's measurements as JSON here. */
35
38
  report?: string;
39
+ /** The rig's animation read as its idle (default: idle); naming one the motion spec lacks is refused. */
40
+ idle?: string;
41
+ /** Loop the idle's dropped tracks into the new animation (#43 item 2). */
42
+ carryIdle?: boolean;
43
+ /** A rig-pose-tracks/1 fragment merged into the new animation. */
44
+ merge?: string;
36
45
  }
37
46
 
38
47
  export interface RunResult {
@@ -50,8 +59,8 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
50
59
  throw new Refusal(problems);
51
60
  };
52
61
  const args = new Problems();
53
- for (const [flag, path] of [['--map', a.map], ['--pose', a.pose]] as const) {
54
- if (!existsSync(path)) args.add('ARG_PATH', `${flag} ${path} does not exist`);
62
+ for (const [flag, path] of [['--map', a.map], ['--pose', a.pose], ['--merge', a.merge]] as const) {
63
+ if (path !== undefined && !existsSync(path)) args.add('ARG_PATH', `${flag} ${path} does not exist`);
55
64
  }
56
65
  // A file this run writes goes into a directory that is there already; --build and --render make theirs.
57
66
  for (const [flag, path] of [['--out', a.out], ['--report', a.report]] as const) {
@@ -65,6 +74,11 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
65
74
  const base = baseMotionOf(input);
66
75
  const animations = base.animations as Record<string, unknown>;
67
76
  if (a.name in animations) refuse([{ code: 'MOTION_NAME_FREE', text: `the rig's motion spec already has an animation "${a.name}" (it has ${Object.keys(animations).join(', ')}); name this one with --name` }]);
77
+ const idleName = a.idle ?? 'idle';
78
+ if (!(idleName in animations) && (a.idle !== undefined || a.carryIdle === true)) {
79
+ refuse([{ code: 'IDLE_NAME', text: `${a.idle !== undefined ? `--idle "${idleName}"` : '--carry-idle reads the animation "idle", and'} the rig's motion spec ${input.motion === null ? '— there is none, so it has no animations' : `${input.motion} has no animation "${idleName}" (it has ${Object.keys(animations).join(', ') || 'none'})`}; name the rig's idle with --idle` }]);
80
+ }
81
+ const fragment = a.merge === undefined ? [] : readFragment(parseJson(readFileSync(a.merge, 'utf8'), a.merge, 'MERGE_JSON'), a.merge);
68
82
  const map = readMap(readFileSync(a.map, 'utf8'), a.map);
69
83
  const poseText = readFileSync(a.pose, 'utf8');
70
84
  const seq = a.coco ? convertCoco17(poseText, a.pose) : readPoseSequence(poseText, a.pose);
@@ -85,7 +99,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
85
99
  resolveMap(map, rest, a.map, slots);
86
100
 
87
101
  const r = retarget(rest, map, frames, times, a, slots);
88
- 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}°)`);
102
+ 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' : ''}${a.carryIdle === true ? ', carry-idle' : ''}${a.merge !== undefined ? `, merge ${a.merge}` : ''}`);
89
103
  say(` RIGC rig-c ${rigc.version} (${rigc.entry}) gates and poses this run`);
90
104
  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)})`);
91
105
  for (const h of r.holds) say(` HOLD ${holdLine(h, r.times)}`);
@@ -104,8 +118,59 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
104
118
  }
105
119
  for (const e of r.range) say(` RANGE ${rangeLine(e, r.times)}`);
106
120
  for (const e of r.artRange) say(` ART-RANGE ${artRangeLine(e, r.times)}`);
121
+ for (const l of r.legScale) say(` LEG-SCALE ${legScaleLine(l, r.times)}`);
122
+ 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`);
123
+ if (r.feet !== null) {
124
+ const f = r.feet;
125
+ const span = (x: [number, number]) => (x[0] === x[1] ? `${x[0]}` : `${x[0]}–${x[1]}`);
126
+ 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)`);
127
+ say(` FEET supports: ${f.supports.map((x) => `${x.side} ${span(x.frames)}`).join(', ') || 'none'}`);
128
+ say(` FEET jumps (both ankles and mid_hip > 0.05 leg lengths above their rolling floor, ≥ 2 frames): ${f.jumps.map(span).join(', ') || 'none'}`);
129
+ 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'}`);
130
+ }
107
131
 
108
- const motion = { ...base, animations: { ...animations, [a.name]: r.animation } };
132
+ // The idle against the new animation (#43 item 2): what it drops, and with --carry-idle, looped back in.
133
+ const groups = (isObject(base.groups) ? base.groups : {}) as Record<string, string[]>;
134
+ const easings = (isObject(base.easings) ? base.easings : {}) as Record<string, number[]>;
135
+ const own = r.animation.tracks as unknown as RigcTrack[];
136
+ const T = r.animation.duration;
137
+ let idle: IdleReading | null = null;
138
+ if (!(idleName in animations)) say(` IDLE ${input.motion === null ? 'the rig has no motion spec' : `the rig's motion spec has no animation "idle" (it has ${Object.keys(animations).join(', ') || 'none'})`}; no idle read (--idle names another)`);
139
+ else {
140
+ const setupOf = (t: Target, property: string, sample: unknown): unknown => {
141
+ if (t.kind === 'slot' && property === 'attachment') return slots.get(t.name)?.setup;
142
+ if ((t.kind === 'bone' || t.kind === 'group') && Array.isArray(sample)) return boneIdentity(property, sample.length);
143
+ return undefined;
144
+ };
145
+ const mapped = [...map.chains.flatMap((c) => c.bones), ...(map.translate === null ? [] : [map.translate.bone])];
146
+ const below = new Map<string, string[]>();
147
+ for (const m of new Set(mapped)) {
148
+ for (let b = rest.get(m)?.parent ?? null; b !== null; b = rest.get(b)?.parent ?? null) {
149
+ const list = below.get(b) ?? [];
150
+ if (!list.includes(m)) list.push(m);
151
+ below.set(b, list);
152
+ }
153
+ }
154
+ idle = readIdle(idleName, a.name, animations[idleName] as Record<string, unknown>, own, T, { groups, easings }, setupOf, a.carryIdle === true, (bone) => below.get(bone) ?? [], (bone) => {
155
+ const hip = map.translate?.bone;
156
+ if (hip === undefined) return null;
157
+ for (let b = rest.get(hip)?.parent ?? null; b !== null; b = rest.get(b)?.parent ?? null) if (b === bone) return hip;
158
+ return null;
159
+ });
160
+ say(` IDLE animation "${idleName}" (${idle.duration.toFixed(3)}s, ${idle.tracks} track(s)) against "${a.name}": ${idle.dropped.length} dropped (${a.carryIdle === true ? `${idle.carriedTracks.length} carried, looped to ${T.toFixed(3)}s` : 'not carried; --carry-idle loops them in'}), ${idle.keptOut.length} kept out`);
161
+ for (const d of idle.dropped) say(` IDLE-DROPPED ${droppedLine(d, a.name, a.carryIdle === true, T)}`);
162
+ for (const k of idle.keptOut) say(` IDLE-KEPT-OUT ${keptOutLine(k)}`);
163
+ for (const n of idle.notTracks) say(` IDLE-KEPT-OUT "${idleName}"'s ${n} timeline(s): not a track; neither compared nor carried`);
164
+ }
165
+ const carried = idle?.carriedTracks ?? [];
166
+ if (fragment.length > 0) {
167
+ const problems = mergeProblems(fragment, a.merge!, a.name, [...own, ...carried], T, groups);
168
+ if (problems.length > 0) refuse(problems);
169
+ for (const tr of fragment) say(` MERGE ${a.merge}: ${trackLabel(tr, groups)}, ${tr.keys.length} key(s) at t=${tr.keys[0]!.t}${tr.keys.length > 1 ? `–${tr.keys[tr.keys.length - 1]!.t}` : ''}s`);
170
+ }
171
+ const added = [...carried, ...fragment];
172
+ const animation = added.length === 0 ? r.animation : { ...r.animation, tracks: [...own, ...added] };
173
+ const motion = { ...base, animations: { ...animations, [a.name]: animation } };
109
174
  const g1 = gate(input.rig, motion, join(scratch, 'with'), a.profile, a.images);
110
175
  if (!g1.green) {
111
176
  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 }))]);
@@ -116,17 +181,41 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
116
181
  const posed = poseFrames(doc, a.name, r.times);
117
182
  const landings = measureLanding(doc, a.name, map, r, posed);
118
183
  const p = new Problems();
184
+ // A carried idle track on a bone above a mapped one moves it where the keys did not ask (the key formula holds unmapped bones at setup).
185
+ const carriedAbove = (bone: string): string => {
186
+ const above = new Set<string>();
187
+ for (let b = rest.get(bone)?.parent ?? null; b !== null; b = rest.get(b)?.parent ?? null) above.add(b);
188
+ const hits = carried.filter((tr) => {
189
+ const t = targetOf(tr);
190
+ return t !== null && (t.kind === 'bone' ? above.has(t.name) : t.kind === 'group' && (groups[t.name] ?? []).some((m) => above.has(m)));
191
+ });
192
+ return hits.length === 0 ? '' : `; --carry-idle carried ${hits.map((tr) => trackLabel(tr, groups)).join(', ')} onto bones above it`;
193
+ };
119
194
  for (const l of landings) {
120
- const unit = l.kind === 'angle' ? '°' : ' units';
121
- const tol = l.kind === 'angle' ? ANGLE_TOLERANCE : ORIGIN_TOLERANCE;
122
- 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}`;
195
+ const unit = l.kind === 'angle' ? '°' : l.kind === 'scale' ? ' of its setup length' : ' units';
196
+ const what = l.kind === 'angle' ? 'aim' : l.kind === 'scale' ? 'scale' : l.kind === 'foot' ? 'ankle' : 'origin';
197
+ const tol = l.kind === 'foot' ? Number(l.tolerance.toPrecision(3)) : l.tolerance;
198
+ 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}`;
123
199
  if (landed(l)) say(` LAND ${line}`);
124
- 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)`);
200
+ 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)${carriedAbove(l.bone)}`);
125
201
  }
126
202
  p.throwIfAny();
127
203
 
128
- const posture = measurePosture(posed, new Map(doc.bones.map((b) => [b.name, b.length ?? 0])), map, r, frames, a.minScore);
204
+ const lengths = new Map(doc.bones.map((b) => [b.name, b.length ?? 0]));
205
+ const posture = measurePosture(posed, lengths, map, r, frames, a.minScore);
129
206
  if (posture !== null) for (const l of postureLines(posture)) say(` POSTURE ${l}`);
207
+ const legBones = legBonesOf(map);
208
+ const feetReading = legBones === null ? null : measureFeet(posed, lengths, r, legBones);
209
+ if (feetReading !== null) {
210
+ const f = feetReading;
211
+ 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`);
212
+ const worst = (k: 'verticalLegs' | 'horizontalLegs') => f.plants.reduce<(typeof f.plants)[number] | null>((m, x) => (m === null || x[k] > m[k] ? x : m), null);
213
+ const wv = worst('verticalLegs');
214
+ const wh = worst('horizontalLegs');
215
+ 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]})`);
216
+ }
217
+ const shear = r.scales.size === 0 ? [] : measureShear(doc, posed, r.scales.keys());
218
+ 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`);
130
219
 
131
220
  const dts = r.times.slice(1).map((t, i) => t - r.times[i]!).sort((x, y) => x - y);
132
221
  const fps = dts.length === 0 ? 12 : Math.max(1, Math.min(60, Math.round(1 / dts[dts.length >> 1]!)));
@@ -160,7 +249,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
160
249
  animation: a.name,
161
250
  frames: seq.frames.length,
162
251
  duration: r.animation.duration,
163
- 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 },
252
+ 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, idle: idleName, carryIdle: a.carryIdle === true, merge: a.merge ?? null },
164
253
  scale: r.scale,
165
254
  holds: r.holds,
166
255
  short: r.short,
@@ -176,6 +265,13 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
176
265
  turns: r.turns,
177
266
  range: r.range,
178
267
  artRange: r.artRange,
268
+ legScale: r.legScale.map(({ scale: _scale, ...rest }) => rest),
269
+ pivot: r.pivot,
270
+ 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 },
271
+ shear,
272
+ idleDropped: idle === null ? null : idle.dropped,
273
+ idleKeptOut: idle === null ? null : idle.keptOut,
274
+ merged: fragment.map((tr) => ({ track: trackLabel(tr, groups), keys: tr.keys.length })),
179
275
  }, null, 2)}\n`);
180
276
  }
181
277
  say(` wrote ${a.out} — animations: ${Object.keys(motion.animations).join(', ')}`);
@@ -235,3 +331,18 @@ function windowLines(map: JointMap, switches: ReadonlyArray<{ set: string; frame
235
331
  }
236
332
  return out;
237
333
  }
334
+
335
+ /** Each leg's first bone (its root, the thigh) and last bone (whose tip is the ankle), when the map has both. */
336
+ function legBonesOf(map: JointMap): { r: { root: string; ankle: string }; l: { root: string; ankle: string } } | null {
337
+ const l = legsOf(map, 'reading the feet');
338
+ if ('problems' in l) return null;
339
+ 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 } };
340
+ }
341
+
342
+ /** A LEG-SCALE line: the segment, its straight length, its ratio's spread, and the frames clamped. */
343
+ function legScaleLine(l: LegScale, times: readonly number[]): string {
344
+ const span = (x: [number, number]) => (x[0] === x[1] ? `${x[0]}` : `${x[0]}–${x[1]}`);
345
+ const count = (rs: Array<[number, number]>) => rs.reduce((k, x) => k + x[1] - x[0] + 1, 0);
346
+ 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(', ')})`);
347
+ 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')}`;
348
+ }