@rig-c/pose 0.17.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
@@ -39,6 +39,15 @@
39
39
  * less than `STILL_SPEED` for `STILL_FRAMES` frames (rig-c/pose#47, round 2).
40
40
  * The floor is rolling because an ankle's picture y drifts with depth: under
41
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.
42
51
  */
43
52
  import type { Chain, JointMap, Rest } from './map.ts';
44
53
 
@@ -325,6 +334,28 @@ export interface Feet {
325
334
  steps: Step[];
326
335
  /** The frames a change of support (or a landing, or a re-plant) was keyed on: the blend starts there. */
327
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';
328
359
  }
329
360
 
330
361
  /** Each frame's rolling floor of a picture-y series: its `FLOOR_PERCENTILE` over the frames within ± `FLOOR_SECONDS`. */
@@ -339,9 +370,36 @@ export function rollingFloor(ys: readonly number[], times: readonly number[]): n
339
370
  /**
340
371
  * Which foot supports on each frame, from the reference ankles and hips (picture
341
372
  * 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.
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`.
343
385
  */
344
- 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[] } {
345
403
  const n = ankle.r.length;
346
404
  const legLength = {} as Record<Side, number>;
347
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);
@@ -364,7 +422,9 @@ export function supportsOf(ankle: Record<Side, readonly Pt[]>, hip: Record<Side,
364
422
  // Ankle speeds, leg lengths a frame (0 on the first frame).
365
423
  const speed = {} as Record<Side, number[]>;
366
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]));
367
- 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;
368
428
  const still = (s: Side, i: number) => {
369
429
  for (let k = i - STILL_FRAMES + 1; k <= i; k++) if (k >= 0 && !(speed[s][k]! < STILL_SPEED)) return false;
370
430
  return true;
@@ -375,6 +435,7 @@ export function supportsOf(ankle: Record<Side, readonly Pt[]>, hip: Record<Side,
375
435
  const open: Partial<Record<Side, Step>> = {};
376
436
  let cur: Side | null = null;
377
437
  let lowerFrom = -1;
438
+ const released: number[] = [];
378
439
  const lower = (cands: Side[], i: number): Side => (cands.length === 2 ? (ankle.l[i]![1] > ankle.r[i]![1] ? 'l' : 'r') : cands[0]!);
379
440
  for (let i = 0; i < n; i++) {
380
441
  // A released ankle re-plants once it has been still for STILL_FRAMES frames.
@@ -392,9 +453,12 @@ export function supportsOf(ankle: Record<Side, readonly Pt[]>, hip: Record<Side,
392
453
  continue;
393
454
  }
394
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));
395
459
  if (cur !== null && moving(cur, i)) {
396
460
  const other: Side = cur === 'r' ? 'l' : 'r';
397
- const to: Side | null = free(other) ? other : null;
461
+ const to: Side | null = may(other) ? other : null;
398
462
  const st: Step = { side: cur, frames: [i, n - 1], replant: null, to };
399
463
  steps.push(st);
400
464
  open[cur] = st;
@@ -402,7 +466,7 @@ export function supportsOf(ankle: Record<Side, readonly Pt[]>, hip: Record<Side,
402
466
  cur = to;
403
467
  lowerFrom = -1;
404
468
  } else if (cur === null) {
405
- const cands = SIDES.filter(free);
469
+ const cands = SIDES.filter(may);
406
470
  if (cands.length > 0) {
407
471
  cur = lower(cands, i);
408
472
  changes.push({ frame: i, from: null, to: cur });
@@ -410,11 +474,16 @@ export function supportsOf(ankle: Record<Side, readonly Pt[]>, hip: Record<Side,
410
474
  }
411
475
  } else {
412
476
  const other: Side = cur === 'r' ? 'l' : 'r';
413
- if (free(other) && ankle[other][i]![1] - ankle[cur][i]![1] >= PLANT_SWITCH * mean) {
477
+ if ((free(other) || lowerAnkle) && higher(cur)) {
414
478
  if (lowerFrom < 0) lowerFrom = i;
415
479
  if (secondsOf([lowerFrom, i]) >= PLANT_SWITCH_SECONDS - 1e-9) {
416
- changes.push({ frame: i, from: cur, to: other });
417
- 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
+ }
418
487
  lowerFrom = -1;
419
488
  }
420
489
  } else lowerFrom = -1;
@@ -429,7 +498,7 @@ export function supportsOf(ankle: Record<Side, readonly Pt[]>, hip: Record<Side,
429
498
  if (last !== undefined && last.side === s && last.frames[1] === i - 1) last.frames[1] = i;
430
499
  else supports.push({ side: s, frames: [i, i] });
431
500
  }
432
- return { legLength, support, supports, jumps, steps, changes };
501
+ return { feet: { legLength, support, supports, jumps, steps, changes }, released };
433
502
  }
434
503
 
435
504
  /** One support span's shift onto the floor (rig-c/pose#85): its side, its frames, and floor − its planted height, world units. */
@@ -466,16 +535,41 @@ export interface Floor {
466
535
  * `blends` lists those frames with the anchor's y, and `holdBlend` stretches
467
536
  * the supporting leg to keep it there. Null `floor` in the result: there is no
468
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`).
469
543
  */
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);
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);
472
546
  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 };
547
+ if (heights.length === 0) return { hip: picture.hip, floor: null, blends: picture.blends, closure: null };
474
548
  if (heights.some((h) => h === undefined)) throw new Error('plantHip: a support span starts on a frame no plant started on');
475
549
  const y = floor ?? percentile(heights as number[], 0.5);
476
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]! }));
477
- const placed = placeHip(feet, offset, travel, start, y);
478
- return { hip: placed.hip, floor: { y, given: floor !== undefined, shifts }, blends: placed.blends };
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 };
479
573
  }
480
574
 
481
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. */
@@ -490,17 +584,19 @@ export interface BlendFrame {
490
584
  * picture puts it (`floorY` null) or at world y `floorY`; and each plant's
491
585
  * picture anchor y, by the frame its support span starts on.
492
586
  */
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[] } {
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[] } {
494
588
  const n = feet.support.length;
495
589
  const hip: Pt[] = [];
496
590
  const anchors = new Map<number, number>();
497
591
  const blends: BlendFrame[] = [];
498
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. */
499
- 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 };
500
594
  const of = (mode: Mode, i: number): Pt => {
501
595
  if (mode.kind === 'plant') {
502
596
  const o = offset(i, mode.side);
503
- 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]];
504
600
  }
505
601
  const a = travel(i);
506
602
  const b = travel(mode.from);
@@ -509,7 +605,7 @@ function placeHip(feet: Feet, offset: (i: number, s: Side) => Pt, travel: (i: nu
509
605
  /** A plant from frame i: its anchor's x where the foot is, its y there or on the floor. */
510
606
  const plant = (i: number, side: Side, at: Pt): Mode => {
511
607
  anchors.set(i, at[1]);
512
- return { kind: 'plant', side, anchor: [at[0], floorY ?? at[1]] };
608
+ return { kind: 'plant', side, anchor: [at[0], floorY ?? at[1]], from: i };
513
609
  };
514
610
  let mode: Mode | null = null;
515
611
  let from: Mode | null = null;
@@ -551,23 +647,72 @@ function placeHip(feet: Feet, offset: (i: number, s: Side) => Pt, travel: (i: nu
551
647
 
552
648
  // --- stance (rig-c/pose#60) --------------------------------------------------
553
649
 
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. */
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
+ */
555
654
  export const STANCE_BOUNDS: readonly [number, number] = [0.92, 1.08];
556
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
+
557
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. */
558
693
  export const STANCE_RELEASE = 0.8;
559
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. */
560
695
  export const STANCE_MIN_SECONDS = 0.1;
561
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
+
562
705
  /** A run of frames with one free side in one state. */
563
706
  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';
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;
566
709
  /** The side that is not supporting. */
567
710
  side: Side;
568
711
  frames: [number, number];
569
- /** 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). */
570
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;
571
716
  /** `both`: the smallest and largest stretch keyed (bounded); `lifted`: null. */
572
717
  k: { min: number; max: number } | null;
573
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. */
@@ -582,6 +727,8 @@ export interface StanceOver {
582
727
  needed: number | null;
583
728
  /** The free ankle's largest distance left from the floor after the bound, world units. */
584
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];
585
732
  }
586
733
 
587
734
  export interface Stance {
@@ -592,6 +739,8 @@ export interface Stance {
592
739
  /** The shortest run kept as its own state, seconds (#87). */
593
740
  minSeconds: number;
594
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>>;
595
744
  runs: StanceRun[];
596
745
  over: StanceOver[];
597
746
  /** Per frame: the free side and the stretch keyed on it, or null (lifted, a jump, no support). */
@@ -608,26 +757,33 @@ export interface StancePosed {
608
757
  * Both feet planted when the free ankle is near the floor (rig-c/pose#60). On a
609
758
  * frame with a supporting side (so outside every jump), the free ankle's height
610
759
  * 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
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
613
763
  * k = (root y − floor y) / (root y − ankle y) on the posed rig (`posed`, world,
614
764
  * y up), the floor being the supporting ankle's posed y; k is held within
615
765
  * `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.
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.
618
774
  *
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
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
622
778
  * run of one state shorter than `STANCE_MIN_SECONDS` (`secondsOf`, the
623
779
  * retarget's timing of a run) between two runs of the other state, on the same
624
780
  * free side and with no frame between, takes their state, and is listed with
625
781
  * `absorbed`.
626
782
  */
627
- export function stanceOf(feet: Feet, ankle: Record<Side, readonly Pt[]>, tolerance: number, posed: (i: number) => StancePosed, secondsOf: (run: [number, number]) => number): 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 {
628
784
  const n = feet.support.length;
629
785
  const stretch: Stance['stretch'] = new Array(n).fill(null);
630
- type Read = { state: 'both' | 'lifted'; side: Side; height: number };
786
+ type Read = { state: StanceState; side: Side; height: number };
631
787
  const read: Array<Read | null> = new Array<Read | null>(n).fill(null);
632
788
  for (let i = 0; i < n; i++) {
633
789
  const s = feet.support[i] ?? null;
@@ -636,9 +792,12 @@ export function stanceOf(feet: Feet, ankle: Record<Side, readonly Pt[]>, toleran
636
792
  read[i] = { state: 'both', side: f, height: (ankle[s][i]![1] - ankle[f][i]![1]) / feet.legLength[f] };
637
793
  }
638
794
  // 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.
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');
642
801
  const absorbed: StanceRun[] = [];
643
802
  for (let i = 0; i < n; ) {
644
803
  const r0 = read[i];
@@ -648,34 +807,36 @@ export function stanceOf(feet: Feet, ankle: Record<Side, readonly Pt[]>, toleran
648
807
  }
649
808
  let end = i;
650
809
  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';
810
+ let planted = Math.abs(r0.height) <= tolerance;
652
811
  let j = i;
653
812
  while (j <= end) {
654
- const crosses = (k: number) => (state === 'both' ? read[k]!.height > tolerance : read[k]!.height <= STANCE_RELEASE * tolerance);
813
+ const crosses = (k: number) => (planted ? Math.abs(read[k]!.height) > tolerance : Math.abs(read[k]!.height) <= STANCE_RELEASE * tolerance);
655
814
  if (!crosses(j)) {
656
- read[j]!.state = state;
815
+ read[j]!.state = planted ? 'both' : away(read[j]!.height);
657
816
  j++;
658
817
  continue;
659
818
  }
660
819
  let k = j;
661
820
  while (k + 1 <= end && crosses(k + 1)) k++;
662
- const other = state === 'both' ? 'lifted' : 'both';
663
821
  if (k < end && secondsOf([j, k]) < STANCE_MIN_SECONDS) {
664
822
  let maxHeight = -Infinity;
823
+ let far = 0;
665
824
  for (let x = j; x <= k; x++) {
666
- read[x]!.state = state;
825
+ read[x]!.state = planted ? 'both' : read[x - 1]!.state;
667
826
  maxHeight = Math.max(maxHeight, read[x]!.height);
827
+ if (Math.abs(read[x]!.height) > Math.abs(far)) far = read[x]!.height;
668
828
  }
669
- absorbed.push({ state: other, side: r0.side, frames: [j, k], maxHeight, k: null, absorbed: true });
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 });
670
831
  } else {
671
- state = other;
672
- for (let x = j; x <= k; x++) read[x]!.state = state;
832
+ planted = !planted;
833
+ for (let x = j; x <= k; x++) read[x]!.state = planted ? 'both' : away(read[x]!.height);
673
834
  }
674
835
  j = k + 1;
675
836
  }
676
837
  i = end + 1;
677
838
  }
678
- type Row = { state: 'both' | 'lifted'; side: Side; height: number; k: number | null; needed: number | null; over: boolean; left: number };
839
+ type Row = { state: StanceState; side: Side; height: number; k: number | null; needed: number | null; over: boolean; left: number; lo: number };
679
840
  const rows: Array<Row | null> = new Array<Row | null>(n).fill(null);
680
841
  for (let i = 0; i < n; i++) {
681
842
  const r = read[i];
@@ -683,8 +844,9 @@ export function stanceOf(feet: Feet, ankle: Record<Side, readonly Pt[]>, toleran
683
844
  const s: Side = r.side === 'r' ? 'l' : 'r';
684
845
  const f = r.side;
685
846
  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 };
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 };
688
850
  continue;
689
851
  }
690
852
  const p = posed(i);
@@ -693,12 +855,13 @@ export function stanceOf(feet: Feet, ankle: Record<Side, readonly Pt[]>, toleran
693
855
  const a = p.ankle[f][1];
694
856
  const ok = root - a > 1e-9 && root - floor > 1e-9;
695
857
  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];
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];
698
860
  const left = Math.abs(root - k * (root - a) - floor);
699
- rows[i] = { state: 'both', side: f, height, k, needed, over, left };
861
+ rows[i] = { state: 'both', side: f, height, k, needed, over, left, lo };
700
862
  if (k !== 1) stretch[i] = { side: f, k };
701
863
  }
864
+ const boundOf = (lo: number): { bound?: [number, number] } => (lo < STANCE_BOUNDS[0] ? { bound: [lo, STANCE_BOUNDS[1]] } : {});
702
865
  const runs: StanceRun[] = [];
703
866
  const over: StanceOver[] = [];
704
867
  for (let i = 0; i < n; i++) {
@@ -708,19 +871,26 @@ export function stanceOf(feet: Feet, ankle: Record<Side, readonly Pt[]>, toleran
708
871
  if (last !== undefined && last.frames[1] === i - 1 && last.state === r.state && last.side === r.side) {
709
872
  last.frames[1] = i;
710
873
  last.maxHeight = Math.max(last.maxHeight, r.height);
874
+ if (last.maxDepth !== undefined) last.maxDepth = Math.max(last.maxDepth, -r.height);
711
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) };
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 } });
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 } });
713
877
  if (!r.over) continue;
