@rig-c/pose 0.16.0 → 0.18.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
@@ -38,6 +39,15 @@
38
39
  * less than `STILL_SPEED` for `STILL_FRAMES` frames (rig-c/pose#47, round 2).
39
40
  * The floor is rolling because an ankle's picture y drifts with depth: under
40
41
  * one floor for the sequence, HQ's dancer standing still read as jumping.
42
+ *
43
+ * **In place** (rig-c/pose#92). A sequence declared `travel: "in-place"` is a
44
+ * gait that does not travel: its supporting foot moves back in the picture
45
+ * while the body stays, so holding it turns that slide into travel. Under the
46
+ * declaration each plant's anchor follows its own ankle's track (`follow`, the
47
+ * picture x scaled) from the frame it planted, its y still on the one floor; no
48
+ * step is read, since a foot that follows its track has no plant to drag; and
49
+ * the hip's x drift from the first frame to the last is spread back over the
50
+ * frames (`closeLoop`), so the loop carries no net travel.
41
51
  */
42
52
  import type { Chain, JointMap, Rest } from './map.ts';
43
53
 
@@ -324,6 +334,28 @@ export interface Feet {
324
334
  steps: Step[];
325
335
  /** The frames a change of support (or a landing, or a re-plant) was keyed on: the blend starts there. */
326
336
  changes: Array<{ frame: number; from: Side | null; to: Side }>;
337
+ /**
338
+ * rig-c/pose#113, read from the lower ankle: each run of frames whose
339
+ * supporting side differs from the reading before #113 (`was`, `now`; null:
340
+ * no foot supports). Absent where the rule does not apply (in place, or
341
+ * without `lowerAnkle`).
342
+ */
343
+ lowered?: Lowered[];
344
+ }
345
+
346
+ /** A run of frames whose support the lower-ankle rule (rig-c/pose#113) reads differently from the reading before it. */
347
+ export interface Lowered {
348
+ frames: [number, number];
349
+ was: Side | null;
350
+ now: Side | null;
351
+ /**
352
+ * Why no foot supports, where `now` is null: `released` — the supporting foot
353
+ * was released on the run's first frame because the other ankle had been lower
354
+ * past the margin for `PLANT_SWITCH_SECONDS` and could not take support
355
+ * (released by a step, or moving); `higher` — the only foot free to take
356
+ * support was the higher one.
357
+ */
358
+ cause?: 'released' | 'higher';
327
359
  }
328
360
 
329
361
  /** Each frame's rolling floor of a picture-y series: its `FLOOR_PERCENTILE` over the frames within ± `FLOOR_SECONDS`. */
@@ -338,9 +370,36 @@ export function rollingFloor(ys: readonly number[], times: readonly number[]): n
338
370
  /**
339
371
  * Which foot supports on each frame, from the reference ankles and hips (picture
340
372
  * px, y down) — the header's rules. `times` are the key times; `secondsOf`
341
- * times a run of frames as the rest of the retarget does.
373
+ * times a run of frames as the rest of the retarget does. `inPlace` (#92): no
374
+ * ankle is moving for the step rules, so nothing is released and every foot is
375
+ * free to take support.
376
+ *
377
+ * `lowerAnkle` (rig-c/pose#113, `--plant-feet` on a sequence not declared in
378
+ * place): a foot whose ankle is higher in the picture than the other's by
379
+ * `PLANT_SWITCH` leg lengths or more never takes support — not when the plant
380
+ * starts, not when a step releases the other foot. A supporting foot whose
381
+ * other ankle has been that much lower for `PLANT_SWITCH_SECONDS` hands support
382
+ * to it when it is free (as before) and is otherwise released (the hip follows
383
+ * the pose until a foot may take support). Steps and jumps are read as before.
384
+ * The frames whose support this changes are `lowered`.
342
385
  */
343
- export function supportsOf(ankle: Record<Side, readonly Pt[]>, hip: Record<Side, readonly Pt[]>, times: readonly number[], secondsOf: (run: [number, number]) => number): Feet {
386
+ export function supportsOf(ankle: Record<Side, readonly Pt[]>, hip: Record<Side, readonly Pt[]>, times: readonly number[], secondsOf: (run: [number, number]) => number, inPlace = false, lowerAnkle = false): Feet {
387
+ if (inPlace || !lowerAnkle) return readSupports(ankle, hip, times, secondsOf, inPlace, false).feet;
388
+ const { feet, released } = readSupports(ankle, hip, times, secondsOf, false, true);
389
+ const before = readSupports(ankle, hip, times, secondsOf, false, false).feet.support;
390
+ const lowered: Lowered[] = [];
391
+ feet.support.forEach((now, i) => {
392
+ const was = before[i] ?? null;
393
+ if (was === now) return;
394
+ const last = lowered[lowered.length - 1];
395
+ if (last !== undefined && last.frames[1] === i - 1 && last.was === was && last.now === now) last.frames[1] = i;
396
+ else lowered.push({ frames: [i, i], was, now, ...(now === null ? { cause: released.includes(i) ? ('released' as const) : ('higher' as const) } : {}) });
397
+ });
398
+ return { ...feet, lowered };
399
+ }
400
+
401
+ /** The reader; `released` (#113): the frames a supporting foot was released because the lower foot could not take support. */
402
+ function readSupports(ankle: Record<Side, readonly Pt[]>, hip: Record<Side, readonly Pt[]>, times: readonly number[], secondsOf: (run: [number, number]) => number, inPlace: boolean, lowerAnkle: boolean): { feet: Feet; released: number[] } {
344
403
  const n = ankle.r.length;
345
404
  const legLength = {} as Record<Side, number>;
346
405
  for (const s of SIDES) legLength[s] = percentile(ankle[s].map((a, i) => Math.hypot(a[0] - hip[s][i]![0], a[1] - hip[s][i]![1])), 0.5);
@@ -363,7 +422,9 @@ export function supportsOf(ankle: Record<Side, readonly Pt[]>, hip: Record<Side,
363
422
  // Ankle speeds, leg lengths a frame (0 on the first frame).
364
423
  const speed = {} as Record<Side, number[]>;
365
424
  for (const s of SIDES) speed[s] = ankle[s].map((a, i) => (i === 0 ? 0 : Math.hypot(a[0] - ankle[s][i - 1]![0], a[1] - ankle[s][i - 1]![1]) / legLength[s]));
366
- const moving = (s: Side, i: number) => speed[s][i]! > STEP_SPEED;
425
+ // In place (rig-c/pose#92) the supporting foot follows its own track instead of being held, so a
426
+ // moving ankle has no plant to drag and nothing is released: no step, and every foot is free.
427
+ const moving = (s: Side, i: number) => !inPlace && speed[s][i]! > STEP_SPEED;
367
428
  const still = (s: Side, i: number) => {
368
429
  for (let k = i - STILL_FRAMES + 1; k <= i; k++) if (k >= 0 && !(speed[s][k]! < STILL_SPEED)) return false;
369
430
  return true;
@@ -374,6 +435,7 @@ export function supportsOf(ankle: Record<Side, readonly Pt[]>, hip: Record<Side,
374
435
  const open: Partial<Record<Side, Step>> = {};
375
436
  let cur: Side | null = null;
376
437
  let lowerFrom = -1;
438
+ const released: number[] = [];
377
439
  const lower = (cands: Side[], i: number): Side => (cands.length === 2 ? (ankle.l[i]![1] > ankle.r[i]![1] ? 'l' : 'r') : cands[0]!);
378
440
  for (let i = 0; i < n; i++) {
379
441
  // A released ankle re-plants once it has been still for STILL_FRAMES frames.
@@ -391,9 +453,12 @@ export function supportsOf(ankle: Record<Side, readonly Pt[]>, hip: Record<Side,
391
453
  continue;
392
454
  }
393
455
  const free = (s: Side) => open[s] === undefined && !moving(s, i);
456
+ // #113: a foot higher than the other by PLANT_SWITCH leg lengths or more does not take support.
457
+ const higher = (s: Side) => ankle[s === 'r' ? 'l' : 'r'][i]![1] - ankle[s][i]![1] >= PLANT_SWITCH * mean;
458
+ const may = (s: Side) => free(s) && !(lowerAnkle && higher(s));
394
459
  if (cur !== null && moving(cur, i)) {
395
460
  const other: Side = cur === 'r' ? 'l' : 'r';
396
- const to: Side | null = free(other) ? other : null;
461
+ const to: Side | null = may(other) ? other : null;
397
462
  const st: Step = { side: cur, frames: [i, n - 1], replant: null, to };
398
463
  steps.push(st);
399
464
  open[cur] = st;
@@ -401,7 +466,7 @@ export function supportsOf(ankle: Record<Side, readonly Pt[]>, hip: Record<Side,
401
466
  cur = to;
402
467
  lowerFrom = -1;
403
468
  } else if (cur === null) {
404
- const cands = SIDES.filter(free);
469
+ const cands = SIDES.filter(may);
405
470
  if (cands.length > 0) {
406
471
  cur = lower(cands, i);
407
472
  changes.push({ frame: i, from: null, to: cur });
@@ -409,11 +474,16 @@ export function supportsOf(ankle: Record<Side, readonly Pt[]>, hip: Record<Side,
409
474
  }
410
475
  } else {
411
476
  const other: Side = cur === 'r' ? 'l' : 'r';
412
- if (free(other) && ankle[other][i]![1] - ankle[cur][i]![1] >= PLANT_SWITCH * mean) {
477
+ if ((free(other) || lowerAnkle) && higher(cur)) {
413
478
  if (lowerFrom < 0) lowerFrom = i;
414
479
  if (secondsOf([lowerFrom, i]) >= PLANT_SWITCH_SECONDS - 1e-9) {
415
- changes.push({ frame: i, from: cur, to: other });
416
- cur = other;
480
+ if (free(other)) {
481
+ changes.push({ frame: i, from: cur, to: other });
482
+ cur = other;
483
+ } else {
484
+ cur = null; // #113: the lower foot may not take support (released, or moving); neither foot supports
485
+ released.push(i);
486
+ }
417
487
  lowerFrom = -1;
418
488
  }
419
489
  } else lowerFrom = -1;
@@ -428,29 +498,115 @@ export function supportsOf(ankle: Record<Side, readonly Pt[]>, hip: Record<Side,
428
498
  if (last !== undefined && last.side === s && last.frames[1] === i - 1) last.frames[1] = i;
429
499
  else supports.push({ side: s, frames: [i, i] });
430
500
  }
431
- return { legLength, support, supports, jumps, steps, changes };
501
+ return { feet: { legLength, support, supports, jumps, steps, changes }, released };
502
+ }
503
+
504
+ /** One support span's shift onto the floor (rig-c/pose#85): its side, its frames, and floor − its planted height, world units. */
505
+ export interface FloorShift {
506
+ side: Side;
507
+ frames: [number, number];
508
+ shift: number;
509
+ }
510
+
511
+ /** 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. */
512
+ export interface Floor {
513
+ y: number;
514
+ given: boolean;
515
+ shifts: FloorShift[];
432
516
  }
433
517
 
434
518
  /**
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).
519
+ * The hip's world origin on every frame, from the supporting foot (header),
520
+ * with every support span on one floor (rig-c/pose#85). `offset(i, side)` is
521
+ * the ankle's posed position relative to the hip's origin on frame i;
522
+ * `travel(i)` the pose's own travel of the hip from frame 0, world. `start` is
523
+ * the hip's origin on frame 0 (its setup origin).
524
+ *
525
+ * **One floor.** A span's planted height is the world y its supporting ankle is
526
+ * held at when it is held where the picture puts it on the frame it takes
527
+ * support. A 2D rig has one floor, so every span is planted on it instead: the
528
+ * floor is `floor` when given (`--floor`, world units), and otherwise the median
529
+ * of the spans' planted heights; each span's anchor moves in y by floor − its
530
+ * planted height, and the hip with it, so everything above follows. A change of
531
+ * support still blends the two derivations over `PLANT_BLEND` frames, now
532
+ * between two spans on the floor; a jump follows the pose's travel from the hip
533
+ * it left (on the floor) and lands on the floor. On a blend frame the hip is
534
+ * between the two derivations, so the supporting ankle is off its anchor:
535
+ * `blends` lists those frames with the anchor's y, and `holdBlend` stretches
536
+ * the supporting leg to keep it there. Null `floor` in the result: there is no
537
+ * support span.
538
+ *
539
+ * `follow(i, side)` (#92, an in-place sequence): the supporting ankle's track,
540
+ * world x. A plant's anchor x then moves by follow(i) − follow(the frame it
541
+ * planted), and the loop is closed (`closeLoop`, `closure` in the result; null
542
+ * without `follow`).
439
543
  */
440
- export function plantHip(feet: Feet, offset: (i: number, s: Side) => Pt, travel: (i: number) => Pt, start: Pt): Pt[] {
544
+ export function plantHip(feet: Feet, offset: (i: number, s: Side) => Pt, travel: (i: number) => Pt, start: Pt, floor?: number, follow?: (i: number, s: Side) => number): { hip: Pt[]; floor: Floor | null; blends: BlendFrame[]; closure: Closure | null } {
545
+ const picture = placeHip(feet, offset, travel, start, null, follow);
546
+ const heights = feet.supports.map((sp) => picture.anchors.get(sp.frames[0]));
547
+ if (heights.length === 0) return { hip: picture.hip, floor: null, blends: picture.blends, closure: null };
548
+ if (heights.some((h) => h === undefined)) throw new Error('plantHip: a support span starts on a frame no plant started on');
549
+ const y = floor ?? percentile(heights as number[], 0.5);
550
+ const shifts = feet.supports.map((sp, k) => ({ side: sp.side, frames: [sp.frames[0], sp.frames[1]] as [number, number], shift: y - heights[k]! }));
551
+ const placed = placeHip(feet, offset, travel, start, y, follow);
552
+ const closure = follow === undefined ? null : closeLoop(placed.hip);
553
+ return { hip: placed.hip, floor: { y, given: floor !== undefined, shifts }, blends: placed.blends, closure };
554
+ }
555
+
556
+ /** In place (rig-c/pose#92): the hip's x travel from the first frame to the last before the loop was closed, world units, and the frames it was spread over. */
557
+ export interface Closure {
558
+ drift: number;
559
+ frames: number;
560
+ }
561
+
562
+ /**
563
+ * Close an in-place loop (rig-c/pose#92): the hip's x on frame i moves by
564
+ * −drift · i / (n − 1), drift its x on the last frame less its x on the first,
565
+ * so the last frame's hip x is the first's and the loop carries no net travel.
566
+ * The y is the floor's (#85) and is not touched. In place.
567
+ */
568
+ export function closeLoop(hip: Pt[]): Closure {
569
+ const n = hip.length;
570
+ const drift = n > 1 ? hip[n - 1]![0] - hip[0]![0] : 0;
571
+ if (n > 1) for (let i = 0; i < n; i++) hip[i]![0] -= (drift * i) / (n - 1);
572
+ return { drift, frames: n };
573
+ }
574
+
575
+ /** 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. */
576
+ export interface BlendFrame {
577
+ frame: number;
578
+ side: Side;
579
+ y: number;
580
+ }
581
+
582
+ /**
583
+ * The hip placed from the supporting foot, each plant's anchor held where the
584
+ * picture puts it (`floorY` null) or at world y `floorY`; and each plant's
585
+ * picture anchor y, by the frame its support span starts on.
586
+ */
587
+ function placeHip(feet: Feet, offset: (i: number, s: Side) => Pt, travel: (i: number) => Pt, start: Pt, floorY: number | null, follow?: (i: number, s: Side) => number): { hip: Pt[]; anchors: Map<number, number>; blends: BlendFrame[] } {
441
588
  const n = feet.support.length;
442
589
  const hip: Pt[] = [];
590
+ const anchors = new Map<number, number>();
591
+ const blends: BlendFrame[] = [];
443
592
  /** 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
- type Mode = { kind: 'plant'; side: Side; anchor: Pt } | { kind: 'travel'; from: number; at: Pt };
593
+ type Mode = { kind: 'plant'; side: Side; anchor: Pt; from: number } | { kind: 'travel'; from: number; at: Pt };
445
594
  const of = (mode: Mode, i: number): Pt => {
446
595
  if (mode.kind === 'plant') {
447
596
  const o = offset(i, mode.side);
448
- return [mode.anchor[0] - o[0], mode.anchor[1] - o[1]];
597
+ // In place (#92) the anchor's x follows the supporting ankle's own track from the frame it planted.
598
+ const dx = follow === undefined ? 0 : follow(i, mode.side) - follow(mode.from, mode.side);
599
+ return [mode.anchor[0] + dx - o[0], mode.anchor[1] - o[1]];
449
600
  }
450
601
  const a = travel(i);
451
602
  const b = travel(mode.from);
452
603
  return [mode.at[0] + a[0] - b[0], mode.at[1] + a[1] - b[1]];
453
604
  };
605
+ /** A plant from frame i: its anchor's x where the foot is, its y there or on the floor. */
606
+ const plant = (i: number, side: Side, at: Pt): Mode => {
607
+ anchors.set(i, at[1]);
608
+ return { kind: 'plant', side, anchor: [at[0], floorY ?? at[1]], from: i };
609
+ };
454
610
  let mode: Mode | null = null;
455
611
  let from: Mode | null = null;
456
612
  let blendAt = -1;
@@ -460,7 +616,7 @@ export function plantHip(feet: Feet, offset: (i: number, s: Side) => Pt, travel:
460
616
  if (s === null) mode = { kind: 'travel', from: 0, at: start };
461
617
  else {
462
618
  const o = offset(0, s);
463
- mode = { kind: 'plant', side: s, anchor: [start[0] + o[0], start[1] + o[1]] };
619
+ mode = plant(0, s, [start[0] + o[0], start[1] + o[1]]);
464
620
  }
465
621
  } else if (s === null && mode!.kind === 'plant') {
466
622
  mode = { kind: 'travel', from: i - 1, at: hip[i - 1]! };
@@ -471,7 +627,7 @@ export function plantHip(feet: Feet, offset: (i: number, s: Side) => Pt, travel:
471
627
  const here = of(mode!, i);
472
628
  const o = offset(i, s);
473
629
  from = mode;
474
- mode = { kind: 'plant', side: s, anchor: [here[0] + o[0], here[1] + o[1]] };
630
+ mode = plant(i, s, [here[0] + o[0], here[1] + o[1]]);
475
631
  blendAt = i;
476
632
  }
477
633
  const to = of(mode!, i);
@@ -479,30 +635,88 @@ export function plantHip(feet: Feet, offset: (i: number, s: Side) => Pt, travel:
479
635
  const w = (i - blendAt + 1) / (PLANT_BLEND + 1);
480
636
  const f = of(from, i);
481
637
  hip.push([f[0] + (to[0] - f[0]) * w, f[1] + (to[1] - f[1]) * w]);
638
+ const m = mode!;
639
+ if (m.kind === 'plant') blends.push({ frame: i, side: m.side, y: m.anchor[1] });
482
640
  } else {
483
641
  from = null;
484
642
  hip.push(to);
485
643
  }
486
644
  }
487
- return hip;
645
+ return { hip, anchors, blends };
488
646
  }
489
647
 
490
648
  // --- stance (rig-c/pose#60) --------------------------------------------------
491
649
 
492
- /** 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. */
650
+ /**
651
+ * 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.
652
+ * The default: a frame whose leg the picture folds further has its lower bound from the leg's own range (`stanceRangeOf`, #95).
653
+ */
493
654
  export const STANCE_BOUNDS: readonly [number, number] = [0.92, 1.08];
494
655
 
656
+ /** Per side, per frame: the lower bound of the leg's stretch (#95). */
657
+ export type StanceLower = Record<Side, readonly number[]>;
658
+
659
+ /**
660
+ * The leg's own range (rig-c/pose#95). Per side, r(i) = the leg's picture
661
+ * length hip → ankle on frame i ÷ its straight length (`STRAIGHT_PERCENTILE`
662
+ * of that length over the sequence, as `--leg-scale` reads a segment's), and
663
+ * the lower bound on frame i is min(`STANCE_BOUNDS[0]`, r(i)): the rig leg may
664
+ * fold as far as the picture's leg is folded on that frame, never less far
665
+ * than the default. A leg with no straight length keeps the default.
666
+ */
667
+ export function stanceRangeOf(ankle: Record<Side, readonly Pt[]>, hip: Record<Side, readonly Pt[]>): StanceLower {
668
+ const out = {} as Record<Side, number[]>;
669
+ for (const s of SIDES) {
670
+ const len = ankle[s].map((a, i) => Math.hypot(a[0] - hip[s][i]![0], a[1] - hip[s][i]![1]));
671
+ const straight = percentile(len, STRAIGHT_PERCENTILE);
672
+ out[s] = len.map((l) => (straight > 1e-9 && Number.isFinite(l) ? Math.min(STANCE_BOUNDS[0], l / straight) : STANCE_BOUNDS[0]));
673
+ }
674
+ return out;
675
+ }
676
+
677
+ /** The lower bound on frame i for side s: the leg's own (#95), or the default. */
678
+ function lowerOf(lower: StanceLower | undefined, s: Side, i: number): number {
679
+ return lower?.[s][i] ?? STANCE_BOUNDS[0];
680
+ }
681
+
682
+ /** The lowest lower bound per side over `frames`, where any is below the default (#95); null where none is. */
683
+ function rangeUsed(lower: StanceLower | undefined, frames: ReadonlyArray<{ side: Side; frame: number }>): Partial<Record<Side, number>> | null {
684
+ const out: Partial<Record<Side, number>> = {};
685
+ for (const { side, frame } of frames) {
686
+ const lo = lowerOf(lower, side, frame);
687
+ if (lo < STANCE_BOUNDS[0]) out[side] = Math.min(out[side] ?? Infinity, lo);
688
+ }
689
+ return Object.keys(out).length === 0 ? null : out;
690
+ }
691
+
692
+ /** 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. */
693
+ export const STANCE_RELEASE = 0.8;
694
+ /** 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. */
695
+ export const STANCE_MIN_SECONDS = 0.1;
696
+
697
+ /**
698
+ * A free ankle's state on a frame (#60, #114): `both`, within the tolerance of
699
+ * the supporting one in either direction, so both feet are planted; `lifted`,
700
+ * past it above; `below`, past it below — a support error, not a stance, never
701
+ * planted. Past the tolerance either way the pose's height is kept.
702
+ */
703
+ export type StanceState = 'both' | 'lifted' | 'below';
704
+
495
705
  /** A run of frames with one free side in one state. */
496
706
  export interface StanceRun {
497
- /** `both`: the free ankle within the tolerance of the floor, so both feet are planted; `lifted`: above it, the pose's height kept. */
498
- state: 'both' | 'lifted';
707
+ /** `both`: the free ankle within the tolerance of the floor, so both feet are planted; `lifted`: past it above, `below`: past it below (#114), the pose's height kept. */
708
+ state: StanceState;
499
709
  /** The side that is not supporting. */
500
710
  side: Side;
501
711
  frames: [number, number];
502
- /** The free ankle's largest height above the supporting one in the picture, in that side's leg lengths. */
712
+ /** The free ankle's largest height above the supporting one in the picture, in that side's leg lengths (negative where it is below on every frame). */
503
713
  maxHeight: number;
714
+ /** `below` (#114): the free ankle's largest distance below the supporting one in the picture, in that side's leg lengths. */
715
+ maxDepth?: number;
504
716
  /** `both`: the smallest and largest stretch keyed (bounded); `lifted`: null. */
505
717
  k: { min: number; max: number } | null;
718
+ /** #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. */
719
+ absorbed?: true;
506
720
  }
507
721
 
508
722
  /** A run of planted-both frames whose stretch needed a factor past the bounds (or could not reach: `needed` null). */
@@ -513,12 +727,20 @@ export interface StanceOver {
513
727
  needed: number | null;
514
728
  /** The free ankle's largest distance left from the floor after the bound, world units. */
515
729
  left: number;
730
+ /** #95: the bound on the frame of `needed`, where the leg's own range set it below the default. */
731
+ bound?: [number, number];
516
732
  }
517
733
 
518
734
  export interface Stance {
519
735
  /** The tolerance, in leg lengths (FEET's: the median picture hip → ankle of the free side). */
520
736
  tolerance: number;
737
+ /** A lifted foot is planted again under `release × tolerance` (#87). */
738
+ release: number;
739
+ /** The shortest run kept as its own state, seconds (#87). */
740
+ minSeconds: number;
521
741
  bounds: readonly [number, number];
742
+ /** #95: per side, the lowest lower bound the leg's own range set on a planted-both frame, where any is below `bounds[0]`. */
743
+ range?: Partial<Record<Side, number>>;
522
744
  runs: StanceRun[];
523
745
  over: StanceOver[];
524
746
  /** Per frame: the free side and the stretch keyed on it, or null (lifted, a jump, no support). */
@@ -535,26 +757,96 @@ export interface StancePosed {
535
757
  * Both feet planted when the free ankle is near the floor (rig-c/pose#60). On a
536
758
  * frame with a supporting side (so outside every jump), the free ankle's height
537
759
  * above the supporting one is read in the picture (`ankle`, px, y down) in the
538
- * free side's leg length — the unit of every FEET threshold. Within `tolerance`,
539
- * the frame is planted both: the free leg is to be stretched along its chain by
760
+ * free side's leg length — the unit of every FEET threshold. The tolerance is a
761
+ * distance (#114): within `tolerance` of the supporting ankle, above it or below
762
+ * it (|h| ≤ tolerance), the frame is planted both: the free leg is to be stretched along its chain by
540
763
  * k = (root y − floor y) / (root y − ankle y) on the posed rig (`posed`, world,
541
764
  * y up), the floor being the supporting ankle's posed y; k is held within
542
765
  * `STANCE_BOUNDS` (a free ankle below the floor shortens its leg, one above
543
- * lengthens it), and a frame past them is listed in `over`. Above the tolerance
544
- * the foot is lifted and keeps the pose's height.
766
+ * lengthens it), its lower bound on the frame the leg's own (`lower`, #95)
767
+ * where the picture folds that leg further, and a frame past them is listed in
768
+ * `over`. Past the tolerance above, the foot is lifted and keeps the pose's
769
+ * height. Past it below (h < −tolerance), the free ankle is far below the
770
+ * supporting one: a support error, not a stance. That frame is never planted
771
+ * both, keeps the pose's height, and is read `below` (#114); before #114 the
772
+ * gap was signed, and a lifted foot read as supporting had the grounded leg
773
+ * shrunk up to it.
774
+ *
775
+ * The state is read with hysteresis (#87) on |h|: a planted foot leaves the
776
+ * stance when |h| goes past `tolerance`, and one past it is planted again only
777
+ * under `STANCE_RELEASE × tolerance`, so noise at the threshold does not flip it. A
778
+ * run of one state shorter than `STANCE_MIN_SECONDS` (`secondsOf`, the
779
+ * retarget's timing of a run) between two runs of the other state, on the same
780
+ * free side and with no frame between, takes their state, and is listed with
781
+ * `absorbed`.
545
782
  */
546
- export function stanceOf(feet: Feet, ankle: Record<Side, readonly Pt[]>, tolerance: number, posed: (i: number) => StancePosed): Stance {
783
+ export function stanceOf(feet: Feet, ankle: Record<Side, readonly Pt[]>, tolerance: number, posed: (i: number) => StancePosed, secondsOf: (run: [number, number]) => number, lower?: StanceLower): Stance {
547
784
  const n = feet.support.length;
548
785
  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);
786
+ type Read = { state: StanceState; side: Side; height: number };
787
+ const read: Array<Read | null> = new Array<Read | null>(n).fill(null);
551
788
  for (let i = 0; i < n; i++) {
552
789
  const s = feet.support[i] ?? null;
553
790
  if (s === null) continue;
554
791
  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) {
557
- rows[i] = { state: 'lifted', side: f, height, k: null, needed: null, over: false, left: 0 };
792
+ read[i] = { state: 'both', side: f, height: (ankle[s][i]![1] - ankle[f][i]![1]) / feet.legLength[f] };
793
+ }
794
+ // Blocks of consecutive frames with one free side. Inside a block the state changes only when the
795
+ // other state's condition (out of the stance: |h| past the tolerance; planted again: |h| under
796
+ // STANCE_RELEASE of it) holds for STANCE_MIN_SECONDS; a shorter crossing with the current state on
797
+ // both sides of it is absorbed. A crossing that runs to the block's end is taken: nothing of the
798
+ // current state follows it. Out of the stance, a frame is `lifted` or `below` by the sign of h; a
799
+ // frame absorbed into it takes the state of the frame before it.
800
+ const away = (h: number): 'lifted' | 'below' => (h > 0 ? 'lifted' : 'below');
801
+ const absorbed: StanceRun[] = [];
802
+ for (let i = 0; i < n; ) {
803
+ const r0 = read[i];
804
+ if (r0 === null || r0 === undefined) {
805
+ i++;
806
+ continue;
807
+ }
808
+ let end = i;
809
+ while (end + 1 < n && read[end + 1] !== null && read[end + 1]!.side === r0.side) end++;
810
+ let planted = Math.abs(r0.height) <= tolerance;
811
+ let j = i;
812
+ while (j <= end) {
813
+ const crosses = (k: number) => (planted ? Math.abs(read[k]!.height) > tolerance : Math.abs(read[k]!.height) <= STANCE_RELEASE * tolerance);
814
+ if (!crosses(j)) {
815
+ read[j]!.state = planted ? 'both' : away(read[j]!.height);
816
+ j++;
817
+ continue;
818
+ }
819
+ let k = j;
820
+ while (k + 1 <= end && crosses(k + 1)) k++;
821
+ if (k < end && secondsOf([j, k]) < STANCE_MIN_SECONDS) {
822
+ let maxHeight = -Infinity;
823
+ let far = 0;
824
+ for (let x = j; x <= k; x++) {
825
+ read[x]!.state = planted ? 'both' : read[x - 1]!.state;
826
+ maxHeight = Math.max(maxHeight, read[x]!.height);
827
+ if (Math.abs(read[x]!.height) > Math.abs(far)) far = read[x]!.height;
828
+ }
829
+ const other: StanceState = planted ? away(far) : 'both';
830
+ absorbed.push({ state: other, side: r0.side, frames: [j, k], maxHeight, ...(other === 'below' ? { maxDepth: -far } : {}), k: null, absorbed: true });
831
+ } else {
832
+ planted = !planted;
833
+ for (let x = j; x <= k; x++) read[x]!.state = planted ? 'both' : away(read[x]!.height);
834
+ }
835
+ j = k + 1;
836
+ }
837
+ i = end + 1;
838
+ }
839
+ type Row = { state: StanceState; side: Side; height: number; k: number | null; needed: number | null; over: boolean; left: number; lo: number };
840
+ const rows: Array<Row | null> = new Array<Row | null>(n).fill(null);
841
+ for (let i = 0; i < n; i++) {
842
+ const r = read[i];
843
+ if (r === null || r === undefined) continue;
844
+ const s: Side = r.side === 'r' ? 'l' : 'r';
845
+ const f = r.side;
846
+ const height = r.height;
847
+ const lo = lowerOf(lower, f, i);
848
+ if (r.state !== 'both') {
849
+ rows[i] = { state: r.state, side: f, height, k: null, needed: null, over: false, left: 0, lo };
558
850
  continue;
559
851
  }
560
852
  const p = posed(i);
@@ -563,12 +855,13 @@ export function stanceOf(feet: Feet, ankle: Record<Side, readonly Pt[]>, toleran
563
855
  const a = p.ankle[f][1];
564
856
  const ok = root - a > 1e-9 && root - floor > 1e-9;
565
857
  const needed = ok ? (root - floor) / (root - a) : null;
566
- const k = needed === null ? 1 : Math.min(Math.max(needed, STANCE_BOUNDS[0]), STANCE_BOUNDS[1]);
567
- const over = needed === null || needed < STANCE_BOUNDS[0] || needed > STANCE_BOUNDS[1];
858
+ const k = needed === null ? 1 : Math.min(Math.max(needed, lo), STANCE_BOUNDS[1]);
859
+ const over = needed === null || needed < lo || needed > STANCE_BOUNDS[1];
568
860
  const left = Math.abs(root - k * (root - a) - floor);
569
- rows[i] = { state: 'both', side: f, height, k, needed, over, left };
861
+ rows[i] = { state: 'both', side: f, height, k, needed, over, left, lo };
570
862
  if (k !== 1) stretch[i] = { side: f, k };
571
863
  }
864
+ const boundOf = (lo: number): { bound?: [number, number] } => (lo < STANCE_BOUNDS[0] ? { bound: [lo, STANCE_BOUNDS[1]] } : {});
572
865
  const runs: StanceRun[] = [];
573
866
  const over: StanceOver[] = [];
574
867
  for (let i = 0; i < n; i++) {
@@ -578,15 +871,104 @@ export function stanceOf(feet: Feet, ankle: Record<Side, readonly Pt[]>, toleran
578
871
  if (last !== undefined && last.frames[1] === i - 1 && last.state === r.state && last.side === r.side) {
579
872
  last.frames[1] = i;
580
873
  last.maxHeight = Math.max(last.maxHeight, r.height);
874
+ if (last.maxDepth !== undefined) last.maxDepth = Math.max(last.maxDepth, -r.height);
581
875
  if (last.k !== null && r.k !== null) last.k = { min: Math.min(last.k.min, r.k), max: Math.max(last.k.max, r.k) };
582
- } else runs.push({ state: r.state, side: r.side, frames: [i, i], maxHeight: r.height, k: r.k === null ? null : { min: r.k, max: r.k } });
876
+ } else runs.push({ state: r.state, side: r.side, frames: [i, i], maxHeight: r.height, ...(r.state === 'below' ? { maxDepth: -r.height } : {}), k: r.k === null ? null : { min: r.k, max: r.k } });
583
877
  if (!r.over) continue;
584
878
  const o = over[over.length - 1];
585
879
  if (o !== undefined && o.frames[1] === i - 1 && o.side === r.side) {
586
880
  o.frames[1] = i;
587
- o.needed = o.needed === null || r.needed === null ? null : Math.abs(r.needed - 1) > Math.abs(o.needed - 1) ? r.needed : o.needed;
881
+ const takes = o.needed !== null && (r.needed === null || Math.abs(r.needed - 1) > Math.abs(o.needed - 1));
882
+ o.needed = o.needed === null || r.needed === null ? null : takes ? r.needed : o.needed;
883
+ if (takes) {
884
+ delete o.bound;
885
+ Object.assign(o, boundOf(r.lo));
886
+ }
588
887
  o.left = Math.max(o.left, r.left);
589
- } else over.push({ side: r.side, frames: [i, i], needed: r.needed, left: r.left });
888
+ } else over.push({ side: r.side, frames: [i, i], needed: r.needed, left: r.left, ...boundOf(r.lo) });
889
+ }
890
+ for (const x of absorbed) runs.push(x);
891
+ runs.sort((a, b) => a.frames[0] - b.frames[0] || (a.absorbed === true ? 1 : 0) - (b.absorbed === true ? 1 : 0));
892
+ const range = rangeUsed(lower, rows.flatMap((r, i) => (r !== null && r.state === 'both' ? [{ side: r.side, frame: i }] : [])));
893
+ return { tolerance, release: STANCE_RELEASE, minSeconds: STANCE_MIN_SECONDS, bounds: STANCE_BOUNDS, ...(range === null ? {} : { range }), runs, over, stretch };
894
+ }
895
+
896
+ // --- the blend held on the floor (rig-c/pose#85) ------------------------------
897
+
898
+ /** 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). */
899
+ export const BLEND_HOLD_WITHIN = 0.01;
900
+
901
+ /** A run of blend frames on one supporting side whose leg was stretched. */
902
+ export interface BlendRun {
903
+ side: Side;
904
+ frames: [number, number];
905
+ /** The smallest and largest stretch keyed (bounded). */
906
+ k: { min: number; max: number };
907
+ /** #95: the lowest lower bound on the run's frames, where the leg's own range set it below the default. */
908
+ lower?: number;
909
+ }
910
+
911
+ export interface BlendHold {
912
+ bounds: readonly [number, number];
913
+ /** #95: per side, the lowest lower bound the leg's own range set on a blend frame, where any is below `bounds[0]`. */
914
+ range?: Partial<Record<Side, number>>;
915
+ runs: BlendRun[];
916
+ /** Runs whose stretch needed a factor past the bounds: held at the bound, the remainder left to the blend. */
917
+ over: StanceOver[];
918
+ /** Per frame: the supporting side and the stretch keyed on it, or null. */
919
+ stretch: Array<{ side: Side; k: number } | null>;
920
+ }
921
+
922
+ /**
923
+ * The supporting foot does not leave the floor during a blend (rig-c/pose#85).
924
+ * On a blend frame the hip is between the old derivation and the new, so the
925
+ * supporting ankle is off the y its plant holds. Its leg is stretched along its
926
+ * chain about the chain's root, as `--stance` stretches a free leg (`stanceOf`):
927
+ * k = (root y − anchor y) / (root y − ankle y) on the posed rig (`posed`, world,
928
+ * y up), held within `STANCE_BOUNDS` — its lower bound on the frame the leg's own
929
+ * (`lower`, #95) as `stanceOf`'s; a frame past them is listed in `over`, and
930
+ * the remainder stays with the blend. An ankle within `BLEND_HOLD_WITHIN` of the
931
+ * anchor is not stretched.
932
+ */
933
+ export function holdBlend(blends: readonly BlendFrame[], n: number, posed: (i: number) => StancePosed, lower?: StanceLower): BlendHold {
934
+ const stretch: BlendHold['stretch'] = new Array(n).fill(null);
935
+ const runs: BlendRun[] = [];
936
+ const over: StanceOver[] = [];
937
+ const held: Array<{ side: Side; frame: number }> = [];
938
+ for (const b of blends) {
939
+ const p = posed(b.frame);
940
+ const root = p.root[b.side][1];
941
+ const a = p.ankle[b.side][1];
942
+ if (Math.abs(a - b.y) <= BLEND_HOLD_WITHIN) continue;
943
+ held.push({ side: b.side, frame: b.frame });
944
+ const ok = root - a > 1e-9 && root - b.y > 1e-9;
945
+ const needed = ok ? (root - b.y) / (root - a) : null;
946
+ const lo = lowerOf(lower, b.side, b.frame);
947
+ const k = needed === null ? 1 : Math.min(Math.max(needed, lo), STANCE_BOUNDS[1]);
948
+ const left = Math.abs(root - k * (root - a) - b.y);
949
+ const bound = lo < STANCE_BOUNDS[0] ? { bound: [lo, STANCE_BOUNDS[1]] as [number, number] } : {};
950
+ if (k !== 1) {
951
+ stretch[b.frame] = { side: b.side, k };
952
+ const last = runs[runs.length - 1];
953
+ if (last !== undefined && last.side === b.side && last.frames[1] === b.frame - 1) {
954
+ last.frames[1] = b.frame;
955
+ last.k = { min: Math.min(last.k.min, k), max: Math.max(last.k.max, k) };
956
+ if (lo < STANCE_BOUNDS[0]) last.lower = Math.min(last.lower ?? Infinity, lo);
957
+ } else runs.push({ side: b.side, frames: [b.frame, b.frame], k: { min: k, max: k }, ...(lo < STANCE_BOUNDS[0] ? { lower: lo } : {}) });
958
+ }
959
+ if (needed !== null && needed >= lo && needed <= STANCE_BOUNDS[1]) continue;
960
+ const o = over[over.length - 1];
961
+ if (o !== undefined && o.side === b.side && o.frames[1] === b.frame - 1) {
962
+ o.frames[1] = b.frame;
963
+ const takes = o.needed !== null && (needed === null || Math.abs(needed - 1) > Math.abs(o.needed - 1));
964
+ o.needed = o.needed === null || needed === null ? null : takes ? needed : o.needed;
965
+ if (takes) {
966
+ delete o.bound;
967
+ Object.assign(o, bound);
968
+ }
969
+ o.left = Math.max(o.left, left);
970
+ } else over.push({ side: b.side, frames: [b.frame, b.frame], needed, left, ...bound });
590
971
  }
591
- return { tolerance, bounds: STANCE_BOUNDS, runs, over, stretch };
972
+ const range = rangeUsed(lower, held);
973
+ return { bounds: STANCE_BOUNDS, ...(range === null ? {} : { range }), runs, over, stretch };
592
974
  }