@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/README.md +16 -16
- package/cli.ts +1 -1
- package/package.json +1 -1
- package/src/clean.ts +115 -21
- package/src/legs.ts +174 -46
- package/src/pose.ts +82 -33
- package/src/retarget.ts +199 -11
- package/src/run.ts +3 -2
- package/src/switch.ts +33 -5
- package/src/verify.ts +221 -17
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
|
|
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
|
|
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
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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
|
|
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 (
|
|
480
|
-
if (
|
|
481
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
533
|
-
* it
|
|
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
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
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
|
-
|
|
593
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
622
|
-
mode
|
|
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
|
-
|
|
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)
|
|
86
|
-
*
|
|
87
|
-
*
|
|
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
|
-
| {
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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
|
|
493
|
-
|
|
494
|
-
|
|
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
|
|
528
|
-
for (const c of
|
|
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
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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
|
|
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) && !
|
|
593
|
-
if (none.length > 0) p.add('POSE_CLEANED', `${hw}: frame(s) ${spans(none)} are in none of
|
|
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) && !
|
|
598
|
-
if (unfilled.length > 0) p.add('POSE_CLEANED', `${hw}: frame(s) ${spans(unfilled)} are declined but neither
|
|
599
|
-
const undeclared = [...
|
|
600
|
-
if (undeclared.length > 0) p.add('POSE_CLEANED', `${hw}: frame(s) ${spans(undeclared)} are
|
|
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
|
};
|