@rig-c/pose 0.19.0 → 0.20.1

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
@@ -32,9 +32,11 @@
32
32
  * derivations in y over `PLANT_BLEND` frames while taking the new derivation's x
33
33
  * immediately (rig-c/pose#102). A jump — both ankles and mid_hip more
34
34
  * than `JUMP_RISE` leg lengths above their rolling floor (the 95th-percentile
35
- * picture y over ± `FLOOR_SECONDS`), for ≥ `JUMP_FRAMES` frames — releases the
36
- * plant: the hip follows the pose's travel from where it was, and re-plants on
37
- * 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
38
40
  * `STEP_SPEED` leg lengths a frame its plant is released, the other foot takes
39
41
  * support if it is planted (not released, not moving) and otherwise the hip
40
42
  * follows the pose; the released ankle may support again once it has moved
@@ -251,6 +253,17 @@ export function rootOf(rest: Rest, b: string): string {
251
253
 
252
254
  // --- leg scale -------------------------------------------------------------
253
255
 
256
+ export interface SupportScaleRun {
257
+ side: Side;
258
+ frames: [number, number];
259
+ /** The lowest and highest scale asked on the run's frames. */
260
+ asked: { min: number; max: number };
261
+ /** The lowest and highest scale keyed on the run's frames (clamped at `bound` when `asked` went under #95's bound). */
262
+ used: { min: number; max: number };
263
+ /** Present when `asked` went under #95's bound on any frame of the run: the lowest bound on the run, and the largest hip rise (world units, ≥ 0). */
264
+ clamped?: { bound: number; hipRise: number };
265
+ }
266
+
254
267
  export interface LegScale {
255
268
  chain: string;
256
269
  bone: string;
@@ -266,13 +279,23 @@ export interface LegScale {
266
279
  high: Array<[number, number]>;
267
280
  /** The scale keyed on each frame (clamped). */
268
281
  scale: number[];
282
+ /** The scale the pose asked for on each frame before the clamp floor (`min(ratio, limits[1])`, rig-c/pose#107). */
283
+ asked: number[];
284
+ /**
285
+ * rig-c/pose#133: under `--plant-feet`, runs of supporting frames on this
286
+ * segment where the asked scale went under `limits[0]` and followed `asked`
287
+ * down to the bound #95 derives from the leg's own range (`min(limits[0], r(i))`).
288
+ * Absent when no supporting frame on this segment asked under `limits[0]`.
289
+ */
290
+ support?: SupportScaleRun[];
269
291
  }
270
292
 
271
- /** One segment's scale per frame: picture length ÷ its 95th percentile, clamped; and the runs clamped. */
293
+ /** One segment's scale per frame: picture length ÷ its 95th percentile, clamped; its asked scale without the clamp floor (#107); and the runs clamped. */
272
294
  export function legScaleOf(lengths: readonly number[], limits: [number, number]): Omit<LegScale, 'chain' | 'bone' | 'joints'> {
273
295
  const straight = percentile(lengths, STRAIGHT_PERCENTILE);
274
296
  const ratio = lengths.map((l) => l / straight);
275
297
  const scale = ratio.map((r) => Math.min(Math.max(r, limits[0]), limits[1]));
298
+ const asked = ratio.map((r) => Math.min(r, limits[1]));
276
299
  const sorted = [...ratio].sort((a, b) => a - b);
277
300
  const mid = sorted.length >> 1;
278
301
  const median = sorted.length % 2 === 1 ? sorted[mid]! : (sorted[mid - 1]! + sorted[mid]!) / 2;
@@ -283,6 +306,7 @@ export function legScaleOf(lengths: readonly number[], limits: [number, number])
283
306
  low: runs(ratio.map((r) => r < limits[0])),
284
307
  high: runs(ratio.map((r) => r > limits[1])),
285
308
  scale,
309
+ asked,
286
310
  };
287
311
  }
288
312
 
@@ -339,10 +363,18 @@ export interface Feet {
339
363
  /**
340
364
  * rig-c/pose#113, read from the lower ankle: each run of frames whose
341
365
  * 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
366
+ * no foot is planted). Absent where the rule does not apply (in place, or
343
367
  * without `lowerAnkle`).
344
368
  */
345
369
  lowered?: Lowered[];
370
+ /**
371
+ * rig-c/pose#119: runs of frames after a jump or a release where no foot is
372
+ * planted because the only foot free under #113 is the higher one, and the
373
+ * lower foot supports as a tracked foot (its x follows its own scaled ankle
374
+ * track, its y sits on #85's floor) until a foot plants. Absent where the
375
+ * rule does not apply.
376
+ */
377
+ tracked?: Array<{ side: Side; frames: [number, number] }>;
346
378
  }
347
379
 
348
380
  /** A run of frames whose support the lower-ankle rule (rig-c/pose#113) reads differently from the reading before it. */
@@ -383,7 +415,9 @@ export function rollingFloor(ys: readonly number[], times: readonly number[]): n
383
415
  * other ankle has been that much lower for `PLANT_SWITCH_SECONDS` hands support
384
416
  * to it when it is free (as before) and is otherwise released (the hip follows
385
417
  * the pose until a foot may take support). Steps and jumps are read as before.
386
- * The frames whose support this changes are `lowered`.
418
+ * The frames whose support this changes are `lowered`. On a frame where no foot
419
+ * is planted because the only foot free is the higher one (rig-c/pose#119), the
420
+ * lower foot is tracked (`tracked`).
387
421
  */
388
422
  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
423
  if (inPlace || !lowerAnkle) return readSupports(ankle, hip, times, secondsOf, inPlace, false).feet;
@@ -407,6 +441,9 @@ function readSupports(ankle: Record<Side, readonly Pt[]>, hip: Record<Side, read
407
441
  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);
408
442
  const mean = (legLength.r + legLength.l) / 2;
409
443
  // A jump: both ankles and mid_hip above their rolling floors, for ≥ JUMP_FRAMES frames.
444
+ // #101: at a hand-over the front foot has not landed yet while the back ankle lifts its heel; when
445
+ // the front foot lands (the first frame after its lifted run) while the back ankle stays up, a back
446
+ // ankle that holds its height in y (< STILL_SPEED a frame for STILL_FRAMES frames) is a raised heel.
410
447
  const midY = hip.r.map((h, i) => (h[1] + hip.l[i]![1]) / 2);
411
448
  const rises: Array<{ ys: number[]; unit: number }> = [
412
449
  { ys: ankle.r.map((a) => a[1]), unit: legLength.r },
@@ -417,8 +454,23 @@ function readSupports(ankle: Record<Side, readonly Pt[]>, hip: Record<Side, read
417
454
  const floor = rollingFloor(ys, times);
418
455
  return ys.map((y, i) => floor[i]! - y > JUMP_RISE * unit);
419
456
  });
457
+ const ankleRuns = { r: runs(up[0]!), l: runs(up[1]!) };
458
+ const stillY = (s: Side, from: number): boolean => {
459
+ if (from + STILL_FRAMES > n) return false;
460
+ for (let k = from; k < from + STILL_FRAMES; k++) {
461
+ if (!up[s === 'r' ? 0 : 1]![k] || Math.abs(ankle[s][k]![1] - ankle[s][k - 1]![1]) / legLength[s] >= STILL_SPEED) return false;
462
+ }
463
+ return true;
464
+ };
465
+ const raisedHeel = (a: number, b: number): boolean => {
466
+ const rSpan = ankleRuns.r.find(([s, e]) => s <= a && b <= e)!;
467
+ const lSpan = ankleRuns.l.find(([s, e]) => s <= a && b <= e)!;
468
+ if (rSpan[1] === lSpan[1]) return false;
469
+ const [fSpan, back]: [[number, number], Side] = rSpan[1] < lSpan[1] ? [rSpan, 'l'] : [lSpan, 'r'];
470
+ return stillY(back, fSpan[1] + 1);
471
+ };
420
472
  const lifted = Array.from({ length: n }, (_, i) => up.every((u) => u[i]));
421
- const jumps = runs(lifted).filter(([a, b]) => b - a + 1 >= JUMP_FRAMES);
473
+ const jumps = runs(lifted).filter(([a, b]) => b - a + 1 >= JUMP_FRAMES && !raisedHeel(a, b));
422
474
  const jump = new Array<boolean>(n).fill(false);
423
475
  for (const [a, b] of jumps) for (let i = a; i <= b; i++) jump[i] = true;
424
476
  // Ankle speeds, leg lengths a frame (0 on the first frame).
@@ -432,6 +484,7 @@ function readSupports(ankle: Record<Side, readonly Pt[]>, hip: Record<Side, read
432
484
  return true;
433
485
  };
434
486
  const support: Array<Side | null> = new Array<Side | null>(n).fill(null);
487
+ const trkAt: Array<Side | null> = new Array<Side | null>(n).fill(null);
435
488
  const changes: Feet['changes'] = [];
436
489
  const steps: Step[] = [];
437
490
  const open: Partial<Record<Side, Step>> = {};
@@ -505,6 +558,9 @@ function readSupports(ankle: Record<Side, readonly Pt[]>, hip: Record<Side, read
505
558
  freeFrom = -1;
506
559
  }
507
560
  }
561
+ if (lowerAnkle && cur === null && released[released.length - 1] !== i && SIDES.some((x) => free(x) && higher(x))) {
562
+ trkAt[i] = lower(['r', 'l'], i);
563
+ }
508
564
  support[i] = cur;
509
565
  }
510
566
  const supports: Feet['supports'] = [];
@@ -515,7 +571,15 @@ function readSupports(ankle: Record<Side, readonly Pt[]>, hip: Record<Side, read
515
571
  if (last !== undefined && last.side === s && last.frames[1] === i - 1) last.frames[1] = i;
516
572
  else supports.push({ side: s, frames: [i, i] });
517
573
  }
518
- return { feet: { legLength, support, supports, jumps, steps, changes }, released };
574
+ const tracked: Array<{ side: Side; frames: [number, number] }> = [];
575
+ for (let i = 0; i < n; i++) {
576
+ const s = trkAt[i];
577
+ if (s === null || s === undefined) continue;
578
+ const last = tracked[tracked.length - 1];
579
+ if (last !== undefined && last.side === s && last.frames[1] === i - 1) last.frames[1] = i;
580
+ else tracked.push({ side: s, frames: [i, i] });
581
+ }
582
+ return { feet: { legLength, support, supports, jumps, steps, changes, ...(tracked.length > 0 ? { tracked } : {}) }, released };
519
583
  }
