@rig-c/pose 0.15.0 → 0.17.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/legs.ts CHANGED
@@ -25,7 +25,8 @@
25
25
  * every frame one foot supports: the one whose reference ankle is lower in the
26
26
  * picture, changing only when the other ankle has been ≥ `PLANT_SWITCH` leg
27
27
  * lengths lower for ≥ `PLANT_SWITCH_SECONDS`. The supporting foot is held where
28
- * it was on the frame it took support, and the hip is placed so it stays there,
28
+ * it was on the frame it took support, moved in y onto the one floor every span
29
+ * shares (rig-c/pose#85, `plantHip`), and the hip is placed so it stays there,
29
30
  * through the posed (scaled) legs. A change of support blends the two
30
31
  * derivations over `PLANT_BLEND` frames. A jump — both ankles and mid_hip more
31
32
  * than `JUMP_RISE` leg lengths above their rolling floor (the 95th-percentile
@@ -431,15 +432,69 @@ export function supportsOf(ankle: Record<Side, readonly Pt[]>, hip: Record<Side,
431
432
  return { legLength, support, supports, jumps, steps, changes };
432
433
  }
433
434
 
435
+ /** One support span's shift onto the floor (rig-c/pose#85): its side, its frames, and floor − its planted height, world units. */
436
+ export interface FloorShift {
437
+ side: Side;
438
+ frames: [number, number];
439
+ shift: number;
440
+ }
441
+
442
+ /** Where the planted feet were put (rig-c/pose#85): the floor's world y, whether `--floor` gave it, and each support span's shift. */
443
+ export interface Floor {
444
+ y: number;
445
+ given: boolean;
446
+ shifts: FloorShift[];
447
+ }
448
+
449
+ /**
450
+ * The hip's world origin on every frame, from the supporting foot (header),
451
+ * with every support span on one floor (rig-c/pose#85). `offset(i, side)` is
452
+ * the ankle's posed position relative to the hip's origin on frame i;
453
+ * `travel(i)` the pose's own travel of the hip from frame 0, world. `start` is
454
+ * the hip's origin on frame 0 (its setup origin).
455
+ *
456
+ * **One floor.** A span's planted height is the world y its supporting ankle is
457
+ * held at when it is held where the picture puts it on the frame it takes
458
+ * support. A 2D rig has one floor, so every span is planted on it instead: the
459
+ * floor is `floor` when given (`--floor`, world units), and otherwise the median
460
+ * of the spans' planted heights; each span's anchor moves in y by floor − its
461
+ * planted height, and the hip with it, so everything above follows. A change of
462
+ * support still blends the two derivations over `PLANT_BLEND` frames, now
463
+ * between two spans on the floor; a jump follows the pose's travel from the hip
464
+ * it left (on the floor) and lands on the floor. On a blend frame the hip is
465
+ * between the two derivations, so the supporting ankle is off its anchor:
466
+ * `blends` lists those frames with the anchor's y, and `holdBlend` stretches
467
+ * the supporting leg to keep it there. Null `floor` in the result: there is no
468
+ * support span.
469
+ */
470
+ export function plantHip(feet: Feet, offset: (i: number, s: Side) => Pt, travel: (i: number) => Pt, start: Pt, floor?: number): { hip: Pt[]; floor: Floor | null; blends: BlendFrame[] } {
471
+ const picture = placeHip(feet, offset, travel, start, null);
472
+ const heights = feet.supports.map((sp) => picture.anchors.get(sp.frames[0]));
473
+ if (heights.length === 0) return { hip: picture.hip, floor: null, blends: picture.blends };
474
+ if (heights.some((h) => h === undefined)) throw new Error('plantHip: a support span starts on a frame no plant started on');
475
+ const y = floor ?? percentile(heights as number[], 0.5);
476
+ const shifts = feet.supports.map((sp, k) => ({ side: sp.side, frames: [sp.frames[0], sp.frames[1]] as [number, number], shift: y - heights[k]! }));
477
+ const placed = placeHip(feet, offset, travel, start, y);
478
+ return { hip: placed.hip, floor: { y, given: floor !== undefined, shifts }, blends: placed.blends };
479
+ }
480
+
481
+ /** A frame inside the blend after a change of support (or a landing): the side now supporting, and the world y its plant holds the ankle at. */
482
+ export interface BlendFrame {
483
+ frame: number;
484
+ side: Side;
485
+ y: number;
486
+ }
487
+
434
488
  /**
435
- * The hip's world origin on every frame, from the supporting foot (header).
436
- * `offset(i, side)` is the ankle's posed position relative to the hip's origin
437
- * on frame i; `travel(i)` the pose's own travel of the hip from frame 0, world.
438
- * `start` is the hip's origin on frame 0 (its setup origin).
489
+ * The hip placed from the supporting foot, each plant's anchor held where the
490
+ * picture puts it (`floorY` null) or at world y `floorY`; and each plant's
491
+ * picture anchor y, by the frame its support span starts on.
439
492
  */
440
- export function plantHip(feet: Feet, offset: (i: number, s: Side) => Pt, travel: (i: number) => Pt, start: Pt): Pt[] {
493
+ function placeHip(feet: Feet, offset: (i: number, s: Side) => Pt, travel: (i: number) => Pt, start: Pt, floorY: number | null): { hip: Pt[]; anchors: Map<number, number>; blends: BlendFrame[] } {
441
494
  const n = feet.support.length;
442
495
  const hip: Pt[] = [];
496
+ const anchors = new Map<number, number>();
497
+ const blends: BlendFrame[] = [];
443
498
  /** How the hip is derived on a frame: held over a foot's anchor, or carried by the pose's travel from a frame's hip. */
444
499
  type Mode = { kind: 'plant'; side: Side; anchor: Pt } | { kind: 'travel'; from: number; at: Pt };
445
500
  const of = (mode: Mode, i: number): Pt => {
@@ -451,6 +506,11 @@ export function plantHip(feet: Feet, offset: (i: number, s: Side) => Pt, travel:
451
506
  const b = travel(mode.from);
452
507
  return [mode.at[0] + a[0] - b[0], mode.at[1] + a[1] - b[1]];
453
508
  };
509
+ /** A plant from frame i: its anchor's x where the foot is, its y there or on the floor. */
510
+ const plant = (i: number, side: Side, at: Pt): Mode => {
511
+ anchors.set(i, at[1]);
512
+ return { kind: 'plant', side, anchor: [at[0], floorY ?? at[1]] };
513
+ };
454
514
  let mode: Mode | null = null;
455
515
  let from: Mode | null = null;
456
516
  let blendAt = -1;
@@ -460,7 +520,7 @@ export function plantHip(feet: Feet, offset: (i: number, s: Side) => Pt, travel:
460
520
  if (s === null) mode = { kind: 'travel', from: 0, at: start };
461
521
  else {
462
522
  const o = offset(0, s);
463
- mode = { kind: 'plant', side: s, anchor: [start[0] + o[0], start[1] + o[1]] };
523
+ mode = plant(0, s, [start[0] + o[0], start[1] + o[1]]);
464
524
  }
465
525
  } else if (s === null && mode!.kind === 'plant') {
466
526
  mode = { kind: 'travel', from: i - 1, at: hip[i - 1]! };
@@ -471,7 +531,7 @@ export function plantHip(feet: Feet, offset: (i: number, s: Side) => Pt, travel:
471
531
  const here = of(mode!, i);
472
532
  const o = offset(i, s);
473
533
  from = mode;
474
- mode = { kind: 'plant', side: s, anchor: [here[0] + o[0], here[1] + o[1]] };
534
+ mode = plant(i, s, [here[0] + o[0], here[1] + o[1]]);
475
535
  blendAt = i;
476
536
  }
477
537
  const to = of(mode!, i);
@@ -479,12 +539,14 @@ export function plantHip(feet: Feet, offset: (i: number, s: Side) => Pt, travel:
479
539
  const w = (i - blendAt + 1) / (PLANT_BLEND + 1);
480
540
  const f = of(from, i);
481
541
  hip.push([f[0] + (to[0] - f[0]) * w, f[1] + (to[1] - f[1]) * w]);
542
+ const m = mode!;
543
+ if (m.kind === 'plant') blends.push({ frame: i, side: m.side, y: m.anchor[1] });
482
544
  } else {
483
545
  from = null;
484
546
  hip.push(to);
485
547
  }
486
548
  }
487
- return hip;
549
+ return { hip, anchors, blends };
488
550
  }
489
551
 
490
552
  // --- stance (rig-c/pose#60) --------------------------------------------------
@@ -492,6 +554,11 @@ export function plantHip(feet: Feet, offset: (i: number, s: Side) => Pt, travel:
492
554
  /** A free leg is scaled within these factors to reach the floor (raised from below or lowered from above); past them the frame is reported, not forced. */
493
555
  export const STANCE_BOUNDS: readonly [number, number] = [0.92, 1.08];
494
556
 
557
+ /** A lifted free foot is planted again only when its height comes back under this fraction of the tolerance (#87): hysteresis, a stated factor, not tuned. */
558
+ export const STANCE_RELEASE = 0.8;
559
+ /** A run of one state shorter than this many seconds, between two runs of the other state, takes their state (#87): a foot cannot take a stance for one frame. */
560
+ export const STANCE_MIN_SECONDS = 0.1;
561
+
495
562
  /** A run of frames with one free side in one state. */
496
563
  export interface StanceRun {
497
564
  /** `both`: the free ankle within the tolerance of the floor, so both feet are planted; `lifted`: above it, the pose's height kept. */
@@ -503,6 +570,8 @@ export interface StanceRun {
503
570
  maxHeight: number;
504
571
  /** `both`: the smallest and largest stretch keyed (bounded); `lifted`: null. */
505
572
  k: { min: number; max: number } | null;
573
+ /** #87: a run read in `state`, shorter than `STANCE_MIN_SECONDS` between two runs of the other state, which took their state; its frames are in their run too, and `k` is null. */
574
+ absorbed?: true;
506
575
  }
507
576
 
508
577
  /** A run of planted-both frames whose stretch needed a factor past the bounds (or could not reach: `needed` null). */
@@ -518,6 +587,10 @@ export interface StanceOver {
518
587
  export interface Stance {
519
588
  /** The tolerance, in leg lengths (FEET's: the median picture hip → ankle of the free side). */
520
589
  tolerance: number;
590
+ /** A lifted foot is planted again under `release × tolerance` (#87). */
591
+ release: number;
592
+ /** The shortest run kept as its own state, seconds (#87). */
593
+ minSeconds: number;
521
594
  bounds: readonly [number, number];
522
595
  runs: StanceRun[];
523
596
  over: StanceOver[];
@@ -542,18 +615,75 @@ export interface StancePosed {
542
615
  * `STANCE_BOUNDS` (a free ankle below the floor shortens its leg, one above
543
616
  * lengthens it), and a frame past them is listed in `over`. Above the tolerance
544
617
  * the foot is lifted and keeps the pose's height.
618
+ *
619
+ * The state is read with hysteresis (#87): a planted foot is lifted when its
620
+ * height goes past `tolerance`, and a lifted one is planted again only under
621
+ * `STANCE_RELEASE × tolerance`, so noise at the threshold does not flip it. A
622
+ * run of one state shorter than `STANCE_MIN_SECONDS` (`secondsOf`, the
623
+ * retarget's timing of a run) between two runs of the other state, on the same
624
+ * free side and with no frame between, takes their state, and is listed with
625
+ * `absorbed`.
545
626
  */
546
- export function stanceOf(feet: Feet, ankle: Record<Side, readonly Pt[]>, tolerance: number, posed: (i: number) => StancePosed): Stance {
627
+ export function stanceOf(feet: Feet, ankle: Record<Side, readonly Pt[]>, tolerance: number, posed: (i: number) => StancePosed, secondsOf: (run: [number, number]) => number): Stance {
547
628
  const n = feet.support.length;
548
629
  const stretch: Stance['stretch'] = new Array(n).fill(null);
549
- type Row = { state: 'both' | 'lifted'; side: Side; height: number; k: number | null; needed: number | null; over: boolean; left: number };
550
- const rows: Array<Row | null> = new Array<Row | null>(n).fill(null);
630
+ type Read = { state: 'both' | 'lifted'; side: Side; height: number };
631
+ const read: Array<Read | null> = new Array<Read | null>(n).fill(null);
551
632
  for (let i = 0; i < n; i++) {
552
633
  const s = feet.support[i] ?? null;
553
634
  if (s === null) continue;
554
635
  const f: Side = s === 'r' ? 'l' : 'r';
555
- const height = (ankle[s][i]![1] - ankle[f][i]![1]) / feet.legLength[f];
556
- if (height > tolerance) {
636
+ read[i] = { state: 'both', side: f, height: (ankle[s][i]![1] - ankle[f][i]![1]) / feet.legLength[f] };
637
+ }
638
+ // Blocks of consecutive frames with one free side. Inside a block the state changes only when the
639
+ // other state's condition (lifted: past the tolerance; planted again: under STANCE_RELEASE of it)
640
+ // holds for STANCE_MIN_SECONDS; a shorter crossing with the current state on both sides of it is
641
+ // absorbed. A crossing that runs to the block's end is taken: nothing of the current state follows it.
642
+ const absorbed: StanceRun[] = [];
643
+ for (let i = 0; i < n; ) {
644
+ const r0 = read[i];
645
+ if (r0 === null || r0 === undefined) {
646
+ i++;
647
+ continue;
648
+ }
649
+ let end = i;
650
+ while (end + 1 < n && read[end + 1] !== null && read[end + 1]!.side === r0.side) end++;
651
+ let state: 'both' | 'lifted' = r0.height > tolerance ? 'lifted' : 'both';
652
+ let j = i;
653
+ while (j <= end) {
654
+ const crosses = (k: number) => (state === 'both' ? read[k]!.height > tolerance : read[k]!.height <= STANCE_RELEASE * tolerance);
655
+ if (!crosses(j)) {
656
+ read[j]!.state = state;
657
+ j++;
658
+ continue;
659
+ }
660
+ let k = j;
661
+ while (k + 1 <= end && crosses(k + 1)) k++;
662
+ const other = state === 'both' ? 'lifted' : 'both';
663
+ if (k < end && secondsOf([j, k]) < STANCE_MIN_SECONDS) {
664
+ let maxHeight = -Infinity;
665
+ for (let x = j; x <= k; x++) {
666
+ read[x]!.state = state;
667
+ maxHeight = Math.max(maxHeight, read[x]!.height);
668
+ }
669
+ absorbed.push({ state: other, side: r0.side, frames: [j, k], maxHeight, k: null, absorbed: true });
670
+ } else {
671
+ state = other;
672
+ for (let x = j; x <= k; x++) read[x]!.state = state;
673
+ }
674
+ j = k + 1;
675
+ }
676
+ i = end + 1;
677
+ }
678
+ type Row = { state: 'both' | 'lifted'; side: Side; height: number; k: number | null; needed: number | null; over: boolean; left: number };
679
+ const rows: Array<Row | null> = new Array<Row | null>(n).fill(null);
680
+ for (let i = 0; i < n; i++) {
681
+ const r = read[i];
682
+ if (r === null || r === undefined) continue;
683
+ const s: Side = r.side === 'r' ? 'l' : 'r';
684
+ const f = r.side;
685
+ const height = r.height;
686
+ if (r.state === 'lifted') {
557
687
  rows[i] = { state: 'lifted', side: f, height, k: null, needed: null, over: false, left: 0 };
558
688
  continue;
559
689
  }
@@ -588,5 +718,71 @@ export function stanceOf(feet: Feet, ankle: Record<Side, readonly Pt[]>, toleran
588
718
  o.left = Math.max(o.left, r.left);
589
719
  } else over.push({ side: r.side, frames: [i, i], needed: r.needed, left: r.left });
590
720
  }
591
- return { tolerance, bounds: STANCE_BOUNDS, runs, over, stretch };
721
+ for (const x of absorbed) runs.push(x);
722
+ runs.sort((a, b) => a.frames[0] - b.frames[0] || (a.absorbed === true ? 1 : 0) - (b.absorbed === true ? 1 : 0));
723
+ return { tolerance, release: STANCE_RELEASE, minSeconds: STANCE_MIN_SECONDS, bounds: STANCE_BOUNDS, runs, over, stretch };
724
+ }
725
+
726
+ // --- the blend held on the floor (rig-c/pose#85) ------------------------------
727
+
728
+ /** A supporting ankle already within this many world units of its anchor on a blend frame is left as it is: the landing check's origin tolerance (`verify.ts` ORIGIN_TOLERANCE). */
729
+ export const BLEND_HOLD_WITHIN = 0.01;
730
+
731
+ /** A run of blend frames on one supporting side whose leg was stretched. */
732
+ export interface BlendRun {
733
+ side: Side;
734
+ frames: [number, number];
735
+ /** The smallest and largest stretch keyed (bounded). */
736
+ k: { min: number; max: number };
737
+ }
738
+
739
+ export interface BlendHold {
740
+ bounds: readonly [number, number];
741
+ runs: BlendRun[];
742
+ /** Runs whose stretch needed a factor past the bounds: held at the bound, the remainder left to the blend. */
743
+ over: StanceOver[];
744
+ /** Per frame: the supporting side and the stretch keyed on it, or null. */
745
+ stretch: Array<{ side: Side; k: number } | null>;
746
+ }
747
+
748
+ /**
749
+ * The supporting foot does not leave the floor during a blend (rig-c/pose#85).
750
+ * On a blend frame the hip is between the old derivation and the new, so the
751
+ * supporting ankle is off the y its plant holds. Its leg is stretched along its
752
+ * chain about the chain's root, as `--stance` stretches a free leg (`stanceOf`):
753
+ * k = (root y − anchor y) / (root y − ankle y) on the posed rig (`posed`, world,
754
+ * y up), held within `STANCE_BOUNDS`; a frame past them is listed in `over`, and
755
+ * the remainder stays with the blend. An ankle within `BLEND_HOLD_WITHIN` of the
756
+ * anchor is not stretched.
757
+ */
758
+ export function holdBlend(blends: readonly BlendFrame[], n: number, posed: (i: number) => StancePosed): BlendHold {
759
+ const stretch: BlendHold['stretch'] = new Array(n).fill(null);
760
+ const runs: BlendRun[] = [];
761
+ const over: StanceOver[] = [];
762
+ for (const b of blends) {
763
+ const p = posed(b.frame);
764
+ const root = p.root[b.side][1];
765
+ const a = p.ankle[b.side][1];
766
+ if (Math.abs(a - b.y) <= BLEND_HOLD_WITHIN) continue;
767
+ const ok = root - a > 1e-9 && root - b.y > 1e-9;
768
+ const needed = ok ? (root - b.y) / (root - a) : null;
769
+ const k = needed === null ? 1 : Math.min(Math.max(needed, STANCE_BOUNDS[0]), STANCE_BOUNDS[1]);
770
+ const left = Math.abs(root - k * (root - a) - b.y);
771
+ if (k !== 1) {
772
+ stretch[b.frame] = { side: b.side, k };
773
+ const last = runs[runs.length - 1];
774
+ if (last !== undefined && last.side === b.side && last.frames[1] === b.frame - 1) {
775
+ last.frames[1] = b.frame;
776
+ last.k = { min: Math.min(last.k.min, k), max: Math.max(last.k.max, k) };
777
+ } else runs.push({ side: b.side, frames: [b.frame, b.frame], k: { min: k, max: k } });
778
+ }
779
+ if (needed !== null && needed >= STANCE_BOUNDS[0] && needed <= STANCE_BOUNDS[1]) continue;
780
+ const o = over[over.length - 1];
781
+ if (o !== undefined && o.side === b.side && o.frames[1] === b.frame - 1) {
782
+ o.frames[1] = b.frame;
783
+ o.needed = o.needed === null || needed === null ? null : Math.abs(needed - 1) > Math.abs(o.needed - 1) ? needed : o.needed;
784
+ o.left = Math.max(o.left, left);
785
+ } else over.push({ side: b.side, frames: [b.frame, b.frame], needed, left });
786
+ }
787
+ return { bounds: STANCE_BOUNDS, runs, over, stretch };
592
788
  }