@rig-c/pose 0.19.0 → 0.20.1

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
@@ -82,7 +82,7 @@ import { Problems, Refusal } from './problems.ts';
82
82
  import type { ArtRange, ByHip, Raised, ReachShort, SlotTrack, Switch, SwitchOptions } from './switch.ts';
83
83
  import { clampArt, coveredChains, isRaised, measureArt, overflow, RANGE_SLACK, reachLimit, runsOf, selectSwitches, twoBone } from './switch.ts';
84
84
  import type { BlendHold, Closure, Feet, Floor, LegScale, PathPose, Posing, Side, Stance } from './legs.ts';
85
- import { holdBlend, isLegSegment, legScaleOf, legsOf, pathFrom, plantHip, posePath, rootOf, SIDES, stanceOf, stanceRangeOf, supportsOf, tipOf } from './legs.ts';
85
+ import { holdBlend, isLegSegment, legScaleOf, legsOf, pathFrom, plantHip, posePath, rootOf, scaleSupport, SIDES, stanceOf, stanceRangeOf, supportsOf, tipOf } from './legs.ts';
86
86
 
87
87
  const DEG = 180 / Math.PI;
88
88
 
@@ -163,18 +163,29 @@ export interface Flip {
163
163
  clipped: 'start' | 'end' | 'both' | null;
164
164
  }
165
165
 
166
- /** One span of frames under the short cutoff: the segment's angle there was interpolated, not read (`holdShort`). */
166
+ /**
167
+ * One span of frames under `--short` (angle interpolated, `holdShort`) or under
168
+ * `SHORT_STEP_FRACTION` with a one-frame step over `SHORT_STEP_CAP` (step
169
+ * capped, `capShortSteps`, #110).
170
+ */
167
171
  export interface Short {
168
172
  chain: string;
169
173
  bone: string;
170
174
  /** The segment the bone carries: from joint, to joint. */
171
175
  joints: [string, string];
172
- /** First and last frame under the cutoff; every one of them had its angle replaced, and the frames either side keep the input's. */
176
+ /** First and last frame whose angle was replaced; the frames either side keep the input's. */
173
177
  frames: [number, number];
174
178
  /** The lowest picture length on those frames, as a fraction of the segment's median picture length. */
175
179
  minFraction: number;
180
+ /** Present when a foreshortened segment (< `SHORT_STEP_FRACTION`) had its per-frame step capped (#110): the one-frame step after capping and the largest step asked, degrees, signed. */
181
+ step?: number;
182
+ asked?: number;
176
183
  }
177
184
 
185
+ /** A limb segment shorter than this fraction of its median picture length has its per-frame angle step capped at `SHORT_STEP_CAP` degrees (rig-c/pose#110). */
186
+ export const SHORT_STEP_FRACTION = 0.6;
187
+ export const SHORT_STEP_CAP = 30;
188
+
178
189
  /**
179
190
  * A segment under the short cutoff long enough to be a pose (the limb pointing
180
191
  * at the camera), not a blur: short spans merged across a gap of `END_ON_GAP`
@@ -571,6 +582,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
571
582
  // against its own median, on the points the angle was read from.
572
583
  const short: Short[] = [];
573
584
  const endOn: EndOn[] = [];
585
+ const fractions = new Map<string, { chain: string; joints: [string, string]; fraction: number[] }>();
574
586
  if (opts.short !== 'off') {
575
587
  const cutoff = opts.short;
576
588
  for (const c of map.chains) {
@@ -586,7 +598,9 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
586
598
  p.add('POSE_SHORT_MEDIAN', `chain "${c.name}" — bone "${b}"'s segment ${joints[0]} → ${joints[1]} has median picture length ${med}px over the ${n} frames; --short measures a frame's length against that median, and there is none to measure against`);
587
599
  return;
588
600
  }
589
- const spans = holdShort(targets.get(b)!, lengths.map((l) => l / med), cutoff);
601
+ const fraction = lengths.map((l) => l / med);
602
+ fractions.set(b, { chain: c.name, joints, fraction });
603
+ const spans = holdShort(targets.get(b)!, fraction, cutoff);
590
604
  for (const s of spans) short.push({ chain: c.name, bone: b, joints, ...s });
591
605
  for (const run of mergeRuns(spans, END_ON_GAP)) {
592
606
  const seconds = Math.round((endOf(run.frames[1]) - keyTimes[run.frames[0]]!) * 1e6) / 1e6;
@@ -692,12 +706,16 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
692
706
  if (opts.floor !== undefined && !plantOn) p.add('FLOOR', `--floor ${opts.floor} is the floor --plant-feet plants every support span on, and --plant-feet is not given`);
693
707
  if (opts.floor !== undefined && !Number.isFinite(opts.floor)) p.add('FLOOR', `--floor is ${opts.floor}; it must be a finite world y`);
694
708
  p.throwIfAny();
695
- /** Every bone on the legs' path posed on frame i, by the targets as they stand. */
696
- const poseLegs = (i: number): PathPose => {
697
- 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);
709
+ const askedScales = new Map<string, number[]>();
710
+ for (const ls of legScale) askedScales.set(ls.bone, ls.asked);
711
+ /** Every bone on the legs' path posed on frame i, by the targets and the given scale series. */
712
+ const poseLegsWith = (i: number, sc: ReadonlyMap<string, number[]>): PathPose => {
713
+ 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) => sc.get(b)?.[i] ?? null);
698
714
  if (typeof r === 'string') throw new Refusal([{ code: 'LEGS_MAP', text: r }]);