520
584
 
521
585
  /** One support span's shift onto the floor (rig-c/pose#85): its side, its frames, and floor − its planted height, world units. */
@@ -532,6 +596,8 @@ export interface Floor {
532
596
  shifts: FloorShift[];
533
597
  }
534
598
 
599
+ let trackFn: ((i: number, s: Side) => number) | undefined;
600
+
535
601
  /**
536
602
  * The hip's world origin on every frame, from the supporting foot (header),
537
603
  * with every support span on one floor (rig-c/pose#85). `offset(i, side)` is
@@ -548,7 +614,8 @@ export interface Floor {
548
614
  * support still blends the two derivations' y over `PLANT_BLEND` frames, now
549
615
  * between two spans on the floor, while holding the planted foot's x from the
550
616
  * frame it lands on (#102); a jump follows the pose's travel from the hip
551
- * it left (on the floor) and lands on the floor. On a blend frame the hip is
617
+ * it left (on the floor), carried so the landing frame meets the floor and no
618
+ * frame inside the flight puts a foot below it (#103). On a blend frame the hip is
552
619
  * between the two derivations, so the supporting ankle is off its anchor:
553
620
  * `blends` lists those frames with the anchor's y, and `holdBlend` stretches
554
621
  * the supporting leg to keep it there. Null `floor` in the result: there is no
@@ -557,18 +624,24 @@ export interface Floor {
557
624
  * `follow(i, side)` (#92, an in-place sequence): the supporting ankle's track,
558
625
  * world x. A plant's anchor x then moves by follow(i) − follow(the frame it
559
626
  * planted), and the loop is closed (`closeLoop`, `closure` in the result; null
560
- * without `follow`).
627
+ * without `follow`). `track(i, side)` (#119): the same world-x ankle track for
628
+ * `feet.tracked` frames on a sequence not declared in place.
561
629
  */
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);
564
- const heights = feet.supports.map((sp) => picture.anchors.get(sp.frames[0]));
565
- if (heights.length === 0) return { hip: picture.hip, floor: null, blends: picture.blends, closure: null };
566
- if (heights.some((h) => h === undefined)) throw new Error('plantHip: a support span starts on a frame no plant started on');
567
- const y = floor ?? percentile(heights as number[], 0.5);
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]! }));
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 };
630
+ 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 } {
631
+ trackFn = track;
632
+ try {
633
+ const picture = placeHip(feet, offset, travel, start, null, follow);
634
+ const heights = feet.supports.map((sp) => picture.anchors.get(sp.frames[0]));
635
+ if (heights.length === 0) return { hip: picture.hip, floor: null, blends: picture.blends, closure: null };
636
+ if (heights.some((h) => h === undefined)) throw new Error('plantHip: a support span starts on a frame no plant started on');
637
+ const y = floor ?? percentile(heights as number[], 0.5);
638
+ const shifts = feet.supports.map((sp, k) => ({ side: sp.side, frames: [sp.frames[0], sp.frames[1]] as [number, number], shift: y - heights[k]! }));
639
+ const placed = placeHip(feet, offset, travel, start, y, follow);
640
+ const closure = follow === undefined ? null : closeLoop(placed.hip);
641
+ return { hip: placed.hip, floor: { y, given: floor !== undefined, shifts }, blends: placed.blends, closure };
642
+ } finally {
643
+ trackFn = undefined;
644
+ }
572
645
  }
