@rig-c/pose 0.31.1 → 0.32.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 +42 -19
- package/cli.ts +84 -25
- package/package.json +1 -1
- package/src/clean.ts +23 -2
- package/src/legs.ts +93 -0
- package/src/map.ts +35 -4
- package/src/problems.ts +9 -2
- package/src/retarget.ts +461 -27
- package/src/run.ts +332 -46
- package/src/verify.ts +111 -2
package/src/retarget.ts
CHANGED
|
@@ -81,8 +81,8 @@ import { TRAVEL_IN_PLACE } from './pose.ts';
|
|
|
81
81
|
import { Problems, Refusal } from './problems.ts';
|
|
82
82
|
import type { ArtRange, AttachmentKey, ByHip, Raised, ReachShort, SlotTrack, Switch, SwitchOptions } from './switch.ts';
|
|
83
83
|
import { clampArt, coveredChains, isRaised, measureArt, overflow, RAISED_WRIST_EYES, RANGE_SLACK, reachLimit, runsOf, selectSwitches, twoBone } from './switch.ts';
|
|
84
|
-
import type { BlendHold, Closure, Feet, Floor, LegScale, PathPose, Posing, Side, Stance } from './legs.ts';
|
|
85
|
-
import { figureScaleOf, holdBlend, isLegSegment, legScaleOf, legsOf, pathFrom, percentile, plantHip, posePath, rootOf, scaleSupport, SIDES, stanceOf, stanceRangeOf, STRAIGHT_PERCENTILE, supportsOf, tipOf, unshortenStanceShin } from './legs.ts';
|
|
84
|
+
import type { BlendHold, Closure, Feet, Floor, LegScale, LegSegmentRatio, PathPose, Posing, Side, Stance } from './legs.ts';
|
|
85
|
+
import { figureScaleOf, freeAnkleShiftOf, holdBlend, isLegSegment, legScaleOf, legsOf, pathFrom, percentile, plantHip, posePath, rootOf, scaleSupport, shiftOver, SIDES, stanceOf, stanceRangeOf, STRAIGHT_PERCENTILE, supportsOf, tipOf, unshortenStanceShin } from './legs.ts';
|
|
86
86
|
import type { BodyReading, FaceReading, YawSetSwitch } from './face.ts';
|
|
87
87
|
import { readBody, readFace, readFaceAngles, selectYawSets } from './face.ts';
|
|
88
88
|
|
|
@@ -124,12 +124,29 @@ export const NOD_RESIDUAL_HEAD_GAIN = 1.6;
|
|
|
124
124
|
*/
|
|
125
125
|
export const HEAD_ANCHOR_FRAME: 'head-local' | 'world-fixed' | 'off' = 'head-local';
|
|
126
126
|
|
|
127
|
+
/** How the nod's baseline was read (rig-c/pose#241): none on an in-place sequence (frame 0, no clamp), else the ±0.5 s detrend window, wrapped on a `clean --loop` sequence or cut at the clip's ends. */
|
|
128
|
+
export type NodWindow = 'in-place' | 'wrapped' | 'cut';
|
|
129
|
+
|
|
127
130
|
export interface HeadNodReading {
|
|
128
131
|
bone: string;
|
|
132
|
+
/** The chain that carries the eye line (rig-c/pose#241). */
|
|
133
|
+
chain: string;
|
|
129
134
|
maxNod: number;
|
|
130
135
|
rawY: number[];
|
|
131
136
|
clampedY: number[];
|
|
132
137
|
residual: number[];
|
|
138
|
+
/** The baseline's window (#241), and its half width in frames (0 in place). */
|
|
139
|
+
window: NodWindow;
|
|
140
|
+
halfWin: number;
|
|
141
|
+
/** Whether a translate track was keyed (false: the eyes did not move against the neck past the writer's rounding). */
|
|
142
|
+
keyed: boolean;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Why the eye-line chain keys no nod (rig-c/pose#241): switched off in the map, or a reading it cannot take. */
|
|
146
|
+
export interface NodSkipped {
|
|
147
|
+
bone: string;
|
|
148
|
+
chain: string;
|
|
149
|
+
why: string;
|
|
133
150
|
}
|
|
134
151
|
|
|
135
152
|
export interface RetargetOptions extends SwitchOptions {
|
|
@@ -153,8 +170,10 @@ export interface RetargetOptions extends SwitchOptions {
|
|
|
153
170
|
floor?: number;
|
|
154
171
|
/** While a set whose arm is drawn raised shows, its wrist is placed against the rig's head in eye distances (rig-c/pose#62); false (the default) keeps the pose's angles. */
|
|
155
172
|
raisedByHead?: boolean;
|
|
156
|
-
/** The sequence's `travel` (rig-c/pose#92): "in-place" makes --plant-feet let the supporting foot follow its own track and close the loop; absent, nothing changes. */
|
|
173
|
+
/** The sequence's `travel` (rig-c/pose#92, #242): "in-place" reads the supports, the camera-zoom normalisations and the head nod as a loop, with or without --plant-feet, and makes --plant-feet let the supporting foot follow its own track and close the loop; absent, nothing changes. */
|
|
157
174
|
travel?: Travel;
|
|
175
|
+
/** The sequence was cleaned with `--loop` (its `cleaned.params.loop`, rig-c/pose#241): the head nod's detrend window wraps, the last frame running into the first. */
|
|
176
|
+
loop?: boolean;
|
|
158
177
|
}
|
|
159
178
|
|
|
160
179
|
export interface Hold {
|
|
@@ -296,6 +315,51 @@ export interface RangeDemand {
|
|
|
296
315
|
limits: [number, number];
|
|
297
316
|
}
|
|
298
317
|
|
|
318
|
+
/**
|
|
319
|
+
* A `reach` chain whose aim departs from its pose pair's own picture angle
|
|
320
|
+
* (rig-c/pose#236), and the two things that make it: the bone's origin sits off
|
|
321
|
+
* the anchor's (a lever: the target is the pair's end, so a pair turning by θ
|
|
322
|
+
* about the anchor turns the aim from an origin partway up it by more than θ),
|
|
323
|
+
* or a bone above it is turned, or translated other than by the anchor's own
|
|
324
|
+
* travel (which the target carries too, so it cancels). A reading — the keys
|
|
325
|
+
* are the reach's, unchanged.
|
|
326
|
+
*/
|
|
327
|
+
export interface ReachReading {
|
|
328
|
+
chain: string;
|
|
329
|
+
bone: string;
|
|
330
|
+
/** The pair: the chain's first joint (the translated joint) and its second. */
|
|
331
|
+
joints: [string, string];
|
|
332
|
+
/** The anchor (the translated bone), and the setup distance from its origin to the bone's: world units, and as a fraction of the reached length (the pair's median picture length, scaled). */
|
|
333
|
+
anchor: string;
|
|
334
|
+
offset: { units: number; ofLength: number };
|
|
335
|
+
/** The bones above it the animation moves: by a rotate track, or a translate track other than the anchor's. */
|
|
336
|
+
above: Array<{ bone: string; property: 'rotate' | 'translate'; chain: string | null }>;
|
|
337
|
+
/** aim − the pair's angle, degrees: the largest |·| over the frames and its frame, and the mean |second difference| of it, °/frame². */
|
|
338
|
+
departure: { max: number; frame: number; meanD2: number | null };
|
|
339
|
+
/** Mean |second difference|, °/frame², of the reach's aim and of the pair's own angle, over every frame; and aim ÷ pair (null when the pair is still: its figure rounds to 0). */
|
|
340
|
+
aimD2: number | null;
|
|
341
|
+
pairD2: number | null;
|
|
342
|
+
ratio: number | null;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Mean |second difference| of a series of angles, degrees: |wrap(v[i+1] − v[i])
|
|
347
|
+
* − wrap(v[i] − v[i−1])| over every i whose three frames `ok` admits (every
|
|
348
|
+
* frame when it is omitted), and how many triples that was. null on none.
|
|
349
|
+
*/
|
|
350
|
+
export function meanSecondDiff(series: ReadonlyArray<readonly number[]>, ok?: (i: number) => boolean): { value: number | null; triples: number } {
|
|
351
|
+
let sum = 0;
|
|
352
|
+
let triples = 0;
|
|
353
|
+
for (const v of series) {
|
|
354
|
+
for (let i = 1; i + 1 < v.length; i++) {
|
|
355
|
+
if (ok !== undefined && !(ok(i - 1) && ok(i) && ok(i + 1))) continue;
|
|
356
|
+
sum += Math.abs(wrap(v[i + 1]! - v[i]!) - wrap(v[i]! - v[i - 1]!));
|
|
357
|
+
triples++;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return { value: triples === 0 ? null : sum / triples, triples };
|
|
361
|
+
}
|
|
362
|
+
|
|
299
363
|
/** The lines across the body a `TURN` is read on: the subject's right joint, then its left twin. */
|
|
300
364
|
export const TURN_LINES: ReadonlyArray<[string, string, string]> = [['hip', 'r_hip', 'l_hip'], ['shoulder', 'r_shoulder', 'l_shoulder'], ['eye', 'r_eye', 'l_eye']];
|
|
301
365
|
/** A line shorter in the picture than this fraction of its median is turned (rig-c/pose#6: between the front-facing dance's floor, 0.81, and a side-on body's 0.17–0.49). */
|
|
@@ -324,6 +388,8 @@ export interface Retarget {
|
|
|
324
388
|
flips: Flip[];
|
|
325
389
|
/** The figure scale, rig units per picture pixel, and the lengths it came from. */
|
|
326
390
|
scale: { factor: number; pose: number; rig: number } | null;
|
|
391
|
+
/** rig-c/pose#240: what the figure scale is read against — its segment's foreshortening, the legs' and hips' ratios, the hip's travel; null without a scale. */
|
|
392
|
+
scaleFit: ScaleFit | null;
|
|
327
393
|
/** The spans each alternate set shows, with their art turn. */
|
|
328
394
|
switches: Switch[];
|
|
329
395
|
/** Per switched or hidden slot, what it shows on each frame (null: nothing). */
|
|
@@ -340,6 +406,12 @@ export interface Retarget {
|
|
|
340
406
|
artRange: ArtRange[];
|
|
341
407
|
/** With --leg-scale: each leg segment's scale, straight length and clamped runs. */
|
|
342
408
|
legScale: LegScale[];
|
|
409
|
+
/** rig-c/pose#237: every mapped leg segment's picture length ÷ straight (min, median, max), with or without --leg-scale. */
|
|
410
|
+
segments: LegSegmentRatio[];
|
|
411
|
+
/** rig-c/pose#237: each side's straight leg, the 95th percentile of its picture length hip → ankle, px (LEG-SCALE's percentile, against FEET's median); null without both legs read. */
|
|
412
|
+
legStraight: Record<Side, number> | null;
|
|
413
|
+
/** rig-c/pose#237: each side's rig leg at setup, thigh origin → ankle, world units — the leg length a clamp's shift of the ankle is read in; null without both legs. */
|
|
414
|
+
rigLeg: Record<Side, number> | null;
|
|
343
415
|
/** Per scaled bone, the scale its x axis must reach on each frame (relative to setup); the scalex key of a bone under a scaled bone differs from it. */
|
|
344
416
|
scales: Map<string, number[]>;
|
|
345
417
|
/** The supports and jumps read off the reference ankles, when the map has both legs (whether or not --plant-feet used them). */
|
|
@@ -354,6 +426,8 @@ export interface Retarget {
|
|
|
354
426
|
blendHold: BlendHold | null;
|
|
355
427
|
/** With --plant-feet on an in-place sequence: the hip's x drift closed over the loop (#92). */
|
|
356
428
|
closure: Closure | null;
|
|
429
|
+
/** On an in-place sequence without --plant-feet (#242): the translated bone's world x on the last frame less its x on the first, which nothing closes; null otherwise, and when no bone is translated. */
|
|
430
|
+
openDrift: number | null;
|
|
357
431
|
/** Each ankle's world position on every frame, as the keys put it, when legs are scaled or feet planted. */
|
|
358
432
|
ankles: { bones: Record<Side, string>; at: Record<Side, Pt[]> } | null;
|
|
359
433
|
/** The translated bone's origin against its thigh roots' midpoint, at setup. */
|
|
@@ -370,6 +444,10 @@ export interface Retarget {
|
|
|
370
444
|
figureScale: FigureScaleReading;
|
|
371
445
|
/** Per-frame vertical head nod reading (`rawY`, `clampedY`, and clamped `residual`, `#111`, `#181`, `#190`). */
|
|
372
446
|
headNod: HeadNodReading | null;
|
|
447
|
+
/** The eye-line chain keys no nod, and why (rig-c/pose#241); null when it does or when no chain carries the eye line. */
|
|
448
|
+
nodSkipped: NodSkipped | null;
|
|
449
|
+
/** Each `reach` chain hanging below a bone the animation turns or translates (#236): its aim's departure from the pose pair's angle. */
|
|
450
|
+
reach: ReachReading[];
|
|
373
451
|
}
|
|
374
452
|
|
|
375
453
|
export interface FigureScaleFrame {
|
|
@@ -403,6 +481,46 @@ export interface Pivot {
|
|
|
403
481
|
|
|
404
482
|
type Pt = [number, number];
|
|
405
483
|
|
|
484
|
+
/** A ratio band, rig units per picture px: the rig's length over the picture's straight (95th percentile) length, to over its median. */
|
|
485
|
+
export interface ScaleBand {
|
|
486
|
+
/** rig / picture 95th percentile — the factor that matches the length where the picture shows it longest. */
|
|
487
|
+
lo: number;
|
|
488
|
+
/** rig / picture median — the factor that matches it on the median frame. */
|
|
489
|
+
hi: number;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** rig-c/pose#240: the figure scale beside what it could have been read off instead. */
|
|
493
|
+
export interface ScaleFit {
|
|
494
|
+
/** The `translate.scale.pose` segment: its median picture length (what the factor divides by) against its 95th percentile. */
|
|
495
|
+
segment: { joints: [string, string]; median: number; straight: number; ratio: number };
|
|
496
|
+
/** Each side's leg, hip → ankle: the rig's at setup (thigh origin → ankle, units), the picture's median and 95th percentile (px), and the band rig ÷ those; null without both legs. */
|
|
497
|
+
leg: { rig: Record<Side, number>; median: Record<Side, number>; straight: Record<Side, number>; band: ScaleBand; within: boolean } | null;
|
|
498
|
+
/** The hips' width: the rig's thigh roots apart at setup (units), the picture's r_hip → l_hip median and 95th percentile (px), and the band; null without both legs and both hips read. */
|
|
499
|
+
hip: { rig: number; median: number; straight: number; band: ScaleBand; within: boolean } | null;
|
|
500
|
+
/** Whether `scale.factor` lies outside the leg band and outside the hip band (`withinPx`); null unless both are read. */
|
|
501
|
+
outside: boolean | null;
|
|
502
|
+
/** The translated joint's range over the frames, x and y: the pose's in px, the keyed bone's world origin in units — each also in leg lengths (the pose's straight leg, the rig's at setup, the two sides' mean) and hip widths (the pose's 95th percentile, the rig's at setup); null where the unit is not read. */
|
|
503
|
+
travel: {
|
|
504
|
+
joint: string;
|
|
505
|
+
bone: string;
|
|
506
|
+
pose: { x: number; y: number; legs: [number, number] | null; hips: [number, number] | null };
|
|
507
|
+
rig: { x: number; y: number; legs: [number, number] | null; hips: [number, number] | null };
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Whether `scale.factor` sizes a length as the picture shows it: the rig's
|
|
513
|
+
* length carried into the picture by the factor (rig ÷ factor, px) lies between
|
|
514
|
+
* the picture's median and 95th percentile, give or take one picture px — the
|
|
515
|
+
* picture's own unit, so a band the picture never widened (a length it shows the
|
|
516
|
+
* same on every frame) is not missed by a rounding the picture cannot show.
|
|
517
|
+
*/
|
|
518
|
+
export function withinPx(factor: number, rig: number, median: number, straight: number): boolean {
|
|
519
|
+
const px = rig / factor;
|
|
520
|
+
return px >= Math.min(median, straight) - 1 && px <= Math.max(median, straight) + 1;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
|
|
406
524
|
/** The value of a point on one frame, or why it is not good. */
|
|
407
525
|
export function pointOn(joints: Partial<Record<Body18, Joint>>, name: string, minScore: number): Pt | string {
|
|
408
526
|
if (isBody18(name)) {
|
|
@@ -467,6 +585,57 @@ function pivotOf(rest: Rest, bone: string | null, legs: ReturnType<typeof legsOf
|
|
|
467
585
|
return { bone, roots, offset, distance, hipWidths: width > 1e-9 ? distance / width : NaN };
|
|
468
586
|
}
|
|
469
587
|
|
|
588
|
+
/** Two bones' setup origins apart, world units: the rig's hip width when they are the thigh roots. */
|
|
589
|
+
function rootsApart(rest: Rest, a: string, b: string): number {
|
|
590
|
+
const x = rest.get(a)!;
|
|
591
|
+
const y = rest.get(b)!;
|
|
592
|
+
return Math.hypot(y.worldX - x.worldX, y.worldY - x.worldY);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const rangeOf = (xs: readonly number[]): number => (xs.length === 0 ? 0 : Math.max(...xs) - Math.min(...xs));
|
|
596
|
+
|
|
597
|
+
/** rig-c/pose#240: the SCALE reading, from fields the retarget already read — nothing here changes a key. */
|
|
598
|
+
function scaleFitOf(o: {
|
|
599
|
+
joints: [string, string]; joint: string; bone: string; lengths: number[]; factor: number;
|
|
600
|
+
series: ReadonlyMap<string, Pt[]>; good: ReadonlyMap<string, boolean[]>;
|
|
601
|
+
legLength: Record<Side, number> | null; legStraight: Record<Side, number> | null; rigLeg: Record<Side, number> | null;
|
|
602
|
+
rigHip: number | null; origin: readonly Pt[];
|
|
603
|
+
}): ScaleFit {
|
|
604
|
+
const segMedian = median(o.lengths);
|
|
605
|
+
const segStraight = percentile(o.lengths, STRAIGHT_PERCENTILE);
|
|
606
|
+
const leg = o.legLength === null || o.legStraight === null || o.rigLeg === null ? null : (() => {
|
|
607
|
+
const rig = o.rigLeg!, med = o.legLength!, str = o.legStraight!;
|
|
608
|
+
const at = (q: Record<Side, number>) => SIDES.map((s) => rig[s] / q[s]);
|
|
609
|
+
return { rig, median: med, straight: str, band: { lo: Math.min(...at(str)), hi: Math.max(...at(med)) }, within: SIDES.some((s) => withinPx(o.factor, rig[s], med[s], str[s])) };
|
|
610
|
+
})();
|
|
611
|
+
const hipWidths: number[] = [];
|
|
612
|
+
const R = o.series.get('r_hip'), L = o.series.get('l_hip'), gr = o.good.get('r_hip'), gl = o.good.get('l_hip');
|
|
613
|
+
if (R !== undefined && L !== undefined && gr !== undefined && gl !== undefined) {
|
|
614
|
+
for (let i = 0; i < R.length; i++) if (gr[i] && gl[i]) hipWidths.push(Math.hypot(L[i]![0] - R[i]![0], L[i]![1] - R[i]![1]));
|
|
615
|
+
}
|
|
616
|
+
const hipMedian = hipWidths.length === 0 ? NaN : median(hipWidths);
|
|
617
|
+
const hipStraight = percentile(hipWidths, STRAIGHT_PERCENTILE);
|
|
618
|
+
const hip = o.rigHip === null || !(hipMedian > 1e-6) || !(hipStraight > 1e-6) || !(o.rigHip > 1e-6) ? null
|
|
619
|
+
: { rig: o.rigHip, median: hipMedian, straight: hipStraight, band: { lo: o.rigHip / hipStraight, hi: o.rigHip / hipMedian }, within: withinPx(o.factor, o.rigHip, hipMedian, hipStraight) };
|
|
620
|
+
const P = o.series.get(o.joint)!;
|
|
621
|
+
const px = rangeOf(P.map((p) => p[0])), py = rangeOf(P.map((p) => p[1]));
|
|
622
|
+
const ux = rangeOf(o.origin.map((p) => p[0])), uy = rangeOf(o.origin.map((p) => p[1]));
|
|
623
|
+
const mean = (q: Record<Side, number>) => (q.r + q.l) / 2;
|
|
624
|
+
const per = (x: number, y: number, unit: number | null): [number, number] | null => (unit === null || !(unit > 1e-6) ? null : [x / unit, y / unit]);
|
|
625
|
+
return {
|
|
626
|
+
segment: { joints: o.joints, median: segMedian, straight: segStraight, ratio: segMedian / segStraight },
|
|
627
|
+
leg,
|
|
628
|
+
hip,
|
|
629
|
+
outside: leg === null || hip === null ? null : !leg.within && !hip.within,
|
|
630
|
+
travel: {
|
|
631
|
+
joint: o.joint,
|
|
632
|
+
bone: o.bone,
|
|
633
|
+
pose: { x: px, y: py, legs: per(px, py, o.legStraight === null ? null : mean(o.legStraight)), hips: per(px, py, hip === null ? null : hip.straight) },
|
|
634
|
+
rig: { x: ux, y: uy, legs: per(ux, uy, o.rigLeg === null ? null : mean(o.rigLeg)), hips: per(ux, uy, hip === null ? null : hip.rig) },
|
|
635
|
+
},
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
|
|
470
639
|
/**
|
|
471
640
|
* Retarget. Refuses (every reason at once) when a point the map needs is never
|
|
472
641
|
* good, when a hinge's side cannot be read off a straight rest, and — with
|
|
@@ -600,6 +769,8 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
600
769
|
// first, because a reach is aimed from where the hip has put its bone.
|
|
601
770
|
let origin: Retarget['origin'] = null;
|
|
602
771
|
let scale: Retarget['scale'] = null;
|
|
772
|
+
/** The scale segment's picture length on every frame both its points are good (#240's SCALE reading). */
|
|
773
|
+
let scaleLengths: number[] = [];
|
|
603
774
|
let translateTrack: MotionTrack | null = null;
|
|
604
775
|
/** The translated bone's world travel from its setup origin, per frame. */
|
|
605
776
|
const travel: Pt[] = [];
|
|
@@ -621,6 +792,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
621
792
|
else {
|
|
622
793
|
const s = rigLen / poseLen;
|
|
623
794
|
scale = { factor: s, pose: poseLen, rig: rigLen };
|
|
795
|
+
scaleLengths = lengths;
|
|
624
796
|
const P = series.get(t.joint)!;
|
|
625
797
|
for (let i = 0; i < n; i++) {
|
|
626
798
|
const wx = (P[i]![0] - P[0]![0]) * s;
|
|
@@ -720,7 +892,8 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
720
892
|
for (let k = 1; k < c.bones.length; k++) checkHinge(c, k, targets, restOf, keyTimes, opts, bends, p);
|
|
721
893
|
}
|
|
722
894
|
if (opts.bends === 'refuse' && bends.length > 0) {
|
|
723
|
-
|
|
895
|
+
// #238: one line per hinge — its count, worst angle, frame range and the remedy; the per-frame lines are its detail (--verbose).
|
|
896
|
+
for (const g of bendGroups(bends)) p.add('POSE_BEND_ONE_WAY', bendGroupLine(g, 'refuse'), g.events.map((e) => ({ code: 'POSE_BEND_ONE_WAY', text: bendLine(e) })));
|
|
724
897
|
}
|
|
725
898
|
p.throwIfAny();
|
|
726
899
|
|
|
@@ -729,9 +902,16 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
729
902
|
// which are aimed from where the hip is.
|
|
730
903
|
const legScaleOn = opts.legScale !== undefined && opts.legScale !== 'off';
|
|
731
904
|
const plantOn = opts.plantFeet === true;
|
|
732
|
-
/**
|
|
733
|
-
|
|
905
|
+
/**
|
|
906
|
+
* #92, #242: the sequence declares itself in place. A property of the sequence, not of --plant-feet: every reading
|
|
907
|
+
* below takes it with or without the flag — the supports (no step releases a foot), the camera-zoom normalisations
|
|
908
|
+
* (#159, #163, #173: a loop has no zoom, and their rolling windows do not wrap), the trunk scale (#172) and the head
|
|
909
|
+
* nod (no detrend window, no clamp). What only planting can do — the supporting foot following its own track and the
|
|
910
|
+
* loop closed on the planted hip — stays under `plantOn`, and without it `openDrift` says what was not closed.
|
|
911
|
+
*/
|
|
912
|
+
const inPlace = opts.travel === TRAVEL_IN_PLACE;
|
|
734
913
|
let closure: Closure | null = null;
|
|
914
|
+
let openDrift: number | null = null;
|
|
735
915
|
const legScale: LegScale[] = [];
|
|
736
916
|
const scales = new Map<string, number[]>();
|
|
737
917
|
let stance: Stance | null = null;
|
|
@@ -903,8 +1083,12 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
903
1083
|
const poseScale: number[] | null = t !== null && scale !== null && n > 25 && !inPlace ? continuousScale : null;
|
|
904
1084
|
let trunkScaleBone: string | null = null;
|
|
905
1085
|
let trunkScale: number[] | null = null;
|
|
906
|
-
|
|
907
|
-
|
|
1086
|
+
// #237: every mapped leg segment's picture length ÷ straight, with or without --leg-scale: the
|
|
1087
|
+
// straight lengths --leg-scale scales against, so the minimum read here is the one it would clamp.
|
|
1088
|
+
const segments: LegSegmentRatio[] = [];
|
|
1089
|
+
{
|
|
1090
|
+
/** Without --leg-scale nothing is keyed: [1, 1] only lets `legScaleOf` read the ratio. */
|
|
1091
|
+
const limits: [number, number] = legScaleOn ? (opts.legScale as [number, number]) : [1, 1];
|
|
908
1092
|
const rawSegments: Array<{ chain: string; bone: string; joints: [string, string]; kind: string; lengths: number[]; ownStraight: number }> = [];
|
|
909
1093
|
for (const c of map.chains) {
|
|
910
1094
|
if (c.reach) continue;
|
|
@@ -912,7 +1096,8 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
912
1096
|
const joints: [string, string] = [c.joints[k]!, c.joints[k + 1]!];
|
|
913
1097
|
if (!isLegSegment(joints[0], joints[1])) return;
|
|
914
1098
|
if (c.aim[k] !== 'x') {
|
|
915
|
-
p.add('LEGS_MAP', `--leg-scale scales chain "${c.name}" bone "${b}" (${joints[0]} → ${joints[1]}) along its own x axis, and the map aims it ${JSON.stringify(c.aim[k])}; a leg segment it scales must be aimed "x"`);
|
|
1099
|
+
if (legScaleOn) p.add('LEGS_MAP', `--leg-scale scales chain "${c.name}" bone "${b}" (${joints[0]} → ${joints[1]}) along its own x axis, and the map aims it ${JSON.stringify(c.aim[k])}; a leg segment it scales must be aimed "x"`);
|
|
1100
|
+
segments.push({ chain: c.name, bone: b, joints, straight: null, ratio: null, why: `aimed ${JSON.stringify(c.aim[k])}, and --leg-scale scales a segment aimed "x"` });
|
|
916
1101
|
return;
|
|
917
1102
|
}
|
|
918
1103
|
const from = series.get(joints[0])!;
|
|
@@ -920,10 +1105,12 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
920
1105
|
const lengths = from.map((f, i) => Math.hypot(to[i]![0] - f[0], to[i]![1] - f[1]));
|
|
921
1106
|
const ownStraight = percentile(lengths, STRAIGHT_PERCENTILE);
|
|
922
1107
|
if (!(ownStraight > 1e-6)) {
|
|
923
|
-
p.add('LEGS_MAP', `--leg-scale: chain "${c.name}" bone "${b}"'s segment ${joints[0]} → ${joints[1]} has a straight length (95th percentile) of ${ownStraight}px; there is nothing to scale against`);
|
|
1108
|
+
if (legScaleOn) p.add('LEGS_MAP', `--leg-scale: chain "${c.name}" bone "${b}"'s segment ${joints[0]} → ${joints[1]} has a straight length (95th percentile) of ${ownStraight}px; there is nothing to scale against`);
|
|
1109
|
+
segments.push({ chain: c.name, bone: b, joints, straight: null, ratio: null, why: `its straight length (95th percentile) is ${ownStraight}px` });
|
|
924
1110
|
return;
|
|
925
1111
|
}
|
|
926
1112
|
rawSegments.push({ chain: c.name, bone: b, joints, kind: `${joints[0].slice(2)}:${joints[1].slice(2)}`, lengths, ownStraight });
|
|
1113
|
+
segments.push({ chain: c.name, bone: b, joints, straight: null, ratio: null });
|
|
927
1114
|
});
|
|
928
1115
|
}
|
|
929
1116
|
const straightByKind = new Map<string, number>();
|
|
@@ -971,9 +1158,17 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
971
1158
|
};
|
|
972
1159
|
for (const seg of rawSegments) {
|
|
973
1160
|
const ls = legScaleOf(seg.lengths, limits, straightByKind.get(seg.kind)! * rigShareOf(seg));
|
|
974
|
-
|
|
975
|
-
|
|
1161
|
+
const row = segments.find((x) => x.bone === seg.bone)!;
|
|
1162
|
+
row.straight = ls.straight;
|
|
1163
|
+
row.ratio = ls.ratio;
|
|
1164
|
+
if (legScaleOn) {
|
|
1165
|
+
legScale.push({ chain: seg.chain, bone: seg.bone, joints: seg.joints, ...ls });
|
|
1166
|
+
scales.set(seg.bone, ls.scale);
|
|
1167
|
+
}
|
|
976
1168
|
}
|
|
1169
|
+
}
|
|
1170
|
+
if (legScaleOn) {
|
|
1171
|
+
const limits = opts.legScale as [number, number];
|
|
977
1172
|
if (legScale.length === 0 && !p.any) p.add('LEGS_MAP', '--leg-scale scales the leg segments (two of one side\'s hip, knee and ankle), and the map aims none');
|
|
978
1173
|
const isTrunkPair = (a: string, b: string): boolean => (a === 'mid_hip' && (b === 'neck' || b === 'mid_shoulder')) || (b === 'mid_hip' && (a === 'neck' || a === 'mid_shoulder'));
|
|
979
1174
|
const trunkChain = map.chains.find((c) => c.bones.length === 1 && isTrunkPair(c.joints[0]!, c.joints[1]!));
|
|
@@ -1107,6 +1302,57 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
1107
1302
|
stretchChain(stance.stretch);
|
|
1108
1303
|
}
|
|
1109
1304
|
}
|
|
1305
|
+
// #237: what the clamps did to a free ankle's height — read, nothing keyed changes. Each side's rig
|
|
1306
|
+
// leg at setup is the unit; the clamped frames of a free leg go on LEG-SCALE, and a lifted or below
|
|
1307
|
+
// STANCE run says the pose's height was kept only where no clamp moved it.
|
|
1308
|
+
let rigLeg: Record<Side, number> | null = null;
|
|
1309
|
+
if (legs !== null && 'legs' in legs) {
|
|
1310
|
+
const L = legs.legs;
|
|
1311
|
+
const of = (s: Side): number => {
|
|
1312
|
+
const o = rest.get(L[s].chain.bones[0]!)!;
|
|
1313
|
+
const e = rest.get(L[s].ankleBone)!;
|
|
1314
|
+
return Math.hypot(e.worldX + e.a * e.length - o.worldX, e.worldY + e.c * e.length - o.worldY);
|
|
1315
|
+
};
|
|
1316
|
+
rigLeg = { r: of('r'), l: of('l') };
|
|
1317
|
+
}
|
|
1318
|
+
if (legScaleOn && feet !== null && legPath !== null && rigLeg !== null && legs !== null && 'legs' in legs) {
|
|
1319
|
+
const L = legs.legs;
|
|
1320
|
+
const F = feet;
|
|
1321
|
+
const legUnit = rigLeg;
|
|
1322
|
+
const trackedSide = new Array<Side | null>(n).fill(null);
|
|
1323
|
+
for (const tr of F.tracked ?? []) for (let i = tr.frames[0]; i <= tr.frames[1]; i++) trackedSide[i] = tr.side;
|
|
1324
|
+
const freeOf = (i: number): Side | null => {
|
|
1325
|
+
const s = F.support[i] ?? trackedSide[i] ?? null;
|
|
1326
|
+
return s === null ? null : s === 'r' ? 'l' : 'r';
|
|
1327
|
+
};
|
|
1328
|
+
const tipY = (i: number, side: Side, scaleOf: (b: string) => number | null): number => {
|
|
1329
|
+
const r = posePath(rest, legPath!, (b): Posing | null => (targets.has(b) ? { turn: restTurn(b, i), target: targets.get(b)![i]!, aimX: aimX.get(b)! } : null), scaleOf);
|
|
1330
|
+
if (typeof r === 'string') throw new Refusal([{ code: 'LEGS_MAP', text: r }]);
|
|
1331
|
+
return tipOf(rest, r, L[side].ankleBone)[1];
|
|
1332
|
+
};
|
|
1333
|
+
const shift = freeAnkleShiftOf(n, legScale, freeOf, tipY);
|
|
1334
|
+
for (const ls of legScale) {
|
|
1335
|
+
const side: Side = ls.joints[0].startsWith('r_') ? 'r' : 'l';
|
|
1336
|
+
const frames: number[] = [];
|
|
1337
|
+
for (let i = 0; i < n; i++) if (ls.scale[i] !== ls.picture[i] && freeOf(i) === side) frames.push(i);
|
|
1338
|
+
const free = shiftOver(side, frames, shift, legUnit[side]);
|
|
1339
|
+
if (free !== null) ls.free = free;
|
|
1340
|
+
}
|
|
1341
|
+
for (const run of stance?.runs ?? []) {
|
|
1342
|
+
if (run.state === 'both' || run.absorbed === true) continue;
|
|
1343
|
+
const frames = Array.from({ length: run.frames[1] - run.frames[0] + 1 }, (_, k) => run.frames[0] + k);
|
|
1344
|
+
const moved = shiftOver(run.side, frames, shift, legUnit[run.side]);
|
|
1345
|
+
if (moved !== null) run.moved = moved;
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
const legStraight: Record<Side, number> | null = feet === null ? null : (() => {
|
|
1349
|
+
const out = {} as Record<Side, number>;
|
|
1350
|
+
for (const s of SIDES) {
|
|
1351
|
+
const h = series.get(`${s}_hip`)!;
|
|
1352
|
+
out[s] = percentile(series.get(`${s}_ankle`)!.map((a, i) => Math.hypot(a[0] - h[i]![0], a[1] - h[i]![1])), STRAIGHT_PERCENTILE);
|
|
1353
|
+
}
|
|
1354
|
+
return out;
|
|
1355
|
+
})();
|
|
1110
1356
|
if (t !== null && scale !== null) {
|
|
1111
1357
|
const bone = rest.get(t.bone)!;
|
|
1112
1358
|
const parent = bone.parent === null ? null : rest.get(bone.parent)!;
|
|
@@ -1126,6 +1372,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
1126
1372
|
}
|
|
1127
1373
|
translateTrack = { bone: t.bone, property: 'translate', keys };
|
|
1128
1374
|
origin = { bone: t.bone, at };
|
|
1375
|
+
if (inPlace && !plantOn && n > 0) openDrift = r6(travel[n - 1]![0] - travel[0]![0]);
|
|
1129
1376
|
}
|
|
1130
1377
|
|
|
1131
1378
|
// Reaches: from the bone's posed origin at the pose's end point, anchored at
|
|
@@ -1153,6 +1400,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
1153
1400
|
let headNod: Pt[] | null = null;
|
|
1154
1401
|
let headNodTrack: MotionTrack | null = null;
|
|
1155
1402
|
let headNodReading: HeadNodReading | null = null;
|
|
1403
|
+
let nodSkipped: NodSkipped | null = null;
|
|
1156
1404
|
const posedOrigin = (b: string, i: number): Pt => {
|
|
1157
1405
|
const own = rest.get(b)!;
|
|
1158
1406
|
if (t !== null && b === t.bone) return [own.worldX + (travel[i]?.[0] ?? 0), own.worldY + (travel[i]?.[1] ?? 0)];
|
|
@@ -1183,6 +1431,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
1183
1431
|
};
|
|
1184
1432
|
const reaches = map.chains.filter((c) => c.reach).sort((x, y) => depth(x.bones[0]!) - depth(y.bones[0]!));
|
|
1185
1433
|
let aimReach: ((c: Chain) => void) | null = null;
|
|
1434
|
+
const reachAims = new Map<string, { aim: number[]; pair: number[]; length: number }>();
|
|
1186
1435
|
if (reaches.length > 0 && t !== null && scale !== null) {
|
|
1187
1436
|
const s = scale.factor;
|
|
1188
1437
|
const tb = rest.get(t.bone)!;
|
|
@@ -1200,6 +1449,8 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
1200
1449
|
}
|
|
1201
1450
|
fillLeading(angles, () => p.add('POSE_SEGMENT_ZERO', `chain "${c.name}" — bone "${b}" reaches for ${c.joints[1]}, which the pose puts on the bone's posed origin on every frame; it has no direction`));
|
|
1202
1451
|
targets.set(b, angles);
|
|
1452
|
+
// #236: the aim as reached, before its own range clamp, beside the pair's own angle (a reading).
|
|
1453
|
+
reachAims.set(c.name, { aim: [...angles], pair: Array.from({ length: n }, (_, i) => pictureAngle(anchor[i]!, end[i]!)), length: median(Array.from({ length: n }, (_, i) => Math.hypot(end[i]![0] - anchor[i]![0], end[i]![1] - anchor[i]![1]) * s)) });
|
|
1203
1454
|
};
|
|
1204
1455
|
for (const c of reaches) aimReach(c);
|
|
1205
1456
|
}
|
|
@@ -1286,10 +1537,27 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
1286
1537
|
if (!((ja === 'r_eye' && jb === 'l_eye') || (ja === 'l_eye' && jb === 'r_eye'))) continue;
|
|
1287
1538
|
const hb = c.bones[k]!;
|
|
1288
1539
|
const ha = c.aim[k]!;
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
if (
|
|
1540
|
+
const skip = (why: string): void => {
|
|
1541
|
+
nodSkipped = { bone: hb, chain: c.name, why };
|
|
1542
|
+
};
|
|
1543
|
+
if (c.nod === false) {
|
|
1544
|
+
skip(`the chain declares "nod": false`);
|
|
1545
|
+
break;
|
|
1546
|
+
}
|
|
1547
|
+
if (t?.bone === hb) {
|
|
1548
|
+
skip(`the map's translate moves bone "${hb}"`);
|
|
1549
|
+
break;
|
|
1550
|
+
}
|
|
1551
|
+
if (sw.offsetTracks.some((tr) => tr.bone === hb)) {
|
|
1552
|
+
skip(`a set's offsets translate bone "${hb}"`);
|
|
1553
|
+
break;
|
|
1554
|
+
}
|
|
1555
|
+
let underScaled: string | null = null;
|
|
1556
|
+
for (let a = rest.get(hb)!.parent; a !== null; a = rest.get(a)!.parent) if (scales.has(a) && underScaled === null) underScaled = a;
|
|
1557
|
+
if (underScaled !== null) {
|
|
1558
|
+
skip(`bone "${hb}" hangs below the scaled bone "${underScaled}"`);
|
|
1559
|
+
break;
|
|
1560
|
+
}
|
|
1293
1561
|
const rEye = series.get('r_eye')!;
|
|
1294
1562
|
const lEye = series.get('l_eye')!;
|
|
1295
1563
|
const neckSeries = series.get('neck') ?? (() => {
|
|
@@ -1305,6 +1573,8 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
1305
1573
|
const refEye = median(rEye.map((re, i) => Math.hypot(lEye[i]![0] - re[0], lEye[i]![1] - re[1])));
|
|
1306
1574
|
if (refEye > 1e-6 && rigEye > 1e-6) nodScale = rigEye / refEye;
|
|
1307
1575
|
}
|
|
1576
|
+
if (neckSeries === undefined) skip('neck is good on no frame');
|
|
1577
|
+
else if (nodScale === null) skip(`no scale: the map translates no bone and aim ${JSON.stringify(ha)} names no two points to measure the eye distance on`);
|
|
1308
1578
|
if (neckSeries !== undefined && nodScale !== null) {
|
|
1309
1579
|
const bone = rest.get(hb)!;
|
|
1310
1580
|
const parent = bone.parent === null ? null : rest.get(bone.parent)!;
|
|
@@ -1325,12 +1595,20 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
1325
1595
|
const maxNod = !inPlace && neckRestLen > 1e-6 ? 0.02 * neckRestLen : Infinity;
|
|
1326
1596
|
const refEN = rEye.map((re, i) => -((re[1] + lEye[i]![1]) / 2 - neckSeries[i]![1]));
|
|
1327
1597
|
const halfWin = Math.max(1, Math.round(0.5 / Math.max(1e-6, (keyTimes[n - 1]! - keyTimes[0]!) / Math.max(1, n - 1))));
|
|
1328
|
-
const
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
const
|
|
1598
|
+
const windowed = maxNod < Infinity && n > 2 * halfWin;
|
|
1599
|
+
// #241: on a `clean --loop` sequence the window wraps (the last frame runs into the first), as clean's own
|
|
1600
|
+
// windows do; cut at the ends instead, the two ends of the loop are read against different baselines.
|
|
1601
|
+
const nodWindow: NodWindow = !windowed ? 'in-place' : opts.loop === true ? 'wrapped' : 'cut';
|
|
1602
|
+
const around = (xs: readonly number[], i: number): number[] => {
|
|
1603
|
+
if (nodWindow !== 'wrapped') return xs.slice(Math.max(0, i - halfWin), Math.min(n, i + halfWin + 1));
|
|
1604
|
+
const out: number[] = [];
|
|
1605
|
+
for (let j = i - halfWin; j <= i + halfWin; j++) out.push(xs[((j % n) + n) % n]!);
|
|
1606
|
+
return out;
|
|
1607
|
+
};
|
|
1608
|
+
const nodMed = windowed ? refEN.map((_, i) => median(around(refEN, i))) : refEN.map(() => refEN[0]!);
|
|
1609
|
+
const nodBase = windowed
|
|
1332
1610
|
? nodMed.map((_, i) => {
|
|
1333
|
-
const sl = nodMed
|
|
1611
|
+
const sl = around(nodMed, i);
|
|
1334
1612
|
return sl.reduce((a, b) => a + b, 0) / sl.length;
|
|
1335
1613
|
})
|
|
1336
1614
|
: nodMed;
|
|
@@ -1345,8 +1623,9 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
1345
1623
|
return Math.max(-maxNod, Math.min(maxNod, v));
|
|
1346
1624
|
});
|
|
1347
1625
|
const residual = rawNod.map((v, i) => v - dy[i]!);
|
|
1348
|
-
|
|
1349
|
-
|
|
1626
|
+
const keyed = dy.some((v) => Math.abs(r3(v)) > 0);
|
|
1627
|
+
headNodReading = { bone: hb, chain: c.name, maxNod, rawY: rawNod, clampedY: dy, residual, window: nodWindow, halfWin: windowed ? halfWin : 0, keyed };
|
|
1628
|
+
if (keyed) {
|
|
1350
1629
|
const keys: MotionKey[] = [];
|
|
1351
1630
|
const nod: Pt[] = [];
|
|
1352
1631
|
const ref0 = refEN[0]! / (poseScale?.[0] ?? 1);
|
|
@@ -2753,15 +3032,95 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
2753
3032
|
if (translateTrack !== null) tracks.push(translateTrack);
|
|
2754
3033
|
if (headNodTrack !== null) tracks.push(headNodTrack);
|
|
2755
3034
|
tracks.push(...sw.offsetTracks, ...sw.slotTracks);
|
|
2756
|
-
|
|
3035
|
+
const reach = reachReadings(rest, map, tracks, reachAims, t?.bone ?? null);
|
|
3036
|
+
|
|
3037
|
+
const pivot = pivotOf(rest, t?.bone ?? null, legs);
|
|
3038
|
+
const scaleFit = t === null || scale === null || origin === null ? null : scaleFitOf({
|
|
3039
|
+
joints: t.scale.pose, joint: t.joint, bone: t.bone, lengths: scaleLengths, factor: scale.factor,
|
|
3040
|
+
series, good, legLength: feet?.legLength ?? null, legStraight, rigLeg,
|
|
3041
|
+
rigHip: legs !== null && 'legs' in legs ? rootsApart(rest, legs.legs.r.chain.bones[0]!, legs.legs.l.chain.bones[0]!) : null,
|
|
3042
|
+
origin: origin.at,
|
|
3043
|
+
});
|
|
2757
3044
|
return {
|
|
2758
3045
|
animation: { duration: keyTimes[n - 1]!, tracks },
|
|
2759
|
-
times: keyTimes, targets, origin, holds, bends, short, endOn, flips, scale,
|
|
3046
|
+
times: keyTimes, targets, origin, holds, bends, short, endOn, flips, scale, scaleFit,
|
|
2760
3047
|
switches: sw.switches, shown: sw.shown, distances: sw.distances, turns, turnsUnread, range, artRange,
|
|
2761
|
-
legScale, scales, feet, planted: plantOn, stance, floor, blendHold, closure, ankles, pivot
|
|
3048
|
+
legScale, segments, legStraight, rigLeg, scales, feet, planted: plantOn, stance, floor, blendHold, closure, openDrift, ankles, pivot, raised, byHip, face, body, figureScale, headNod: headNodReading, nodSkipped, reach,
|
|
2762
3049
|
};
|
|
2763
3050
|
}
|
|
2764
3051
|
|
|
3052
|
+
/** A reach bone's origin within this many world units of the anchor's, with nothing above it moved, is aimed along the pair: no REACH line. */
|
|
3053
|
+
export const REACH_OFFSET = 1e-6;
|
|
3054
|
+
|
|
3055
|
+
/** Round a second-difference figure, °/frame², to 1e-4. */
|
|
3056
|
+
const r4 = (v: number) => {
|
|
3057
|
+
const r = Math.round(v * 10000) / 10000;
|
|
3058
|
+
return Object.is(r, -0) ? 0 : r;
|
|
3059
|
+
};
|
|
3060
|
+
|
|
3061
|
+
/**
|
|
3062
|
+
* #236: for each reach chain, the bones above it the animation moves — a rotate
|
|
3063
|
+
* track, or a translate track other than the anchor's (its travel is in the
|
|
3064
|
+
* target too) — and, when there are any, its aim's departure from the pair.
|
|
3065
|
+
*/
|
|
3066
|
+
function reachReadings(rest: Rest, map: JointMap, tracks: ReadonlyArray<MotionTrack | SlotTrack>, aims: ReadonlyMap<string, { aim: number[]; pair: number[]; length: number }>, anchor: string | null): ReachReading[] {
|
|
3067
|
+
const out: ReachReading[] = [];
|
|
3068
|
+
if (anchor === null) return out;
|
|
3069
|
+
const a0 = rest.get(anchor)!;
|
|
3070
|
+
const moved = new Map<string, Set<'rotate' | 'translate'>>();
|
|
3071
|
+
for (const tr of tracks) {
|
|
3072
|
+
if (!('bone' in tr) || (tr.property !== 'rotate' && tr.property !== 'translate')) continue;
|
|
3073
|
+
if (tr.property === 'translate' && tr.bone === anchor) continue;
|
|
3074
|
+
const s = moved.get(tr.bone) ?? new Set();
|
|
3075
|
+
s.add(tr.property);
|
|
3076
|
+
moved.set(tr.bone, s);
|
|
3077
|
+
}
|
|
3078
|
+
for (const c of map.chains) {
|
|
3079
|
+
const got = aims.get(c.name);
|
|
3080
|
+
if (!c.reach || got === undefined) continue;
|
|
3081
|
+
const b = c.bones[0]!;
|
|
3082
|
+
const above: ReachReading['above'] = [];
|
|
3083
|
+
for (let a = rest.get(b)?.parent ?? null; a !== null; a = rest.get(a)?.parent ?? null) {
|
|
3084
|
+
for (const property of ['rotate', 'translate'] as const) {
|
|
3085
|
+
if (moved.get(a)?.has(property) !== true) continue;
|
|
3086
|
+
above.push({ bone: a, property, chain: property === 'rotate' ? (map.chains.find((x) => x.bones.includes(a))?.name ?? null) : null });
|
|
3087
|
+
}
|
|
3088
|
+
}
|
|
3089
|
+
const bo = rest.get(b)!;
|
|
3090
|
+
const units = Math.hypot(bo.worldX - a0.worldX, bo.worldY - a0.worldY);
|
|
3091
|
+
if (above.length === 0 && units <= REACH_OFFSET) continue;
|
|
3092
|
+
const dep = got.aim.map((v, i) => wrap(v - got.pair[i]!));
|
|
3093
|
+
let frame = 0;
|
|
3094
|
+
for (let i = 1; i < dep.length; i++) if (Math.abs(dep[i]!) > Math.abs(dep[frame]!)) frame = i;
|
|
3095
|
+
const d2 = meanSecondDiff([dep]).value;
|
|
3096
|
+
const aimD2 = meanSecondDiff([got.aim]).value;
|
|
3097
|
+
const pairD2 = meanSecondDiff([got.pair]).value;
|
|
3098
|
+
out.push({
|
|
3099
|
+
chain: c.name,
|
|
3100
|
+
bone: b,
|
|
3101
|
+
joints: [c.joints[0]!, c.joints[1]!],
|
|
3102
|
+
anchor,
|
|
3103
|
+
offset: { units: r3(units), ofLength: got.length > 0 ? r3(units / got.length) : 0 },
|
|
3104
|
+
above,
|
|
3105
|
+
departure: { max: r3(Math.abs(dep[frame] ?? 0)), frame, meanD2: d2 === null ? null : r4(d2) },
|
|
3106
|
+
aimD2: aimD2 === null ? null : r4(aimD2),
|
|
3107
|
+
pairD2: pairD2 === null ? null : r4(pairD2),
|
|
3108
|
+
ratio: aimD2 === null || pairD2 === null || r4(pairD2) === 0 ? null : Number((aimD2 / pairD2).toFixed(3)),
|
|
3109
|
+
});
|
|
3110
|
+
}
|
|
3111
|
+
return out;
|
|
3112
|
+
}
|
|
3113
|
+
|
|
3114
|
+
/** The `REACH` line of a reach chain whose aim departs from its pair (#236). */
|
|
3115
|
+
export function reachCostLine(e: ReachReading, times: readonly number[]): string {
|
|
3116
|
+
const f = (v: number | null) => (v === null ? 'not measured' : `${v.toFixed(4)}°/f²`);
|
|
3117
|
+
const why: string[] = [];
|
|
3118
|
+
if (e.offset.units > REACH_OFFSET) why.push(`its origin sits ${e.offset.units.toFixed(2)} units from the anchor "${e.anchor}"'s at setup (${e.offset.ofLength.toFixed(3)} of the reached length)`);
|
|
3119
|
+
if (e.above.length > 0) why.push(`it hangs below ${e.above.map((x) => `"${x.bone}" (${x.property === 'rotate' ? (x.chain === null ? 'rotate keys' : `turned by chain "${x.chain}"`) : 'translate keys'})`).join(', ')}`);
|
|
3120
|
+
const ratio = e.ratio === null ? (e.pairD2 === 0 ? ' — the pair is still' : '') : ` (${e.ratio.toFixed(2)}×)`;
|
|
3121
|
+
return `chain "${e.chain}" bone "${e.bone}" (${e.joints[0]} → ${e.joints[1]}) is aimed from its posed origin at the pair's end — ${why.join(', and ')}: the aim departs from the pair's own angle by up to ${e.departure.max.toFixed(3)}° (frame ${e.departure.frame}, t=${times[e.departure.frame]!.toFixed(3)}s), mean |Δ²| of the departure ${f(e.departure.meanD2)}; mean |Δ²| aim ${f(e.aimD2)} against the pair's ${f(e.pairD2)}${ratio} — rotation the pose pair does not have, the cost of "reach"; a reading`;
|
|
3122
|
+
}
|
|
3123
|
+
|
|
2765
3124
|
/** What a placed wrist is measured against: the head (#62, the eyes) or the hip (#93, the hips) — two pose joints and the rig's two points. */
|
|
2766
3125
|
interface Anchor {
|
|
2767
3126
|
kind: 'head' | 'hip';
|
|
@@ -3192,7 +3551,7 @@ function checkHinge(c: Chain, k: number, targets: Map<string, number[]>, restOf:
|
|
|
3192
3551
|
else if (c.bend === 'cw') side = -1;
|
|
3193
3552
|
else {
|
|
3194
3553
|
if (Math.abs(restInner) < 0.5) {
|
|
3195
|
-
p.add('MAP_BEND_SIDE', `chain "${c.name}" — the hinge at ${joint} is straight at rest (inner angle ${restInner.toFixed(3)}°), so the rest pose does not say which way it bends; state "bend": "ccw", "cw" or "free"`);
|
|
3554
|
+
p.add('MAP_BEND_SIDE', `chain "${c.name}" — the hinge at ${joint} is straight at rest (inner angle ${restInner.toFixed(3)}°), so the rest pose does not say which way it bends; state "bend": "ccw", "cw" or "free" — ${bendFreeRemedy(c.name)}`);
|
|
3196
3555
|
return;
|
|
3197
3556
|
}
|
|
3198
3557
|
side = restInner > 0 ? 1 : -1;
|
|
@@ -3213,8 +3572,83 @@ export function bendLine(e: BendEvent): string {
|
|
|
3213
3572
|
return `chain "${e.chain}" hinge ${e.joint}, frame ${e.frame} (t=${e.t.toFixed(3)}s) — inner angle ${e.angle.toFixed(1)}°; it bends ${sense} at rest (${e.rest.toFixed(1)}°), so the angle must stay ${e.side > 0 ? '≥' : '≤'} 0`;
|
|
3214
3573
|
}
|
|
3215
3574
|
|
|
3575
|
+
/**
|
|
3576
|
+
* The remedy both bend refusals name (rig-c/pose#238), as the README's map
|
|
3577
|
+
* section ("bend") gives it: front-facing material goes "free", a side view
|
|
3578
|
+
* keeps a side.
|
|
3579
|
+
*/
|
|
3580
|
+
export function bendFreeRemedy(chain: string): string {
|
|
3581
|
+
return `seen from the front, the side a hinge's picture falls on is not its limit, so for front-facing material state "bend": "free" on chain "${chain}"; keep a side ("rest", "ccw", "cw") only for a side view, where the picture's side is the joint's own`;
|
|
3582
|
+
}
|
|
3583
|
+
|
|
3584
|
+
/** One hinge's wrong-way frames (#238): what its collapsed line says. */
|
|
3585
|
+
export interface BendGroup {
|
|
3586
|
+
chain: string;
|
|
3587
|
+
joint: string;
|
|
3588
|
+
side: 1 | -1;
|
|
3589
|
+
rest: number;
|
|
3590
|
+
/** Every frame it bent the wrong way, in frame order — the per-frame lines. */
|
|
3591
|
+
events: BendEvent[];
|
|
3592
|
+
/** The event furthest on the wrong side. */
|
|
3593
|
+
worst: BendEvent;
|
|
3594
|
+
/** Runs of consecutive frames. */
|
|
3595
|
+
runs: number;
|
|
3596
|
+
}
|
|
3597
|
+
|
|
3598
|
+
/** The bend events grouped per hinge, in the order the hinges were first met (#238). */
|
|
3599
|
+
export function bendGroups(events: readonly BendEvent[]): BendGroup[] {
|
|
3600
|
+
const out = new Map<string, BendGroup>();
|
|
3601
|
+
for (const e of events) {
|
|
3602
|
+
const k = `${e.chain}\u0000${e.joint}`;
|
|
3603
|
+
const g = out.get(k);
|
|
3604
|
+
if (g === undefined) {
|
|
3605
|
+
out.set(k, { chain: e.chain, joint: e.joint, side: e.side, rest: e.rest, events: [e], worst: e, runs: 1 });
|
|
3606
|
+
continue;
|
|
3607
|
+
}
|
|
3608
|
+
if (e.frame !== g.events[g.events.length - 1]!.frame + 1) g.runs++;
|
|
3609
|
+
g.events.push(e);
|
|
3610
|
+
if (-e.side * e.angle > -g.worst.side * g.worst.angle) g.worst = e;
|
|
3611
|
+
}
|
|
3612
|
+
return [...out.values()];
|
|
3613
|
+
}
|
|
3614
|
+
|
|
3615
|
+
/** One hinge's collapsed line (#238): a refusal names the remedy; a clamp, that every frame was straightened. */
|
|
3616
|
+
export function bendGroupLine(g: BendGroup, mode: 'refuse' | 'clamp'): string {
|
|
3617
|
+
const sense = g.side > 0 ? 'counter-clockwise (+)' : 'clockwise (−)';
|
|
3618
|
+
const first = g.events[0]!, last = g.events[g.events.length - 1]!;
|
|
3619
|
+
const range = first.frame === last.frame ? `frame ${first.frame} (t=${first.t.toFixed(3)}s)` : `frames ${first.frame}–${last.frame} (t=${first.t.toFixed(3)}–${last.t.toFixed(3)}s)`;
|
|
3620
|
+
const head = `chain "${g.chain}" hinge ${g.joint}, ${g.events.length} frame(s) in ${g.runs} run(s), ${range} — worst inner angle ${g.worst.angle.toFixed(1)}° at frame ${g.worst.frame} (t=${g.worst.t.toFixed(3)}s); it bends ${sense} at rest (${g.rest.toFixed(1)}°), so the angle must stay ${g.side > 0 ? '≥' : '≤'} 0`;
|
|
3621
|
+
return mode === 'refuse'
|
|
3622
|
+
? `${head}; ${bendFreeRemedy(g.chain)}; a near-straight knee's noise takes --bends clamp or --bend-tolerance <deg> (each frame: --verbose)`
|
|
3623
|
+
: `${head} — clamped straight on each (each frame: --verbose; --report's "bends")`;
|
|
3624
|
+
}
|
|
3625
|
+
|
|
3216
3626
|
/** Holds grouped per point, one line each span. */
|
|
3217
3627
|
export function holdLine(h: Hold, times: readonly number[]): string {
|
|
3218
3628
|
const span = h.from === h.to ? `frame ${h.from} (t=${times[h.from]!.toFixed(3)}s)` : `frames ${h.from}–${h.to} (t=${times[h.from]!.toFixed(3)}–${times[h.to]!.toFixed(3)}s)`;
|
|
3219
3629
|
return `point "${h.point}" ${span}: ${h.why}; held frame ${h.source}'s value`;
|
|
3220
3630
|
}
|
|
3631
|
+
|
|
3632
|
+
/**
|
|
3633
|
+
* The `NOD` line (rig-c/pose#241): what the eye-line chain did to the head — the keyed range, the window its baseline
|
|
3634
|
+
* was read over, whether the neck clamp acted and so coupled the nod into the head's rotate (and the face pitch). Or,
|
|
3635
|
+
* when no nod is keyed, why. `pitch`: the map keys a face pitch dial.
|
|
3636
|
+
*/
|
|
3637
|
+
export function nodLine(h: HeadNodReading | null, skipped: NodSkipped | null, pitch: boolean): string | null {
|
|
3638
|
+
if (h === null) return skipped === null ? null : `none on bone "${skipped.bone}" (chain "${skipped.chain}", r_eye → l_eye): ${skipped.why} — no translate keyed, nothing coupled into its rotate${pitch ? ' or the face pitch' : ''}`;
|
|
3639
|
+
const head = `bone "${h.bone}" (chain "${h.chain}", r_eye → l_eye)`;
|
|
3640
|
+
if (!h.keyed) return `${head}: the eyes do not move against the neck past the writer's 0.001 units — no translate keyed, nothing coupled`;
|
|
3641
|
+
const lo = Math.min(...h.clampedY);
|
|
3642
|
+
const hi = Math.max(...h.clampedY);
|
|
3643
|
+
const sign = (v: number): string => `${v >= 0 ? '+' : ''}${v.toFixed(3)}`;
|
|
3644
|
+
const window = h.window === 'in-place'
|
|
3645
|
+
? 'window none (in-place): the baseline is frame 0, no clamp, nothing coupled'
|
|
3646
|
+
: `window ±${h.halfWin} frame(s) (0.5 s) running median, detrended, ${h.window === 'wrapped' ? 'wrapped on the loop' : 'cut at the clip\'s ends'}`;
|
|
3647
|
+
const clamped = h.residual.filter((r) => r !== 0).length;
|
|
3648
|
+
const clamp = h.window === 'in-place'
|
|
3649
|
+
? ''
|
|
3650
|
+
: clamped === 0
|
|
3651
|
+
? `; clamp ±${h.maxNod.toFixed(3)} units (2% of the neck's setup length) never acted, so nothing is coupled`
|
|
3652
|
+
: `; clamp ±${h.maxNod.toFixed(3)} units (2% of the neck's setup length) acted on ${clamped} frame(s), so the clamped nod is coupled in phase into bone "${h.bone}"'s rotate (+${NOD_RESIDUAL_HEAD_GAIN}°/unit)${pitch ? ` and the face pitch (+${NOD_RESIDUAL_PITCH_GAIN}°/unit)` : ''} on every frame`;
|
|
3653
|
+
return `${head}: translate y ${sign(lo)} … ${sign(hi)} units over ${h.clampedY.length} frame(s); ${window}${clamp}`;
|
|
3654
|
+
}
|