699
715
  return r;
700
716
  };
717
+ /** Every bone on the legs' path posed on frame i, by the targets as they stand. */
718
+ const poseLegs = (i: number): PathPose => poseLegsWith(i, scales);
701
719
  if (plantOn) {
702
720
  // The plant is derived from the targets before the range clamp, so nothing after may turn a bone on the legs' path.
703
721
  const onPath = new Set(legPath!);
@@ -706,20 +724,28 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
706
724
  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`);
707
725
  }
708
726
  p.throwIfAny();
727
+ // #107: the clamp floor holds the bone, not the hip; the plant places the hip from the scale the pose asked for.
709
728
  const offsets = Array.from({ length: n }, (_, i) => {
710
- const lp = poseLegs(i);
729
+ const lp = poseLegsWith(i, askedScales);
711
730
  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 : '') };
712
731
  });
713
732
  const tb = rest.get(t!.bone)!;
714
733
  // #85: every support span on one floor — the median of their planted heights, or --floor.
715
734
  // #92: in place, the supporting ankle's own picture x, scaled, is the track its anchor follows.
716
- const follow = inPlace ? (i: number, s: Side) => series.get(`${s}_ankle`)![i]![0] * scale!.factor : undefined;
717
- const planted = plantHip(feet!, (i, s) => offsets[i]![s], (i) => poseTravel[i]!, [tb.worldX, tb.worldY], opts.floor, follow);
735
+ // #119: a tracked support follows the lower ankle's own picture x, scaled, on the floor until it is still.
736
+ const track = (i: number, s: Side) => series.get(`${s}_ankle`)![i]![0] * scale!.factor;
737
+ const follow = inPlace ? track : undefined;
738
+ const planted = plantHip(feet!, (i, s) => offsets[i]![s], (i) => poseTravel[i]!, [tb.worldX, tb.worldY], opts.floor, follow, track);
718
739
  const hip = planted.hip;
719
740
  floor = planted.floor;
720
741
  closure = planted.closure;
721
- for (let i = 0; i < n; i++) travel[i] = [hip[i]![0] - tb.worldX, hip[i]![1] - tb.worldY];
722
742
  const L = (legs as { legs: Record<Side, { chain: Chain; ankleBone: string }> }).legs;
743
+ // #133: on a frame where the supporting leg asks under --leg-scale's floor, its scalex follows the asked
744
+ // value down to #95's bound so the ankle stays on #85's floor; the free leg keeps the configured clamp.
745
+ if (legScaleOn && feet !== null) {
746
+ scaleSupport(legScale, scales, feet, L, hip, (i, s) => offsets[i]![s], (i, s) => tipOf(rest, poseLegs(i), L[s].ankleBone));
747
+ }
748
+ for (let i = 0; i < n; i++) travel[i] = [hip[i]![0] - tb.worldX, hip[i]![1] - tb.worldY];
723
749
  const world = (i: number, v: Pt): Pt => [hip[i]![0] + v[0], hip[i]![1] + v[1]];
724
750
  /** Both legs posed on frame i by the scales as they stand, world (y up): each chain's root and ankle. */
725
751
  const posedFeet = (i: number) => {
@@ -805,16 +831,20 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
805
831
  * offset turned by the nearest mapped bone above — the key formula's own
806
832
  * assumption (unit scale, no shear), which the landing check holds to account.
807
833
  */
834
+ let headNodBone: string | null = null;
835
+ let headNod: Pt[] | null = null;
836
+ let headNodTrack: MotionTrack | null = null;
808
837
  const posedOrigin = (b: string, i: number): Pt => {
809
838
  const own = rest.get(b)!;
810
839
  if (t !== null && b === t.bone) return [own.worldX + (travel[i]?.[0] ?? 0), own.worldY + (travel[i]?.[1] ?? 0)];
811
- if (own.parent === null) return [own.worldX, own.worldY];
840
+ const nod = b === headNodBone ? (headNod?.[i] ?? [0, 0]) : [0, 0];
841
+ if (own.parent === null) return [own.worldX + nod[0], own.worldY + nod[1]];
812
842
  const parent = own.parent;
813
843
  const po = posedOrigin(parent, i);
814
844
  const r = turnAbove(parent, i) / DEG;
815
845
  const ox = own.worldX - rest.get(parent)!.worldX;
816
846
  const oy = own.worldY - rest.get(parent)!.worldY;
817
- return [po[0] + Math.cos(r) * ox - Math.sin(r) * oy, po[1] + Math.sin(r) * ox + Math.cos(r) * oy];
847
+ return [po[0] + Math.cos(r) * ox - Math.sin(r) * oy + nod[0], po[1] + Math.sin(r) * ox + Math.cos(r) * oy + nod[1]];
818
848
  };
819
849
  /** A rig point (a bone, `name:tip`, `a+b`) posed on frame i the same way. */
820
850
  const posedPoint = (spec: string, i: number): Pt => {
@@ -868,6 +898,13 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
868
898
  };
869
899
  const turn = (bone: string, i: number) => targets.get(bone)![i]! - restOf.get(bone)!;
870
900
  const covered = coveredChains(sw.switches, n);
901
+ if (opts.short !== 'off') {
902
+ for (const [b, info] of fractions) {
903
+ for (const s of capShortSteps(targets.get(b)!, info.fraction, covered.get(info.chain))) {
904
+ short.push({ chain: info.chain, bone: b, joints: info.joints, ...s });
905
+ }
906
+ }
907
+ }
871
908
  const range: RangeDemand[] = [];
872
909
  const order = map.chains.flatMap((c) => c.bones.map((b) => ({ c, b }))).filter(({ c, b }) => c.reach || b in c.range);
873
910
  order.sort((x, y) => depth(x.b) - depth(y.b));
@@ -895,6 +932,74 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
895
932
  }
896
933
  p.throwIfAny();
897
934
 
935
+ // #111: the head chain's nod channel — the eyes' vertical travel against the
936
+ // neck, measured in the rig's own units, keyed as a translate on the head bone
937
+ // (whose rotate track carries roll only).
938
+ for (const c of map.chains) {
939
+ for (let k = 0; k + 1 < c.joints.length; k++) {
940
+ const ja = c.joints[k]!;
941
+ const jb = c.joints[k + 1]!;
942
+ if (!((ja === 'r_eye' && jb === 'l_eye') || (ja === 'l_eye' && jb === 'r_eye'))) continue;
943
+ const hb = c.bones[k]!;
944
+ const ha = c.aim[k]!;
945
+ if (t?.bone === hb || sw.offsetTracks.some((tr) => tr.bone === hb)) break;
946
+ let underScaled = false;
947
+ for (let a = rest.get(hb)!.parent; a !== null; a = rest.get(a)!.parent) if (scales.has(a)) underScaled = true;
948
+ if (underScaled) break;
949
+ const rEye = series.get('r_eye')!;
950
+ const lEye = series.get('l_eye')!;
951
+ const neckSeries = series.get('neck') ?? (() => {
952
+ const h = heldOf('neck');
953
+ return 'never' in h ? undefined : h.at;
954
+ })();
955
+ let nodScale: number | null = scale?.factor ?? null;
956
+ if (nodScale === null && ha.includes('>')) {
957
+ const [pa, pb] = aimEnds(hb, ha);
958
+ const a0 = rigPoint(rest, pa)!;
959
+ const b0 = rigPoint(rest, pb)!;
960
+ const rigEye = Math.hypot(b0[0] - a0[0], b0[1] - a0[1]);
961
+ const refEye = median(rEye.map((re, i) => Math.hypot(lEye[i]![0] - re[0], lEye[i]![1] - re[1])));
962
+ if (refEye > 1e-6 && rigEye > 1e-6) nodScale = rigEye / refEye;
963
+ }
964
+ if (neckSeries !== undefined && nodScale !== null) {
965
+ const refEN = rEye.map((re, i) => -((re[1] + lEye[i]![1]) / 2 - neckSeries[i]![1]));
966
+ const dy = refEN.map((v) => (v - refEN[0]!) * nodScale);
967
+ if (dy.some((v) => Math.abs(r3(v)) > 0)) {
968
+ const bone = rest.get(hb)!;
969
+ const parent = bone.parent === null ? null : rest.get(bone.parent)!;
970
+ const keys: MotionKey[] = [];
971
+ const nod: Pt[] = [];
972
+ for (let i = 0; i < n; i++) {
973
+ const wy = dy[i]!;
974
+ let lx = 0;
975
+ let ly = r3(wy);
976
+ let awx = 0;
977
+ let awy = ly;
978
+ if (parent !== null) {
979
+ const r = turnAbove(parent.name, i) / DEG;
980
+ const pa = Math.cos(r) * parent.a - Math.sin(r) * parent.c;
981
+ const pb = Math.cos(r) * parent.b - Math.sin(r) * parent.d;
982
+ const pc = Math.sin(r) * parent.a + Math.cos(r) * parent.c;
983
+ const pd = Math.sin(r) * parent.b + Math.cos(r) * parent.d;
984
+ const det = pa * pd - pb * pc;
985
+ lx = r3((-pb * wy) / det);
986
+ ly = r3((pa * wy) / det);
987
+ awx = pa * lx + pb * ly;
988
+ awy = pc * lx + pd * ly;
989
+ }
990
+ keys.push({ t: keyTimes[i]!, v: [lx, ly] });
991
+ nod.push([awx, awy]);
992
+ }
993
+ headNodBone = hb;
994
+ headNod = nod;
995
+ headNodTrack = { bone: hb, property: 'translate', keys };
996
+ }
997
+ }
998
+ break;
999
+ }
1000
+ if (headNodTrack !== null) break;
1001
+ }
1002
+
898
1003
  // --raised-by-head (#62, HQ's ruling): while a set whose arm is drawn raised shows, its wrist goes where
899
1004
  // the reference's is against the head, in eye distances (each side's median over the span). Solved last
900
1005
  // of the bones: every bone above the arm, the head and the trunk's reach included, has its final target
@@ -1176,6 +1281,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1176
1281
  }
1177
1282
  }
1178
1283
  if (translateTrack !== null) tracks.push(translateTrack);
1284
+ if (headNodTrack !== null) tracks.push(headNodTrack);
1179
1285
  tracks.push(...sw.offsetTracks, ...sw.slotTracks);
1180
1286
 
1181
1287
  return {
@@ -1302,10 +1408,97 @@ export function mergeRuns(spans: readonly ShortSpan[], gap: number): ShortSpan[]
1302
1408
  return out;
1303
1409
  }
1304
1410
 
1411
+ /**
1412
+ * Cap one-frame steps on a foreshortened segment (#110), in place. Where a
1413
+ * segment's picture length is under `SHORT_STEP_FRACTION` (0.60) of its median
1414
+ * on either side of a frame step (above `--short`'s hold cutoff, where two end-on
1415
+ * pixels still swing the angle faster than the limb moved) and the one-frame turn
1416
+ * exceeds `SHORT_STEP_CAP` (30°), spread the turn across a symmetric window of
1417
+ * frames the same way `spreadFlips` does (`lo = k - h - 1`, `hi = k + h`), bounded
1418
+ * by the ends of the pose and by any active alternate set on the chain (`covered`).
1419
+ */
1420
+ export function capShortSteps(
1421
+ angles: number[],
1422
+ fraction: readonly number[],
1423
+ covered?: readonly boolean[],
1424
+ fractionCutoff: number = SHORT_STEP_FRACTION,
1425
+ stepCap: number = SHORT_STEP_CAP,
1426
+ ): ShortSpan[] {
1427
+ const n = angles.length;
1428
+ if (n < 2) return [];
1429
+ const out: ShortSpan[] = [];
1430
+ for (let segStart = 0; segStart < n;) {
1431
+ if (covered?.[segStart] === true) {
1432
+ segStart++;
1433
+ continue;
1434
+ }
1435
+ let segEnd = segStart;
1436
+ while (segEnd + 1 < n && covered?.[segEnd + 1] !== true) segEnd++;
1437
+ if (segEnd - segStart >= 1) {
1438
+ const u: number[] = new Array<number>(n).fill(0);
1439
+ u[segStart] = angles[segStart]!;
1440
+ for (let i = segStart + 1; i <= segEnd; i++) u[i] = u[i - 1]! + wrap(angles[i]! - angles[i - 1]!);
1441
+ const fits = (lo: number, hi: number) => Math.abs(u[hi]! - u[lo]!) <= stepCap * (hi - lo);
1442
+ interface Win { lo: number; hi: number; asked: number }
1443
+ const wins: Win[] = [];
1444
+ for (let k = segStart + 1; k <= segEnd; k++) {
1445
+ if (!(Math.min(fraction[k - 1]!, fraction[k]!) < fractionCutoff)) continue;
1446
+ const jump = u[k]! - u[k - 1]!;
1447
+ if (Math.abs(jump) <= stepCap) continue;
1448
+ let h = 1;
1449
+ let lo = Math.max(segStart, k - h - 1);
1450
+ let hi = Math.min(segEnd, k + h);
1451
+ while (!fits(lo, hi) && (lo > segStart || hi < segEnd)) {
1452
+ h++;
1453
+ lo = Math.max(segStart, k - h - 1);
1454
+ hi = Math.min(segEnd, k + h);
1455
+ }
1456
+ wins.push({ lo, hi, asked: jump });
1457
+ }
1458
+ for (let changed = true; changed;) {
1459
+ changed = false;
1460
+ wins.sort((a, b) => a.lo - b.lo);
1461
+ for (let i = 0; i + 1 < wins.length; i++) {
1462
+ const a = wins[i]!;
1463
+ const b = wins[i + 1]!;
1464
+ if (b.lo >= a.hi) continue;
1465
+ wins.splice(i, 2, { lo: a.lo, hi: Math.max(a.hi, b.hi), asked: Math.abs(b.asked) > Math.abs(a.asked) ? b.asked : a.asked });
1466
+ changed = true;
1467
+ break;
1468
+ }
1469
+ if (changed) continue;
1470
+ for (const w of wins) {
1471
+ if (fits(w.lo, w.hi) || (w.lo === segStart && w.hi === segEnd)) continue;
1472
+ w.lo = Math.max(segStart, w.lo - 1);
1473
+ w.hi = Math.min(segEnd, w.hi + 1);
1474
+ changed = true;
1475
+ }
1476
+ }
1477
+ for (const w of wins) {
1478
+ if (w.lo + 1 > w.hi - 1) continue;
1479
+ const step = (u[w.hi]! - u[w.lo]!) / (w.hi - w.lo);
1480
+ for (let i = w.lo + 1; i < w.hi; i++) angles[i] = wrap(u[w.lo]! + step * (i - w.lo));
1481
+ let minFraction = Infinity;
1482
+ for (let i = w.lo + 1; i <= w.hi - 1; i++) minFraction = Math.min(minFraction, fraction[i]!);
1483
+ if (!(minFraction < fractionCutoff)) {
1484
+ minFraction = Math.min(minFraction, fraction[w.lo]!, fraction[w.hi]!);
1485
+ }
1486
+ out.push({ frames: [w.lo + 1, w.hi - 1], minFraction, step: r3(step), asked: r3(w.asked) });
1487
+ }
1488
+ }
1489
+ segStart = segEnd + 1;
1490
+ }
1491
+ return out;
1492
+ }
1493
+
1305
1494
  const spanOf = (frames: [number, number], times: readonly number[]): string =>
1306
1495
  frames[0] === frames[1] ? `frame ${frames[0]} (t=${times[frames[0]]!.toFixed(3)}s)` : `frames ${frames[0]}–${frames[1]} (t=${times[frames[0]]!.toFixed(3)}–${times[frames[1]]!.toFixed(3)}s)`;
1307
1496
 
1308
1497
  export function shortLine(s: Short, times: readonly number[], cutoff: number): string {
1498
+ if (s.step !== undefined && s.asked !== undefined) {
1499
+ const sg = (v: number) => `${v >= 0 ? '+' : '−'}${Math.abs(v).toFixed(1)}°`;
1500
+ return `chain "${s.chain}" bone "${s.bone}" (${s.joints[0]} → ${s.joints[1]}) ${spanOf(s.frames, times)}: picture length down to ${s.minFraction.toFixed(2)} of its median, under ${SHORT_STEP_FRACTION.toFixed(2)}, turned ${sg(s.asked)} in one frame (over ${SHORT_STEP_CAP}°); spread to ${sg(s.step)} per frame`;
1501
+ }
1309
1502
  const lo = s.frames[0] - 1;
1310
1503
  const hi = s.frames[1] + 1;
1311
1504
  const held = lo < 0 && hi >= times.length
package/src/run.ts CHANGED
@@ -18,7 +18,7 @@ import { artRangeLine, chainsMoving, raisedLine, RAISED_WITHIN, reachLine, switc
18
18
  import { landed, measureFeet, measureLanding, measurePosture, measureShear, poseFrames, postureLines } from './verify.ts';
19
19
  import { legsOf, PLANT_SWITCH, STANCE_BOUNDS } from './legs.ts';
20
20
  import { M2_IDENTITY, mulM2, reduceKeys, reduceScale, reduceUnderScale, SCALE_REDUCE, setupLocal, type M2, type ReduceBone, type Reduced, type ReduceRow, type UnderBone } from './reduce.ts';
21
- import type { BlendHold, Closure, Floor, LegScale, Lowered, Side, Stance } from './legs.ts';
21
+ import type { BlendHold, Closure, Floor, LegScale, Lowered, Side, Stance, SupportScaleRun } from './legs.ts';
22
22
 
23
23
  export interface RetargetArgs extends RetargetOptions {
24
24
  rig: string;
@@ -138,6 +138,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
138
138
  for (const e of r.byHip) if (e.short.length > 0) say(` REACH ${reachLine(e, r.times)}`);
139
139
  for (const e of r.artRange) say(` ART-RANGE ${artRangeLine(e, r.times)}`);
140
140
  for (const l of r.legScale) say(` LEG-SCALE ${legScaleLine(l, r.times)}`);
141
+ for (const l of r.legScale) for (const s of l.support ?? []) say(` LEG-SCALE ${supportScaleLine(l, s, r.times)}`);
141
142
  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`);