714
878
  const o = over[over.length - 1];
715
879
  if (o !== undefined && o.frames[1] === i - 1 && o.side === r.side) {
716
880
  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;
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
+ }
718
887
  o.left = Math.max(o.left, r.left);
719
- } 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) });
720
889
  }
721
890
  for (const x of absorbed) runs.push(x);
722
891
  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 };
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 };
724
894
  }
725
895
 
726
896
  // --- the blend held on the floor (rig-c/pose#85) ------------------------------
@@ -734,10 +904,14 @@ export interface BlendRun {
734
904
  frames: [number, number];
735
905
  /** The smallest and largest stretch keyed (bounded). */
736
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;
737
909
  }
738
910
 
739
911
  export interface BlendHold {
740
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>>;
741
915
  runs: BlendRun[];
742
916
  /** Runs whose stretch needed a factor past the bounds: held at the bound, the remainder left to the blend. */
743
917
  over: StanceOver[];
@@ -751,38 +925,50 @@ export interface BlendHold {
751
925
  * supporting ankle is off the y its plant holds. Its leg is stretched along its
752
926
  * chain about the chain's root, as `--stance` stretches a free leg (`stanceOf`):
753
927
  * 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
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
755
930
  * the remainder stays with the blend. An ankle within `BLEND_HOLD_WITHIN` of the
756
931
  * anchor is not stretched.
757
932
  */
758
- export function holdBlend(blends: readonly BlendFrame[], n: number, posed: (i: number) => StancePosed): BlendHold {
933
+ export function holdBlend(blends: readonly BlendFrame[], n: number, posed: (i: number) => StancePosed, lower?: StanceLower): BlendHold {
759
934
  const stretch: BlendHold['stretch'] = new Array(n).fill(null);
760
935
  const runs: BlendRun[] = [];
761
936
  const over: StanceOver[] = [];
937
+ const held: Array<{ side: Side; frame: number }> = [];
762
938
  for (const b of blends) {
763
939
  const p = posed(b.frame);
764
940
  const root = p.root[b.side][1];
765
941
  const a = p.ankle[b.side][1];
766
942
  if (Math.abs(a - b.y) <= BLEND_HOLD_WITHIN) continue;
943
+ held.push({ side: b.side, frame: b.frame });
767
944
  const ok = root - a > 1e-9 && root - b.y > 1e-9;
768
945
  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]);
946
+ const lo = lowerOf(lower, b.side, b.frame);
947
+ const k = needed === null ? 1 : Math.min(Math.max(needed, lo), STANCE_BOUNDS[1]);
770
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] } : {};
771
950
  if (k !== 1) {
772
951
  stretch[b.frame] = { side: b.side, k };
773
952
  const last = runs[runs.length - 1];
774
953
  if (last !== undefined && last.side === b.side && last.frames[1] === b.frame - 1) {
775
954
  last.frames[1] = b.frame;
776
955
  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 } });
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 } : {}) });
778
958
  }
779
- if (needed !== null && needed >= STANCE_BOUNDS[0] && needed <= STANCE_BOUNDS[1]) continue;
959
+ if (needed !== null && needed >= lo && needed <= STANCE_BOUNDS[1]) continue;
780
960
  const o = over[over.length - 1];
781
961
  if (o !== undefined && o.side === b.side && o.frames[1] === b.frame - 1) {
782
962
  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;
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
+ }
784
969
  o.left = Math.max(o.left, left);
785
- } else over.push({ side: b.side, frames: [b.frame, b.frame], needed, left });
970
+ } else over.push({ side: b.side, frames: [b.frame, b.frame], needed, left, ...bound });
786
971
  }
787
- return { bounds: STANCE_BOUNDS, runs, over, stretch };
972
+ const range = rangeUsed(lower, held);
973
+ return { bounds: STANCE_BOUNDS, ...(range === null ? {} : { range }), runs, over, stretch };
788
974
  }