@rig-c/pose 0.8.1 → 0.10.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 +55 -4
- package/cli.ts +42 -2
- package/package.json +1 -1
- package/src/idle.ts +421 -0
- package/src/legs.ts +488 -0
- package/src/retarget.ts +193 -18
- package/src/run.ts +123 -12
- package/src/verify.ts +158 -6
package/src/retarget.ts
CHANGED
|
@@ -64,11 +64,13 @@ import { screenToSpineDegrees } from 'rig-c/transform';
|
|
|
64
64
|
import type { Body18 } from './joints.ts';
|
|
65
65
|
import { isBody18, VIRTUAL } from './joints.ts';
|
|
66
66
|
import type { Chain, JointMap, Rest, RigSlots } from './map.ts';
|
|
67
|
-
import { aimAngle, rigPoint } from './map.ts';
|
|
67
|
+
import { aimAngle, descends, rigPoint } from './map.ts';
|
|
68
68
|
import type { Joint } from './pose.ts';
|
|
69
|
-
import { Problems } from './problems.ts';
|
|
69
|
+
import { Problems, Refusal } from './problems.ts';
|
|
70
70
|
import type { ArtRange, SlotTrack, Switch, SwitchOptions } from './switch.ts';
|
|
71
71
|
import { clampArt, coveredChains, measureArt, overflow, RANGE_SLACK, runsOf, selectSwitches } from './switch.ts';
|
|
72
|
+
import type { Feet, LegScale, PathPose, Posing, Side } from './legs.ts';
|
|
73
|
+
import { isLegSegment, legScaleOf, legsOf, pathFrom, plantHip, posePath, rootOf, SIDES, supportsOf, tipOf } from './legs.ts';
|
|
72
74
|
|
|
73
75
|
const DEG = 180 / Math.PI;
|
|
74
76
|
|
|
@@ -85,6 +87,10 @@ export interface RetargetOptions extends SwitchOptions {
|
|
|
85
87
|
flip: number | 'off';
|
|
86
88
|
/** A segment shorter in the picture than this fraction of its median length has its angle held there (`holdShort`); 'off' reads every frame. */
|
|
87
89
|
short: number | 'off';
|
|
90
|
+
/** Leg segments scaled along their x axis by picture length over straight length, clamped to [min, max] (`legs.ts`); 'off' (the default) scales nothing. */
|
|
91
|
+
legScale?: [number, number] | 'off';
|
|
92
|
+
/** The hip derived from the supporting foot (`legs.ts`); false (the default) moves it by the pose's travel. */
|
|
93
|
+
plantFeet?: boolean;
|
|
88
94
|
}
|
|
89
95
|
|
|
90
96
|
export interface Hold {
|
|
@@ -170,7 +176,7 @@ export interface MotionKey {
|
|
|
170
176
|
}
|
|
171
177
|
export interface MotionTrack {
|
|
172
178
|
bone: string;
|
|
173
|
-
property: 'rotate' | 'translate';
|
|
179
|
+
property: 'rotate' | 'translate' | 'scalex';
|
|
174
180
|
keys: MotionKey[];
|
|
175
181
|
}
|
|
176
182
|
export interface Animation {
|
|
@@ -251,6 +257,29 @@ export interface Retarget {
|
|
|
251
257
|
range: RangeDemand[];
|
|
252
258
|
/** Every run a bone inside a span turned from its drawn angle past its range. */
|
|
253
259
|
artRange: ArtRange[];
|
|
260
|
+
/** With --leg-scale: each leg segment's scale, straight length and clamped runs. */
|
|
261
|
+
legScale: LegScale[];
|
|
262
|
+
/** 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. */
|
|
263
|
+
scales: Map<string, number[]>;
|
|
264
|
+
/** The supports and jumps read off the reference ankles, when the map has both legs (whether or not --plant-feet used them). */
|
|
265
|
+
feet: Feet | null;
|
|
266
|
+
/** Whether the hip was derived from the supporting foot. */
|
|
267
|
+
planted: boolean;
|
|
268
|
+
/** Each ankle's world position on every frame, as the keys put it, when legs are scaled or feet planted. */
|
|
269
|
+
ankles: { bones: Record<Side, string>; at: Record<Side, Pt[]> } | null;
|
|
270
|
+
/** The translated bone's origin against its thigh roots' midpoint, at setup. */
|
|
271
|
+
pivot: Pivot | null;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** The hip's pivot against the thigh roots, at setup (rig-c/pose#47). */
|
|
275
|
+
export interface Pivot {
|
|
276
|
+
bone: string;
|
|
277
|
+
roots: [string, string];
|
|
278
|
+
/** Hip origin − the roots' midpoint, world units. */
|
|
279
|
+
offset: Pt;
|
|
280
|
+
distance: number;
|
|
281
|
+
/** The distance in hip widths (the roots' distance apart). */
|
|
282
|
+
hipWidths: number;
|
|
254
283
|
}
|
|
255
284
|
|
|
256
285
|
type Pt = [number, number];
|
|
@@ -358,6 +387,24 @@ const r3 = (v: number) => {
|
|
|
358
387
|
const r = Math.round(v * 1000) / 1000;
|
|
359
388
|
return Object.is(r, -0) ? 0 : r;
|
|
360
389
|
};
|
|
390
|
+
/** A scale key: to 1e-6, so its rounding moves an ankle 500 units out by at most 0.00025. */
|
|
391
|
+
const r6 = (v: number) => {
|
|
392
|
+
const r = Math.round(v * 1e6) / 1e6;
|
|
393
|
+
return Object.is(r, -0) ? 0 : r;
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
/** The translated bone's origin against the midpoint of the two legs' first bones, at setup. */
|
|
397
|
+
function pivotOf(rest: Rest, bone: string | null, legs: ReturnType<typeof legsOf> | null): Pivot | null {
|
|
398
|
+
if (bone === null || legs === null || !('legs' in legs)) return null;
|
|
399
|
+
const roots: [string, string] = [legs.legs.r.chain.bones[0]!, legs.legs.l.chain.bones[0]!];
|
|
400
|
+
const a = rest.get(roots[0])!;
|
|
401
|
+
const b = rest.get(roots[1])!;
|
|
402
|
+
const h = rest.get(bone)!;
|
|
403
|
+
const offset: Pt = [h.worldX - (a.worldX + b.worldX) / 2, h.worldY - (a.worldY + b.worldY) / 2];
|
|
404
|
+
const width = Math.hypot(b.worldX - a.worldX, b.worldY - a.worldY);
|
|
405
|
+
const distance = Math.hypot(offset[0], offset[1]);
|
|
406
|
+
return { bone, roots, offset, distance, hipWidths: width > 1e-9 ? distance / width : NaN };
|
|
407
|
+
}
|
|
361
408
|
|
|
362
409
|
/**
|
|
363
410
|
* Retarget. Refuses (every reason at once) when a point the map needs is never
|
|
@@ -493,30 +540,17 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
493
540
|
const s = rigLen / poseLen;
|
|
494
541
|
scale = { factor: s, pose: poseLen, rig: rigLen };
|
|
495
542
|
const P = series.get(t.joint)!;
|
|
496
|
-
const bone = rest.get(t.bone)!;
|
|
497
|
-
const parent = bone.parent === null ? null : rest.get(bone.parent)!;
|
|
498
|
-
const keys: MotionKey[] = [];
|
|
499
|
-
const at: Pt[] = [];
|
|
500
543
|
for (let i = 0; i < n; i++) {
|
|
501
544
|
const wx = (P[i]![0] - P[0]![0]) * s;
|
|
502
545
|
const wy = -(P[i]![1] - P[0]![1]) * s;
|
|
503
|
-
let lx = wx;
|
|
504
|
-
let ly = wy;
|
|
505
|
-
if (parent !== null) {
|
|
506
|
-
const det = parent.a * parent.d - parent.b * parent.c;
|
|
507
|
-
lx = (parent.d * wx - parent.b * wy) / det;
|
|
508
|
-
ly = (-parent.c * wx + parent.a * wy) / det;
|
|
509
|
-
}
|
|
510
|
-
keys.push({ t: keyTimes[i]!, v: [r3(lx), r3(ly)] });
|
|
511
|
-
at.push([bone.worldX + wx, bone.worldY + wy]);
|
|
512
546
|
travel.push([wx, wy]);
|
|
513
547
|
}
|
|
514
|
-
translateTrack = { bone: t.bone, property: 'translate', keys };
|
|
515
|
-
origin = { bone: t.bone, at };
|
|
516
548
|
}
|
|
517
549
|
}
|
|
518
550
|
}
|
|
519
551
|
p.throwIfAny();
|
|
552
|
+
/** The pose's own travel of the hip, world, before --plant-feet replaces it. */
|
|
553
|
+
const poseTravel: Pt[] = travel.map((v) => [v[0], v[1]]);
|
|
520
554
|
|
|
521
555
|
// Each mapped bone's target world angle per frame, reaches last.
|
|
522
556
|
const targets = new Map<string, number[]>();
|
|
@@ -595,6 +629,111 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
595
629
|
}
|
|
596
630
|
p.throwIfAny();
|
|
597
631
|
|
|
632
|
+
// The legs (#47): each leg segment's scale, the supports and jumps, and with
|
|
633
|
+
// --plant-feet the hip derived from the supporting foot — before the reaches,
|
|
634
|
+
// which are aimed from where the hip is.
|
|
635
|
+
const legScaleOn = opts.legScale !== undefined && opts.legScale !== 'off';
|
|
636
|
+
const plantOn = opts.plantFeet === true;
|
|
637
|
+
const legScale: LegScale[] = [];
|
|
638
|
+
const scales = new Map<string, number[]>();
|
|
639
|
+
let feet: Feet | null = null;
|
|
640
|
+
let legs: ReturnType<typeof legsOf> | null = null;
|
|
641
|
+
let legPath: string[] | null = null;
|
|
642
|
+
let legTop: string | null = null;
|
|
643
|
+
const restTurn = (b: string, i: number): number => targets.get(b)![i]! - restOf.get(b)!;
|
|
644
|
+
const aimX = new Map<string, boolean>();
|
|
645
|
+
for (const c of map.chains) c.bones.forEach((b, k) => aimX.set(b, c.aim[k] === 'x'));
|
|
646
|
+
{
|
|
647
|
+
const why = plantOn ? '--plant-feet' : legScaleOn ? '--leg-scale' : 'reading the feet';
|
|
648
|
+
legs = legsOf(map, why);
|
|
649
|
+
if ('problems' in legs) {
|
|
650
|
+
if (plantOn || legScaleOn) for (const x of legs.problems) p.add('LEGS_MAP', x);
|
|
651
|
+
} else {
|
|
652
|
+
const L = legs.legs;
|
|
653
|
+
const top = t !== null && SIDES.every((s) => L[s].ankleBone === t.bone || descends(rest, L[s].ankleBone, t.bone)) ? t.bone : rootOf(rest, L.r.ankleBone);
|
|
654
|
+
if (plantOn && (t === null || top !== t.bone)) p.add('LEGS_MAP', t === null ? '--plant-feet places the hip, and the map has no "translate"' : `--plant-feet places the translated bone "${t.bone}", and the legs' last bones (${L.r.ankleBone}, ${L.l.ankleBone}) do not both descend from it`);
|
|
655
|
+
const path = pathFrom(rest, top, [L.r.ankleBone, L.l.ankleBone]);
|
|
656
|
+
if (path === null || rootOf(rest, L.l.ankleBone) !== rootOf(rest, L.r.ankleBone)) {
|
|
657
|
+
if (plantOn || legScaleOn) p.add('LEGS_MAP', `the legs' last bones ${L.r.ankleBone} and ${L.l.ankleBone} do not hang from one bone`);
|
|
658
|
+
} else {
|
|
659
|
+
legPath = path;
|
|
660
|
+
legTop = top;
|
|
661
|
+
}
|
|
662
|
+
const hipOf = (s: Side) => series.get(`${s}_hip`);
|
|
663
|
+
const ankleOf = (s: Side) => series.get(`${s}_ankle`);
|
|
664
|
+
const hr = hipOf('r'), hl = hipOf('l'), ar = ankleOf('r'), al = ankleOf('l');
|
|
665
|
+
if (hr !== undefined && hl !== undefined && ar !== undefined && al !== undefined) feet = supportsOf({ r: ar, l: al }, { r: hr, l: hl }, keyTimes, secondsOf);
|
|
666
|
+
else if (plantOn) p.add('LEGS_MAP', `--plant-feet reads both hips and both ankles from the pose; the map's chains use ${['r_hip', 'l_hip', 'r_ankle', 'l_ankle'].filter((j) => !series.has(j)).join(', ')} nowhere`);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
if (legScaleOn) {
|
|
670
|
+
const limits = opts.legScale as [number, number];
|
|
671
|
+
for (const c of map.chains) {
|
|
672
|
+
if (c.reach) continue;
|
|
673
|
+
c.bones.forEach((b, k) => {
|
|
674
|
+
const joints: [string, string] = [c.joints[k]!, c.joints[k + 1]!];
|
|
675
|
+
if (!isLegSegment(joints[0], joints[1])) return;
|
|
676
|
+
if (c.aim[k] !== 'x') {
|
|
677
|
+
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"`);
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
const from = series.get(joints[0])!;
|
|
681
|
+
const to = series.get(joints[1])!;
|
|
682
|
+
const ls = legScaleOf(from.map((f, i) => Math.hypot(to[i]![0] - f[0], to[i]![1] - f[1])), limits);
|
|
683
|
+
if (!(ls.straight > 1e-6)) {
|
|
684
|
+
p.add('LEGS_MAP', `--leg-scale: chain "${c.name}" bone "${b}"'s segment ${joints[0]} → ${joints[1]} has a straight length (95th percentile) of ${ls.straight}px; there is nothing to scale against`);
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
legScale.push({ chain: c.name, bone: b, joints, ...ls });
|
|
688
|
+
scales.set(b, ls.scale);
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
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');
|
|
692
|
+
}
|
|
693
|
+
p.throwIfAny();
|
|
694
|
+
/** Every bone on the legs' path posed on frame i, by the targets as they stand. */
|
|
695
|
+
const poseLegs = (i: number): PathPose => {
|
|
696
|
+
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) => scales.get(b)?.[i] ?? null);
|
|
697
|
+
if (typeof r === 'string') throw new Refusal([{ code: 'LEGS_MAP', text: r }]);
|
|
698
|
+
return r;
|
|
699
|
+
};
|
|
700
|
+
if (plantOn) {
|
|
701
|
+
// The plant is derived from the targets before the range clamp, so nothing after may turn a bone on the legs' path.
|
|
702
|
+
const onPath = new Set(legPath!);
|
|
703
|
+
for (const c of map.chains) for (const b of Object.keys(c.range)) if (onPath.has(b)) p.add('LEGS_RANGE', `--plant-feet places the hip through chain "${c.name}" bone "${b}", which has a "range"; a clamp after the plant would move the planted foot — drop the range or --plant-feet`);
|
|
704
|
+
for (const a of map.alternates) {
|
|
705
|
+
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`);
|
|
706
|
+
}
|
|
707
|
+
p.throwIfAny();
|
|
708
|
+
const offsets = Array.from({ length: n }, (_, i) => {
|
|
709
|
+
const lp = poseLegs(i);
|
|
710
|
+
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 : '') };
|
|
711
|
+
});
|
|
712
|
+
const tb = rest.get(t!.bone)!;
|
|
713
|
+
const hip = plantHip(feet!, (i, s) => offsets[i]![s], (i) => poseTravel[i]!, [tb.worldX, tb.worldY]);
|
|
714
|
+
for (let i = 0; i < n; i++) travel[i] = [hip[i]![0] - tb.worldX, hip[i]![1] - tb.worldY];
|
|
715
|
+
}
|
|
716
|
+
if (t !== null && scale !== null) {
|
|
717
|
+
const bone = rest.get(t.bone)!;
|
|
718
|
+
const parent = bone.parent === null ? null : rest.get(bone.parent)!;
|
|
719
|
+
const keys: MotionKey[] = [];
|
|
720
|
+
const at: Pt[] = [];
|
|
721
|
+
for (let i = 0; i < n; i++) {
|
|
722
|
+
const [wx, wy] = travel[i]!;
|
|
723
|
+
let lx = wx;
|
|
724
|
+
let ly = wy;
|
|
725
|
+
if (parent !== null) {
|
|
726
|
+
const det = parent.a * parent.d - parent.b * parent.c;
|
|
727
|
+
lx = (parent.d * wx - parent.b * wy) / det;
|
|
728
|
+
ly = (-parent.c * wx + parent.a * wy) / det;
|
|
729
|
+
}
|
|
730
|
+
keys.push({ t: keyTimes[i]!, v: [r3(lx), r3(ly)] });
|
|
731
|
+
at.push([bone.worldX + wx, bone.worldY + wy]);
|
|
732
|
+
}
|
|
733
|
+
translateTrack = { bone: t.bone, property: 'translate', keys };
|
|
734
|
+
origin = { bone: t.bone, at };
|
|
735
|
+
}
|
|
736
|
+
|
|
598
737
|
// Reaches: from the bone's posed origin at the pose's end point, anchored at
|
|
599
738
|
// the translated bone. A reach above another is aimed first.
|
|
600
739
|
const depth = (b: string): number => {
|
|
@@ -685,6 +824,25 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
685
824
|
const artRange = measureArt(sw.switches, map, targets, endOf, keyTimes, RANGE_GAP);
|
|
686
825
|
clampArt(sw.switches, map, targets);
|
|
687
826
|
|
|
827
|
+
// The legs on their final targets: a bone under a scaled bone is keyed through
|
|
828
|
+
// its parent's posed matrix (legs.ts), and each ankle's world position is read.
|
|
829
|
+
const legPoses: PathPose[] | null = legPath !== null && (legScaleOn || plantOn) ? Array.from({ length: n }, (_, i) => poseLegs(i)) : null;
|
|
830
|
+
let ankles: Retarget['ankles'] = null;
|
|
831
|
+
if (legPoses !== null && legs !== null && 'legs' in legs) {
|
|
832
|
+
const L = legs.legs;
|
|
833
|
+
const topRest = rest.get(legTop!)!;
|
|
834
|
+
const at = { r: [] as Pt[], l: [] as Pt[] };
|
|
835
|
+
legPoses.forEach((lp, i) => {
|
|
836
|
+
const o: Pt = origin !== null && origin.bone === legTop ? origin.at[i]! : [topRest.worldX, topRest.worldY];
|
|
837
|
+
for (const sd of SIDES) {
|
|
838
|
+
const tip = tipOf(rest, lp, L[sd].ankleBone);
|
|
839
|
+
at[sd].push([o[0] + tip[0], o[1] + tip[1]]);
|
|
840
|
+
}
|
|
841
|
+
});
|
|
842
|
+
ankles = { bones: { r: L.r.ankleBone, l: L.l.ankleBone }, at };
|
|
843
|
+
}
|
|
844
|
+
const solvedKey = (b: string, i: number) => legPoses?.[i]!.solved.get(b);
|
|
845
|
+
|
|
688
846
|
// Keys: the bone's turn less the turn of the nearest mapped bone above it.
|
|
689
847
|
const tracks: Array<MotionTrack | SlotTrack> = [];
|
|
690
848
|
for (const c of map.chains) {
|
|
@@ -699,6 +857,22 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
699
857
|
keys.push({ t: keyTimes[i]!, v: [r3(v)] });
|
|
700
858
|
}
|
|
701
859
|
tracks.push({ bone: b, property: 'rotate', keys });
|
|
860
|
+
// A bone under a scaled bone: the rotate key that lands it through its parent's scale (legs.ts).
|
|
861
|
+
if (solvedKey(b, 0) !== undefined) {
|
|
862
|
+
let last = 0;
|
|
863
|
+
keys.forEach((k, i) => {
|
|
864
|
+
const raw = solvedKey(b, i)!.rotate;
|
|
865
|
+
last = i === 0 ? wrap(raw) : last + wrap(raw - last);
|
|
866
|
+
k.v = [r3(last)];
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
// Scale keys: a scaled leg segment's own scale, or the scale that lands a bone under one at its asked length.
|
|
872
|
+
for (const c of map.chains) {
|
|
873
|
+
for (const b of c.bones) {
|
|
874
|
+
if (!scales.has(b) && solvedKey(b, 0) === undefined) continue;
|
|
875
|
+
tracks.push({ bone: b, property: 'scalex', keys: Array.from({ length: n }, (_, i) => ({ t: keyTimes[i]!, v: [r6(solvedKey(b, i)?.scale ?? scales.get(b)?.[i] ?? 1)] })) });
|
|
702
876
|
}
|
|
703
877
|
}
|
|
704
878
|
if (translateTrack !== null) tracks.push(translateTrack);
|
|
@@ -708,6 +882,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
708
882
|
animation: { duration: keyTimes[n - 1]!, tracks },
|
|
709
883
|
times: keyTimes, targets, origin, holds, bends, short, endOn, flips, scale,
|
|
710
884
|
switches: sw.switches, shown: sw.shown, distances: sw.distances, turns, turnsUnread, range, artRange,
|
|
885
|
+
legScale, scales, feet, planted: plantOn, ankles, pivot: pivotOf(rest, t?.bone ?? null, legs),
|
|
711
886
|
};
|
|
712
887
|
}
|
|
713
888
|
|
package/src/run.ts
CHANGED
|
@@ -9,11 +9,14 @@ import { tmpdir } from 'node:os';
|
|
|
9
9
|
import { dirname, join, resolve } from 'node:path';
|
|
10
10
|
import { readMap, resolveMap, type JointMap } from './map.ts';
|
|
11
11
|
import { convertCoco17, personTrack, readPoseSequence } from './pose.ts';
|
|
12
|
-
import { lineOf, Problems, Refusal, type Problem } from './problems.ts';
|
|
12
|
+
import { isObject, lineOf, parseJson, Problems, Refusal, type Problem } from './problems.ts';
|
|
13
|
+
import { boneIdentity, droppedLine, keptOutLine, mergeProblems, readFragment, readIdle, targetOf, trackLabel, type IdleReading, type RigcTrack, type Target } from './idle.ts';
|
|
13
14
|
import { bendLine, endOnLine, flipLine, holdLine, rangeLine, retarget, shortLine, turnLine, type RetargetOptions } from './retarget.ts';
|
|
14
15
|
import { attachmentBones, baseMotionOf, buildInto, rigcVersion, folds, gate, readBuild, resolveRig, restOf, slotsOf } from './rigc.ts';
|
|
15
16
|
import { artRangeLine, chainsMoving, switchLine, uncoveredFolds } from './switch.ts';
|
|
16
|
-
import { landed, measureLanding, measurePosture, poseFrames, postureLines
|
|
17
|
+
import { landed, measureFeet, measureLanding, measurePosture, measureShear, poseFrames, postureLines } from './verify.ts';
|
|
18
|
+
import { legsOf } from './legs.ts';
|
|
19
|
+
import type { LegScale } from './legs.ts';
|
|
17
20
|
|
|
18
21
|
export interface RetargetArgs extends RetargetOptions {
|
|
19
22
|
rig: string;
|
|
@@ -33,6 +36,12 @@ export interface RetargetArgs extends RetargetOptions {
|
|
|
33
36
|
render?: string;
|
|
34
37
|
/** Write the run's measurements as JSON here. */
|
|
35
38
|
report?: string;
|
|
39
|
+
/** The rig's animation read as its idle (default: idle); naming one the motion spec lacks is refused. */
|
|
40
|
+
idle?: string;
|
|
41
|
+
/** Loop the idle's dropped tracks into the new animation (#43 item 2). */
|
|
42
|
+
carryIdle?: boolean;
|
|
43
|
+
/** A rig-pose-tracks/1 fragment merged into the new animation. */
|
|
44
|
+
merge?: string;
|
|
36
45
|
}
|
|
37
46
|
|
|
38
47
|
export interface RunResult {
|
|
@@ -50,8 +59,8 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
50
59
|
throw new Refusal(problems);
|
|
51
60
|
};
|
|
52
61
|
const args = new Problems();
|
|
53
|
-
for (const [flag, path] of [['--map', a.map], ['--pose', a.pose]] as const) {
|
|
54
|
-
if (!existsSync(path)) args.add('ARG_PATH', `${flag} ${path} does not exist`);
|
|
62
|
+
for (const [flag, path] of [['--map', a.map], ['--pose', a.pose], ['--merge', a.merge]] as const) {
|
|
63
|
+
if (path !== undefined && !existsSync(path)) args.add('ARG_PATH', `${flag} ${path} does not exist`);
|
|
55
64
|
}
|
|
56
65
|
// A file this run writes goes into a directory that is there already; --build and --render make theirs.
|
|
57
66
|
for (const [flag, path] of [['--out', a.out], ['--report', a.report]] as const) {
|
|
@@ -65,6 +74,11 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
65
74
|
const base = baseMotionOf(input);
|
|
66
75
|
const animations = base.animations as Record<string, unknown>;
|
|
67
76
|
if (a.name in animations) refuse([{ code: 'MOTION_NAME_FREE', text: `the rig's motion spec already has an animation "${a.name}" (it has ${Object.keys(animations).join(', ')}); name this one with --name` }]);
|
|
77
|
+
const idleName = a.idle ?? 'idle';
|
|
78
|
+
if (!(idleName in animations) && (a.idle !== undefined || a.carryIdle === true)) {
|
|
79
|
+
refuse([{ code: 'IDLE_NAME', text: `${a.idle !== undefined ? `--idle "${idleName}"` : '--carry-idle reads the animation "idle", and'} the rig's motion spec ${input.motion === null ? '— there is none, so it has no animations' : `${input.motion} has no animation "${idleName}" (it has ${Object.keys(animations).join(', ') || 'none'})`}; name the rig's idle with --idle` }]);
|
|
80
|
+
}
|
|
81
|
+
const fragment = a.merge === undefined ? [] : readFragment(parseJson(readFileSync(a.merge, 'utf8'), a.merge, 'MERGE_JSON'), a.merge);
|
|
68
82
|
const map = readMap(readFileSync(a.map, 'utf8'), a.map);
|
|
69
83
|
const poseText = readFileSync(a.pose, 'utf8');
|
|
70
84
|
const seq = a.coco ? convertCoco17(poseText, a.pose) : readPoseSequence(poseText, a.pose);
|
|
@@ -85,7 +99,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
85
99
|
resolveMap(map, rest, a.map, slots);
|
|
86
100
|
|
|
87
101
|
const r = retarget(rest, map, frames, times, a, slots);
|
|
88
|
-
say(`rig-pose retarget: ${seq.frames.length} frame(s), ${r.animation.duration.toFixed(3)}s, onto "${input.name}" as "${a.name}" — ${r.animation.tracks.length} track(s); min-score ${a.minScore}, smooth ${a.smooth}, flip ${a.flip === 'off' ? 'off' : `${a.flip}°`}, short ${a.short}, bends ${a.bends} (tolerance ${a.bendTolerance}°)`);
|
|
102
|
+
say(`rig-pose retarget: ${seq.frames.length} frame(s), ${r.animation.duration.toFixed(3)}s, onto "${input.name}" as "${a.name}" — ${r.animation.tracks.length} track(s); min-score ${a.minScore}, smooth ${a.smooth}, flip ${a.flip === 'off' ? 'off' : `${a.flip}°`}, short ${a.short}, bends ${a.bends} (tolerance ${a.bendTolerance}°)${a.legScale !== undefined && a.legScale !== 'off' ? `, leg-scale ${a.legScale[0]}–${a.legScale[1]}` : ''}${a.plantFeet === true ? ', plant-feet' : ''}${a.carryIdle === true ? ', carry-idle' : ''}${a.merge !== undefined ? `, merge ${a.merge}` : ''}`);
|
|
89
103
|
say(` RIGC rig-c ${rigc.version} (${rigc.entry}) gates and poses this run`);
|
|
90
104
|
if (r.scale !== null) say(` SCALE ${r.scale.factor.toFixed(5)} rig units per picture px (rig ${r.scale.rig.toFixed(2)} / pose median ${r.scale.pose.toFixed(2)})`);
|
|
91
105
|
for (const h of r.holds) say(` HOLD ${holdLine(h, r.times)}`);
|
|
@@ -104,8 +118,59 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
104
118
|
}
|
|
105
119
|
for (const e of r.range) say(` RANGE ${rangeLine(e, r.times)}`);
|
|
106
120
|
for (const e of r.artRange) say(` ART-RANGE ${artRangeLine(e, r.times)}`);
|
|
121
|
+
for (const l of r.legScale) say(` LEG-SCALE ${legScaleLine(l, r.times)}`);
|
|
122
|
+
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`);
|
|
123
|
+
if (r.feet !== null) {
|
|
124
|
+
const f = r.feet;
|
|
125
|
+
const span = (x: [number, number]) => (x[0] === x[1] ? `${x[0]}` : `${x[0]}–${x[1]}`);
|
|
126
|
+
say(` FEET ${r.planted ? 'planted' : 'read, not planted (no --plant-feet)'}: leg length (median hip → ankle) r ${f.legLength.r.toFixed(1)}px, l ${f.legLength.l.toFixed(1)}px; ${f.supports.length} support span(s), ${f.changes.filter((c) => c.from !== null).length} change(s) of support, ${f.jumps.length} jump(s), ${f.steps.length} step(s)`);
|
|
127
|
+
say(` FEET supports: ${f.supports.map((x) => `${x.side} ${span(x.frames)}`).join(', ') || 'none'}`);
|
|
128
|
+
say(` FEET jumps (both ankles and mid_hip > 0.05 leg lengths above their rolling floor, ≥ 2 frames): ${f.jumps.map(span).join(', ') || 'none'}`);
|
|
129
|
+
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'}`);
|
|
130
|
+
}
|
|
107
131
|
|
|
108
|
-
|
|
132
|
+
// The idle against the new animation (#43 item 2): what it drops, and with --carry-idle, looped back in.
|
|
133
|
+
const groups = (isObject(base.groups) ? base.groups : {}) as Record<string, string[]>;
|
|
134
|
+
const easings = (isObject(base.easings) ? base.easings : {}) as Record<string, number[]>;
|
|
135
|
+
const own = r.animation.tracks as unknown as RigcTrack[];
|
|
136
|
+
const T = r.animation.duration;
|
|
137
|
+
let idle: IdleReading | null = null;
|
|
138
|
+
if (!(idleName in animations)) say(` IDLE ${input.motion === null ? 'the rig has no motion spec' : `the rig's motion spec has no animation "idle" (it has ${Object.keys(animations).join(', ') || 'none'})`}; no idle read (--idle names another)`);
|
|
139
|
+
else {
|
|
140
|
+
const setupOf = (t: Target, property: string, sample: unknown): unknown => {
|
|
141
|
+
if (t.kind === 'slot' && property === 'attachment') return slots.get(t.name)?.setup;
|
|
142
|
+
if ((t.kind === 'bone' || t.kind === 'group') && Array.isArray(sample)) return boneIdentity(property, sample.length);
|
|
143
|
+
return undefined;
|
|
144
|
+
};
|
|
145
|
+
const mapped = [...map.chains.flatMap((c) => c.bones), ...(map.translate === null ? [] : [map.translate.bone])];
|
|
146
|
+
const below = new Map<string, string[]>();
|
|
147
|
+
for (const m of new Set(mapped)) {
|
|
148
|
+
for (let b = rest.get(m)?.parent ?? null; b !== null; b = rest.get(b)?.parent ?? null) {
|
|
149
|
+
const list = below.get(b) ?? [];
|
|
150
|
+
if (!list.includes(m)) list.push(m);
|
|
151
|
+
below.set(b, list);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
idle = readIdle(idleName, a.name, animations[idleName] as Record<string, unknown>, own, T, { groups, easings }, setupOf, a.carryIdle === true, (bone) => below.get(bone) ?? [], (bone) => {
|
|
155
|
+
const hip = map.translate?.bone;
|
|
156
|
+
if (hip === undefined) return null;
|
|
157
|
+
for (let b = rest.get(hip)?.parent ?? null; b !== null; b = rest.get(b)?.parent ?? null) if (b === bone) return hip;
|
|
158
|
+
return null;
|
|
159
|
+
});
|
|
160
|
+
say(` IDLE animation "${idleName}" (${idle.duration.toFixed(3)}s, ${idle.tracks} track(s)) against "${a.name}": ${idle.dropped.length} dropped (${a.carryIdle === true ? `${idle.carriedTracks.length} carried, looped to ${T.toFixed(3)}s` : 'not carried; --carry-idle loops them in'}), ${idle.keptOut.length} kept out`);
|
|
161
|
+
for (const d of idle.dropped) say(` IDLE-DROPPED ${droppedLine(d, a.name, a.carryIdle === true, T)}`);
|
|
162
|
+
for (const k of idle.keptOut) say(` IDLE-KEPT-OUT ${keptOutLine(k)}`);
|
|
163
|
+
for (const n of idle.notTracks) say(` IDLE-KEPT-OUT "${idleName}"'s ${n} timeline(s): not a track; neither compared nor carried`);
|
|
164
|
+
}
|
|
165
|
+
const carried = idle?.carriedTracks ?? [];
|
|
166
|
+
if (fragment.length > 0) {
|
|
167
|
+
const problems = mergeProblems(fragment, a.merge!, a.name, [...own, ...carried], T, groups);
|
|
168
|
+
if (problems.length > 0) refuse(problems);
|
|
169
|
+
for (const tr of fragment) say(` MERGE ${a.merge}: ${trackLabel(tr, groups)}, ${tr.keys.length} key(s) at t=${tr.keys[0]!.t}${tr.keys.length > 1 ? `–${tr.keys[tr.keys.length - 1]!.t}` : ''}s`);
|
|
170
|
+
}
|
|
171
|
+
const added = [...carried, ...fragment];
|
|
172
|
+
const animation = added.length === 0 ? r.animation : { ...r.animation, tracks: [...own, ...added] };
|
|
173
|
+
const motion = { ...base, animations: { ...animations, [a.name]: animation } };
|
|
109
174
|
const g1 = gate(input.rig, motion, join(scratch, 'with'), a.profile, a.images);
|
|
110
175
|
if (!g1.green) {
|
|
111
176
|
refuse([{ code: 'RIG_GATE', text: `rigc build --profile ${a.profile} is red with "${a.name}" in (exit ${g1.run.status}); nothing written` }, ...g1.fails.map((f) => ({ code: 'RIGC', text: f }))]);
|
|
@@ -116,17 +181,41 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
116
181
|
const posed = poseFrames(doc, a.name, r.times);
|
|
117
182
|
const landings = measureLanding(doc, a.name, map, r, posed);
|
|
118
183
|
const p = new Problems();
|
|
184
|
+
// A carried idle track on a bone above a mapped one moves it where the keys did not ask (the key formula holds unmapped bones at setup).
|
|
185
|
+
const carriedAbove = (bone: string): string => {
|
|
186
|
+
const above = new Set<string>();
|
|
187
|
+
for (let b = rest.get(bone)?.parent ?? null; b !== null; b = rest.get(b)?.parent ?? null) above.add(b);
|
|
188
|
+
const hits = carried.filter((tr) => {
|
|
189
|
+
const t = targetOf(tr);
|
|
190
|
+
return t !== null && (t.kind === 'bone' ? above.has(t.name) : t.kind === 'group' && (groups[t.name] ?? []).some((m) => above.has(m)));
|
|
191
|
+
});
|
|
192
|
+
return hits.length === 0 ? '' : `; --carry-idle carried ${hits.map((tr) => trackLabel(tr, groups)).join(', ')} onto bones above it`;
|
|
193
|
+
};
|
|
119
194
|
for (const l of landings) {
|
|
120
|
-
const unit = l.kind === 'angle' ? '°' : ' units';
|
|
121
|
-
const
|
|
122
|
-
const
|
|
195
|
+
const unit = l.kind === 'angle' ? '°' : l.kind === 'scale' ? ' of its setup length' : ' units';
|
|
196
|
+
const what = l.kind === 'angle' ? 'aim' : l.kind === 'scale' ? 'scale' : l.kind === 'foot' ? 'ankle' : 'origin';
|
|
197
|
+
const tol = l.kind === 'foot' ? Number(l.tolerance.toPrecision(3)) : l.tolerance;
|
|
198
|
+
const line = `bone "${l.bone}" ${what} — worst |reached − asked| ${l.worst.toPrecision(3)}${unit} at frame ${l.frame} (t=${r.times[l.frame]!.toFixed(3)}s); the tolerance is ${tol}${unit}`;
|
|
123
199
|
if (landed(l)) say(` LAND ${line}`);
|
|
124
|
-
else p.add('POSE_LANDS', `${line} — the rig does not turn the way the key formula assumes (a constraint, a scaled or sheared parent, a non-normal inherit mode)`);
|
|
200
|
+
else p.add('POSE_LANDS', `${line} — the rig does not turn the way the key formula assumes (a constraint, a scaled or sheared parent, a non-normal inherit mode)${carriedAbove(l.bone)}`);
|
|
125
201
|
}
|
|
126
202
|
p.throwIfAny();
|
|
127
203
|
|
|
128
|
-
const
|
|
204
|
+
const lengths = new Map(doc.bones.map((b) => [b.name, b.length ?? 0]));
|
|
205
|
+
const posture = measurePosture(posed, lengths, map, r, frames, a.minScore);
|
|
129
206
|
if (posture !== null) for (const l of postureLines(posture)) say(` POSTURE ${l}`);
|
|
207
|
+
const legBones = legBonesOf(map);
|
|
208
|
+
const feetReading = legBones === null ? null : measureFeet(posed, lengths, r, legBones);
|
|
209
|
+
if (feetReading !== null) {
|
|
210
|
+
const f = feetReading;
|
|
211
|
+
say(` FEET the posed build, outside jumps: the lowest ankle's vertical range ${f.lowest.range.toFixed(2)} units = ${f.lowest.legs.toFixed(4)} leg lengths (rig leg r ${f.legLength.r.toFixed(1)}, l ${f.legLength.l.toFixed(1)} units) over ${f.lowest.frames} frames`);
|
|
212
|
+
const worst = (k: 'verticalLegs' | 'horizontalLegs') => f.plants.reduce<(typeof f.plants)[number] | null>((m, x) => (m === null || x[k] > m[k] ? x : m), null);
|
|
213
|
+
const wv = worst('verticalLegs');
|
|
214
|
+
const wh = worst('horizontalLegs');
|
|
215
|
+
if (wv !== null && wh !== null) say(` FEET the supporting ankle over each support span: worst vertical range ${wv.vertical.toFixed(2)} units = ${wv.verticalLegs.toFixed(4)} leg lengths (${wv.side} ${wv.frames[0]}–${wv.frames[1]}), worst horizontal drift ${wh.horizontal.toFixed(2)} units = ${wh.horizontalLegs.toFixed(4)} leg lengths (${wh.side} ${wh.frames[0]}–${wh.frames[1]})`);
|
|
216
|
+
}
|
|
217
|
+
const shear = r.scales.size === 0 ? [] : measureShear(doc, posed, r.scales.keys());
|
|
218
|
+
for (const x of shear) say(` SHEAR bone "${x.bone}": art sheared up to ${x.shear.toFixed(2)}° (frame ${x.frame}); world x axis ${x.x[0].toFixed(3)} … ${x.x[1].toFixed(3)}, y axis ${x.y[0].toFixed(3)} … ${x.y[1].toFixed(3)} of setup — a scaled bone, or one under it; a reading`);
|
|
130
219
|
|
|
131
220
|
const dts = r.times.slice(1).map((t, i) => t - r.times[i]!).sort((x, y) => x - y);
|
|
132
221
|
const fps = dts.length === 0 ? 12 : Math.max(1, Math.min(60, Math.round(1 / dts[dts.length >> 1]!)));
|
|
@@ -160,7 +249,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
160
249
|
animation: a.name,
|
|
161
250
|
frames: seq.frames.length,
|
|
162
251
|
duration: r.animation.duration,
|
|
163
|
-
options: { minScore: a.minScore, smooth: a.smooth, flip: a.flip, short: a.short, bends: a.bends, bendTolerance: a.bendTolerance, switchTolerance: a.switchTolerance, switchMin: a.switchMin, switchAt: a.switchAt, profile: a.profile },
|
|
252
|
+
options: { minScore: a.minScore, smooth: a.smooth, flip: a.flip, short: a.short, bends: a.bends, bendTolerance: a.bendTolerance, switchTolerance: a.switchTolerance, switchMin: a.switchMin, switchAt: a.switchAt, profile: a.profile, legScale: a.legScale ?? 'off', plantFeet: a.plantFeet === true, idle: idleName, carryIdle: a.carryIdle === true, merge: a.merge ?? null },
|
|
164
253
|
scale: r.scale,
|
|
165
254
|
holds: r.holds,
|
|
166
255
|
short: r.short,
|
|
@@ -176,6 +265,13 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
176
265
|
turns: r.turns,
|
|
177
266
|
range: r.range,
|
|
178
267
|
artRange: r.artRange,
|
|
268
|
+
legScale: r.legScale.map(({ scale: _scale, ...rest }) => rest),
|
|
269
|
+
pivot: r.pivot,
|
|
270
|
+
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, reading: feetReading },
|
|
271
|
+
shear,
|
|
272
|
+
idleDropped: idle === null ? null : idle.dropped,
|
|
273
|
+
idleKeptOut: idle === null ? null : idle.keptOut,
|
|
274
|
+
merged: fragment.map((tr) => ({ track: trackLabel(tr, groups), keys: tr.keys.length })),
|
|
179
275
|
}, null, 2)}\n`);
|
|
180
276
|
}
|
|
181
277
|
say(` wrote ${a.out} — animations: ${Object.keys(motion.animations).join(', ')}`);
|
|
@@ -235,3 +331,18 @@ function windowLines(map: JointMap, switches: ReadonlyArray<{ set: string; frame
|
|
|
235
331
|
}
|
|
236
332
|
return out;
|
|
237
333
|
}
|
|
334
|
+
|
|
335
|
+
/** Each leg's first bone (its root, the thigh) and last bone (whose tip is the ankle), when the map has both. */
|
|
336
|
+
function legBonesOf(map: JointMap): { r: { root: string; ankle: string }; l: { root: string; ankle: string } } | null {
|
|
337
|
+
const l = legsOf(map, 'reading the feet');
|
|
338
|
+
if ('problems' in l) return null;
|
|
339
|
+
return { r: { root: l.legs.r.chain.bones[0]!, ankle: l.legs.r.ankleBone }, l: { root: l.legs.l.chain.bones[0]!, ankle: l.legs.l.ankleBone } };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** A LEG-SCALE line: the segment, its straight length, its ratio's spread, and the frames clamped. */
|
|
343
|
+
function legScaleLine(l: LegScale, times: readonly number[]): string {
|
|
344
|
+
const span = (x: [number, number]) => (x[0] === x[1] ? `${x[0]}` : `${x[0]}–${x[1]}`);
|
|
345
|
+
const count = (rs: Array<[number, number]>) => rs.reduce((k, x) => k + x[1] - x[0] + 1, 0);
|
|
346
|
+
const clamp = (rs: Array<[number, number]>, at: number, how: string) => (rs.length === 0 ? `none ${how} ${at}` : `${count(rs)} frame(s) ${how} ${at}, clamped (frames ${rs.map(span).join(', ')})`);
|
|
347
|
+
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')}`;
|
|
348
|
+
}
|