@rig-c/pose 0.19.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
@@ -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
@@ -266,13 +268,16 @@ export interface LegScale {
266
268
  high: Array<[number, number]>;
267
269
  /** The scale keyed on each frame (clamped). */
268
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[];
269
273
  }
270
274
 
271
- /** 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. */
272
276
  export function legScaleOf(lengths: readonly number[], limits: [number, number]): Omit<LegScale, 'chain' | 'bone' | 'joints'> {
273
277
  const straight = percentile(lengths, STRAIGHT_PERCENTILE);
274
278
  const ratio = lengths.map((l) => l / straight);
275
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]));
276
281
  const sorted = [...ratio].sort((a, b) => a - b);
277
282
  const mid = sorted.length >> 1;
278
283
  const median = sorted.length % 2 === 1 ? sorted[mid]! : (sorted[mid - 1]! + sorted[mid]!) / 2;
@@ -283,6 +288,7 @@ export function legScaleOf(lengths: readonly number[], limits: [number, number])
283
288
  low: runs(ratio.map((r) => r < limits[0])),
284
289
  high: runs(ratio.map((r) => r > limits[1])),
285
290
  scale,
291
+ asked,
286
292
  };
287
293
  }
288
294
 
@@ -339,10 +345,18 @@ export interface Feet {
339
345
  /**
340
346
  * rig-c/pose#113, read from the lower ankle: each run of frames whose
341
347
  * 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
348
+ * no foot is planted). Absent where the rule does not apply (in place, or
343
349
  * without `lowerAnkle`).
344
350
  */
345
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] }>;
346
360
  }
347
361
 
348
362
  /** A run of frames whose support the lower-ankle rule (rig-c/pose#113) reads differently from the reading before it. */
@@ -383,7 +397,9 @@ export function rollingFloor(ys: readonly number[], times: readonly number[]): n
383
397
  * other ankle has been that much lower for `PLANT_SWITCH_SECONDS` hands support
384
398
  * to it when it is free (as before) and is otherwise released (the hip follows
385
399
  * the pose until a foot may take support). Steps and jumps are read as before.
