@rig-c/pose 0.8.0 → 0.9.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 +33 -5
- package/cli.ts +29 -1
- package/package.json +1 -1
- package/src/legs.ts +488 -0
- package/src/pose.ts +17 -4
- package/src/retarget.ts +193 -18
- package/src/rigc.ts +13 -4
- package/src/run.ts +97 -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/rigc.ts
CHANGED
|
@@ -96,11 +96,11 @@ export interface Gate {
|
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
/** Build `rig` with `motion` under rigc's gate into `dir/build`; nothing is read from a red build. */
|
|
99
|
-
export function gate(rig: string, motion: Record<string, unknown>, dir: string, profile: string): Gate {
|
|
99
|
+
export function gate(rig: string, motion: Record<string, unknown>, dir: string, profile: string, images?: string): Gate {
|
|
100
100
|
mkdirSync(dir, { recursive: true });
|
|
101
101
|
const motionPath = join(dir, 'motion.json');
|
|
102
102
|
writeFileSync(motionPath, `${JSON.stringify(motion, null, 2)}\n`);
|
|
103
|
-
return buildInto(rig, motionPath, join(dir, 'build'), profile, join(dir, 'build-report.json'));
|
|
103
|
+
return buildInto(rig, motionPath, join(dir, 'build'), profile, join(dir, 'build-report.json'), images);
|
|
104
104
|
}
|
|
105
105
|
|
|
106
106
|
/**
|
|
@@ -112,8 +112,8 @@ export function gate(rig: string, motion: Record<string, unknown>, dir: string,
|
|
|
112
112
|
* never copied out, which would carry a path climbing from the scratch
|
|
113
113
|
* directory to the filesystem root. --copy-images puts the pages beside it.
|
|
114
114
|
*/
|
|
115
|
-
export function buildInto(rig: string, motionPath: string, out: string, profile: string, report?: string): Gate {
|
|
116
|
-
const run = runRigc(['build', '--rig', rig, '--motion', motionPath, '--out', out, '--profile', profile, '--copy-images', ...(report === undefined ? [] : ['--report', report])]);
|
|
115
|
+
export function buildInto(rig: string, motionPath: string, out: string, profile: string, report?: string, images?: string): Gate {
|
|
116
|
+
const run = runRigc(['build', '--rig', rig, '--motion', motionPath, '--out', out, '--profile', profile, '--copy-images', ...(images === undefined ? [] : ['--images', images]), ...(report === undefined ? [] : ['--report', report])]);
|
|
117
117
|
const lines = run.out.split('\n');
|
|
118
118
|
return {
|
|
119
119
|
run,
|
|
@@ -196,3 +196,12 @@ export function folds(build: string, animation: string, dir: string, fps: number
|
|
|
196
196
|
frames: setDir,
|
|
197
197
|
};
|
|
198
198
|
}
|
|
199
|
+
|
|
200
|
+
/** rig-c's version and the entry its launcher picked (`rigc --version`), so a run names the compiler that gated it. */
|
|
201
|
+
export function rigcVersion(): { version: string; entry: string } {
|
|
202
|
+
const r = runRigc(['--version']);
|
|
203
|
+
const lines = r.out.split('\n').map((l) => l.trim()).filter((l) => l !== '');
|
|
204
|
+
const version = lines.find((l) => /^\d+\.\d+\.\d+/.test(l)) ?? lines[0] ?? 'unknown';
|
|
205
|
+
const entry = lines.find((l) => l.startsWith('entry:'))?.slice('entry:'.length).trim() ?? 'not stated';
|
|
206
|
+
return { version, entry };
|
|
207
|
+
}
|
package/src/run.ts
CHANGED
|
@@ -7,13 +7,15 @@
|
|
|
7
7
|
import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
8
8
|
import { tmpdir } from 'node:os';
|
|
9
9
|
import { dirname, join, resolve } from 'node:path';
|
|
10
|
-
import { readMap, resolveMap } from './map.ts';
|
|
10
|
+
import { readMap, resolveMap, type JointMap } from './map.ts';
|
|
11
11
|
import { convertCoco17, personTrack, readPoseSequence } from './pose.ts';
|
|
12
12
|
import { lineOf, Problems, Refusal, type Problem } from './problems.ts';
|
|
13
13
|
import { bendLine, endOnLine, flipLine, holdLine, rangeLine, retarget, shortLine, turnLine, type RetargetOptions } from './retarget.ts';
|
|
14
|
-
import { attachmentBones, baseMotionOf, buildInto, folds, gate, readBuild, resolveRig, restOf, slotsOf } from './rigc.ts';
|
|
14
|
+
import { attachmentBones, baseMotionOf, buildInto, rigcVersion, folds, gate, readBuild, resolveRig, restOf, slotsOf } from './rigc.ts';
|
|
15
15
|
import { artRangeLine, chainsMoving, switchLine, uncoveredFolds } from './switch.ts';
|
|
16
|
-
import { landed, measureLanding, measurePosture, poseFrames, postureLines
|
|
16
|
+
import { landed, measureFeet, measureLanding, measurePosture, measureShear, poseFrames, postureLines } from './verify.ts';
|
|
17
|
+
import { legsOf } from './legs.ts';
|
|
18
|
+
import type { LegScale } from './legs.ts';
|
|
17
19
|
|
|
18
20
|
export interface RetargetArgs extends RetargetOptions {
|
|
19
21
|
rig: string;
|
|
@@ -24,6 +26,8 @@ export interface RetargetArgs extends RetargetOptions {
|
|
|
24
26
|
coco: boolean;
|
|
25
27
|
person?: string;
|
|
26
28
|
baseMotion?: string;
|
|
29
|
+
/** Passed to rigc build as --images: the directory the rig spec's images resolve in, when rig.json's own `images` does not point there (a copied rig). */
|
|
30
|
+
images?: string;
|
|
27
31
|
profile: 'spine' | 'spine-html';
|
|
28
32
|
/** Keep the gated build here. */
|
|
29
33
|
build?: string;
|
|
@@ -72,17 +76,19 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
72
76
|
const scratch = mkdtempSync(join(tmpdir(), 'rig-pose-'));
|
|
73
77
|
try {
|
|
74
78
|
// The rig as it stands, through the gate, so the rest pose is rig-c's reading of a green build.
|
|
75
|
-
const g0 = gate(input.rig, base, join(scratch, 'base'), a.profile);
|
|
79
|
+
const g0 = gate(input.rig, base, join(scratch, 'base'), a.profile, a.images);
|
|
76
80
|
if (!g0.green) {
|
|
77
81
|
refuse([{ code: 'RIG_GATE_BASE', text: `${input.rig} with ${input.motion ?? 'no motion spec'} is red under rigc build --profile ${a.profile} before any animation is added (exit ${g0.run.status})` }, ...g0.fails.map((f) => ({ code: 'RIGC', text: f }))]);
|
|
78
82
|
}
|
|
79
83
|
const doc0 = readBuild(g0.build);
|
|
84
|
+
const rigc = rigcVersion();
|
|
80
85
|
const rest = restOf(doc0);
|
|
81
86
|
const slots = slotsOf(doc0);
|
|
82
87
|
resolveMap(map, rest, a.map, slots);
|
|
83
88
|
|
|
84
89
|
const r = retarget(rest, map, frames, times, a, slots);
|
|
85
|
-
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}°)`);
|
|
90
|
+
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' : ''}`);
|
|
91
|
+
say(` RIGC rig-c ${rigc.version} (${rigc.entry}) gates and poses this run`);
|
|
86
92
|
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)})`);
|
|
87
93
|
for (const h of r.holds) say(` HOLD ${holdLine(h, r.times)}`);
|
|
88
94
|
if (a.short !== 'off') {
|
|
@@ -96,12 +102,23 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
96
102
|
if (map.alternates.length > 0) {
|
|
97
103
|
if (r.switches.length === 0) say(` SWITCH none — no set within ${a.switchTolerance}° of the pose for ${a.switchMin}s`);
|
|
98
104
|
for (const s of r.switches) say(` SWITCH ${switchLine(s, r.times)}`);
|
|
105
|
+
for (const l of windowLines(map, r.switches, r.distances, r.times, a.switchTolerance)) say(` FRAMES ${l}`);
|
|
99
106
|
}
|
|
100
107
|
for (const e of r.range) say(` RANGE ${rangeLine(e, r.times)}`);
|
|
101
108
|
for (const e of r.artRange) say(` ART-RANGE ${artRangeLine(e, r.times)}`);
|
|
109
|
+
for (const l of r.legScale) say(` LEG-SCALE ${legScaleLine(l, r.times)}`);
|
|
110
|
+
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`);
|
|
111
|
+
if (r.feet !== null) {
|
|
112
|
+
const f = r.feet;
|
|
113
|
+
const span = (x: [number, number]) => (x[0] === x[1] ? `${x[0]}` : `${x[0]}–${x[1]}`);
|
|
114
|
+
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)`);
|
|
115
|
+
say(` FEET supports: ${f.supports.map((x) => `${x.side} ${span(x.frames)}`).join(', ') || 'none'}`);
|
|
116
|
+
say(` FEET jumps (both ankles and mid_hip > 0.05 leg lengths above their rolling floor, ≥ 2 frames): ${f.jumps.map(span).join(', ') || 'none'}`);
|
|
117
|
+
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'}`);
|
|
118
|
+
}
|
|
102
119
|
|
|
103
120
|
const motion = { ...base, animations: { ...animations, [a.name]: r.animation } };
|
|
104
|
-
const g1 = gate(input.rig, motion, join(scratch, 'with'), a.profile);
|
|
121
|
+
const g1 = gate(input.rig, motion, join(scratch, 'with'), a.profile, a.images);
|
|
105
122
|
if (!g1.green) {
|
|
106
123
|
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 }))]);
|
|
107
124
|
}
|
|
@@ -112,16 +129,30 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
112
129
|
const landings = measureLanding(doc, a.name, map, r, posed);
|
|
113
130
|
const p = new Problems();
|
|
114
131
|
for (const l of landings) {
|
|
115
|
-
const unit = l.kind === 'angle' ? '°' : ' units';
|
|
116
|
-
const
|
|
117
|
-
const
|
|
132
|
+
const unit = l.kind === 'angle' ? '°' : l.kind === 'scale' ? ' of its setup length' : ' units';
|
|
133
|
+
const what = l.kind === 'angle' ? 'aim' : l.kind === 'scale' ? 'scale' : l.kind === 'foot' ? 'ankle' : 'origin';
|
|
134
|
+
const tol = l.kind === 'foot' ? Number(l.tolerance.toPrecision(3)) : l.tolerance;
|
|
135
|
+
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}`;
|
|
118
136
|
if (landed(l)) say(` LAND ${line}`);
|
|
119
137
|
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)`);
|
|
120
138
|
}
|
|
121
139
|
p.throwIfAny();
|
|
122
140
|
|
|
123
|
-
const
|
|
141
|
+
const lengths = new Map(doc.bones.map((b) => [b.name, b.length ?? 0]));
|
|
142
|
+
const posture = measurePosture(posed, lengths, map, r, frames, a.minScore);
|
|
124
143
|
if (posture !== null) for (const l of postureLines(posture)) say(` POSTURE ${l}`);
|
|
144
|
+
const legBones = legBonesOf(map);
|
|
145
|
+
const feetReading = legBones === null ? null : measureFeet(posed, lengths, r, legBones);
|
|
146
|
+
if (feetReading !== null) {
|
|
147
|
+
const f = feetReading;
|
|
148
|
+
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`);
|
|
149
|
+
const worst = (k: 'verticalLegs' | 'horizontalLegs') => f.plants.reduce<(typeof f.plants)[number] | null>((m, x) => (m === null || x[k] > m[k] ? x : m), null);
|
|
150
|
+
const wv = worst('verticalLegs');
|
|
151
|
+
const wh = worst('horizontalLegs');
|
|
152
|
+
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]})`);
|
|
153
|
+
}
|
|
154
|
+
const shear = r.scales.size === 0 ? [] : measureShear(doc, posed, r.scales.keys());
|
|
155
|
+
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`);
|
|
125
156
|
|
|
126
157
|
const dts = r.times.slice(1).map((t, i) => t - r.times[i]!).sort((x, y) => x - y);
|
|
127
158
|
const fps = dts.length === 0 ? 12 : Math.max(1, Math.min(60, Math.round(1 / dts[dts.length >> 1]!)));
|
|
@@ -139,7 +170,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
139
170
|
if (a.build !== undefined) {
|
|
140
171
|
// Built again where it will live, not copied: rigc's page paths are relative to the build (#18).
|
|
141
172
|
rmSync(a.build, { recursive: true, force: true });
|
|
142
|
-
const kept = buildInto(input.rig, join(scratch, 'with', 'motion.json'), resolve(a.build), a.profile);
|
|
173
|
+
const kept = buildInto(input.rig, join(scratch, 'with', 'motion.json'), resolve(a.build), a.profile, undefined, a.images);
|
|
143
174
|
if (!kept.green) refuse([{ code: 'RIG_GATE', text: `rigc build into --build ${a.build} is red where the same build was green in scratch (exit ${kept.run.status}); nothing written` }, ...kept.fails.map((x) => ({ code: 'RIGC', text: x }))]);
|
|
144
175
|
}
|
|
145
176
|
writeFileSync(a.out, `${JSON.stringify(motion, null, 2)}\n`);
|
|
@@ -150,11 +181,12 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
150
181
|
if (a.report !== undefined) {
|
|
151
182
|
writeFileSync(a.report, `${JSON.stringify({
|
|
152
183
|
spec: 'rig-pose-report/1',
|
|
184
|
+
rigc,
|
|
153
185
|
rig: input.name,
|
|
154
186
|
animation: a.name,
|
|
155
187
|
frames: seq.frames.length,
|
|
156
188
|
duration: r.animation.duration,
|
|
157
|
-
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 },
|
|
189
|
+
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 },
|
|
158
190
|
scale: r.scale,
|
|
159
191
|
holds: r.holds,
|
|
160
192
|
short: r.short,
|
|
@@ -170,6 +202,10 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
170
202
|
turns: r.turns,
|
|
171
203
|
range: r.range,
|
|
172
204
|
artRange: r.artRange,
|
|
205
|
+
legScale: r.legScale.map(({ scale: _scale, ...rest }) => rest),
|
|
206
|
+
pivot: r.pivot,
|
|
207
|
+
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 },
|
|
208
|
+
shear,
|
|
173
209
|
}, null, 2)}\n`);
|
|
174
210
|
}
|
|
175
211
|
say(` wrote ${a.out} — animations: ${Object.keys(motion.animations).join(', ')}`);
|
|
@@ -195,3 +231,52 @@ function spans(ts: number[], step: number): string {
|
|
|
195
231
|
}
|
|
196
232
|
|
|
197
233
|
export { lineOf };
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* For each set with `frames` windows: every stretch of a window the set did not
|
|
237
|
+
* show on, and why — the pose there was past the tolerance (with the largest
|
|
238
|
+
* distance), or within it but the run was dropped (shorter than --switch-min, or
|
|
239
|
+
* taken by another set's span). A window never forces a set; this says so.
|
|
240
|
+
*/
|
|
241
|
+
function windowLines(map: JointMap, switches: ReadonlyArray<{ set: string; frames: [number, number] }>, distances: ReadonlyMap<string, readonly number[]>, times: readonly number[], tolerance: number): string[] {
|
|
242
|
+
const out: string[] = [];
|
|
243
|
+
for (const set of map.alternates) {
|
|
244
|
+
if (set.frames === null) continue;
|
|
245
|
+
const shown = new Set<number>();
|
|
246
|
+
for (const sw of switches) if (sw.set === set.name) for (let i = sw.frames[0]; i <= sw.frames[1]; i++) shown.add(i);
|
|
247
|
+
const d = distances.get(set.name)!;
|
|
248
|
+
for (const [first, last] of set.frames) {
|
|
249
|
+
const lastIn = Math.min(last, times.length - 1);
|
|
250
|
+
const parts: string[] = [];
|
|
251
|
+
for (let i = first; i <= lastIn;) {
|
|
252
|
+
if (shown.has(i)) { i++; continue; }
|
|
253
|
+
let j = i;
|
|
254
|
+
while (j + 1 <= lastIn && !shown.has(j + 1)) j++;
|
|
255
|
+
const ds = d.slice(i, j + 1);
|
|
256
|
+
const worst = Math.max(...ds);
|
|
257
|
+
const over = ds.filter((x) => x > tolerance).length;
|
|
258
|
+
const span = i === j ? `frame ${i}` : `frames ${i}–${j}`;
|
|
259
|
+
parts.push(over === ds.length ? `${span} outside the tolerance (up to ${worst.toFixed(1)}° > ${tolerance}°)` : over === 0 ? `${span} within the tolerance but not shown (a run shorter than --switch-min, or another set's span)` : `${span} partly outside the tolerance (up to ${worst.toFixed(1)}°)`);
|
|
260
|
+
i = j + 1;
|
|
261
|
+
}
|
|
262
|
+
const head = `set "${set.name}" window ${first}–${last}`;
|
|
263
|
+
out.push(parts.length === 0 ? `${head}: shown on every frame` : `${head}: ${parts.join('; ')} — a window restricts a set, it never forces one`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return out;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Each leg's first bone (its root, the thigh) and last bone (whose tip is the ankle), when the map has both. */
|
|
270
|
+
function legBonesOf(map: JointMap): { r: { root: string; ankle: string }; l: { root: string; ankle: string } } | null {
|
|
271
|
+
const l = legsOf(map, 'reading the feet');
|
|
272
|
+
if ('problems' in l) return null;
|
|
273
|
+
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 } };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** A LEG-SCALE line: the segment, its straight length, its ratio's spread, and the frames clamped. */
|
|
277
|
+
function legScaleLine(l: LegScale, times: readonly number[]): string {
|
|
278
|
+
const span = (x: [number, number]) => (x[0] === x[1] ? `${x[0]}` : `${x[0]}–${x[1]}`);
|
|
279
|
+
const count = (rs: Array<[number, number]>) => rs.reduce((k, x) => k + x[1] - x[0] + 1, 0);
|
|
280
|
+
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(', ')})`);
|
|
281
|
+
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')}`;
|
|
282
|
+
}
|