573
646
 
574
647
  /** 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. */
@@ -607,8 +680,14 @@ function placeHip(feet: Feet, offset: (i: number, s: Side) => Pt, travel: (i: nu
607
680
  const hip: Pt[] = [];
608
681
  const anchors = new Map<number, number>();
609
682
  const blends: BlendFrame[] = [];
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. */
611
- type Mode = { kind: 'plant'; side: Side; anchor: Pt; from: number } | { kind: 'travel'; from: number; at: Pt };
683
+ const tracked = new Array<Side | null>(n).fill(null);
684
+ for (const t of feet.tracked ?? []) for (let i = t.frames[0]; i <= t.frames[1]; i++) tracked[i] = t.side;
685
+ const trkX = trackFn ?? follow;
686
+ /** 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). */
687
+ type Mode =
688
+ | { kind: 'plant'; side: Side; anchor: Pt; from: number }
689
+ | { kind: 'track'; side: Side; anchor: Pt; from: number; at: Pt }
690
+ | { kind: 'travel'; from: number; at: Pt; end?: number; land?: number; carry?: number };
612
691
  const of = (mode: Mode, i: number): Pt => {
613
692
  if (mode.kind === 'plant') {
614
693
  const o = offset(i, mode.side);
@@ -616,9 +695,40 @@ function placeHip(feet: Feet, offset: (i: number, s: Side) => Pt, travel: (i: nu
616
695
  const dx = follow === undefined ? 0 : follow(i, mode.side) - follow(mode.from, mode.side);
617
696
  return [mode.anchor[0] + dx - o[0], mode.anchor[1] - o[1]];
618
697
  }
698
+ if (mode.kind === 'track') {
699
+ const o = offset(i, mode.side);
700
+ const dx = trkX === undefined ? o[0] - offset(mode.from, mode.side)[0] : trkX(i, mode.side) - trkX(mode.from, mode.side);
701
+ const y = floorY === null ? mode.at[1] + travel(i)[1] - travel(mode.from)[1] : mode.anchor[1] - o[1];
702
+ return [mode.anchor[0] + dx - o[0], y];
703
+ }
619
704
  const a = travel(i);
620
705
  const b = travel(mode.from);
621
- return [mode.at[0] + a[0] - b[0], mode.at[1] + a[1] - b[1]];
706
+ let y = mode.at[1] + a[1] - b[1];
707
+ if (mode.carry !== undefined && mode.land !== undefined && mode.end !== undefined && floorY !== null) {
708
+ if (mode.land < n && i <= mode.land && mode.land > mode.from) {
709
+ y += mode.carry * ((i - mode.from) / (mode.land - mode.from));
710
+ }
711
+ if (i <= mode.end) {
712
+ y = Math.max(y, floorY - Math.min(offset(i, 'r')[1], offset(i, 'l')[1]));
713
+ }
714
+ }
715
+ return [mode.at[0] + a[0] - b[0], y];
716
+ };
717
+ /** A travel span from `startFrame` off frame `from`'s hip `at`: carried to meet `floorY` at landing when it contains a jump (#103). */
718
+ const makeTravel = (startFrame: number, from: number, at: Pt): Mode => {
719
+ let end = startFrame;
720
+ while (end + 1 < n && (feet.support[end + 1] ?? null) === null && (tracked[end + 1] ?? null) === null) end++;
721
+ const isJump = floorY !== null && feet.jumps.some(([ja, jb]) => ja <= end && jb >= startFrame);
722
+ if (!isJump) return { kind: 'travel', from, at };
723
+ const land = end + 1;
724
+ let carry = 0;
725
+ if (land < n) {
726
+ const landSide = (feet.support[land] ?? tracked[land] ?? null)!;
727
+ const rawLandY = at[1] + travel(land)[1] - travel(from)[1];
728
+ const targetLandY = floorY - offset(land, landSide)[1];
729
+ carry = targetLandY - rawLandY;
730
+ }
731
+ return { kind: 'travel', from, at, end, land, carry };
622
732
  };
623
733
  /** A plant from frame i: its anchor's x where the foot is, its y there or on the floor. */
624
734
  const plant = (i: number, side: Side, at: Pt): Mode => {
@@ -630,23 +740,38 @@ function placeHip(feet: Feet, offset: (i: number, s: Side) => Pt, travel: (i: nu
630
740
  let blendAt = -1;
631
741
  for (let i = 0; i < n; i++) {
632
742
  const s = feet.support[i] ?? null;
743
+ const tr = s === null ? (tracked[i] ?? null) : null;
633
744
  if (i === 0) {
634
- if (s === null) mode = { kind: 'travel', from: 0, at: start };
635
- else {
745
+ if (s !== null) {
636
746
  const o = offset(0, s);
637
747
  mode = plant(0, s, [start[0] + o[0], start[1] + o[1]]);
748
+ } else if (tr !== null) {
749
+ const o = offset(0, tr);
750
+ mode = { kind: 'track', side: tr, anchor: [start[0] + o[0], floorY ?? (start[1] + o[1])], from: 0, at: start };
751
+ } else {
752
+ mode = makeTravel(0, 0, start);
638
753
  }
639
- } else if (s === null && mode!.kind === 'plant') {
640
- mode = { kind: 'travel', from: i - 1, at: hip[i - 1]! };
754
+ } else if (s !== null) {
755
+ if (mode!.kind !== 'plant' || mode!.side !== s) {
756
+ // The new support is held where it is on this frame, under the hip the old derivation gives here.
757
+ const here = of(mode!, i);
758
+ const o = offset(i, s);
759
+ from = mode;
760
+ mode = plant(i, s, [here[0] + o[0], here[1] + o[1]]);
761
+ blendAt = i;
762
+ }
763
+ } else if (tr !== null) {
764
+ if (mode!.kind !== 'track' || mode!.side !== tr) {
765
+ const prev = hip[i - 1]!;
766
+ const o = offset(i - 1, tr);
767
+ mode = { kind: 'track', side: tr, anchor: [prev[0] + o[0], floorY ?? (prev[1] + o[1])], from: i - 1, at: prev };
768
+ from = null;
769
+ blendAt = -1;
770
+ }
771
+ } else if (mode!.kind !== 'travel') {
772
+ mode = makeTravel(i, i - 1, hip[i - 1]!);
641
773
  from = null;
642
774
  blendAt = -1;
643
- } else if (s !== null && (mode!.kind === 'travel' || (mode as { side: Side }).side !== s)) {
644
- // The new support is held where it is on this frame, under the hip the old derivation gives here.
645
- const here = of(mode!, i);
646
- const o = offset(i, s);
647
- from = mode;
648
- mode = plant(i, s, [here[0] + o[0], here[1] + o[1]]);
649
- blendAt = i;
650
775
  }
651
776
  const to = of(mode!, i);
652
777
  if (from !== null && i - blendAt < PLANT_BLEND) {
@@ -693,6 +818,96 @@ export function stanceRangeOf(ankle: Record<Side, readonly Pt[]>, hip: Record<Si
693
818
  return out;
694
819
  }
695
820
 
821
+ /**
822
+ * Under `--plant-feet` and `--leg-scale`, a supporting leg segment that asks a
823
+ * scale under `--leg-scale`'s floor (`limits[0]`) follows its asked scale down
824
+ * to the bound #95 derives from the leg's own range (`min(limits[0], r(i))`) so
825
+ * the supporting ankle stays on #85's floor after #107 (rig-c/pose#133). The
826
+ * free leg keeps the configured clamp. When an asked scale goes under #95's
827
+ * bound (`lower`, defaulting to the segment's own range `asked[i]`), it is
828
+ * clamped there and the hip rises by the vertical remainder (`askedOffset −
829
+ * usedOffset`), never sinks.
830
+ */
831
+ export function scaleSupport(
832
+ legScale: LegScale[],
833
+ scales: Map<string, number[]>,
834
+ feet: Feet,
835
+ legs: Record<Side, { chain: Chain; ankleBone: string }>,
836
+ hip: Pt[],
837
+ askedOffset: (i: number, s: Side) => Pt,
838
+ usedOffset: (i: number, s: Side) => Pt,
839
+ lower?: StanceLower,
840
+ ): void {
841
+ const n = feet.support.length;
842
+ const tracked = new Array<Side | null>(n).fill(null);
843
+ for (const t of feet.tracked ?? []) for (let i = t.frames[0]; i <= t.frames[1]; i++) tracked[i] = t.side;
844
+ const byBone = new Map(legScale.map((ls) => [ls.bone, ls]));
845
+ const copied = new Set<string>();
846
+ const ensureCopy = (b: string): number[] => {
847
+ const cur = scales.get(b)!;
848
+ if (copied.has(b)) return cur;
849
+ const copy = [...cur];
850
+ scales.set(b, copy);
851
+ copied.add(b);
852
+ return copy;
853
+ };
854
+ const perBone = new Map<string, Array<{ frame: number; side: Side; asked: number; used: number; bound: number; clamped: boolean; hipRise: number }>>();
855
+ for (let i = 0; i < n; i++) {
856
+ const s = feet.support[i] ?? tracked[i] ?? null;
857
+ if (s === null) continue;
858
+ const touched: Array<{ bone: string; asked: number; used: number; bound: number; clamped: boolean }> = [];
859
+ for (const b of legs[s].chain.bones) {
860
+ const ls = byBone.get(b);
861
+ if (ls === undefined) continue;
862
+ const asked = ls.asked[i]!;
863
+ const floor = ls.limits[0];
864
+ if (!(asked < floor)) continue;
865
+ const r = lower !== undefined ? lower[s][i]! : asked;
866
+ const bound = Math.min(floor, r);
867
+ const used = Math.max(asked, bound);
868
+ const clamped = used > asked + 1e-12;
869
+ ensureCopy(b)[i] = used;
870
+ touched.push({ bone: b, asked, used, bound, clamped });
871
+ }
872
+ if (touched.length === 0) continue;
873
+ let hipRise = 0;
874
+ if (touched.some((t) => t.clamped)) {
875
+ hipRise = Math.max(0, askedOffset(i, s)[1] - usedOffset(i, s)[1]);
876
+ hip[i]![1] += hipRise;
877
+ }
878
+ for (const t of touched) {
879
+ const list = perBone.get(t.bone) ?? [];
880
+ list.push({ frame: i, side: s, ...t, hipRise });
881
+ perBone.set(t.bone, list);
882
+ }
883
+ }
884
+ for (const ls of legScale) {
885
+ const entries = perBone.get(ls.bone);
886
+ if (entries === undefined || entries.length === 0) continue;
887
+ const out: SupportScaleRun[] = [];
888
+ for (const e of entries) {
889
+ const last = out[out.length - 1];
890
+ if (last !== undefined && last.side === e.side && last.frames[1] === e.frame - 1 && (last.clamped !== undefined) === e.clamped) {
891
+ last.frames[1] = e.frame;
892
+ last.asked = { min: Math.min(last.asked.min, e.asked), max: Math.max(last.asked.max, e.asked) };
893
+ last.used = { min: Math.min(last.used.min, e.used), max: Math.max(last.used.max, e.used) };
894
+ if (last.clamped !== undefined) {
895
+ last.clamped = { bound: Math.min(last.clamped.bound, e.bound), hipRise: Math.max(last.clamped.hipRise, e.hipRise) };
896
+ }
897
+ } else {
898
+ out.push({
899
+ side: e.side,
900
+ frames: [e.frame, e.frame],
901
+ asked: { min: e.asked, max: e.asked },
902
+ used: { min: e.used, max: e.used },
903
+ ...(e.clamped ? { clamped: { bound: e.bound, hipRise: e.hipRise } } : {}),
904
+ });
905
+ }
906
+ }
907
+ ls.support = out;
908
+ }
909
+ }
910
+
696
911
  /** The lower bound on frame i for side s: the leg's own (#95), or the default. */
697
912
  function lowerOf(lower: StanceLower | undefined, s: Side, i: number): number {
698
913
  return lower?.[s][i] ?? STANCE_BOUNDS[0];
@@ -802,10 +1017,12 @@ export interface StancePosed {
802
1017
  export function stanceOf(feet: Feet, ankle: Record<Side, readonly Pt[]>, tolerance: number, posed: (i: number) => StancePosed, secondsOf: (run: [number, number]) => number, lower?: StanceLower): Stance {
803
1018
  const n = feet.support.length;
804
1019
  const stretch: Stance['stretch'] = new Array(n).fill(null);
1020
+ const tracked = new Array<Side | null>(n).fill(null);
1021
+ for (const t of feet.tracked ?? []) for (let i = t.frames[0]; i <= t.frames[1]; i++) tracked[i] = t.side;
805
1022
  type Read = { state: StanceState; side: Side; height: number };
806
1023
  const read: Array<Read | null> = new Array<Read | null>(n).fill(null);
807
1024
  for (let i = 0; i < n; i++) {
808
- const s = feet.support[i] ?? null;
1025
+ const s = feet.support[i] ?? tracked[i] ?? null;
809
1026
  if (s === null) continue;
810
1027
  const f: Side = s === 'r' ? 'l' : 'r';
811
1028
  read[i] = { state: 'both', side: f, height: (ankle[s][i]![1] - ankle[f][i]![1]) / feet.legLength[f] };
package/src/pose.ts CHANGED
@@ -150,6 +150,7 @@ export interface HoldParams {
150
150
  export interface CleanHold {
151
151
  joint: Body18;
152
152
  frames: [number, number];
153
+ excursion?: [number, number];
153
154
  jitter: { before: number | null; after: number | null };
154
155
  }
155
156
  /**
@@ -432,10 +433,16 @@ function readCleaned(p: Problems, where: string, c: unknown, frames: number | nu
432
433
  p.add('POSE_CLEANED', `${hw} is ${typeName(h)}; a hold is { joint, frames: [first, last], jitter: { before, after } }`);
433
434
  return;
434
435
  }
435
- refuseUnknownKeys(p, 'POSE_CLEANED', hw, h, ['joint', 'frames', 'jitter']);
436
+ refuseUnknownKeys(p, 'POSE_CLEANED', hw, h, ['joint', 'frames', 'excursion', 'jitter']);
436
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(', ')}`);
437
438
  const f = h.frames;
438
- 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
+ }
439
446
  const j = h.jitter;
440
447
  if (!isObject(j)) p.add('POSE_CLEANED', `${hw} "jitter" is ${typeName(j)}; it must be { before, after }`);
441
448
  else {
@@ -642,7 +649,7 @@ function readHipsInferred(p: Problems, hw: string, h: unknown, frames: number |
642
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`);
643
650
  }
644
651
  const holdParamsOf = (h: HoldParams | 'off'): HoldParams | 'off' => (h === 'off' ? 'off' : { speed: h.speed, reach: h.reach, minSeconds: h.minSeconds, blend: h.blend });
645
- 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 } });
646
653
  const copyUnheld = (u: CleanUnheld): CleanUnheld => ({ joint: u.joint, frames: [u.frames[0], u.frames[1]], test: u.test, value: u.value });
647
654
 
648
655
  function readJoints(p: Problems, pw: string, joints: unknown): Partial<Record<Body18, Joint>> {
@@ -836,7 +843,7 @@ export function writePoseSequence(seq: PoseSequence): string {
836
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) } : {}) },
837
844
  spikes: { found: c.spikes.found.map((s) => ({ frame: s.frame, joint: s.joint })), untested: [...c.spikes.untested] },
838
845
  jitter: { before: jit(c.jitter.before), after: jit(c.jitter.after) },
839
- ...(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) } })) } : {}),
840
847
  ...(c.seams !== undefined ? { seams: c.seams.map((x) => ({ joint: x.joint, frame: x.frame, step: round(x.step, 4), gap: x.gap })) } : {}),
841
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) })) } : {}),
842
849
  };