386
- * 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`).
387
403
  */
388
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 {
389
405
  if (inPlace || !lowerAnkle) return readSupports(ankle, hip, times, secondsOf, inPlace, false).feet;
@@ -407,6 +423,9 @@ function readSupports(ankle: Record<Side, readonly Pt[]>, hip: Record<Side, read
407
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);
408
424
  const mean = (legLength.r + legLength.l) / 2;
409
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.
410
429
  const midY = hip.r.map((h, i) => (h[1] + hip.l[i]![1]) / 2);
411
430
  const rises: Array<{ ys: number[]; unit: number }> = [
412
431
  { ys: ankle.r.map((a) => a[1]), unit: legLength.r },
@@ -417,8 +436,23 @@ function readSupports(ankle: Record<Side, readonly Pt[]>, hip: Record<Side, read
417
436
  const floor = rollingFloor(ys, times);
418
437
  return ys.map((y, i) => floor[i]! - y > JUMP_RISE * unit);
419
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
+ };
420
454
  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);
455
+ const jumps = runs(lifted).filter(([a, b]) => b - a + 1 >= JUMP_FRAMES && !raisedHeel(a, b));
422
456
  const jump = new Array<boolean>(n).fill(false);
423
457
  for (const [a, b] of jumps) for (let i = a; i <= b; i++) jump[i] = true;
424
458
  // Ankle speeds, leg lengths a frame (0 on the first frame).
@@ -432,6 +466,7 @@ function readSupports(ankle: Record<Side, readonly Pt[]>, hip: Record<Side, read
432
466
  return true;
433
467
  };
434
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);
435
470
  const changes: Feet['changes'] = [];
436
471
  const steps: Step[] = [];
437
472
  const open: Partial<Record<Side, Step>> = {};
@@ -505,6 +540,9 @@ function readSupports(ankle: Record<Side, readonly Pt[]>, hip: Record<Side, read
505
540
  freeFrom = -1;
506
541
  }
507
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);
545
+ }
508
546
  support[i] = cur;
509
547
  }
510
548
  const supports: Feet['supports'] = [];
@@ -515,7 +553,15 @@ function readSupports(ankle: Record<Side, readonly Pt[]>, hip: Record<Side, read
515
553
  if (last !== undefined && last.side === s && last.frames[1] === i - 1) last.frames[1] = i;
516
554
  else supports.push({ side: s, frames: [i, i] });
517
555
  }
518
- 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 };
519
565
  }
520
566
 
521
567
  /** 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 +578,8 @@ export interface Floor {
532
578
  shifts: FloorShift[];
533
579
  }
534
580
 
581
+ let trackFn: ((i: number, s: Side) => number) | undefined;
582
+
535
583
  /**
536
584
  * The hip's world origin on every frame, from the supporting foot (header),
537
585
  * with every support span on one floor (rig-c/pose#85). `offset(i, side)` is
@@ -548,7 +596,8 @@ export interface Floor {
548
596
  * support still blends the two derivations' y over `PLANT_BLEND` frames, now
549
597
  * between two spans on the floor, while holding the planted foot's x from the
550
598
  * 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
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
552
601
  * between the two derivations, so the supporting ankle is off its anchor:
553
602
  * `blends` lists those frames with the anchor's y, and `holdBlend` stretches
554
603
  * the supporting leg to keep it there. Null `floor` in the result: there is no
@@ -557,18 +606,24 @@ export interface Floor {
557
606
  * `follow(i, side)` (#92, an in-place sequence): the supporting ankle's track,
558
607
  * world x. A plant's anchor x then moves by follow(i) − follow(the frame it
559
608
  * planted), and the loop is closed (`closeLoop`, `closure` in the result; null
560
- * 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.
561
611
  */
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 };
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
+ }
572
627
  }
573
628
 
574
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. */
@@ -607,8 +662,14 @@ function placeHip(feet: Feet, offset: (i: number, s: Side) => Pt, travel: (i: nu
607
662
  const hip: Pt[] = [];
608
663
  const anchors = new Map<number, number>();
609
664
  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 };
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 };
612
673
  const of = (mode: Mode, i: number): Pt => {
613
674
  if (mode.kind === 'plant') {
614
675
  const o = offset(i, mode.side);
@@ -616,9 +677,40 @@ function placeHip(feet: Feet, offset: (i: number, s: Side) => Pt, travel: (i: nu
616
677
  const dx = follow === undefined ? 0 : follow(i, mode.side) - follow(mode.from, mode.side);
617
678
  return [mode.anchor[0] + dx - o[0], mode.anchor[1] - o[1]];
618
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
+ }
619
686
  const a = travel(i);
620
687
  const b = travel(mode.from);
621
- 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 };
622
714
  };
623
715
  /** A plant from frame i: its anchor's x where the foot is, its y there or on the floor. */
624
716
  const plant = (i: number, side: Side, at: Pt): Mode => {
@@ -630,23 +722,38 @@ function placeHip(feet: Feet, offset: (i: number, s: Side) => Pt, travel: (i: nu
630
722
  let blendAt = -1;
631
723
  for (let i = 0; i < n; i++) {
632
724
  const s = feet.support[i] ?? null;
725
+ const tr = s === null ? (tracked[i] ?? null) : null;
633
726
  if (i === 0) {
634
- if (s === null) mode = { kind: 'travel', from: 0, at: start };
635
- else {
727
+ if (s !== null) {
636
728
  const o = offset(0, s);
637
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);
638
735
  }
639
- } else if (s === null && mode!.kind === 'plant') {
640
- 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]!);
641
755
  from = null;
642
756
  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
757
  }
651
758
  const to = of(mode!, i);
652
759
  if (from !== null && i - blendAt < PLANT_BLEND) {
@@ -802,10 +909,12 @@ export interface StancePosed {
802
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 {
803
910
  const n = feet.support.length;
804
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;
805
914
  type Read = { state: StanceState; side: Side; height: number };
806
915
  const read: Array<Read | null> = new Array<Read | null>(n).fill(null);
807
916
  for (let i = 0; i < n; i++) {
808
- const s = feet.support[i] ?? null;
917
+ const s = feet.support[i] ?? tracked[i] ?? null;
809
918
  if (s === null) continue;
810
919
  const f: Side = s === 'r' ? 'l' : 'r';
811
920
  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
  };