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