@rig-c/pose 0.18.0 → 0.20.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,15 +24,19 @@
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
- * picture y over ± `FLOOR_SECONDS`), for ≥ `JUMP_FRAMES` frames — releases the
34
- * plant: the hip follows the pose's travel from where it was, and re-plants on
35
- * landing. A step does too: while the supporting ankle moves more than
35
+ * picture y over ± `FLOOR_SECONDS`), for ≥ `JUMP_FRAMES` frames, excluding a
36
+ * hand-over whose back ankle stays up and holds its height in y (`< STILL_SPEED`
37
+ * for `STILL_FRAMES` frames) when the front foot lands (a raised heel,
38
+ * rig-c/pose#101) — releases the plant: the hip follows the pose's travel from
39
+ * where it was, and re-plants on landing. A step does too: while the supporting ankle moves more than
36
40
  * `STEP_SPEED` leg lengths a frame its plant is released, the other foot takes
37
41
  * support if it is planted (not released, not moving) and otherwise the hip
38
42
  * follows the pose; the released ankle may support again once it has moved
@@ -264,13 +268,16 @@ export interface LegScale {
264
268
  high: Array<[number, number]>;
265
269
  /** The scale keyed on each frame (clamped). */
266
270
  scale: number[];
271
+ /** The scale the pose asked for on each frame before the clamp floor (`min(ratio, limits[1])`, rig-c/pose#107). */
272
+ asked: number[];
267
273
  }
268
274
 
269
- /** One segment's scale per frame: picture length ÷ its 95th percentile, clamped; and the runs clamped. */
275
+ /** One segment's scale per frame: picture length ÷ its 95th percentile, clamped; its asked scale without the clamp floor (#107); and the runs clamped. */
270
276
  export function legScaleOf(lengths: readonly number[], limits: [number, number]): Omit<LegScale, 'chain' | 'bone' | 'joints'> {
271
277
  const straight = percentile(lengths, STRAIGHT_PERCENTILE);
272
278
  const ratio = lengths.map((l) => l / straight);
273
279
  const scale = ratio.map((r) => Math.min(Math.max(r, limits[0]), limits[1]));
280
+ const asked = ratio.map((r) => Math.min(r, limits[1]));
274
281
  const sorted = [...ratio].sort((a, b) => a - b);
275
282
  const mid = sorted.length >> 1;
276
283
  const median = sorted.length % 2 === 1 ? sorted[mid]! : (sorted[mid - 1]! + sorted[mid]!) / 2;
@@ -281,6 +288,7 @@ export function legScaleOf(lengths: readonly number[], limits: [number, number])
281
288
  low: runs(ratio.map((r) => r < limits[0])),
282
289
  high: runs(ratio.map((r) => r > limits[1])),
283
290
  scale,
291
+ asked,
284
292
  };
285
293
  }
286
294
 
@@ -337,10 +345,18 @@ export interface Feet {
337
345
  /**
338
346
  * rig-c/pose#113, read from the lower ankle: each run of frames whose
339
347
  * 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
348
+ * no foot is planted). Absent where the rule does not apply (in place, or
341
349
  * without `lowerAnkle`).
342
350
  */
343
351
  lowered?: Lowered[];
352
+ /**
353
+ * rig-c/pose#119: runs of frames after a jump or a release where no foot is
354
+ * planted because the only foot free under #113 is the higher one, and the
355
+ * lower foot supports as a tracked foot (its x follows its own scaled ankle
356
+ * track, its y sits on #85's floor) until a foot plants. Absent where the
357
+ * rule does not apply.
358
+ */
359
+ tracked?: Array<{ side: Side; frames: [number, number] }>;
344
360
  }
345
361
 
346
362
  /** A run of frames whose support the lower-ankle rule (rig-c/pose#113) reads differently from the reading before it. */
@@ -381,7 +397,9 @@ export function rollingFloor(ys: readonly number[], times: readonly number[]): n
381
397
  * other ankle has been that much lower for `PLANT_SWITCH_SECONDS` hands support
382
398
  * to it when it is free (as before) and is otherwise released (the hip follows
383
399
  * the pose until a foot may take support). Steps and jumps are read as before.