142
143
  if (r.feet !== null) {
143
144
  const f = r.feet;
@@ -146,6 +147,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
146
147
  say(` FEET supports: ${f.supports.map((x) => `${x.side} ${span(x.frames)}`).join(', ') || 'none'}`);
147
148
  say(` FEET jumps (both ankles and mid_hip > 0.05 leg lengths above their rolling floor, ≥ 2 frames): ${f.jumps.map(span).join(', ') || 'none'}`);
148
149
  if (f.lowered !== undefined) say(` FEET ${loweredLine(f.lowered)}`);
150
+ if (f.tracked !== undefined) say(` FEET tracked (the lower foot supports on the floor, following its scaled ankle track until still, rig-c/pose#119): ${f.tracked.map((x) => `${x.side} ${span(x.frames)}`).join(', ')}`);
149
151
  if (r.closure !== null) say(` FEET ${travelLine(r.closure)}`);
150
152
  else 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'}`);
151
153
  if (r.floor !== null) {
@@ -315,10 +317,10 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
315
317
  raised: r.raised,
316
318
  // Only when a set declares `place` (#93): a map without one writes the report it always has.
317
319
  ...(map.alternates.some((x) => x.place !== undefined && x.place !== null) ? { byHip: r.byHip } : {}),
318
- legScale: r.legScale.map(({ scale: _scale, ...rest }) => rest),
320
+ legScale: r.legScale.map(({ scale: _scale, asked: _asked, ...rest }) => rest),
319
321
  pivot: r.pivot,
320
322
  stance: r.stance === null ? null : { tolerance: r.stance.tolerance, release: r.stance.release, minSeconds: r.stance.minSeconds, bounds: r.stance.bounds, ...(r.stance.range === undefined ? {} : { range: r.stance.range }), runs: r.stance.runs, over: r.stance.over },
321
- 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, ...(r.feet.lowered === undefined ? {} : { lowered: r.feet.lowered }), floor: r.floor?.y ?? null, shifts: r.floor?.shifts ?? [], blend: r.blendHold === null ? null : { bounds: r.blendHold.bounds, ...(r.blendHold.range === undefined ? {} : { range: r.blendHold.range }), runs: r.blendHold.runs, over: r.blendHold.over }, reading: feetReading, closure: r.closure },
323
+ 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, ...(r.feet.lowered === undefined ? {} : { lowered: r.feet.lowered }), ...(r.feet.tracked === undefined ? {} : { tracked: r.feet.tracked }), floor: r.floor?.y ?? null, shifts: r.floor?.shifts ?? [], blend: r.blendHold === null ? null : { bounds: r.blendHold.bounds, ...(r.blendHold.range === undefined ? {} : { range: r.blendHold.range }), runs: r.blendHold.runs, over: r.blendHold.over }, reading: feetReading, closure: r.closure },
322
324
  shear,
