@rig-c/pose 0.19.0 → 0.20.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -11
- package/cli.ts +1 -1
- package/package.json +1 -1
- package/src/clean.ts +107 -20
- package/src/legs.ts +252 -35
- package/src/pose.ts +11 -4
- package/src/retarget.ts +206 -13
- package/src/run.ts +15 -3
- package/src/switch.ts +33 -5
- package/src/verify.ts +155 -1
package/src/retarget.ts
CHANGED
|
@@ -82,7 +82,7 @@ import { Problems, Refusal } from './problems.ts';
|
|
|
82
82
|
import type { ArtRange, ByHip, Raised, ReachShort, SlotTrack, Switch, SwitchOptions } from './switch.ts';
|
|
83
83
|
import { clampArt, coveredChains, isRaised, measureArt, overflow, RANGE_SLACK, reachLimit, runsOf, selectSwitches, twoBone } from './switch.ts';
|
|
84
84
|
import type { BlendHold, Closure, Feet, Floor, LegScale, PathPose, Posing, Side, Stance } from './legs.ts';
|
|
85
|
-
import { holdBlend, isLegSegment, legScaleOf, legsOf, pathFrom, plantHip, posePath, rootOf, SIDES, stanceOf, stanceRangeOf, supportsOf, tipOf } from './legs.ts';
|
|
85
|
+
import { holdBlend, isLegSegment, legScaleOf, legsOf, pathFrom, plantHip, posePath, rootOf, scaleSupport, SIDES, stanceOf, stanceRangeOf, supportsOf, tipOf } from './legs.ts';
|
|
86
86
|
|
|
87
87
|
const DEG = 180 / Math.PI;
|
|
88
88
|
|
|
@@ -163,18 +163,29 @@ export interface Flip {
|
|
|
163
163
|
clipped: 'start' | 'end' | 'both' | null;
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
-
/**
|
|
166
|
+
/**
|
|
167
|
+
* One span of frames under `--short` (angle interpolated, `holdShort`) or under
|
|
168
|
+
* `SHORT_STEP_FRACTION` with a one-frame step over `SHORT_STEP_CAP` (step
|
|
169
|
+
* capped, `capShortSteps`, #110).
|
|
170
|
+
*/
|
|
167
171
|
export interface Short {
|
|
168
172
|
chain: string;
|
|
169
173
|
bone: string;
|
|
170
174
|
/** The segment the bone carries: from joint, to joint. */
|
|
171
175
|
joints: [string, string];
|
|
172
|
-
/** First and last frame
|
|
176
|
+
/** First and last frame whose angle was replaced; the frames either side keep the input's. */
|
|
173
177
|
frames: [number, number];
|
|
174
178
|
/** The lowest picture length on those frames, as a fraction of the segment's median picture length. */
|
|
175
179
|
minFraction: number;
|
|
180
|
+
/** Present when a foreshortened segment (< `SHORT_STEP_FRACTION`) had its per-frame step capped (#110): the one-frame step after capping and the largest step asked, degrees, signed. */
|
|
181
|
+
step?: number;
|
|
182
|
+
asked?: number;
|
|
176
183
|
}
|
|
177
184
|
|
|
185
|
+
/** A limb segment shorter than this fraction of its median picture length has its per-frame angle step capped at `SHORT_STEP_CAP` degrees (rig-c/pose#110). */
|
|
186
|
+
export const SHORT_STEP_FRACTION = 0.6;
|
|
187
|
+
export const SHORT_STEP_CAP = 30;
|
|
188
|
+
|
|
178
189
|
/**
|
|
179
190
|
* A segment under the short cutoff long enough to be a pose (the limb pointing
|
|
180
191
|
* at the camera), not a blur: short spans merged across a gap of `END_ON_GAP`
|
|
@@ -571,6 +582,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
571
582
|
// against its own median, on the points the angle was read from.
|
|
572
583
|
const short: Short[] = [];
|
|
573
584
|
const endOn: EndOn[] = [];
|
|
585
|
+
const fractions = new Map<string, { chain: string; joints: [string, string]; fraction: number[] }>();
|
|
574
586
|
if (opts.short !== 'off') {
|
|
575
587
|
const cutoff = opts.short;
|
|
576
588
|
for (const c of map.chains) {
|
|
@@ -586,7 +598,9 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
586
598
|
p.add('POSE_SHORT_MEDIAN', `chain "${c.name}" — bone "${b}"'s segment ${joints[0]} → ${joints[1]} has median picture length ${med}px over the ${n} frames; --short measures a frame's length against that median, and there is none to measure against`);
|
|
587
599
|
return;
|
|
588
600
|
}
|
|
589
|
-
const
|
|
601
|
+
const fraction = lengths.map((l) => l / med);
|
|
602
|
+
fractions.set(b, { chain: c.name, joints, fraction });
|
|
603
|
+
const spans = holdShort(targets.get(b)!, fraction, cutoff);
|
|
590
604
|
for (const s of spans) short.push({ chain: c.name, bone: b, joints, ...s });
|
|
591
605
|
for (const run of mergeRuns(spans, END_ON_GAP)) {
|
|
592
606
|
const seconds = Math.round((endOf(run.frames[1]) - keyTimes[run.frames[0]]!) * 1e6) / 1e6;
|
|
@@ -692,12 +706,16 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
692
706
|
if (opts.floor !== undefined && !plantOn) p.add('FLOOR', `--floor ${opts.floor} is the floor --plant-feet plants every support span on, and --plant-feet is not given`);
|
|
693
707
|
if (opts.floor !== undefined && !Number.isFinite(opts.floor)) p.add('FLOOR', `--floor is ${opts.floor}; it must be a finite world y`);
|
|
694
708
|
p.throwIfAny();
|
|
695
|
-
|
|
696
|
-
const
|
|
697
|
-
|
|
709
|
+
const askedScales = new Map<string, number[]>();
|
|
710
|
+
for (const ls of legScale) askedScales.set(ls.bone, ls.asked);
|
|
711
|
+
/** Every bone on the legs' path posed on frame i, by the targets and the given scale series. */
|
|
712
|
+
const poseLegsWith = (i: number, sc: ReadonlyMap<string, number[]>): PathPose => {
|
|
713
|
+
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), (b) => sc.get(b)?.[i] ?? null);
|
|
698
714
|
if (typeof r === 'string') throw new Refusal([{ code: 'LEGS_MAP', text: r }]);
|
|
699
715
|
return r;
|
|
700
716
|
};
|
|
717
|
+
/** Every bone on the legs' path posed on frame i, by the targets as they stand. */
|
|
718
|
+
const poseLegs = (i: number): PathPose => poseLegsWith(i, scales);
|
|
701
719
|
if (plantOn) {
|
|
702
720
|
// The plant is derived from the targets before the range clamp, so nothing after may turn a bone on the legs' path.
|
|
703
721
|
const onPath = new Set(legPath!);
|
|
@@ -706,20 +724,28 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
706
724
|
for (const b of [...Object.keys(a.turn), ...Object.keys(a.offsets)]) if (onPath.has(b)) p.add('LEGS_RANGE', `--plant-feet places the hip through bone "${b}", which set "${a.name}" turns or offsets; that would move the planted foot — drop it from the set or --plant-feet`);
|
|
707
725
|
}
|
|
708
726
|
p.throwIfAny();
|
|
727
|
+
// #107: the clamp floor holds the bone, not the hip; the plant places the hip from the scale the pose asked for.
|
|
709
728
|
const offsets = Array.from({ length: n }, (_, i) => {
|
|
710
|
-
const lp =
|
|
729
|
+
const lp = poseLegsWith(i, askedScales);
|
|
711
730
|
return { r: tipOf(rest, lp, legs !== null && 'legs' in legs ? legs.legs.r.ankleBone : ''), l: tipOf(rest, lp, legs !== null && 'legs' in legs ? legs.legs.l.ankleBone : '') };
|
|
712
731
|
});
|
|
713
732
|
const tb = rest.get(t!.bone)!;
|
|
714
733
|
// #85: every support span on one floor — the median of their planted heights, or --floor.
|
|
715
734
|
// #92: in place, the supporting ankle's own picture x, scaled, is the track its anchor follows.
|
|
716
|
-
|
|
717
|
-
const
|
|
735
|
+
// #119: a tracked support follows the lower ankle's own picture x, scaled, on the floor until it is still.
|
|
736
|
+
const track = (i: number, s: Side) => series.get(`${s}_ankle`)![i]![0] * scale!.factor;
|
|
737
|
+
const follow = inPlace ? track : undefined;
|
|
738
|
+
const planted = plantHip(feet!, (i, s) => offsets[i]![s], (i) => poseTravel[i]!, [tb.worldX, tb.worldY], opts.floor, follow, track);
|
|
718
739
|
const hip = planted.hip;
|
|
719
740
|
floor = planted.floor;
|
|
720
741
|
closure = planted.closure;
|
|
721
|
-
for (let i = 0; i < n; i++) travel[i] = [hip[i]![0] - tb.worldX, hip[i]![1] - tb.worldY];
|
|
722
742
|
const L = (legs as { legs: Record<Side, { chain: Chain; ankleBone: string }> }).legs;
|
|
743
|
+
// #133: on a frame where the supporting leg asks under --leg-scale's floor, its scalex follows the asked
|
|
744
|
+
// value down to #95's bound so the ankle stays on #85's floor; the free leg keeps the configured clamp.
|
|
745
|
+
if (legScaleOn && feet !== null) {
|
|
746
|
+
scaleSupport(legScale, scales, feet, L, hip, (i, s) => offsets[i]![s], (i, s) => tipOf(rest, poseLegs(i), L[s].ankleBone));
|
|
747
|
+
}
|
|
748
|
+
for (let i = 0; i < n; i++) travel[i] = [hip[i]![0] - tb.worldX, hip[i]![1] - tb.worldY];
|
|
723
749
|
const world = (i: number, v: Pt): Pt => [hip[i]![0] + v[0], hip[i]![1] + v[1]];
|
|
724
750
|
/** Both legs posed on frame i by the scales as they stand, world (y up): each chain's root and ankle. */
|
|
725
751
|
const posedFeet = (i: number) => {
|
|
@@ -805,16 +831,20 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
805
831
|
* offset turned by the nearest mapped bone above — the key formula's own
|
|
806
832
|
* assumption (unit scale, no shear), which the landing check holds to account.
|
|
807
833
|
*/
|
|
834
|
+
let headNodBone: string | null = null;
|
|
835
|
+
let headNod: Pt[] | null = null;
|
|
836
|
+
let headNodTrack: MotionTrack | null = null;
|
|
808
837
|
const posedOrigin = (b: string, i: number): Pt => {
|
|
809
838
|
const own = rest.get(b)!;
|
|
810
839
|
if (t !== null && b === t.bone) return [own.worldX + (travel[i]?.[0] ?? 0), own.worldY + (travel[i]?.[1] ?? 0)];
|
|
811
|
-
|
|
840
|
+
const nod = b === headNodBone ? (headNod?.[i] ?? [0, 0]) : [0, 0];
|
|
841
|
+
if (own.parent === null) return [own.worldX + nod[0], own.worldY + nod[1]];
|
|
812
842
|
const parent = own.parent;
|
|
813
843
|
const po = posedOrigin(parent, i);
|
|
814
844
|
const r = turnAbove(parent, i) / DEG;
|
|
815
845
|
const ox = own.worldX - rest.get(parent)!.worldX;
|
|
816
846
|
const oy = own.worldY - rest.get(parent)!.worldY;
|
|
817
|
-
return [po[0] + Math.cos(r) * ox - Math.sin(r) * oy, po[1] + Math.sin(r) * ox + Math.cos(r) * oy];
|
|
847
|
+
return [po[0] + Math.cos(r) * ox - Math.sin(r) * oy + nod[0], po[1] + Math.sin(r) * ox + Math.cos(r) * oy + nod[1]];
|
|
818
848
|
};
|
|
819
849
|
/** A rig point (a bone, `name:tip`, `a+b`) posed on frame i the same way. */
|
|
820
850
|
const posedPoint = (spec: string, i: number): Pt => {
|
|
@@ -868,6 +898,13 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
868
898
|
};
|
|
869
899
|
const turn = (bone: string, i: number) => targets.get(bone)![i]! - restOf.get(bone)!;
|
|
870
900
|
const covered = coveredChains(sw.switches, n);
|
|
901
|
+
if (opts.short !== 'off') {
|
|
902
|
+
for (const [b, info] of fractions) {
|
|
903
|
+
for (const s of capShortSteps(targets.get(b)!, info.fraction, covered.get(info.chain))) {
|
|
904
|
+
short.push({ chain: info.chain, bone: b, joints: info.joints, ...s });
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
}
|
|
871
908
|
const range: RangeDemand[] = [];
|
|
872
909
|
const order = map.chains.flatMap((c) => c.bones.map((b) => ({ c, b }))).filter(({ c, b }) => c.reach || b in c.range);
|
|
873
910
|
order.sort((x, y) => depth(x.b) - depth(y.b));
|
|
@@ -895,6 +932,74 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
895
932
|
}
|
|
896
933
|
p.throwIfAny();
|
|
897
934
|
|
|
935
|
+
// #111: the head chain's nod channel — the eyes' vertical travel against the
|
|
936
|
+
// neck, measured in the rig's own units, keyed as a translate on the head bone
|
|
937
|
+
// (whose rotate track carries roll only).
|
|
938
|
+
for (const c of map.chains) {
|
|
939
|
+
for (let k = 0; k + 1 < c.joints.length; k++) {
|
|
940
|
+
const ja = c.joints[k]!;
|
|
941
|
+
const jb = c.joints[k + 1]!;
|
|
942
|
+
if (!((ja === 'r_eye' && jb === 'l_eye') || (ja === 'l_eye' && jb === 'r_eye'))) continue;
|
|
943
|
+
const hb = c.bones[k]!;
|
|
944
|
+
const ha = c.aim[k]!;
|
|
945
|
+
if (t?.bone === hb || sw.offsetTracks.some((tr) => tr.bone === hb)) break;
|
|
946
|
+
let underScaled = false;
|
|
947
|
+
for (let a = rest.get(hb)!.parent; a !== null; a = rest.get(a)!.parent) if (scales.has(a)) underScaled = true;
|
|
948
|
+
if (underScaled) break;
|
|
949
|
+
const rEye = series.get('r_eye')!;
|
|
950
|
+
const lEye = series.get('l_eye')!;
|
|
951
|
+
const neckSeries = series.get('neck') ?? (() => {
|
|
952
|
+
const h = heldOf('neck');
|
|
953
|
+
return 'never' in h ? undefined : h.at;
|
|
954
|
+
})();
|
|
955
|
+
let nodScale: number | null = scale?.factor ?? null;
|
|
956
|
+
if (nodScale === null && ha.includes('>')) {
|
|
957
|
+
const [pa, pb] = aimEnds(hb, ha);
|
|
958
|
+
const a0 = rigPoint(rest, pa)!;
|
|
959
|
+
const b0 = rigPoint(rest, pb)!;
|
|
960
|
+
const rigEye = Math.hypot(b0[0] - a0[0], b0[1] - a0[1]);
|
|
961
|
+
const refEye = median(rEye.map((re, i) => Math.hypot(lEye[i]![0] - re[0], lEye[i]![1] - re[1])));
|
|
962
|
+
if (refEye > 1e-6 && rigEye > 1e-6) nodScale = rigEye / refEye;
|
|
963
|
+
}
|
|
964
|
+
if (neckSeries !== undefined && nodScale !== null) {
|
|
965
|
+
const refEN = rEye.map((re, i) => -((re[1] + lEye[i]![1]) / 2 - neckSeries[i]![1]));
|
|
966
|
+
const dy = refEN.map((v) => (v - refEN[0]!) * nodScale);
|
|
967
|
+
if (dy.some((v) => Math.abs(r3(v)) > 0)) {
|
|
968
|
+
const bone = rest.get(hb)!;
|
|
969
|
+
const parent = bone.parent === null ? null : rest.get(bone.parent)!;
|
|
970
|
+
const keys: MotionKey[] = [];
|
|
971
|
+
const nod: Pt[] = [];
|
|
972
|
+
for (let i = 0; i < n; i++) {
|
|
973
|
+
const wy = dy[i]!;
|
|
974
|
+
let lx = 0;
|
|
975
|
+
let ly = r3(wy);
|
|
976
|
+
let awx = 0;
|
|
977
|
+
let awy = ly;
|
|
978
|
+
if (parent !== null) {
|
|
979
|
+
const r = turnAbove(parent.name, i) / DEG;
|
|
980
|
+
const pa = Math.cos(r) * parent.a - Math.sin(r) * parent.c;
|
|
981
|
+
const pb = Math.cos(r) * parent.b - Math.sin(r) * parent.d;
|
|
982
|
+
const pc = Math.sin(r) * parent.a + Math.cos(r) * parent.c;
|
|
983
|
+
const pd = Math.sin(r) * parent.b + Math.cos(r) * parent.d;
|
|
984
|
+
const det = pa * pd - pb * pc;
|
|
985
|
+
lx = r3((-pb * wy) / det);
|
|
986
|
+
ly = r3((pa * wy) / det);
|
|
987
|
+
awx = pa * lx + pb * ly;
|
|
988
|
+
awy = pc * lx + pd * ly;
|
|
989
|
+
}
|
|
990
|
+
keys.push({ t: keyTimes[i]!, v: [lx, ly] });
|
|
991
|
+
nod.push([awx, awy]);
|
|
992
|
+
}
|
|
993
|
+
headNodBone = hb;
|
|
994
|
+
headNod = nod;
|
|
995
|
+
headNodTrack = { bone: hb, property: 'translate', keys };
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
break;
|
|
999
|
+
}
|
|
1000
|
+
if (headNodTrack !== null) break;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
898
1003
|
// --raised-by-head (#62, HQ's ruling): while a set whose arm is drawn raised shows, its wrist goes where
|
|
899
1004
|
// the reference's is against the head, in eye distances (each side's median over the span). Solved last
|
|
900
1005
|
// of the bones: every bone above the arm, the head and the trunk's reach included, has its final target
|
|
@@ -1176,6 +1281,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
1176
1281
|
}
|
|
1177
1282
|
}
|
|
1178
1283
|
if (translateTrack !== null) tracks.push(translateTrack);
|
|
1284
|
+
if (headNodTrack !== null) tracks.push(headNodTrack);
|
|
1179
1285
|
tracks.push(...sw.offsetTracks, ...sw.slotTracks);
|
|
1180
1286
|
|
|
1181
1287
|
return {
|
|
@@ -1302,10 +1408,97 @@ export function mergeRuns(spans: readonly ShortSpan[], gap: number): ShortSpan[]
|
|
|
1302
1408
|
return out;
|
|
1303
1409
|
}
|
|
1304
1410
|
|
|
1411
|
+
/**
|
|
1412
|
+
* Cap one-frame steps on a foreshortened segment (#110), in place. Where a
|
|
1413
|
+
* segment's picture length is under `SHORT_STEP_FRACTION` (0.60) of its median
|
|
1414
|
+
* on either side of a frame step (above `--short`'s hold cutoff, where two end-on
|
|
1415
|
+
* pixels still swing the angle faster than the limb moved) and the one-frame turn
|
|
1416
|
+
* exceeds `SHORT_STEP_CAP` (30°), spread the turn across a symmetric window of
|
|
1417
|
+
* frames the same way `spreadFlips` does (`lo = k - h - 1`, `hi = k + h`), bounded
|
|
1418
|
+
* by the ends of the pose and by any active alternate set on the chain (`covered`).
|
|
1419
|
+
*/
|
|
1420
|
+
export function capShortSteps(
|
|
1421
|
+
angles: number[],
|
|
1422
|
+
fraction: readonly number[],
|
|
1423
|
+
covered?: readonly boolean[],
|
|
1424
|
+
fractionCutoff: number = SHORT_STEP_FRACTION,
|
|
1425
|
+
stepCap: number = SHORT_STEP_CAP,
|
|
1426
|
+
): ShortSpan[] {
|
|
1427
|
+
const n = angles.length;
|
|
1428
|
+
if (n < 2) return [];
|
|
1429
|
+
const out: ShortSpan[] = [];
|
|
1430
|
+
for (let segStart = 0; segStart < n;) {
|
|
1431
|
+
if (covered?.[segStart] === true) {
|
|
1432
|
+
segStart++;
|
|
1433
|
+
continue;
|
|
1434
|
+
}
|
|
1435
|
+
let segEnd = segStart;
|
|
1436
|
+
while (segEnd + 1 < n && covered?.[segEnd + 1] !== true) segEnd++;
|
|
1437
|
+
if (segEnd - segStart >= 1) {
|
|
1438
|
+
const u: number[] = new Array<number>(n).fill(0);
|
|
1439
|
+
u[segStart] = angles[segStart]!;
|
|
1440
|
+
for (let i = segStart + 1; i <= segEnd; i++) u[i] = u[i - 1]! + wrap(angles[i]! - angles[i - 1]!);
|
|
1441
|
+
const fits = (lo: number, hi: number) => Math.abs(u[hi]! - u[lo]!) <= stepCap * (hi - lo);
|
|
1442
|
+
interface Win { lo: number; hi: number; asked: number }
|
|
1443
|
+
const wins: Win[] = [];
|
|
1444
|
+
for (let k = segStart + 1; k <= segEnd; k++) {
|
|
1445
|
+
if (!(Math.min(fraction[k - 1]!, fraction[k]!) < fractionCutoff)) continue;
|
|
1446
|
+
const jump = u[k]! - u[k - 1]!;
|
|
1447
|
+
if (Math.abs(jump) <= stepCap) continue;
|
|
1448
|
+
let h = 1;
|
|
1449
|
+
let lo = Math.max(segStart, k - h - 1);
|
|
1450
|
+
let hi = Math.min(segEnd, k + h);
|
|
1451
|
+
while (!fits(lo, hi) && (lo > segStart || hi < segEnd)) {
|
|
1452
|
+
h++;
|
|
1453
|
+
lo = Math.max(segStart, k - h - 1);
|
|
1454
|
+
hi = Math.min(segEnd, k + h);
|
|
1455
|
+
}
|
|
1456
|
+
wins.push({ lo, hi, asked: jump });
|
|
1457
|
+
}
|
|
1458
|
+
for (let changed = true; changed;) {
|
|
1459
|
+
changed = false;
|
|
1460
|
+
wins.sort((a, b) => a.lo - b.lo);
|
|
1461
|
+
for (let i = 0; i + 1 < wins.length; i++) {
|
|
1462
|
+
const a = wins[i]!;
|
|
1463
|
+
const b = wins[i + 1]!;
|
|
1464
|
+
if (b.lo >= a.hi) continue;
|
|
1465
|
+
wins.splice(i, 2, { lo: a.lo, hi: Math.max(a.hi, b.hi), asked: Math.abs(b.asked) > Math.abs(a.asked) ? b.asked : a.asked });
|
|
1466
|
+
changed = true;
|
|
1467
|
+
break;
|
|
1468
|
+
}
|
|
1469
|
+
if (changed) continue;
|
|
1470
|
+
for (const w of wins) {
|
|
1471
|
+
if (fits(w.lo, w.hi) || (w.lo === segStart && w.hi === segEnd)) continue;
|
|
1472
|
+
w.lo = Math.max(segStart, w.lo - 1);
|
|
1473
|
+
w.hi = Math.min(segEnd, w.hi + 1);
|
|
1474
|
+
changed = true;
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
for (const w of wins) {
|
|
1478
|
+
if (w.lo + 1 > w.hi - 1) continue;
|
|
1479
|
+
const step = (u[w.hi]! - u[w.lo]!) / (w.hi - w.lo);
|
|
1480
|
+
for (let i = w.lo + 1; i < w.hi; i++) angles[i] = wrap(u[w.lo]! + step * (i - w.lo));
|
|
1481
|
+
let minFraction = Infinity;
|
|
1482
|
+
for (let i = w.lo + 1; i <= w.hi - 1; i++) minFraction = Math.min(minFraction, fraction[i]!);
|
|
1483
|
+
if (!(minFraction < fractionCutoff)) {
|
|
1484
|
+
minFraction = Math.min(minFraction, fraction[w.lo]!, fraction[w.hi]!);
|
|
1485
|
+
}
|
|
1486
|
+
out.push({ frames: [w.lo + 1, w.hi - 1], minFraction, step: r3(step), asked: r3(w.asked) });
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
segStart = segEnd + 1;
|
|
1490
|
+
}
|
|
1491
|
+
return out;
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1305
1494
|
const spanOf = (frames: [number, number], times: readonly number[]): string =>
|
|
1306
1495
|
frames[0] === frames[1] ? `frame ${frames[0]} (t=${times[frames[0]]!.toFixed(3)}s)` : `frames ${frames[0]}–${frames[1]} (t=${times[frames[0]]!.toFixed(3)}–${times[frames[1]]!.toFixed(3)}s)`;
|
|
1307
1496
|
|
|
1308
1497
|
export function shortLine(s: Short, times: readonly number[], cutoff: number): string {
|
|
1498
|
+
if (s.step !== undefined && s.asked !== undefined) {
|
|
1499
|
+
const sg = (v: number) => `${v >= 0 ? '+' : '−'}${Math.abs(v).toFixed(1)}°`;
|
|
1500
|
+
return `chain "${s.chain}" bone "${s.bone}" (${s.joints[0]} → ${s.joints[1]}) ${spanOf(s.frames, times)}: picture length down to ${s.minFraction.toFixed(2)} of its median, under ${SHORT_STEP_FRACTION.toFixed(2)}, turned ${sg(s.asked)} in one frame (over ${SHORT_STEP_CAP}°); spread to ${sg(s.step)} per frame`;
|
|
1501
|
+
}
|
|
1309
1502
|
const lo = s.frames[0] - 1;
|
|
1310
1503
|
const hi = s.frames[1] + 1;
|
|
1311
1504
|
const held = lo < 0 && hi >= times.length
|
package/src/run.ts
CHANGED
|
@@ -18,7 +18,7 @@ import { artRangeLine, chainsMoving, raisedLine, RAISED_WITHIN, reachLine, switc
|
|
|
18
18
|
import { landed, measureFeet, measureLanding, measurePosture, measureShear, poseFrames, postureLines } from './verify.ts';
|
|
19
19
|
import { legsOf, PLANT_SWITCH, STANCE_BOUNDS } from './legs.ts';
|
|
20
20
|
import { M2_IDENTITY, mulM2, reduceKeys, reduceScale, reduceUnderScale, SCALE_REDUCE, setupLocal, type M2, type ReduceBone, type Reduced, type ReduceRow, type UnderBone } from './reduce.ts';
|
|
21
|
-
import type { BlendHold, Closure, Floor, LegScale, Lowered, Side, Stance } from './legs.ts';
|
|
21
|
+
import type { BlendHold, Closure, Floor, LegScale, Lowered, Side, Stance, SupportScaleRun } from './legs.ts';
|
|
22
22
|
|
|
23
23
|
export interface RetargetArgs extends RetargetOptions {
|
|
24
24
|
rig: string;
|
|
@@ -138,6 +138,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
138
138
|
for (const e of r.byHip) if (e.short.length > 0) say(` REACH ${reachLine(e, r.times)}`);
|
|
139
139
|
for (const e of r.artRange) say(` ART-RANGE ${artRangeLine(e, r.times)}`);
|
|
140
140
|
for (const l of r.legScale) say(` LEG-SCALE ${legScaleLine(l, r.times)}`);
|
|
141
|
+
for (const l of r.legScale) for (const s of l.support ?? []) say(` LEG-SCALE ${supportScaleLine(l, s, r.times)}`);
|
|
141
142
|
if (r.pivot !== null) say(` PIVOT bone "${r.pivot.bone}"'s origin is ${r.pivot.distance.toFixed(2)} units (${r.pivot.hipWidths.toFixed(3)} hip widths) from its thigh roots' midpoint (${r.pivot.roots.join(' + ')}): dx ${r.pivot.offset[0].toFixed(2)}, dy ${r.pivot.offset[1].toFixed(2)}, at setup — the pivot every hip turn swings the legs about`);
|
|
142
143
|
if (r.feet !== null) {
|
|
143
144
|
const f = r.feet;
|
|
@@ -146,6 +147,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
146
147
|
say(` FEET supports: ${f.supports.map((x) => `${x.side} ${span(x.frames)}`).join(', ') || 'none'}`);
|
|
147
148
|
say(` FEET jumps (both ankles and mid_hip > 0.05 leg lengths above their rolling floor, ≥ 2 frames): ${f.jumps.map(span).join(', ') || 'none'}`);
|
|
148
149
|
if (f.lowered !== undefined) say(` FEET ${loweredLine(f.lowered)}`);
|
|
150
|
+
if (f.tracked !== undefined) say(` FEET tracked (the lower foot supports on the floor, following its scaled ankle track until still, rig-c/pose#119): ${f.tracked.map((x) => `${x.side} ${span(x.frames)}`).join(', ')}`);
|
|
149
151
|
if (r.closure !== null) say(` FEET ${travelLine(r.closure)}`);
|
|
150
152
|
else say(` FEET steps (the supporting ankle > 0.03 leg lengths a frame; re-planted after 2 frames < 0.01): ${f.steps.map((x) => `${x.side} ${span(x.frames)} → ${x.to === null ? 'the pose\'s travel' : `${x.to} supports`}, ${x.replant === null ? 'never re-planted' : `re-planted at ${x.replant}`}`).join('; ') || 'none'}`);
|
|
151
153
|
if (r.floor !== null) {
|
|
@@ -315,10 +317,10 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
315
317
|
raised: r.raised,
|
|
316
318
|
// Only when a set declares `place` (#93): a map without one writes the report it always has.
|
|
317
319
|
...(map.alternates.some((x) => x.place !== undefined && x.place !== null) ? { byHip: r.byHip } : {}),
|
|
318
|
-
legScale: r.legScale.map(({ scale: _scale, ...rest }) => rest),
|
|
320
|
+
legScale: r.legScale.map(({ scale: _scale, asked: _asked, ...rest }) => rest),
|
|
319
321
|
pivot: r.pivot,
|
|
320
322
|
stance: r.stance === null ? null : { tolerance: r.stance.tolerance, release: r.stance.release, minSeconds: r.stance.minSeconds, bounds: r.stance.bounds, ...(r.stance.range === undefined ? {} : { range: r.stance.range }), runs: r.stance.runs, over: r.stance.over },
|
|
321
|
-
feet: r.feet === null ? null : { planted: r.planted, legLength: r.feet.legLength, supports: r.feet.supports, jumps: r.feet.jumps, steps: r.feet.steps, changes: r.feet.changes, ...(r.feet.lowered === undefined ? {} : { lowered: r.feet.lowered }), floor: r.floor?.y ?? null, shifts: r.floor?.shifts ?? [], blend: r.blendHold === null ? null : { bounds: r.blendHold.bounds, ...(r.blendHold.range === undefined ? {} : { range: r.blendHold.range }), runs: r.blendHold.runs, over: r.blendHold.over }, reading: feetReading, closure: r.closure },
|
|
323
|
+
feet: r.feet === null ? null : { planted: r.planted, legLength: r.feet.legLength, supports: r.feet.supports, jumps: r.feet.jumps, steps: r.feet.steps, changes: r.feet.changes, ...(r.feet.lowered === undefined ? {} : { lowered: r.feet.lowered }), ...(r.feet.tracked === undefined ? {} : { tracked: r.feet.tracked }), floor: r.floor?.y ?? null, shifts: r.floor?.shifts ?? [], blend: r.blendHold === null ? null : { bounds: r.blendHold.bounds, ...(r.blendHold.range === undefined ? {} : { range: r.blendHold.range }), runs: r.blendHold.runs, over: r.blendHold.over }, reading: feetReading, closure: r.closure },
|
|
322
324
|
shear,
|
|
323
325
|
idleDropped: idle === null ? null : idle.dropped,
|
|
324
326
|
idleKeptOut: idle === null ? null : idle.keptOut,
|
|
@@ -672,6 +674,16 @@ function legScaleLine(l: LegScale, times: readonly number[]): string {
|
|
|
672
674
|
return `chain "${l.chain}" bone "${l.bone}" (${l.joints[0]} → ${l.joints[1]}): straight length ${l.straight.toFixed(1)}px (95th percentile of ${times.length} frames); picture length ÷ straight ${l.ratio.min.toFixed(3)} … ${l.ratio.max.toFixed(3)} (median ${l.ratio.median.toFixed(3)}); scalex keyed in [${l.limits[0]}, ${l.limits[1]}]: ${clamp(l.low, l.limits[0], 'under')}; ${clamp(l.high, l.limits[1], 'over')}`;
|
|
673
675
|
}
|
|
674
676
|
|
|
677
|
+
/** A LEG-SCALE past the floor (support) line (rig-c/pose#133): the supporting leg segment scaled below `--leg-scale`'s floor down to #95's bound. */
|
|
678
|
+
export function supportScaleLine(l: LegScale, s: SupportScaleRun, times: readonly number[]): string {
|
|
679
|
+
const span = (x: [number, number]) => (x[0] === x[1] ? `frame ${x[0]} (t=${times[x[0]]!.toFixed(3)}s)` : `frames ${x[0]}–${x[1]} (t=${times[x[0]]!.toFixed(3)}–${times[x[1]]!.toFixed(3)}s)`);
|
|
680
|
+
const val = (r: { min: number; max: number }) => (r.min === r.max ? r.min.toFixed(4) : `${r.min.toFixed(4)}–${r.max.toFixed(4)}`);
|
|
681
|
+
if (s.clamped !== undefined) {
|
|
682
|
+
return `past the floor (support): chain "${l.chain}" bone "${l.bone}" (supporting foot ${s.side}) ${span(s.frames)} — asked ${val(s.asked)} under ${l.limits[0]}, clamped at the leg's own range ${s.clamped.bound.toFixed(4)} (#95), used ${val(s.used)}; hip rose by up to +${s.clamped.hipRise.toFixed(2)} units (never sinks)`;
|
|
683
|
+
}
|
|
684
|
+
return `past the floor (support): chain "${l.chain}" bone "${l.bone}" (supporting foot ${s.side}) ${span(s.frames)} — asked ${val(s.asked)} under ${l.limits[0]}, used ${val(s.used)} (within the leg's own range, #95; ankle stays on the floor)`;
|
|
685
|
+
}
|
|
686
|
+
|
|
675
687
|
/**
|
|
676
688
|
* POSE_NOT_CLEANED (#67): retarget reads only a sequence `rig-pose clean` wrote.
|
|
677
689
|
* The message is the command to run on this file; the output name is a
|
package/src/switch.ts
CHANGED
|
@@ -590,22 +590,50 @@ export function artRangeLine(e: ArtRange, times: readonly number[]): string {
|
|
|
590
590
|
return `set "${e.set}" chain "${e.chain}" bone "${e.bone}" turned ${sg(e.turn)} from drawn (range [${e.limits[0]}, ${e.limits[1]}]) ${spanOf(e.frames, times)}: ${e.seconds.toFixed(3)}s, up to ${deg(e.maxOverflow)} past it; ${e.clamped ? 'held at the limit' : 'the bone follows the pose'} — demand for another drawing of the set`;
|
|
591
591
|
}
|
|
592
592
|
|
|
593
|
+
/**
|
|
594
|
+
* The art-range clamp eases in and out at a span's edges over at least
|
|
595
|
+
* `ART_EASE_FRAMES` frames, or enough frames to keep the clamp's step at the
|
|
596
|
+
* boundary within `ART_EASE_STEP` degrees per frame (#110).
|
|
597
|
+
*/
|
|
598
|
+
export const ART_EASE_FRAMES = 3;
|
|
599
|
+
export const ART_EASE_STEP = 3;
|
|
600
|
+
|
|
593
601
|
/**
|
|
594
602
|
* Inside each span, a bone with a `turn` in the set is held within it: its
|
|
595
|
-
* target world angle is drawn + clamp(wrap(target − drawn), min, max)
|
|
596
|
-
*
|
|
597
|
-
*
|
|
603
|
+
* target world angle is drawn + clamp(wrap(target − drawn), min, max), eased
|
|
604
|
+
* in and out over at least `ART_EASE_FRAMES` frames at an edge where the clamp
|
|
605
|
+
* switches on or off across the boundary (#110). Each bone is clamped on its
|
|
606
|
+
* own world angle, and keys are differences of world angles, so a forearm
|
|
607
|
+
* under a clamped upper arm keeps its own target.
|
|
598
608
|
*/
|
|
599
609
|
export function clampArt(switches: readonly Switch[], map: JointMap, targets: Map<string, number[]>): void {
|
|
600
610
|
for (const sw of switches) {
|
|
601
611
|
const set = map.alternates.find((a) => a.name === sw.set)!;
|
|
612
|
+
const [a, b] = sw.frames;
|
|
602
613
|
for (const [bone, limits] of Object.entries(set.turn)) {
|
|
603
614
|
const drawn = set.drawn[bone]!;
|
|
604
615
|
const target = targets.get(bone)!;
|
|
605
|
-
|
|
616
|
+
const n = target.length;
|
|
617
|
+
const delta = (i: number): number => {
|
|
606
618
|
const t = wrap(target[i]! - drawn);
|
|
607
619
|
const c = Math.min(Math.max(t, limits[0]), limits[1]);
|
|
608
|
-
|
|
620
|
+
return Math.abs(c - t) > RANGE_SLACK ? c - t : 0;
|
|
621
|
+
};
|
|
622
|
+
const da = delta(a);
|
|
623
|
+
const db = delta(b);
|
|
624
|
+
const easeIn = a > 0 && da !== 0 && Math.sign(delta(a - 1)) === Math.sign(da)
|
|
625
|
+
? Math.max(ART_EASE_FRAMES, Math.ceil(Math.abs(da) / ART_EASE_STEP) - 1)
|
|
626
|
+
: 0;
|
|
627
|
+
const easeOut = b + 1 < n && db !== 0 && Math.sign(delta(b + 1)) === Math.sign(db)
|
|
628
|
+
? Math.max(ART_EASE_FRAMES, Math.ceil(Math.abs(db) / ART_EASE_STEP) - 1)
|
|
629
|
+
: 0;
|
|
630
|
+
for (let i = a; i <= b; i++) {
|
|
631
|
+
const d = delta(i);
|
|
632
|
+
if (d === 0) continue;
|
|
633
|
+
const wIn = i - a < easeIn ? (i - a + 1) / (easeIn + 1) : 1;
|
|
634
|
+
const wOut = b - i < easeOut ? (b - i + 1) / (easeOut + 1) : 1;
|
|
635
|
+
const w = Math.min(wIn, wOut);
|
|
636
|
+
target[i] = wrap(target[i]! + w * d);
|
|
609
637
|
}
|
|
610
638
|
}
|
|
611
639
|
}
|
package/src/verify.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { poseRawAnimation, poseRawSetup, type CompiledDocument, type RawBone } from 'rig-c/core';
|
|
12
12
|
import type { Body18 } from './joints.ts';
|
|
13
|
-
import { aimAngle, pointAt, type JointMap } from './map.ts';
|
|
13
|
+
import { aimAngle, aimEnds, pointAt, type JointMap } from './map.ts';
|
|
14
14
|
import type { HipsInferred, Joint, PelvisFit, Run } from './pose.ts';
|
|
15
15
|
import type { Retarget } from './retarget.ts';
|
|
16
16
|
import { pictureAngle, pointOn, wrap } from './retarget.ts';
|
|
@@ -289,6 +289,81 @@ export interface Posture {
|
|
|
289
289
|
ratio: number | null;
|
|
290
290
|
ratioSw: number | null;
|
|
291
291
|
} | null;
|
|
292
|
+
/**
|
|
293
|
+
* Present with `--plant-feet` when there is at least one support span (#107):
|
|
294
|
+
* per support span, the detrended (centred ±12-frame moving mean removed)
|
|
295
|
+
* vertical sd, rig/reference ratio and correlation for the hip, the neck, and
|
|
296
|
+
* eyes − neck.
|
|
297
|
+
*/
|
|
298
|
+
bounce?: BounceSpan[];
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export interface BounceMetric {
|
|
302
|
+
reference: number;
|
|
303
|
+
rig: number;
|
|
304
|
+
ratio: number | null;
|
|
305
|
+
corr: number | null;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export interface BounceSpan {
|
|
309
|
+
side: 'r' | 'l';
|
|
310
|
+
frames: [number, number];
|
|
311
|
+
hip: BounceMetric;
|
|
312
|
+
neck: BounceMetric;
|
|
313
|
+
eyesNeck: BounceMetric;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Half-window (frames each side; 25-frame centred window at 24 fps ≈ 1 s) for detrending a support span's bounce (#107). */
|
|
317
|
+
export const POSTURE_DETREND_HALF_WINDOW = 12;
|
|
318
|
+
|
|
319
|
+
function detrend(xs: readonly number[], w = POSTURE_DETREND_HALF_WINDOW): number[] {
|
|
320
|
+
const n = xs.length;
|
|
321
|
+
return xs.map((x, i) => {
|
|
322
|
+
const lo = Math.max(0, i - w);
|
|
323
|
+
const hi = Math.min(n - 1, i + w);
|
|
324
|
+
let sum = 0;
|
|
325
|
+
for (let k = lo; k <= hi; k++) sum += xs[k]!;
|
|
326
|
+
return x - sum / (hi - lo + 1);
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function sdOf(xs: readonly number[]): number {
|
|
331
|
+
if (xs.length === 0) return 0;
|
|
332
|
+
const mean = xs.reduce((a, b) => a + b, 0) / xs.length;
|
|
333
|
+
let s2 = 0;
|
|
334
|
+
for (const x of xs) s2 += (x - mean) * (x - mean);
|
|
335
|
+
return Math.sqrt(s2 / xs.length);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function corrOf(a: readonly number[], b: readonly number[]): number | null {
|
|
339
|
+
if (a.length < 2 || a.length !== b.length) return null;
|
|
340
|
+
const ma = a.reduce((x, y) => x + y, 0) / a.length;
|
|
341
|
+
const mb = b.reduce((x, y) => x + y, 0) / b.length;
|
|
342
|
+
let num = 0;
|
|
343
|
+
let da = 0;
|
|
344
|
+
let db = 0;
|
|
345
|
+
for (let i = 0; i < a.length; i++) {
|
|
346
|
+
const xa = a[i]! - ma;
|
|
347
|
+
const xb = b[i]! - mb;
|
|
348
|
+
num += xa * xb;
|
|
349
|
+
da += xa * xa;
|
|
350
|
+
db += xb * xb;
|
|
351
|
+
}
|
|
352
|
+
const den = Math.sqrt(da * db);
|
|
353
|
+
return den > 1e-9 ? num / den : null;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function bounceMetric(ref: readonly number[], rig: readonly number[]): BounceMetric {
|
|
357
|
+
const dr = detrend(ref);
|
|
358
|
+
const dg = detrend(rig);
|
|
359
|
+
const sr = sdOf(dr);
|
|
360
|
+
const sg = sdOf(dg);
|
|
361
|
+
return {
|
|
362
|
+
reference: sr,
|
|
363
|
+
rig: sg,
|
|
364
|
+
ratio: sr > 1e-9 ? sg / sr : null,
|
|
365
|
+
corr: corrOf(dr, dg),
|
|
366
|
+
};
|
|
292
367
|
}
|
|
293
368
|
|
|
294
369
|
function rangeOf(xs: number[]): Range {
|
|
@@ -407,6 +482,78 @@ export function measurePosture(posed: Array<Map<string, RawBone>>, lengths: Read
|
|
|
407
482
|
ratioSw: rig === null || rig.rangeSw === null ? null : rig.rangeSw / reference.rangeSw,
|
|
408
483
|
};
|
|
409
484
|
}
|
|
485
|
+
const bounce: BounceSpan[] = [];
|
|
486
|
+
if (r.planted && r.feet !== null && r.feet.supports.length > 0) {
|
|
487
|
+
const scaleNeckIdx = map.translate.scale.pose.indexOf('neck');
|
|
488
|
+
const neckChain = map.chains.find((c) => c.joints.includes('neck'));
|
|
489
|
+
const neckJointIdx = neckChain === undefined ? -1 : neckChain.joints.indexOf('neck');
|
|
490
|
+
const rigNeck = posed[0]?.has('neck')
|
|
491
|
+
? 'neck'
|
|
492
|
+
: scaleNeckIdx >= 0
|
|
493
|
+
? map.translate.scale.rig[scaleNeckIdx]!
|
|
494
|
+
: neckChain !== undefined && neckJointIdx > 0
|
|
495
|
+
? (neckChain.aim[neckJointIdx - 1] === 'x' ? `${neckChain.bones[neckJointIdx - 1]}:tip` : neckChain.aim[neckJointIdx - 1]!)
|
|
496
|
+
: rigShoulder;
|
|
497
|
+
const headChain = map.chains.find((c) => c.joints.includes('r_eye') && c.joints.includes('l_eye'));
|
|
498
|
+
const rigNeckY = (b: Map<string, RawBone>): number | null => (rigNeck === null ? null : (pointAt(b, lengths, rigNeck)?.[1] ?? null));
|
|
499
|
+
const rigEyesY = (b: Map<string, RawBone>): number | null => {
|
|
500
|
+
if (headChain !== undefined) {
|
|
501
|
+
const k = headChain.joints.findIndex((j, idx) => idx + 1 < headChain.joints.length && ((j === 'r_eye' && headChain.joints[idx + 1] === 'l_eye') || (j === 'l_eye' && headChain.joints[idx + 1] === 'r_eye')));
|
|
502
|
+
if (k >= 0) {
|
|
503
|
+
const ha = headChain.aim[k]!;
|
|
504
|
+
const hb = headChain.bones[k]!;
|
|
505
|
+
if (ha.includes('>')) {
|
|
506
|
+
const [pa, pb] = aimEnds(hb, ha);
|
|
507
|
+
const ya = pointAt(b, lengths, pa);
|
|
508
|
+
const yb = pointAt(b, lengths, pb);
|
|
509
|
+
if (ya !== null && yb !== null) return (ya[1] + yb[1]) / 2;
|
|
510
|
+
}
|
|
511
|
+
const p = pointAt(b, lengths, ha === 'x' ? hb : ha);
|
|
512
|
+
if (p !== null) return p[1];
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
if (b.has('eye_r') && b.has('eye_l')) return (b.get('eye_r')!.worldY + b.get('eye_l')!.worldY) / 2;
|
|
516
|
+
if (b.has('head')) return b.get('head')!.worldY;
|
|
517
|
+
return rigNeckY(b);
|
|
518
|
+
};
|
|
519
|
+
for (const sp of r.feet.supports) {
|
|
520
|
+
const refHip: number[] = [];
|
|
521
|
+
const rigHipY: number[] = [];
|
|
522
|
+
const refNeck: number[] = [];
|
|
523
|
+
const rigNeckS: number[] = [];
|
|
524
|
+
const refEyesNeck: number[] = [];
|
|
525
|
+
const rigEyesNeck: number[] = [];
|
|
526
|
+
for (let i = sp.frames[0]; i <= sp.frames[1]; i++) {
|
|
527
|
+
const b = posed[i]!;
|
|
528
|
+
const rh = at(i, 'r_hip');
|
|
529
|
+
const lh = at(i, 'l_hip');
|
|
530
|
+
if (rh !== null && lh !== null) {
|
|
531
|
+
refHip.push(-((rh[1] + lh[1]) / 2) * s);
|
|
532
|
+
rigHipY.push(b.get(r.origin.bone)!.worldY);
|
|
533
|
+
}
|
|
534
|
+
const nk = at(i, 'neck');
|
|
535
|
+
const ny = rigNeckY(b);
|
|
536
|
+
if (nk !== null && ny !== null) {
|
|
537
|
+
refNeck.push(-nk[1] * s);
|
|
538
|
+
rigNeckS.push(ny);
|
|
539
|
+
const re = at(i, 'r_eye');
|
|
540
|
+
const le = at(i, 'l_eye');
|
|
541
|
+
const ey = rigEyesY(b);
|
|
542
|
+
if (re !== null && le !== null && ey !== null) {
|
|
543
|
+
refEyesNeck.push(-((re[1] + le[1]) / 2 - nk[1]) * s);
|
|
544
|
+
rigEyesNeck.push(ey - ny);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
bounce.push({
|
|
549
|
+
side: sp.side,
|
|
550
|
+
frames: [sp.frames[0], sp.frames[1]],
|
|
551
|
+
hip: bounceMetric(refHip, rigHipY),
|
|
552
|
+
neck: bounceMetric(refNeck, rigNeckS),
|
|
553
|
+
eyesNeck: bounceMetric(refEyesNeck, rigEyesNeck),
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
}
|
|
410
557
|
const has106 = measured !== null && (measured.scaled !== undefined || measured.unmeasured !== undefined || measured.fit !== undefined);
|
|
411
558
|
return {
|
|
412
559
|
pelvis: pelvis.name,
|
|
@@ -438,6 +585,7 @@ export function measurePosture(posed: Array<Map<string, RawBone>>, lengths: Read
|
|
|
438
585
|
}
|
|
439
586
|
: {}),
|
|
440
587
|
sway,
|
|
588
|
+
...(bounce.length > 0 ? { bounce } : {}),
|
|
441
589
|
};
|
|
442
590
|
}
|
|
443
591
|
|
|
@@ -478,5 +626,11 @@ export function postureLines(q: Posture): string[] {
|
|
|
478
626
|
out.push(`sway, x-range of mid_hip − mid_shoulder: ${ref}, rig ${f1(w.rig.range)} units (${sw}; mid_hip = "${q.rigHip}", mid_shoulder = "${q.rigShoulder}") — rig/reference ${f3(w.ratio!)} in units${ratioSw}`);
|
|
479
627
|
}
|
|
480
628
|
}
|
|
629
|
+
if (q.bounce !== undefined) {
|
|
630
|
+
const fmt = (bm: BounceMetric) => `${bm.ratio === null ? 'n/a' : f3(bm.ratio)} (${f1(bm.rig)}/${f1(bm.reference)}u, r=${bm.corr === null ? 'none' : f3(bm.corr)})`;
|
|
631
|
+
for (const b of q.bounce) {
|
|
632
|
+
out.push(`bounce ${b.side} ${b.frames[0]}–${b.frames[1]} (detrended y sd, rig/reference): hip ${fmt(b.hip)}, neck ${fmt(b.neck)}, eyes − neck ${fmt(b.eyesNeck)}`);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
481
635
|
return out;
|
|
482
636
|
}
|