384
- * The frames whose support this changes are `lowered`.
400
+ * The frames whose support this changes are `lowered`. On a frame where no foot
401
+ * is planted because the only foot free is the higher one (rig-c/pose#119), the
402
+ * lower foot is tracked (`tracked`).
385
403
  */
386
404
  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
405
  if (inPlace || !lowerAnkle) return readSupports(ankle, hip, times, secondsOf, inPlace, false).feet;
@@ -405,6 +423,9 @@ function readSupports(ankle: Record<Side, readonly Pt[]>, hip: Record<Side, read
405
423
  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);
406
424
  const mean = (legLength.r + legLength.l) / 2;
407
425
  // A jump: both ankles and mid_hip above their rolling floors, for ≥ JUMP_FRAMES frames.
426
+ // #101: at a hand-over the front foot has not landed yet while the back ankle lifts its heel; when
427
+ // the front foot lands (the first frame after its lifted run) while the back ankle stays up, a back
428
+ // ankle that holds its height in y (< STILL_SPEED a frame for STILL_FRAMES frames) is a raised heel.
408
429
  const midY = hip.r.map((h, i) => (h[1] + hip.l[i]![1]) / 2);
409
430
  const rises: Array<{ ys: number[]; unit: number }> = [
410
431
  { ys: ankle.r.map((a) => a[1]), unit: legLength.r },
@@ -415,8 +436,23 @@ function readSupports(ankle: Record<Side, readonly Pt[]>, hip: Record<Side, read
415
436
  const floor = rollingFloor(ys, times);
416
437
  return ys.map((y, i) => floor[i]! - y > JUMP_RISE * unit);
417
438
  });
439
+ const ankleRuns = { r: runs(up[0]!), l: runs(up[1]!) };
440
+ const stillY = (s: Side, from: number): boolean => {
441
+ if (from + STILL_FRAMES > n) return false;
442
+ for (let k = from; k < from + STILL_FRAMES; k++) {
443
+ if (!up[s === 'r' ? 0 : 1]![k] || Math.abs(ankle[s][k]![1] - ankle[s][k - 1]![1]) / legLength[s] >= STILL_SPEED) return false;
444
+ }
445
+ return true;
446
+ };
447
+ const raisedHeel = (a: number, b: number): boolean => {
448
+ const rSpan = ankleRuns.r.find(([s, e]) => s <= a && b <= e)!;
449
+ const lSpan = ankleRuns.l.find(([s, e]) => s <= a && b <= e)!;
450
+ if (rSpan[1] === lSpan[1]) return false;
451
+ const [fSpan, back]: [[number, number], Side] = rSpan[1] < lSpan[1] ? [rSpan, 'l'] : [lSpan, 'r'];
452
+ return stillY(back, fSpan[1] + 1);
453
+ };
418
454
  const lifted = Array.from({ length: n }, (_, i) => up.every((u) => u[i]));
419
- const jumps = runs(lifted).filter(([a, b]) => b - a + 1 >= JUMP_FRAMES);
455
+ const jumps = runs(lifted).filter(([a, b]) => b - a + 1 >= JUMP_FRAMES && !raisedHeel(a, b));
420
456
  const jump = new Array<boolean>(n).fill(false);
421
457
  for (const [a, b] of jumps) for (let i = a; i <= b; i++) jump[i] = true;
422
458
  // Ankle speeds, leg lengths a frame (0 on the first frame).
@@ -430,11 +466,13 @@ function readSupports(ankle: Record<Side, readonly Pt[]>, hip: Record<Side, read
430
466
  return true;
431
467
  };
432
468
  const support: Array<Side | null> = new Array<Side | null>(n).fill(null);
469
+ const trkAt: Array<Side | null> = new Array<Side | null>(n).fill(null);
433
470
  const changes: Feet['changes'] = [];
434
471
  const steps: Step[] = [];
435
472
  const open: Partial<Record<Side, Step>> = {};
436
473
  let cur: Side | null = null;
437
474
  let lowerFrom = -1;
475
+ let freeFrom = -1;
438
476
  const released: number[] = [];
439
477
  const lower = (cands: Side[], i: number): Side => (cands.length === 2 ? (ankle.l[i]![1] > ankle.r[i]![1] ? 'l' : 'r') : cands[0]!);
440
478
  for (let i = 0; i < n; i++) {
@@ -450,6 +488,7 @@ function readSupports(ankle: Record<Side, readonly Pt[]>, hip: Record<Side, read
450
488
  if (jump[i]) {
451
489
  cur = null;
452
490
  lowerFrom = -1;
491
+ freeFrom = -1;
453
492
  continue;
454
493
  }
455
494
  const free = (s: Side) => open[s] === undefined && !moving(s, i);
@@ -465,28 +504,44 @@ function readSupports(ankle: Record<Side, readonly Pt[]>, hip: Record<Side, read
465
504
  if (to !== null) changes.push({ frame: i, from: cur, to });
466
505
  cur = to;
467
506
  lowerFrom = -1;
507
+ freeFrom = -1;
468
508
  } else if (cur === null) {
469
509
  const cands = SIDES.filter(may);
470
510
  if (cands.length > 0) {
471
511
  cur = lower(cands, i);
472
512
  changes.push({ frame: i, from: null, to: cur });
473
513
  lowerFrom = -1;
514
+ freeFrom = -1;
474
515
  }
475
516
  } else {
476
517
  const other: Side = cur === 'r' ? 'l' : 'r';
477
518
  if ((free(other) || lowerAnkle) && higher(cur)) {
478
519
  if (lowerFrom < 0) lowerFrom = i;
479
- if (secondsOf([lowerFrom, i]) >= PLANT_SWITCH_SECONDS - 1e-9) {
480
- if (free(other)) {
481
- changes.push({ frame: i, from: cur, to: other });
520
+ if (free(other)) {
521
+ if (freeFrom < 0) freeFrom = i;
522
+ // #102: once confirmed over PLANT_SWITCH_SECONDS, the incoming foot takes support from the landing frame it crossed the margin on.
523
+ if (secondsOf([freeFrom, i]) >= PLANT_SWITCH_SECONDS - 1e-9) {
524
+ for (let k = freeFrom; k < i; k++) support[k] = other;
525
+ changes.push({ frame: freeFrom, from: cur, to: other });
482
526
  cur = other;
483
- } else {
527
+ lowerFrom = -1;
528
+ freeFrom = -1;
529
+ }
530
+ } else {
531
+ freeFrom = -1;
532
+ if (secondsOf([lowerFrom, i]) >= PLANT_SWITCH_SECONDS - 1e-9) {
484
533
  cur = null; // #113: the lower foot may not take support (released, or moving); neither foot supports
485
534
  released.push(i);
535
+ lowerFrom = -1;
486
536
  }
487
- lowerFrom = -1;
488
537
  }
489
- } else lowerFrom = -1;
538
+ } else {
539
+ lowerFrom = -1;
540
+ freeFrom = -1;
541
+ }
542
+ }
543
+ if (lowerAnkle && cur === null && released[released.length - 1] !== i && SIDES.some((x) => free(x) && higher(x))) {
544
+ trkAt[i] = lower(['r', 'l'], i);
490
545
  }
491
546
  support[i] = cur;
492
547
  }
@@ -498,7 +553,15 @@ function readSupports(ankle: Record<Side, readonly Pt[]>, hip: Record<Side, read
498
553
  if (last !== undefined && last.side === s && last.frames[1] === i - 1) last.frames[1] = i;
499
554
  else supports.push({ side: s, frames: [i, i] });
500
555
  }
501
- return { feet: { legLength, support, supports, jumps, steps, changes }, released };
556
+ const tracked: Array<{ side: Side; frames: [number, number] }> = [];
557
+ for (let i = 0; i < n; i++) {
558
+ const s = trkAt[i];
559
+ if (s === null || s === undefined) continue;
560
+ const last = tracked[tracked.length - 1];
561
+ if (last !== undefined && last.side === s && last.frames[1] === i - 1) last.frames[1] = i;
562
+ else tracked.push({ side: s, frames: [i, i] });
563
+ }
564
+ return { feet: { legLength, support, supports, jumps, steps, changes, ...(tracked.length > 0 ? { tracked } : {}) }, released };
502
565
  }
503
566
 
504
567
  /** One support span's shift onto the floor (rig-c/pose#85): its side, its frames, and floor − its planted height, world units. */
@@ -515,6 +578,8 @@ export interface Floor {
515
578
  shifts: FloorShift[];
516
579
  }
517
580
 
581
+ let trackFn: ((i: number, s: Side) => number) | undefined;
582
+
518
583
  /**
519
584
  * The hip's world origin on every frame, from the supporting foot (header),
520
585
  * with every support span on one floor (rig-c/pose#85). `offset(i, side)` is
@@ -528,9 +593,11 @@ export interface Floor {
528
593
  * floor is `floor` when given (`--floor`, world units), and otherwise the median
529
594
  * of the spans' planted heights; each span's anchor moves in y by floor − its
530
595
  * 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
596
+ * support still blends the two derivations' y over `PLANT_BLEND` frames, now
597
+ * between two spans on the floor, while holding the planted foot's x from the
598
+ * frame it lands on (#102); a jump follows the pose's travel from the hip
599
+ * it left (on the floor), carried so the landing frame meets the floor and no
600
+ * frame inside the flight puts a foot below it (#103). On a blend frame the hip is
534
601
  * between the two derivations, so the supporting ankle is off its anchor:
535
602
  * `blends` lists those frames with the anchor's y, and `holdBlend` stretches
536
603
  * the supporting leg to keep it there. Null `floor` in the result: there is no
@@ -539,18 +606,24 @@ export interface Floor {
539
606
  * `follow(i, side)` (#92, an in-place sequence): the supporting ankle's track,
540
607
  * world x. A plant's anchor x then moves by follow(i) − follow(the frame it
541
608
  * planted), and the loop is closed (`closeLoop`, `closure` in the result; null
542
- * without `follow`).
609
+ * without `follow`). `track(i, side)` (#119): the same world-x ankle track for
610
+ * `feet.tracked` frames on a sequence not declared in place.
543
611
  */
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 };
612
+ 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, track?: (i: number, s: Side) => number): { hip: Pt[]; floor: Floor | null; blends: BlendFrame[]; closure: Closure | null } {
613
+ trackFn = track;
614
+ try {
615
+ const picture = placeHip(feet, offset, travel, start, null, follow);
616
+ const heights = feet.supports.map((sp) => picture.anchors.get(sp.frames[0]));
617
+ if (heights.length === 0) return { hip: picture.hip, floor: null, blends: picture.blends, closure: null };
618
+ if (heights.some((h) => h === undefined)) throw new Error('plantHip: a support span starts on a frame no plant started on');
619
+ const y = floor ?? percentile(heights as number[], 0.5);
620
+ const shifts = feet.supports.map((sp, k) => ({ side: sp.side, frames: [sp.frames[0], sp.frames[1]] as [number, number], shift: y - heights[k]! }));
621
+ const placed = placeHip(feet, offset, travel, start, y, follow);
622
+ const closure = follow === undefined ? null : closeLoop(placed.hip);
623
+ return { hip: placed.hip, floor: { y, given: floor !== undefined, shifts }, blends: placed.blends, closure };
624
+ } finally {
625
+ trackFn = undefined;
626
+ }
554
627
  }
