@rig-c/pose 0.30.1 → 0.31.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/README.md CHANGED
@@ -200,7 +200,7 @@ Re-checked on a @rig-c/parts fixture rig with the short hold, the flip guard and
200
200
 
201
201
  A knee bending toward the camera is seen from the front only as a shorter leg, and a rig with rigid legs cannot lower its hip without lifting its feet ([#47](https://github.com/rig-c/pose/issues/47)). Two flags, both off by default:
202
202
 
203
- **`--leg-scale <min,max>`** scales each mapped leg segment (two of one side's hip, knee and ankle) along its own x axis — a `scalex` key — by its picture length on the frame over its **straight length**, the larger of the two sides' 95th-percentile picture lengths for that segment over the sequence ([#148](https://github.com/rig-c/pose/issues/148); on a non-`in-place` sequence longer than 25 frames, each frame's segment length is normalised by a continuous rate-limited figure-scale signal `figureScaleOf` derived from posture-invariant measures — `max(r_thigh + r_shin, l_thigh + l_shin)`, shoulder width and eye width, never the trunk — with frame-to-frame step ≤ 1 % and frozen spans ≥ 5 frames held at their median, so neither camera zoom nor a forward trunk bend reads as the camera moving, and `--camera <file>` writes that track as `rig-pose-camera/1` without changing `motion.json`, [#159](https://github.com/rig-c/pose/issues/159), [#161](https://github.com/rig-c/pose/issues/161), [#163](https://github.com/rig-c/pose/issues/163), [#172](https://github.com/rig-c/pose/issues/172)), clamped to [min, max]. When the camera-normalised trunk (`neck → mid_hip` or `mid_shoulder → mid_hip`) foreshortens or extends by more than 4 % from its standing length, the trunk bone is keyed in `scaley` (for a `+y`-aimed trunk, `scalex` for `+x`) clamped to the same `[min, max]` bounds, carrying the trunk and head's depth bend while keeping the aimed rotation ([#172](https://github.com/rig-c/pose/issues/172)). The bounds are `<min,max>` with `0 < min ≤ 1 ≤ max`. A segment the map aims other than `"x"` is refused (`LEGS_MAP`). `LEG-SCALE` lists each segment's frames clamped at either limit.
203
+ **`--leg-scale <min,max>`** scales each mapped leg segment (two of one side's hip, knee and ankle) along its own x axis — a `scalex` key — by its picture length on the frame over its **straight length**, the larger of the two sides' 95th-percentile picture lengths for that segment over the sequence ([#148](https://github.com/rig-c/pose/issues/148), weighted by the segment's share of the two legs' mean setup bone length when the rig's setup legs differ by more than 0.1 % so an asymmetrically drawn rig places both ankles at the sequence's relative height, [#225](https://github.com/rig-c/pose/issues/225); on a non-`in-place` sequence longer than 25 frames, each frame's segment length is normalised by a continuous rate-limited figure-scale signal `figureScaleOf` derived from posture- and crouch-invariant segment measures — `max(r_thigh + r_shin, l_thigh + l_shin)` plus the `mid_hip → mid_shoulder` trunk segment held at each support span's 95th-percentile straight length when the 5-series consensus moves past ±2 %, never vertical `ankle.y − nose.y` — with frame-to-frame step ≤ 1 % and frozen spans ≥ 5 frames held at their median, so neither camera zoom, a forward trunk bend nor a landing crouch reads as the figure changing size, and `--camera <file>` writes that track as `rig-pose-camera/1` without changing `motion.json`, [#159](https://github.com/rig-c/pose/issues/159), [#161](https://github.com/rig-c/pose/issues/161), [#163](https://github.com/rig-c/pose/issues/163), [#172](https://github.com/rig-c/pose/issues/172), [#226](https://github.com/rig-c/pose/issues/226)), clamped to [min, max]. When the camera-normalised trunk (`neck → mid_hip` or `mid_shoulder → mid_hip`) foreshortens or extends by more than 4 % from its standing length, the trunk bone is keyed in `scaley` (for a `+y`-aimed trunk, `scalex` for `+x`) clamped to the same `[min, max]` bounds, carrying the trunk and head's depth bend while keeping the aimed rotation ([#172](https://github.com/rig-c/pose/issues/172)). The bounds are `<min,max>` with `0 < min ≤ 1 ≤ max`. A segment the map aims other than `"x"` is refused (`LEGS_MAP`). `LEG-SCALE` lists each segment's frames clamped at either limit with the unclamped `asked` scale range on each clamped run (`legScale[].clamped` in `--report`, `{ frames, times, limit, asked: { min, max } }`, [#225](https://github.com/rig-c/pose/issues/225)).
204
204
 
205
205
  rig-c's `scalex` multiplies the bone's x axis, and a child with the `normal` inherit mode takes its parent's whole world matrix. So under a thigh scaled by s, a shin at angle φ in the thigh's axes points at atan2(sin φ, s·cos φ), and is itself scaled by |(s·cos φ, sin φ)|. A child (or its intermediate control bone) with `noScale` or `noScaleOrReflection` ([#153](https://github.com/rig-c/pose/issues/153)) still has its direction carried through the parent's scaled matrix, so its angle is skewed the same way, while rig-c normalises that direction to unit length so the parent's scale is not inherited. The rotate key `turn − turn above` assumes unit scale. A mapped bone under a scaled bone is therefore keyed through its parent's posed matrix: a `rotate` key k and a `scalex` key s' put its world x axis on its target at the scale asked (under `noScale`, s' is the bone's own asked scale s directly). On `dance-a` the unit-scale formula missed the shins by up to 2.4°, their length by 0.16 of setup and the ankles by 38 units; solved, every bone lands. A `normal` child's art is still sheared by its parent's scale, and a bone under it (a foot) is squashed along the shin: `SHEAR` reads both.
206
206
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/pose",
3
- "version": "0.30.1",
3
+ "version": "0.31.0",
4
4
  "description": "Puts motion from a body-18 pose sequence onto a rig built with rig-c: bone keys in rig-c's motion format, checked through rig-c's gate before anything is written. A CLI for agents that cannot see the rig.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/legs.ts CHANGED
@@ -295,11 +295,18 @@ export interface SupportScaleRun {
295
295
  clamped?: { bound: number; hipRise: number };
296
296
  }
297
297
 
298
+ export interface ClampedScaleRun {
299
+ frames: [number, number];
300
+ limit: 'low' | 'high';
301
+ /** The lowest and highest scale asked (`picture length ÷ straight`) on the clamped run's frames (#225). */
302
+ asked: { min: number; max: number };
303
+ }
304
+
298
305
  export interface LegScale {
299
306
  chain: string;
300
307
  bone: string;
301
308
  joints: [string, string];
302
- /** The straight length: the larger of the two sides' 95th-percentile picture lengths for this segment (#148), px. */
309
+ /** The straight length: the larger of the two sides' 95th-percentile picture lengths for this segment (#148), weighted by the bone's setup length share when the rig's legs differ at setup (#225), px. */
303
310
  straight: number;
304
311
  /** The picture length over the straight length, before the clamp: lowest, median, highest. */
305
312
  ratio: { min: number; median: number; max: number };
@@ -308,6 +315,8 @@ export interface LegScale {
308
315
  /** Runs of frames clamped at the low and at the high limit. */
309
316
  low: Array<[number, number]>;
310
317
  high: Array<[number, number]>;
318
+ /** Runs of frames clamped at the low or high limit, with the asked scale range on each run (#225). */
319
+ clamped?: ClampedScaleRun[];
311
320
  /** The scale keyed on each frame (clamped). */
312
321
  scale: number[];
313
322
  /** The scale the pose asked for on each frame before the clamp floor (`min(ratio, limits[1])`, rig-c/pose#107). */
@@ -321,7 +330,7 @@ export interface LegScale {
321
330
  support?: SupportScaleRun[];
322
331
  }
323
332
 
324
- /** One segment's scale per frame: picture length ÷ straight length (the larger of the two sides' 95th percentiles, #148), clamped; its asked scale without the clamp floor (#107); and the runs clamped. */
333
+ /** One segment's scale per frame: picture length ÷ straight length (the larger of the two sides' 95th percentiles, #148, scaled per leg when setup leg lengths differ, #225), clamped; its asked scale without the clamp floor (#107); and the runs clamped with their asked values (#225). */
325
334
  export function legScaleOf(lengths: readonly number[], limits: [number, number], straight = percentile(lengths, STRAIGHT_PERCENTILE)): Omit<LegScale, 'chain' | 'bone' | 'joints'> {
326
335
  const ratio = lengths.map((l) => l / straight);
327
336
  const scale = ratio.map((r) => Math.min(Math.max(r, limits[0]), limits[1]));
@@ -329,12 +338,23 @@ export function legScaleOf(lengths: readonly number[], limits: [number, number],
329
338
  const sorted = [...ratio].sort((a, b) => a - b);
330
339
  const mid = sorted.length >> 1;
331
340
  const median = sorted.length % 2 === 1 ? sorted[mid]! : (sorted[mid - 1]! + sorted[mid]!) / 2;
341
+ const low = runs(ratio.map((r) => r < limits[0]));
342
+ const high = runs(ratio.map((r) => r > limits[1]));
343
+ const spanAsked = ([a, b]: [number, number]): { min: number; max: number } => {
344
+ const sub = ratio.slice(a, b + 1);
345
+ return { min: Math.min(...sub), max: Math.max(...sub) };
346
+ };
347
+ const clamped: ClampedScaleRun[] = [
348
+ ...low.map((frames) => ({ frames, limit: 'low' as const, asked: spanAsked(frames) })),
349
+ ...high.map((frames) => ({ frames, limit: 'high' as const, asked: spanAsked(frames) })),
350
+ ];
332
351
  return {
333
352
  straight,
334
353
  ratio: { min: sorted[0]!, median, max: sorted[sorted.length - 1]! },
335
354
  limits,
336
- low: runs(ratio.map((r) => r < limits[0])),
337
- high: runs(ratio.map((r) => r > limits[1])),
355
+ low,
356
+ high,
357
+ ...(clamped.length > 0 ? { clamped } : {}),
338
358
  scale,
339
359
  asked,
340
360
  };
@@ -671,7 +691,7 @@ export function plantHip(feet: Feet, offset: (i: number, s: Side) => Pt, travel:
671
691
  const y = floor ?? percentile(heights as number[], 0.5);
672
692
  const shifts = feet.supports.map((sp, k) => ({ side: sp.side, frames: [sp.frames[0], sp.frames[1]] as [number, number], shift: y - heights[k]! }));
673
693
  const placed = placeHip(feet, offset, travel, start, y, follow);
674
- const closure = follow === undefined ? null : closeLoop(placed.hip);
694
+ const closure = follow === undefined ? null : closeLoop(placed.hip, feet.supports);
675
695
  return { hip: placed.hip, floor: { y, given: floor !== undefined, shifts }, blends: placed.blends, closure };
676
696
  } finally {
677
697
  trackFn = undefined;
@@ -685,15 +705,35 @@ export interface Closure {
685
705
  }
686
706
 
687
707
  /**
688
- * Close an in-place loop (rig-c/pose#92): the hip's x on frame i moves by
689
- * −drift · i / (n − 1), drift its x on the last frame less its x on the first,
690
- * so the last frame's hip x is the first's and the loop carries no net travel.
691
- * The y is the floor's (#85) and is not touched. In place.
708
+ * Close an in-place loop (rig-c/pose#92, #224): when support spans repeat across
709
+ * multiple cycles, measure the per-cycle x drift between matching phase frames
710
+ * (`cf = 0` of cycle 1 vs cycle `k + 1`) and subtract that linear trend so the
711
+ * within-cycle offset (`cf 0 → last frame of cycle`) is preserved; on a single
712
+ * cycle (or when no repeating support spans are given), subtract the whole-clip
713
+ * displacement `hip[n - 1].x - hip[0].x`. The y is the floor's (#85) and is not
714
+ * touched. In place.
692
715
  */
693
- export function closeLoop(hip: Pt[]): Closure {
716
+ export function closeLoop(hip: Pt[], supports?: Feet['supports']): Closure {
694
717
  const n = hip.length;
695
- const drift = n > 1 ? hip[n - 1]![0] - hip[0]![0] : 0;
696
- if (n > 1) for (let i = 0; i < n; i++) hip[i]![0] -= (drift * i) / (n - 1);
718
+ if (n <= 1) return { drift: 0, frames: n };
719
+ let perFrame = (hip[n - 1]![0] - hip[0]![0]) / (n - 1);
720
+ if (supports !== undefined && supports.length > 0) {
721
+ const bySide: Record<Side, number[]> = { r: [], l: [] };
722
+ for (const sp of supports) {
723
+ if (sp.frames[0] > 0) bySide[sp.side].push(sp.frames[0]);
724
+ }
725
+ const starts = bySide.r.length >= bySide.l.length ? bySide.r : bySide.l;
726
+ if (starts.length >= 2) {
727
+ const period = Math.round((starts[starts.length - 1]! - starts[0]!) / (starts.length - 1));
728
+ const cycles = period > 0 ? Math.floor((n - 1) / period) : 0;
729
+ if (cycles >= 1 && cycles * period < n) {
730
+ const span = cycles * period;
731
+ perFrame = (hip[span]![0] - hip[0]![0]) / span;
732
+ }
733
+ }
734
+ }
735
+ const drift = perFrame * (n - 1);
736
+ for (let i = 0; i < n; i++) hip[i]![0] -= perFrame * i;
697
737
  return { drift, frames: n };
698
738
  }
699
739
 
@@ -739,7 +779,7 @@ function placeHip(feet: Feet, offset: (i: number, s: Side) => Pt, travel: (i: nu
739
779
  const b = travel(mode.from);
740
780
  let y = mode.at[1] + a[1] - b[1];
741
781
  if (mode.carry !== undefined && mode.land !== undefined && mode.end !== undefined && floorY !== null) {
742
- if (mode.land < n && mode.land > mode.from) {
782
+ if ((mode.land < n || follow !== undefined) && mode.land > mode.from) {
743
783
  y += mode.carry * ((Math.min(i, mode.land) - mode.from) / (mode.land - mode.from));
744
784
  }
745
785
  if (i <= mode.end) {
@@ -748,7 +788,7 @@ function placeHip(feet: Feet, offset: (i: number, s: Side) => Pt, travel: (i: nu
748
788
  }
749
789
  return [mode.at[0] + a[0] - b[0], y];
750
790
  };
751
- /** A travel span from `startFrame` off frame `from`'s hip `at`: carried to meet `floorY` at landing when it contains a jump (#103). */
791
+ /** A travel span from `startFrame` off frame `from`'s hip `at`: carried to meet `floorY` at landing when it contains a jump (#103, and wrapped to frame 0 on an in-place loop, #224). */
752
792
  const makeTravel = (startFrame: number, from: number, at: Pt): Mode => {
753
793
  let end = startFrame;
754
794
  while (end + 1 < n && (feet.support[end + 1] ?? null) === null && (tracked[end + 1] ?? null) === null) end++;
@@ -756,10 +796,11 @@ function placeHip(feet: Feet, offset: (i: number, s: Side) => Pt, travel: (i: nu
756
796
  if (!isJump) return { kind: 'travel', from, at };
757
797
  const land = end + 1;
758
798
  let carry = 0;
759
- if (land < n) {
760
- const landSide = (feet.support[land] ?? tracked[land] ?? null)!;
761
- const rawLandY = at[1] + travel(land)[1] - travel(from)[1];
762
- const targetLandY = floorY - offset(land, landSide)[1];
799
+ const landIdx = land < n ? land : follow !== undefined ? 0 : -1;
800
+ const landSide = landIdx >= 0 ? (feet.support[landIdx] ?? tracked[landIdx] ?? null) : null;
801
+ if (landSide !== null) {
802
+ const rawLandY = at[1] + travel(landIdx)[1] - travel(from)[1];
803
+ const targetLandY = floorY - offset(landIdx, landSide)[1];
763
804
  carry = targetLandY - rawLandY;
764
805
  }
765
806
  return { kind: 'travel', from, at, end, land, carry };
@@ -780,7 +821,17 @@ function placeHip(feet: Feet, offset: (i: number, s: Side) => Pt, travel: (i: nu
780
821
  if (i === 0) {
781
822
  if (s !== null) {
782
823
  const o = offset(0, s);
824
+ const anchorY = floorY ?? (start[1] + o[1]);
783
825
  mode = plant(0, s, [start[0] + o[0], start[1] + o[1]]);
826
+ // #224: in a periodic in-place loop whose last frame is in flight, the first cycle's plant blends from that flight just like cycles 2..N.
827
+ if (follow !== undefined && n > 1 && (feet.support[n - 1] ?? null) === null && (tracked[n - 1] ?? null) === null) {
828
+ from = { kind: 'travel', from: 0, at: [start[0], anchorY - o[1]] };
829
+ blendAt = 0;
830
+ blendEnd = 0;
831
+ while (blendEnd + 1 < n && (feet.support[blendEnd + 1] ?? null) === s) blendEnd++;
832
+ blendToFlight =
833
+ blendEnd + 1 < n && (feet.support[blendEnd + 1] ?? null) === null && (tracked[blendEnd + 1] ?? null) === null;
834
+ }
784
835
  } else if (tr !== null) {
785
836
  const o = offset(0, tr);
786
837
  mode = { kind: 'track', side: tr, anchor: [start[0] + o[0], floorY ?? (start[1] + o[1])], from: 0, at: start };
package/src/retarget.ts CHANGED
@@ -839,13 +839,17 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
839
839
  return cons.map((c) => c * legSum[0]!);
840
840
  }
841
841
  }
842
- const top = heldPt('nose') ?? heldPt('neck');
843
- if (top !== null) {
844
- const h = ar.map((r, i) => Math.max(r[1], al[i]![1]) - top[i]![1]);
845
- if (h.every((v) => v > 1e-6)) return h;
842
+ const segH =
843
+ rs !== null && ls !== null
844
+ ? legSum.map((l, i) => l + dist2([(hr[i]![0] + hl[i]![0]) / 2, (hr[i]![1] + hl[i]![1]) / 2], [(rs[i]![0] + ls[i]![0]) / 2, (rs[i]![1] + ls[i]![1]) / 2]))
845
+ : legSum;
846
+ const out = winPercentile(segH, 15, STRAIGHT_PERCENTILE);
847
+ if (feet !== null) {
848
+ for (const sp of feet.supports) {
849
+ const m = percentile(segH.slice(sp.frames[0], sp.frames[1] + 1), STRAIGHT_PERCENTILE);
850
+ for (let i = sp.frames[0]; i <= sp.frames[1]; i++) out[i] = m;
851
+ }
846
852
  }
847
- const out = winPercentile(legSum, 1, STRAIGHT_PERCENTILE);
848
- out[0] = legSum[0]!;
849
853
  return out;
850
854
  }
851
855
  return legSum;
@@ -937,8 +941,24 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
937
941
  }
938
942
  }
939
943
  }
944
+ const rigLenByKind = new Map<string, number[]>();
945
+ for (const seg of rawSegments) {
946
+ const len = rest.get(seg.bone)?.length ?? 0;
947
+ if (len > 1e-6) {
948
+ const list = rigLenByKind.get(seg.kind) ?? [];
949
+ list.push(len);
950
+ rigLenByKind.set(seg.kind, list);
951
+ }
952
+ }
953
+ const rigShareOf = (seg: (typeof rawSegments)[number]): number => {
954
+ const lens = rigLenByKind.get(seg.kind);
955
+ const rigLen = rest.get(seg.bone)?.length ?? 0;
956
+ if (lens === undefined || lens.length < 2 || !(rigLen > 1e-6)) return 1;
957
+ const meanRigLen = lens.reduce((a, b) => a + b, 0) / lens.length;
958
+ return Math.abs(rigLen - meanRigLen) > 1e-3 * meanRigLen ? rigLen / meanRigLen : 1;
959
+ };
940
960
  for (const seg of rawSegments) {
941
- const ls = legScaleOf(seg.lengths, limits, straightByKind.get(seg.kind)!);
961
+ const ls = legScaleOf(seg.lengths, limits, straightByKind.get(seg.kind)! * rigShareOf(seg));
942
962
  legScale.push({ chain: seg.chain, bone: seg.bone, joints: seg.joints, ...ls });
943
963
  scales.set(seg.bone, ls.scale);
944
964
  }
package/src/run.ts CHANGED
@@ -773,12 +773,23 @@ function legRangeLine(which: 'free' | 'supporting', range: Partial<Record<'r' |
773
773
  return `bound from the leg's own range: on a frame where the picture folds the ${which} leg below ${bounds[0]} of its straight length (hip → ankle, its 95th percentile), the lower bound is that fold — lowest ${sides.join(', ')}; the upper stays ${bounds[1]}`;
774
774
  }
775
775
 
776
- /** A LEG-SCALE line: the segment, its straight length, its ratio's spread, and the frames clamped. */
776
+ /** A LEG-SCALE line: the segment, its straight length, its ratio's spread, and the frames clamped (with the asked value, #225). */
777
777
  function legScaleLine(l: LegScale, times: readonly number[]): string {
778
778
  const span = (x: [number, number]) => (x[0] === x[1] ? `${x[0]}` : `${x[0]}–${x[1]}`);
779
779
  const count = (rs: Array<[number, number]>) => rs.reduce((k, x) => k + x[1] - x[0] + 1, 0);
780
- 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(', ')})`);
781
- return `chain "${l.chain}" bone "${l.bone}" (${l.joints[0]} → ${l.joints[1]}): straight length ${l.straight.toFixed(1)}px (larger 95th percentile of ${times.length} frames, both legs); 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')}`;
780
+ const val = (r: { min: number; max: number }) => (r.min.toFixed(3) === r.max.toFixed(3) ? r.min.toFixed(3) : `${r.min.toFixed(3)}–${r.max.toFixed(3)}`);
781
+ const askedOf = (x: [number, number], kind: 'low' | 'high'): string => {
782
+ const c = l.clamped?.find((r) => r.limit === kind && r.frames[0] === x[0] && r.frames[1] === x[1]);
783
+ if (c !== undefined) return `asked ${val(c.asked)}`;
784
+ const sub = l.asked.slice(x[0], x[1] + 1);
785
+ return sub.length === 0 ? '' : `asked ${val({ min: Math.min(...sub), max: Math.max(...sub) })}`;
786
+ };
787
+ const clamp = (rs: Array<[number, number]>, at: number, how: string, kind: 'low' | 'high') => {
788
+ if (rs.length === 0) return `none ${how} ${at}`;
789
+ const detail = rs.length === 1 ? ` (${askedOf(rs[0]!, kind)})` : ` (${rs.map((x) => `${span(x)}: ${askedOf(x, kind)}`).join(', ')})`;
790
+ return `${count(rs)} frame(s) ${how} ${at}, clamped (frames ${rs.map(span).join(', ')})${detail}`;
791
+ };
792
+ return `chain "${l.chain}" bone "${l.bone}" (${l.joints[0]} → ${l.joints[1]}): straight length ${l.straight.toFixed(1)}px (larger 95th percentile of ${times.length} frames, both legs); 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', 'low')}; ${clamp(l.high, l.limits[1], 'over', 'high')}`;
782
793
  }
783
794
 
784
795
  /** 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. */