323
325
  idleDropped: idle === null ? null : idle.dropped,
324
326
  idleKeptOut: idle === null ? null : idle.keptOut,
@@ -672,6 +674,16 @@ function legScaleLine(l: LegScale, times: readonly number[]): string {
672
674
  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')}`;
673
675
  }
674
676
 
677
+ /** A LEG-SCALE past the floor (support) line (rig-c/pose#133): the supporting leg segment scaled below `--leg-scale`'s floor down to #95's bound. */
678
+ export function supportScaleLine(l: LegScale, s: SupportScaleRun, times: readonly number[]): string {
679
+ const span = (x: [number, number]) => (x[0] === x[1] ? `frame ${x[0]} (t=${times[x[0]]!.toFixed(3)}s)` : `frames ${x[0]}–${x[1]} (t=${times[x[0]]!.toFixed(3)}–${times[x[1]]!.toFixed(3)}s)`);
680
+ const val = (r: { min: number; max: number }) => (r.min === r.max ? r.min.toFixed(4) : `${r.min.toFixed(4)}–${r.max.toFixed(4)}`);
681
+ if (s.clamped !== undefined) {
682
+ return `past the floor (support): chain "${l.chain}" bone "${l.bone}" (supporting foot ${s.side}) ${span(s.frames)} — asked ${val(s.asked)} under ${l.limits[0]}, clamped at the leg's own range ${s.clamped.bound.toFixed(4)} (#95), used ${val(s.used)}; hip rose by up to +${s.clamped.hipRise.toFixed(2)} units (never sinks)`;
683
+ }
684
+ return `past the floor (support): chain "${l.chain}" bone "${l.bone}" (supporting foot ${s.side}) ${span(s.frames)} — asked ${val(s.asked)} under ${l.limits[0]}, used ${val(s.used)} (within the leg's own range, #95; ankle stays on the floor)`;
685
+ }
686
+
675
687
  /**
676
688
  * POSE_NOT_CLEANED (#67): retarget reads only a sequence `rig-pose clean` wrote.
677
689
  * The message is the command to run on this file; the output name is a
package/src/switch.ts CHANGED
@@ -590,22 +590,50 @@ export function artRangeLine(e: ArtRange, times: readonly number[]): string {
590
590
  return `set "${e.set}" chain "${e.chain}" bone "${e.bone}" turned ${sg(e.turn)} from drawn (range [${e.limits[0]}, ${e.limits[1]}]) ${spanOf(e.frames, times)}: ${e.seconds.toFixed(3)}s, up to ${deg(e.maxOverflow)} past it; ${e.clamped ? 'held at the limit' : 'the bone follows the pose'} — demand for another drawing of the set`;
591
591
  }
592
592
 
593
+ /**
594
+ * The art-range clamp eases in and out at a span's edges over at least
595
+ * `ART_EASE_FRAMES` frames, or enough frames to keep the clamp's step at the
596
+ * boundary within `ART_EASE_STEP` degrees per frame (#110).
597
+ */
598
+ export const ART_EASE_FRAMES = 3;
599
+ export const ART_EASE_STEP = 3;
600
+
593
601
  /**
594
602
  * Inside each span, a bone with a `turn` in the set is held within it: its
595
- * target world angle is drawn + clamp(wrap(target − drawn), min, max). Each
596
- * bone is clamped on its own world angle, and keys are differences of world
597
- * angles, so a forearm under a clamped upper arm keeps its own target.
603
+ * target world angle is drawn + clamp(wrap(target − drawn), min, max), eased
604
+ * in and out over at least `ART_EASE_FRAMES` frames at an edge where the clamp
605
+ * switches on or off across the boundary (#110). Each bone is clamped on its
606
+ * own world angle, and keys are differences of world angles, so a forearm
607
+ * under a clamped upper arm keeps its own target.
598
608
  */