555
628
 
556
629
  /** 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. */
@@ -589,8 +662,14 @@ function placeHip(feet: Feet, offset: (i: number, s: Side) => Pt, travel: (i: nu
589
662
  const hip: Pt[] = [];
590
663
  const anchors = new Map<number, number>();
591
664
  const blends: BlendFrame[] = [];
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. */
593
- type Mode = { kind: 'plant'; side: Side; anchor: Pt; from: number } | { kind: 'travel'; from: number; at: Pt };
665
+ const tracked = new Array<Side | null>(n).fill(null);
666
+ for (const t of feet.tracked ?? []) for (let i = t.frames[0]; i <= t.frames[1]; i++) tracked[i] = t.side;
667
+ const trkX = trackFn ?? follow;
668
+ /** How the hip is derived on a frame: held over a foot's anchor, tracking the lower foot on the floor (#119), or carried by the pose's travel from a frame's hip (and for a jump against `floorY`, carried so the landing frame meets the floor and frames inside the flight never go below it, #103). */
669
+ type Mode =
670
+ | { kind: 'plant'; side: Side; anchor: Pt; from: number }
671
+ | { kind: 'track'; side: Side; anchor: Pt; from: number; at: Pt }
672
+ | { kind: 'travel'; from: number; at: Pt; end?: number; land?: number; carry?: number };
594
673
  const of = (mode: Mode, i: number): Pt => {
595
674
  if (mode.kind === 'plant') {
596
675
  const o = offset(i, mode.side);
@@ -598,9 +677,40 @@ function placeHip(feet: Feet, offset: (i: number, s: Side) => Pt, travel: (i: nu
598
677
  const dx = follow === undefined ? 0 : follow(i, mode.side) - follow(mode.from, mode.side);
599
678
  return [mode.anchor[0] + dx - o[0], mode.anchor[1] - o[1]];
600
679
  }
680
+ if (mode.kind === 'track') {
681
+ const o = offset(i, mode.side);
682
+ const dx = trkX === undefined ? o[0] - offset(mode.from, mode.side)[0] : trkX(i, mode.side) - trkX(mode.from, mode.side);
683
+ const y = floorY === null ? mode.at[1] + travel(i)[1] - travel(mode.from)[1] : mode.anchor[1] - o[1];
684
+ return [mode.anchor[0] + dx - o[0], y];
685
+ }
601
686
  const a = travel(i);
602
687
  const b = travel(mode.from);
603
- return [mode.at[0] + a[0] - b[0], mode.at[1] + a[1] - b[1]];
688
+ let y = mode.at[1] + a[1] - b[1];
689
+ if (mode.carry !== undefined && mode.land !== undefined && mode.end !== undefined && floorY !== null) {
690
+ if (mode.land < n && i <= mode.land && mode.land > mode.from) {
691
+ y += mode.carry * ((i - mode.from) / (mode.land - mode.from));
692
+ }
693
+ if (i <= mode.end) {
694
+ y = Math.max(y, floorY - Math.min(offset(i, 'r')[1], offset(i, 'l')[1]));
695
+ }
696
+ }
697
+ return [mode.at[0] + a[0] - b[0], y];
698
+ };
699
+ /** A travel span from `startFrame` off frame `from`'s hip `at`: carried to meet `floorY` at landing when it contains a jump (#103). */
700
+ const makeTravel = (startFrame: number, from: number, at: Pt): Mode => {
701
+ let end = startFrame;
702
+ while (end + 1 < n && (feet.support[end + 1] ?? null) === null && (tracked[end + 1] ?? null) === null) end++;
703
+ const isJump = floorY !== null && feet.jumps.some(([ja, jb]) => ja <= end && jb >= startFrame);
704
+ if (!isJump) return { kind: 'travel', from, at };
705
+ const land = end + 1;
706
+ let carry = 0;
707
+ if (land < n) {
708
+ const landSide = (feet.support[land] ?? tracked[land] ?? null)!;
709
+ const rawLandY = at[1] + travel(land)[1] - travel(from)[1];
710
+ const targetLandY = floorY - offset(land, landSide)[1];
711
+ carry = targetLandY - rawLandY;
712
+ }
713
+ return { kind: 'travel', from, at, end, land, carry };
604
714
  };
605
715
  /** A plant from frame i: its anchor's x where the foot is, its y there or on the floor. */
606
716
  const plant = (i: number, side: Side, at: Pt): Mode => {
@@ -612,29 +722,45 @@ function placeHip(feet: Feet, offset: (i: number, s: Side) => Pt, travel: (i: nu
612
722
  let blendAt = -1;
613
723
  for (let i = 0; i < n; i++) {
614
724
  const s = feet.support[i] ?? null;
725
+ const tr = s === null ? (tracked[i] ?? null) : null;
615
726
  if (i === 0) {
616
- if (s === null) mode = { kind: 'travel', from: 0, at: start };
617
- else {
727
+ if (s !== null) {
618
728
  const o = offset(0, s);
619
729
  mode = plant(0, s, [start[0] + o[0], start[1] + o[1]]);
730
+ } else if (tr !== null) {
731
+ const o = offset(0, tr);
732
+ mode = { kind: 'track', side: tr, anchor: [start[0] + o[0], floorY ?? (start[1] + o[1])], from: 0, at: start };
733
+ } else {
734
+ mode = makeTravel(0, 0, start);
620
735
  }
621
- } else if (s === null && mode!.kind === 'plant') {
622
- mode = { kind: 'travel', from: i - 1, at: hip[i - 1]! };
736
+ } else if (s !== null) {
737
+ if (mode!.kind !== 'plant' || mode!.side !== s) {
738
+ // The new support is held where it is on this frame, under the hip the old derivation gives here.
739
+ const here = of(mode!, i);
740
+ const o = offset(i, s);
741
+ from = mode;
742
+ mode = plant(i, s, [here[0] + o[0], here[1] + o[1]]);
743
+ blendAt = i;
744
+ }
745
+ } else if (tr !== null) {
746
+ if (mode!.kind !== 'track' || mode!.side !== tr) {
747
+ const prev = hip[i - 1]!;
748
+ const o = offset(i - 1, tr);
749
+ mode = { kind: 'track', side: tr, anchor: [prev[0] + o[0], floorY ?? (prev[1] + o[1])], from: i - 1, at: prev };
750
+ from = null;
751
+ blendAt = -1;
752
+ }
753
+ } else if (mode!.kind !== 'travel') {
754
+ mode = makeTravel(i, i - 1, hip[i - 1]!);
623
755
  from = null;
624
756
  blendAt = -1;
625
- } else if (s !== null && (mode!.kind === 'travel' || (mode as { side: Side }).side !== s)) {
626
- // The new support is held where it is on this frame, under the hip the old derivation gives here.
627
- const here = of(mode!, i);
628
- const o = offset(i, s);
629
- from = mode;
630
- mode = plant(i, s, [here[0] + o[0], here[1] + o[1]]);
631
- blendAt = i;
632
757
  }
633
758
  const to = of(mode!, i);
634
759
  if (from !== null && i - blendAt < PLANT_BLEND) {
635
760
  const w = (i - blendAt + 1) / (PLANT_BLEND + 1);
636
761
  const f = of(from, i);
637
- hip.push([f[0] + (to[0] - f[0]) * w, f[1] + (to[1] - f[1]) * w]);
762
+ // #102: blend y only; x takes the new derivation immediately so the planted foot does not slide across the blend.
763
+ hip.push([to[0], f[1] + (to[1] - f[1]) * w]);
638
764
  const m = mode!;
639
765
  if (m.kind === 'plant') blends.push({ frame: i, side: m.side, y: m.anchor[1] });
640
766
  } else {
@@ -783,10 +909,12 @@ export interface StancePosed {
783
909
  export function stanceOf(feet: Feet, ankle: Record<Side, readonly Pt[]>, tolerance: number, posed: (i: number) => StancePosed, secondsOf: (run: [number, number]) => number, lower?: StanceLower): Stance {
784
910
  const n = feet.support.length;
785
911
  const stretch: Stance['stretch'] = new Array(n).fill(null);
912
+ const tracked = new Array<Side | null>(n).fill(null);
913
+ for (const t of feet.tracked ?? []) for (let i = t.frames[0]; i <= t.frames[1]; i++) tracked[i] = t.side;
786
914
  type Read = { state: StanceState; side: Side; height: number };
787
915
  const read: Array<Read | null> = new Array<Read | null>(n).fill(null);
788
916
  for (let i = 0; i < n; i++) {
789
- const s = feet.support[i] ?? null;
917
+ const s = feet.support[i] ?? tracked[i] ?? null;
790
918
  if (s === null) continue;
791
919
  const f: Side = s === 'r' ? 'l' : 'r';
792
920
  read[i] = { state: 'both', side: f, height: (ankle[s][i]![1] - ankle[f][i]![1]) / feet.legLength[f] };
package/src/pose.ts CHANGED
@@ -74,36 +74,63 @@ export interface CleanSpike {
74
74
  * pelvis line off the picture (`tools/pelvis.py`, after clean) and turned the
75
75
  * hip pair to a measured angle on every frame. The exact shape:
76
76
  *
77
- * { by: "measured", method: string,
78
- * read: Run[], interpolated: Run[], held: Run[],
77
+ * { by: "measured", method: string, fit?: { frames, gain, corr },
78
+ * read: Run[], interpolated: Run[], scaled?: Run[], held: Run[], unmeasured?: Run[],
79
79
  * declined: Array<{ frames: Run, why: string }> } Run = [first, last]
80
80
  *
81
81
  * `method` names the reading. Runs are integer frame indices inside the
82
82
  * sequence, first ≤ last, increasing and not overlapping within each list.
83
83
  * Every frame of the sequence is in exactly one of `read` (the frame's own
84
84
  * reading), `interpolated` (a declined frame between two reads close in time,
85
- * the angle interpolated) and `held` (any other declined frame, the earlier
86
- * read carried to the end of its run; at the clip's start, the later read). `declined` lists, with the reason, exactly the frames that are
87
- * interpolated or held. Anything else is refused by name (POSE_CLEANED).
85
+ * the angle interpolated), `scaled` (#106: a longer declined run taking the
86
+ * detector hip line scaled by `fit.gain`), `held` (the earlier read carried to
87
+ * the end of its run; at the clip's start, the later read) and `unmeasured`
88
+ * (#106: a longer declined run when `fit.corr` < 0.9). `declined` lists, with
89
+ * the reason, exactly the non-read frames. Anything else is refused by name
90
+ * (POSE_CLEANED).
88
91
  */
89
92
  export type Run = [number, number];
93
+ export interface PelvisFit {
94
+ frames: number;
95
+ gain: number | null;
96
+ corr: number | null;
97
+ }
90
98
  export type HipsInferred =
91
99
  | { by: 'declared' }
92
- | { by: 'measured'; method: string; read: Run[]; interpolated: Run[]; held: Run[]; declined: Array<{ frames: Run; why: string }> };
100
+ | {
101
+ by: 'measured';
102
+ method: string;
103
+ fit?: PelvisFit;
104
+ read: Run[];
105
+ interpolated: Run[];
106
+ scaled?: Run[];
107
+ held: Run[];
108
+ unmeasured?: Run[];
109
+ declined: Array<{ frames: Run; why: string }>;
110
+ };
93
111
 
94
112
  /** A copy of the record, in the writer's key order. */
95
113
  export function copyHipsInferred(h: HipsInferred): HipsInferred {
96
114
  const runs = (rs: Run[]): Run[] => rs.map(([a, b]) => [a, b]);
97
- return h.by === 'declared'
98
- ? { by: 'declared' }
99
- : { by: 'measured', method: h.method, read: runs(h.read), interpolated: runs(h.interpolated), held: runs(h.held), declined: h.declined.map((d) => ({ frames: [d.frames[0], d.frames[1]], why: d.why })) };
115
+ if (h.by === 'declared') return { by: 'declared' };
116
+ return {
117
+ by: 'measured',
118
+ method: h.method,
119
+ ...(h.fit !== undefined ? { fit: { frames: h.fit.frames, gain: h.fit.gain, corr: h.fit.corr } } : {}),
120
+ read: runs(h.read),
121
+ interpolated: runs(h.interpolated),
122
+ ...(h.scaled !== undefined ? { scaled: runs(h.scaled) } : {}),
123
+ held: runs(h.held),
124
+ ...(h.unmeasured !== undefined ? { unmeasured: runs(h.unmeasured) } : {}),
125
+ declined: h.declined.map((d) => ({ frames: [d.frames[0], d.frames[1]], why: d.why })),
126
+ };
100
127
  }
101
128
 
102
- /** The frames a `measured` record gives an angle on (read, interpolated, held), as a set; null for any other record or none. */
129
+ /** The frames a `measured` record gives an angle on (read, interpolated, scaled, held), as a set; null for any other record or none. */
103
130
  export function measuredFrames(h: HipsInferred | undefined): Set<number> | null {
104
131
  if (h === undefined || h.by !== 'measured') return null;
105
132
  const out = new Set<number>();
106
- for (const rs of [h.read, h.interpolated, h.held]) for (const [a, b] of rs) for (let f = a; f <= b; f++) out.add(f);
133
+ for (const rs of [h.read, h.interpolated, h.scaled ?? [], h.held]) for (const [a, b] of rs) for (let f = a; f <= b; f++) out.add(f);
107
134
  return out;
108
135
  }
109
136
 
@@ -123,6 +150,7 @@ export interface HoldParams {
123
150
  export interface CleanHold {
124
151
  joint: Body18;
125
152
  frames: [number, number];
153
+ excursion?: [number, number];
126
154
  jitter: { before: number | null; after: number | null };
127
155
  }
128
156
  /**
@@ -405,10 +433,16 @@ function readCleaned(p: Problems, where: string, c: unknown, frames: number | nu
405
433
  p.add('POSE_CLEANED', `${hw} is ${typeName(h)}; a hold is { joint, frames: [first, last], jitter: { before, after } }`);
406
434
  return;
407
435
  }
408
- refuseUnknownKeys(p, 'POSE_CLEANED', hw, h, ['joint', 'frames', 'jitter']);
436
+ refuseUnknownKeys(p, 'POSE_CLEANED', hw, h, ['joint', 'frames', 'excursion', 'jitter']);
409
437
  if (typeof h.joint !== 'string' || !isBody18(h.joint)) p.add('POSE_CLEANED', `${hw} "joint" is ${typeName(h.joint)}; it must be a body-18 joint: ${BODY18.join(', ')}`);
410
438
  const f = h.frames;
411
- if (!(Array.isArray(f) && f.length === 2 && frameOk(f[0]) && frameOk(f[1]) && (f[0] as number) <= (f[1] as number))) p.add('POSE_CLEANED', `${hw} "frames" is ${typeName(f)}; it must be [first, last], frame indices in ${frameRange}, first ≤ last`);
439
+ const fOk = Array.isArray(f) && f.length === 2 && frameOk(f[0]) && frameOk(f[1]) && (f[0] as number) <= (f[1] as number);
440
+ if (!fOk) p.add('POSE_CLEANED', `${hw} "frames" is ${typeName(f)}; it must be [first, last], frame indices in ${frameRange}, first ≤ last`);
441
+ if ('excursion' in h) {
442
+ const ex = h.excursion;
443
+ const exOk = Array.isArray(ex) && ex.length === 2 && frameOk(ex[0]) && frameOk(ex[1]) && (ex[0] as number) <= (ex[1] as number) && (!fOk || ((f[0] as number) < (ex[0] as number) && (ex[1] as number) < (f[1] as number)));
444
+ if (!exOk) p.add('POSE_CLEANED', `${hw} "excursion" is ${typeName(ex)}; it must be [first, last], frame indices strictly inside "frames", first ≤ last`);
445
+ }
412
446
  const j = h.jitter;
413
447
  if (!isObject(j)) p.add('POSE_CLEANED', `${hw} "jitter" is ${typeName(j)}; it must be { before, after }`);
414
448
  else {
@@ -489,10 +523,22 @@ function readHipsInferred(p: Problems, hw: string, h: unknown, frames: number |
489
523
  return;
490
524
  }
491
525
  const before = p.list.length;
492
- const CLASSES = ['read', 'interpolated', 'held'] as const;
493
- refuseUnknownKeys(p, 'POSE_CLEANED', hw, h, ['by', 'method', ...CLASSES, 'declined']);
494
- for (const k of ['method', ...CLASSES, 'declined']) if (!(k in h)) p.add('POSE_CLEANED', `${hw} has no "${k}"; a measured record is ${measuredShape}`);
526
+ const REQ_CLASSES = ['read', 'interpolated', 'held'] as const;
527
+ const ALL_CLASSES = ['read', 'interpolated', 'scaled', 'held', 'unmeasured'] as const;
528
+ refuseUnknownKeys(p, 'POSE_CLEANED', hw, h, ['by', 'method', 'fit', ...ALL_CLASSES, 'declined']);
529
+ for (const k of ['method', ...REQ_CLASSES, 'declined']) if (!(k in h)) p.add('POSE_CLEANED', `${hw} has no "${k}"; a measured record is ${measuredShape}`);
495
530
  if ('method' in h && !(typeof h.method === 'string' && h.method.trim() !== '')) p.add('POSE_CLEANED', `${hw} "method" is ${typeName(h.method)}; it must be a non-empty string naming the reading`);
531
+ if ('fit' in h) {
532
+ const f = h.fit;
533
+ const fw = `${hw} "fit"`;
534
+ if (!isObject(f)) p.add('POSE_CLEANED', `${fw} is ${typeName(f)}; it must be { frames, gain, corr }`);
535
+ else {
536
+ refuseUnknownKeys(p, 'POSE_CLEANED', fw, f, ['frames', 'gain', 'corr']);
537
+ if (!(Number.isInteger(f.frames) && (f.frames as number) >= 0)) p.add('POSE_CLEANED', `${fw} "frames" is ${typeName(f.frames)}; it must be a whole number of frames ≥ 0`);
538
+ if (!(f.gain === null || (typeof f.gain === 'number' && Number.isFinite(f.gain)))) p.add('POSE_CLEANED', `${fw} "gain" is ${typeName(f.gain)}; it must be a finite number or null`);
539
+ if (!(f.corr === null || (typeof f.corr === 'number' && Number.isFinite(f.corr) && f.corr >= -1 && f.corr <= 1))) p.add('POSE_CLEANED', `${fw} "corr" is ${typeName(f.corr)}; it must be a correlation in [-1, 1] or null`);
540
+ }
541
+ }
496
542
  const range = `[0, ${frames === null ? 'the frame count' : frames - 1}]`;
497
543
  /** A run [first, last], or null after naming what is wrong with it. */
498
544
  const run = (v: unknown, rw: string): Run | null => {
@@ -524,8 +570,8 @@ function readHipsInferred(p: Problems, hw: string, h: unknown, frames: number |
524
570
  if (cur.r[0] <= prev.r[1]) p.add('POSE_CLEANED', `${cur.w} [${cur.r[0]}, ${cur.r[1]}] starts at or before the end of ${prev.w} [${prev.r[0]}, ${prev.r[1]}]; ${what} runs must increase and not overlap`);
525
571
  }
526
572
  };
527
- const classes: Record<(typeof CLASSES)[number], Named[]> = { read: [], interpolated: [], held: [] };
528
- for (const c of CLASSES) {
573
+ const classes: Record<(typeof ALL_CLASSES)[number], Named[]> = { read: [], interpolated: [], scaled: [], held: [], unmeasured: [] };
574
+ for (const c of ALL_CLASSES) {
529
575
  if (!(c in h)) continue;
530
576
  const list = h[c];
531
577
  if (!Array.isArray(list)) {
@@ -560,12 +606,15 @@ function readHipsInferred(p: Problems, hw: string, h: unknown, frames: number |
560
606
  });
561
607
  ordered(declined, 'declined');
562
608
  }
563
- // Across lists: the classes are disjoint, they cover every frame, and declined is exactly interpolated ∪ held.
564
- for (let x = 0; x < CLASSES.length; x++) {
565
- for (let y = x + 1; y < CLASSES.length; y++) {
566
- for (const a of classes[CLASSES[x]!]) {
567
- for (const b of classes[CLASSES[y]!]) {
568
- if (a.r[0] <= b.r[1] && b.r[0] <= a.r[1]) p.add('POSE_CLEANED', `${a.w} [${a.r[0]}, ${a.r[1]}] and ${b.w} [${b.r[0]}, ${b.r[1]}] share frame(s) ${Math.max(a.r[0], b.r[0])}–${Math.min(a.r[1], b.r[1])}; a frame is one of read, interpolated and held`);
609
+ const has106 = 'scaled' in h || 'unmeasured' in h || 'fit' in h;
610
+ const classListLabel = has106 ? 'read, interpolated, scaled, held and unmeasured' : 'read, interpolated and held';
611
+ const filledLabel = has106 ? 'interpolated, scaled, held or unmeasured' : 'interpolated or held';
612
+ // Across lists: the classes are disjoint, they cover every frame, and declined is exactly the non-read classes.
613
+ for (let x = 0; x < ALL_CLASSES.length; x++) {
614
+ for (let y = x + 1; y < ALL_CLASSES.length; y++) {
615
+ for (const a of classes[ALL_CLASSES[x]!]) {
616
+ for (const b of classes[ALL_CLASSES[y]!]) {
617
+ if (a.r[0] <= b.r[1] && b.r[0] <= a.r[1]) p.add('POSE_CLEANED', `${a.w} [${a.r[0]}, ${a.r[1]}] and ${b.w} [${b.r[0]}, ${b.r[1]}] share frame(s) ${Math.max(a.r[0], b.r[0])}–${Math.min(a.r[1], b.r[1])}; a frame is one of ${classListLabel}`);
569
618
  }
570
619
  }
571
620
  }
@@ -586,21 +635,21 @@ function readHipsInferred(p: Problems, hw: string, h: unknown, frames: number |
586
635
  return out.map(([a, b]) => (a === b ? `${a}` : `${a}–${b}`)).join(', ');
587
636
  };
588
637
  const read = set(classes.read);
589
- const filled = set([...classes.interpolated, ...classes.held]);
638
+ const nonRead = set([...classes.interpolated, ...classes.scaled, ...classes.held, ...classes.unmeasured]);
590
639
  const dec = set(declined);
591
640
  if (frames !== null) {
592
- const none = Array.from({ length: frames }, (_, f) => f).filter((f) => !read.has(f) && !filled.has(f));
593
- if (none.length > 0) p.add('POSE_CLEANED', `${hw}: frame(s) ${spans(none)} are in none of read, interpolated and held; every frame of the sequence is one of them`);
641
+ const none = Array.from({ length: frames }, (_, f) => f).filter((f) => !read.has(f) && !nonRead.has(f));
642
+ if (none.length > 0) p.add('POSE_CLEANED', `${hw}: frame(s) ${spans(none)} are in none of ${classListLabel}; every frame of the sequence is one of them`);
594
643
  }
595
644
  const both = [...dec].filter((f) => read.has(f)).sort((a, b) => a - b);
596
645
  if (both.length > 0) p.add('POSE_CLEANED', `${hw}: frame(s) ${spans(both)} are both read and declined; a frame is read or declined, not both`);
597
- const unfilled = [...dec].filter((f) => !read.has(f) && !filled.has(f)).sort((a, b) => a - b);
598
- if (unfilled.length > 0) p.add('POSE_CLEANED', `${hw}: frame(s) ${spans(unfilled)} are declined but neither interpolated nor held; a declined frame takes one of them`);
599
- const undeclared = [...filled].filter((f) => !dec.has(f)).sort((a, b) => a - b);
600
- if (undeclared.length > 0) p.add('POSE_CLEANED', `${hw}: frame(s) ${spans(undeclared)} are interpolated or held but not declined; only a frame the reading declined is filled, and "declined" says why`);
646
+ const unfilled = [...dec].filter((f) => !read.has(f) && !nonRead.has(f)).sort((a, b) => a - b);
647
+ if (unfilled.length > 0) p.add('POSE_CLEANED', `${hw}: frame(s) ${spans(unfilled)} are declined but neither ${filledLabel}; a declined frame takes one of them`);
648
+ const undeclared = [...nonRead].filter((f) => !dec.has(f)).sort((a, b) => a - b);
649
+ if (undeclared.length > 0) p.add('POSE_CLEANED', `${hw}: frame(s) ${spans(undeclared)} are ${filledLabel} but not declined; only a frame the reading declined is filled, and "declined" says why`);
601
650
  }
602
651
  const holdParamsOf = (h: HoldParams | 'off'): HoldParams | 'off' => (h === 'off' ? 'off' : { speed: h.speed, reach: h.reach, minSeconds: h.minSeconds, blend: h.blend });
603
- const copyHold = (h: CleanHold): CleanHold => ({ joint: h.joint, frames: [h.frames[0], h.frames[1]], jitter: { before: h.jitter.before, after: h.jitter.after } });
652
+ const copyHold = (h: CleanHold): CleanHold => ({ joint: h.joint, frames: [h.frames[0], h.frames[1]], ...(h.excursion !== undefined ? { excursion: [h.excursion[0], h.excursion[1]] as [number, number] } : {}), jitter: { before: h.jitter.before, after: h.jitter.after } });
604
653
  const copyUnheld = (u: CleanUnheld): CleanUnheld => ({ joint: u.joint, frames: [u.frames[0], u.frames[1]], test: u.test, value: u.value });
605
654
 
606
655
  function readJoints(p: Problems, pw: string, joints: unknown): Partial<Record<Body18, Joint>> {
@@ -794,7 +843,7 @@ export function writePoseSequence(seq: PoseSequence): string {
794
843
  params: { loop: c.params.loop, legs: c.params.legs, rest: c.params.rest, ...(c.params.hipsInferred !== undefined ? { hipsInferred: copyHipsInferred(c.params.hipsInferred) } : {}), ...(c.params.hold !== undefined ? { hold: holdParamsOf(c.params.hold) } : {}) },
795
844
  spikes: { found: c.spikes.found.map((s) => ({ frame: s.frame, joint: s.joint })), untested: [...c.spikes.untested] },
796
845
  jitter: { before: jit(c.jitter.before), after: jit(c.jitter.after) },
797
- ...(c.holds !== undefined ? { holds: c.holds.map((h) => ({ joint: h.joint, frames: [h.frames[0], h.frames[1]], jitter: { before: jit(h.jitter.before), after: jit(h.jitter.after) } })) } : {}),
846
+ ...(c.holds !== undefined ? { holds: c.holds.map((h) => ({ joint: h.joint, frames: [h.frames[0], h.frames[1]], ...(h.excursion !== undefined ? { excursion: [h.excursion[0], h.excursion[1]] } : {}), jitter: { before: jit(h.jitter.before), after: jit(h.jitter.after) } })) } : {}),
798
847
  ...(c.seams !== undefined ? { seams: c.seams.map((x) => ({ joint: x.joint, frame: x.frame, step: round(x.step, 4), gap: x.gap })) } : {}),
799
848
  ...(c.unheld !== undefined ? { unheld: c.unheld.map((u) => ({ joint: u.joint, frames: [u.frames[0], u.frames[1]], test: u.test, value: u.value === null ? null : round(u.value, 4) })) } : {}),
800
849
  };