599
609
  export function clampArt(switches: readonly Switch[], map: JointMap, targets: Map<string, number[]>): void {
600
610
  for (const sw of switches) {
601
611
  const set = map.alternates.find((a) => a.name === sw.set)!;
612
+ const [a, b] = sw.frames;
602
613
  for (const [bone, limits] of Object.entries(set.turn)) {
603
614
  const drawn = set.drawn[bone]!;
604
615
  const target = targets.get(bone)!;
605
- for (let i = sw.frames[0]; i <= sw.frames[1]; i++) {
616
+ const n = target.length;
617
+ const delta = (i: number): number => {
606
618
  const t = wrap(target[i]! - drawn);
607
619
  const c = Math.min(Math.max(t, limits[0]), limits[1]);
608
- if (Math.abs(c - t) > RANGE_SLACK) target[i] = wrap(drawn + c);
620
+ return Math.abs(c - t) > RANGE_SLACK ? c - t : 0;
621
+ };
622
+ const da = delta(a);
623
+ const db = delta(b);
624
+ const easeIn = a > 0 && da !== 0 && Math.sign(delta(a - 1)) === Math.sign(da)
625
+ ? Math.max(ART_EASE_FRAMES, Math.ceil(Math.abs(da) / ART_EASE_STEP) - 1)
626
+ : 0;
627
+ const easeOut = b + 1 < n && db !== 0 && Math.sign(delta(b + 1)) === Math.sign(db)
628
+ ? Math.max(ART_EASE_FRAMES, Math.ceil(Math.abs(db) / ART_EASE_STEP) - 1)
629
+ : 0;
630
+ for (let i = a; i <= b; i++) {
631
+ const d = delta(i);
632
+ if (d === 0) continue;
633
+ const wIn = i - a < easeIn ? (i - a + 1) / (easeIn + 1) : 1;
634
+ const wOut = b - i < easeOut ? (b - i + 1) / (easeOut + 1) : 1;
635
+ const w = Math.min(wIn, wOut);
636
+ target[i] = wrap(target[i]! + w * d);
609
637
  }
610
638
  }
611
639
  }
package/src/verify.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  */
11
11
  import { poseRawAnimation, poseRawSetup, type CompiledDocument, type RawBone } from 'rig-c/core';
12
12
  import type { Body18 } from './joints.ts';
13
- import { aimAngle, pointAt, type JointMap } from './map.ts';
13
+ import { aimAngle, aimEnds, pointAt, type JointMap } from './map.ts';
14
14
  import type { HipsInferred, Joint, PelvisFit, Run } from './pose.ts';
15
15
  import type { Retarget } from './retarget.ts';
16
16
  import { pictureAngle, pointOn, wrap } from './retarget.ts';
@@ -289,6 +289,81 @@ export interface Posture {
289
289
  ratio: number | null;
290
290
  ratioSw: number | null;
291
291
  } | null;
292
+ /**
293
+ * Present with `--plant-feet` when there is at least one support span (#107):
294
+ * per support span, the detrended (centred ±12-frame moving mean removed)
295
+ * vertical sd, rig/reference ratio and correlation for the hip, the neck, and
296
+ * eyes − neck.
297
+ */
298
+ bounce?: BounceSpan[];
299
+ }
300
+
301
+ export interface BounceMetric {
302
+ reference: number;
303
+ rig: number;
304
+ ratio: number | null;
305
+ corr: number | null;
306
+ }
307
+
308
+ export interface BounceSpan {
309
+ side: 'r' | 'l';
310
+ frames: [number, number];
311
+ hip: BounceMetric;
312
+ neck: BounceMetric;
313
+ eyesNeck: BounceMetric;
314
+ }
315
+
316
+ /** Half-window (frames each side; 25-frame centred window at 24 fps ≈ 1 s) for detrending a support span's bounce (#107). */
317
+ export const POSTURE_DETREND_HALF_WINDOW = 12;
318
+
319
+ function detrend(xs: readonly number[], w = POSTURE_DETREND_HALF_WINDOW): number[] {
320
+ const n = xs.length;
321
+ return xs.map((x, i) => {
322
+ const lo = Math.max(0, i - w);
323
+ const hi = Math.min(n - 1, i + w);
324
+ let sum = 0;
325
+ for (let k = lo; k <= hi; k++) sum += xs[k]!;
326
+ return x - sum / (hi - lo + 1);
327
+ });
328
+ }
329
+
330
+ function sdOf(xs: readonly number[]): number {
331
+ if (xs.length === 0) return 0;
332
+ const mean = xs.reduce((a, b) => a + b, 0) / xs.length;
333
+ let s2 = 0;
334
+ for (const x of xs) s2 += (x - mean) * (x - mean);
335
+ return Math.sqrt(s2 / xs.length);
336
+ }
337
+
338
+ function corrOf(a: readonly number[], b: readonly number[]): number | null {
339
+ if (a.length < 2 || a.length !== b.length) return null;
340
+ const ma = a.reduce((x, y) => x + y, 0) / a.length;
341
+ const mb = b.reduce((x, y) => x + y, 0) / b.length;
342
+ let num = 0;
343
+ let da = 0;
344
+ let db = 0;
345
+ for (let i = 0; i < a.length; i++) {
346
+ const xa = a[i]! - ma;
347
+ const xb = b[i]! - mb;
348
+ num += xa * xb;
349
+ da += xa * xa;
350
+ db += xb * xb;
351
+ }
352
+ const den = Math.sqrt(da * db);
353
+ return den > 1e-9 ? num / den : null;
354
+ }
355
+
356
+ function bounceMetric(ref: readonly number[], rig: readonly number[]): BounceMetric {
357
+ const dr = detrend(ref);
358
+ const dg = detrend(rig);
359
+ const sr = sdOf(dr);
360
+ const sg = sdOf(dg);
361
+ return {
362
+ reference: sr,
363
+ rig: sg,
364
+ ratio: sr > 1e-9 ? sg / sr : null,
365
+ corr: corrOf(dr, dg),
366
+ };
292
367
  }
293
368
 
294
369
  function rangeOf(xs: number[]): Range {
@@ -407,6 +482,78 @@ export function measurePosture(posed: Array<Map<string, RawBone>>, lengths: Read
407
482
  ratioSw: rig === null || rig.rangeSw === null ? null : rig.rangeSw / reference.rangeSw,
408
483
  };
409
484
  }
485
+ const bounce: BounceSpan[] = [];
486
+ if (r.planted && r.feet !== null && r.feet.supports.length > 0) {
487
+ const scaleNeckIdx = map.translate.scale.pose.indexOf('neck');
488
+ const neckChain = map.chains.find((c) => c.joints.includes('neck'));
489
+ const neckJointIdx = neckChain === undefined ? -1 : neckChain.joints.indexOf('neck');
490
+ const rigNeck = posed[0]?.has('neck')
491
+ ? 'neck'
492
+ : scaleNeckIdx >= 0
493
+ ? map.translate.scale.rig[scaleNeckIdx]!
494
+ : neckChain !== undefined && neckJointIdx > 0
495
+ ? (neckChain.aim[neckJointIdx - 1] === 'x' ? `${neckChain.bones[neckJointIdx - 1]}:tip` : neckChain.aim[neckJointIdx - 1]!)
496
+ : rigShoulder;
497
+ const headChain = map.chains.find((c) => c.joints.includes('r_eye') && c.joints.includes('l_eye'));
498
+ const rigNeckY = (b: Map<string, RawBone>): number | null => (rigNeck === null ? null : (pointAt(b, lengths, rigNeck)?.[1] ?? null));
499
+ const rigEyesY = (b: Map<string, RawBone>): number | null => {
500
+ if (headChain !== undefined) {
501
+ const k = headChain.joints.findIndex((j, idx) => idx + 1 < headChain.joints.length && ((j === 'r_eye' && headChain.joints[idx + 1] === 'l_eye') || (j === 'l_eye' && headChain.joints[idx + 1] === 'r_eye')));
502
+ if (k >= 0) {
503
+ const ha = headChain.aim[k]!;
504
+ const hb = headChain.bones[k]!;
505
+ if (ha.includes('>')) {
506
+ const [pa, pb] = aimEnds(hb, ha);
507
+ const ya = pointAt(b, lengths, pa);
508
+ const yb = pointAt(b, lengths, pb);
509
+ if (ya !== null && yb !== null) return (ya[1] + yb[1]) / 2;
510
+ }
511
+ const p = pointAt(b, lengths, ha === 'x' ? hb : ha);
512
+ if (p !== null) return p[1];
513
+ }
514
+ }
515
+ if (b.has('eye_r') && b.has('eye_l')) return (b.get('eye_r')!.worldY + b.get('eye_l')!.worldY) / 2;
516
+ if (b.has('head')) return b.get('head')!.worldY;
517
+ return rigNeckY(b);
518
+ };
519
+ for (const sp of r.feet.supports) {
520
+ const refHip: number[] = [];
521
+ const rigHipY: number[] = [];
522
+ const refNeck: number[] = [];
523
+ const rigNeckS: number[] = [];
524
+ const refEyesNeck: number[] = [];
525
+ const rigEyesNeck: number[] = [];
526
+ for (let i = sp.frames[0]; i <= sp.frames[1]; i++) {
527
+ const b = posed[i]!;
528
+ const rh = at(i, 'r_hip');
529
+ const lh = at(i, 'l_hip');
530
+ if (rh !== null && lh !== null) {
531
+ refHip.push(-((rh[1] + lh[1]) / 2) * s);
532
+ rigHipY.push(b.get(r.origin.bone)!.worldY);
533
+ }
534
+ const nk = at(i, 'neck');
535
+ const ny = rigNeckY(b);
536
+ if (nk !== null && ny !== null) {
537
+ refNeck.push(-nk[1] * s);
538
+ rigNeckS.push(ny);
539
+ const re = at(i, 'r_eye');
540
+ const le = at(i, 'l_eye');
541
+ const ey = rigEyesY(b);
542
+ if (re !== null && le !== null && ey !== null) {
543
+ refEyesNeck.push(-((re[1] + le[1]) / 2 - nk[1]) * s);
544
+ rigEyesNeck.push(ey - ny);
545
+ }
546
+ }
547
+ }
548
+ bounce.push({
549
+ side: sp.side,
550
+ frames: [sp.frames[0], sp.frames[1]],
551
+ hip: bounceMetric(refHip, rigHipY),
552
+ neck: bounceMetric(refNeck, rigNeckS),
553
+ eyesNeck: bounceMetric(refEyesNeck, rigEyesNeck),
554
+ });
555
+ }
556
+ }
410
557
  const has106 = measured !== null && (measured.scaled !== undefined || measured.unmeasured !== undefined || measured.fit !== undefined);
411
558
  return {
412
559
  pelvis: pelvis.name,
@@ -438,6 +585,7 @@ export function measurePosture(posed: Array<Map<string, RawBone>>, lengths: Read
438
585
  }
439
586
  : {}),
440
587
  sway,
588
+ ...(bounce.length > 0 ? { bounce } : {}),
441
589
  };
442
590
  }
443
591
 
@@ -478,5 +626,11 @@ export function postureLines(q: Posture): string[] {
478
626
  out.push(`sway, x-range of mid_hip − mid_shoulder: ${ref}, rig ${f1(w.rig.range)} units (${sw}; mid_hip = "${q.rigHip}", mid_shoulder = "${q.rigShoulder}") — rig/reference ${f3(w.ratio!)} in units${ratioSw}`);
479
627
  }
480
628
  }
629
+ if (q.bounce !== undefined) {
630
+ const fmt = (bm: BounceMetric) => `${bm.ratio === null ? 'n/a' : f3(bm.ratio)} (${f1(bm.rig)}/${f1(bm.reference)}u, r=${bm.corr === null ? 'none' : f3(bm.corr)})`;
631
+ for (const b of q.bounce) {
632
+ out.push(`bounce ${b.side} ${b.frames[0]}–${b.frames[1]} (detrended y sd, rig/reference): hip ${fmt(b.hip)}, neck ${fmt(b.neck)}, eyes − neck ${fmt(b.eyesNeck)}`);
633
+ }
634
+ }
481
635
  return out;
482
636
  }