@rig-c/pose 0.0.0-stage → 0.1.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/src/run.ts ADDED
@@ -0,0 +1,164 @@
1
+ /**
2
+ * `rig-pose retarget`, end to end: read, gate the rig as it is, retarget, gate
3
+ * it with the animation in, measure where the rig landed, read the folds, and
4
+ * write — only when every check passed.
5
+ */
6
+ import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
7
+ import { tmpdir } from 'node:os';
8
+ import { dirname, join } from 'node:path';
9
+ import { readMap, resolveMap } from './map.ts';
10
+ import { convertCoco17, personTrack, readPoseSequence } from './pose.ts';
11
+ import { lineOf, Problems, Refusal, type Problem } from './problems.ts';
12
+ import { bendLine, holdLine, retarget, type RetargetOptions } from './retarget.ts';
13
+ import { baseMotionOf, folds, gate, readBuild, resolveRig, restOf } from './rigc.ts';
14
+ import { landed, measureLanding, measurePosture, poseFrames, postureLines, ANGLE_TOLERANCE, ORIGIN_TOLERANCE } from './verify.ts';
15
+
16
+ export interface RetargetArgs extends RetargetOptions {
17
+ rig: string;
18
+ map: string;
19
+ pose: string;
20
+ out: string;
21
+ name: string;
22
+ coco: boolean;
23
+ person?: string;
24
+ baseMotion?: string;
25
+ profile: 'spine' | 'spine-html';
26
+ /** Keep the gated build here. */
27
+ build?: string;
28
+ /** Keep rigc's render of the animation (frames, contact sheet, folds.json) here. */
29
+ render?: string;
30
+ /** Write the run's measurements as JSON here. */
31
+ report?: string;
32
+ }
33
+
34
+ export interface RunResult {
35
+ lines: string[];
36
+ written: boolean;
37
+ }
38
+
39
+ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunResult {
40
+ const lines: string[] = [];
41
+ const say = (l: string) => {
42
+ lines.push(l);
43
+ log(l);
44
+ };
45
+ const refuse = (problems: readonly Problem[]): never => {
46
+ throw new Refusal(problems);
47
+ };
48
+ const args = new Problems();
49
+ for (const [flag, path] of [['--map', a.map], ['--pose', a.pose]] as const) {
50
+ if (!existsSync(path)) args.add('ARG_PATH', `${flag} ${path} does not exist`);
51
+ }
52
+ // A file this run writes goes into a directory that is there already; --build and --render make theirs.
53
+ for (const [flag, path] of [['--out', a.out], ['--report', a.report]] as const) {
54
+ if (path === undefined) continue;
55
+ const dir = dirname(path);
56
+ if (!existsSync(dir)) args.add('ARG_PATH', `${flag} ${path} — directory ${dir} does not exist`);
57
+ else if (!statSync(dir).isDirectory()) args.add('ARG_PATH', `${flag} ${path} — ${dir} is not a directory`);
58
+ }
59
+ args.throwIfAny();
60
+ const input = resolveRig(a.rig, a.baseMotion);
61
+ const base = baseMotionOf(input);
62
+ const animations = base.animations as Record<string, unknown>;
63
+ 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` }]);
64
+ const map = readMap(readFileSync(a.map, 'utf8'), a.map);
65
+ const poseText = readFileSync(a.pose, 'utf8');
66
+ const seq = a.coco ? convertCoco17(poseText, a.pose) : readPoseSequence(poseText, a.pose);
67
+ const frames = personTrack(seq, a.person, a.pose);
68
+ const times = seq.frames.map((f) => f.t);
69
+
70
+ const scratch = mkdtempSync(join(tmpdir(), 'rig-pose-'));
71
+ try {
72
+ // The rig as it stands, through the gate, so the rest pose is rig-c's reading of a green build.
73
+ const g0 = gate(input.rig, base, join(scratch, 'base'), a.profile);
74
+ if (!g0.green) {
75
+ 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 }))]);
76
+ }
77
+ const rest = restOf(readBuild(g0.build));
78
+ resolveMap(map, rest, a.map);
79
+
80
+ const r = retarget(rest, map, frames, times, a);
81
+ 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}, bends ${a.bends} (tolerance ${a.bendTolerance}°)`);
82
+ 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)})`);
83
+ for (const h of r.holds) say(` HOLD ${holdLine(h, r.times)}`);
84
+ for (const e of r.bends) say(` BEND ${bendLine(e)} — clamped straight`);
85
+
86
+ const motion = { ...base, animations: { ...animations, [a.name]: r.animation } };
87
+ const g1 = gate(input.rig, motion, join(scratch, 'with'), a.profile);
88
+ if (!g1.green) {
89
+ 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 }))]);
90
+ }
91
+ say(` GATE rigc build --profile ${a.profile}: green${g1.summary === null ? '' : ` — ${g1.summary}`}`);
92
+
93
+ const doc = readBuild(g1.build);
94
+ const posed = poseFrames(doc, a.name, r.times);
95
+ const landings = measureLanding(doc, a.name, map, r, posed);
96
+ const p = new Problems();
97
+ for (const l of landings) {
98
+ const unit = l.kind === 'angle' ? '°' : ' units';
99
+ const tol = l.kind === 'angle' ? ANGLE_TOLERANCE : ORIGIN_TOLERANCE;
100
+ const line = `bone "${l.bone}" ${l.kind === 'angle' ? 'aim' : 'origin'} — 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}`;
101
+ if (landed(l)) say(` LAND ${line}`);
102
+ 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)`);
103
+ }
104
+ p.throwIfAny();
105
+
106
+ const posture = measurePosture(posed, new Map(doc.bones.map((b) => [b.name, b.length ?? 0])), map, r, frames, a.minScore);
107
+ if (posture !== null) for (const l of postureLines(posture)) say(` POSTURE ${l}`);
108
+
109
+ const dts = r.times.slice(1).map((t, i) => t - r.times[i]!).sort((x, y) => x - y);
110
+ const fps = dts.length === 0 ? 12 : Math.max(1, Math.min(60, Math.round(1 / dts[dts.length >> 1]!)));
111
+ const f = folds(g1.build, a.name, join(scratch, 'folds'), fps);
112
+ if (f.meshes === null) say(` FOLD rigc render --folds did not read (exit ${f.run.status}); folds not measured`);
113
+ else if (f.meshes.length === 0) say(` FOLD rigc render --folds at ${fps} fps: no mesh folds`);
114
+ else for (const m of f.meshes) say(` FOLD mesh "${m.attachment}" (slot "${m.slot}") folds on ${m.times.length} frame(s) at ${fps} fps: t=${spans(m.times, 1 / fps)}`);
115
+
116
+ writeFileSync(a.out, `${JSON.stringify(motion, null, 2)}\n`);
117
+ if (a.build !== undefined) {
118
+ rmSync(a.build, { recursive: true, force: true });
119
+ cpSync(g1.build, a.build, { recursive: true });
120
+ }
121
+ if (a.render !== undefined && f.meshes !== null) {
122
+ rmSync(a.render, { recursive: true, force: true });
123
+ cpSync(f.frames, a.render, { recursive: true });
124
+ }
125
+ if (a.report !== undefined) {
126
+ writeFileSync(a.report, `${JSON.stringify({
127
+ spec: 'rig-pose-report/1',
128
+ rig: input.name,
129
+ animation: a.name,
130
+ frames: seq.frames.length,
131
+ duration: r.animation.duration,
132
+ options: { minScore: a.minScore, smooth: a.smooth, bends: a.bends, bendTolerance: a.bendTolerance, profile: a.profile },
133
+ scale: r.scale,
134
+ holds: r.holds,
135
+ bends: r.bends,
136
+ gate: g1.summary,
137
+ landings,
138
+ posture,
139
+ folds: f.meshes,
140
+ }, null, 2)}\n`);
141
+ }
142
+ say(` wrote ${a.out} — animations: ${Object.keys(motion.animations).join(', ')}`);
143
+ return { lines, written: true };
144
+ } finally {
145
+ rmSync(scratch, { recursive: true, force: true });
146
+ }
147
+ }
148
+
149
+ function spans(ts: number[], step: number): string {
150
+ const out: string[] = [];
151
+ let s = ts[0]!;
152
+ let e = s;
153
+ for (const t of ts.slice(1)) {
154
+ if (t - e <= step * 1.5) e = t;
155
+ else {
156
+ out.push(s === e ? s.toFixed(3) : `${s.toFixed(3)}–${e.toFixed(3)}`);
157
+ s = e = t;
158
+ }
159
+ }
160
+ out.push(s === e ? s.toFixed(3) : `${s.toFixed(3)}–${e.toFixed(3)}`);
161
+ return out.join(', ');
162
+ }
163
+
164
+ export { lineOf };
package/src/verify.ts ADDED
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Whether the rig lands where the pose says, measured on the gated build by
3
+ * rig-c's own poser — not by the arithmetic that wrote the keys.
4
+ *
5
+ * The retarget's key formula holds for a bone that inherits rotation from its
6
+ * parent with unit scale and no shear, and no constraint pulling it. A rig
7
+ * outside that (an IK constraint on an arm, a scaled parent) is not refused by
8
+ * a guess about which rigs are safe; it is posed, and the distance between the
9
+ * aim each bone reached and the aim the pose asked for is the verdict.
10
+ */
11
+ import { poseRawAnimation, type CompiledDocument, type RawBone } from 'rig-c/core';
12
+ import type { Body18 } from './joints.ts';
13
+ import { aimAngle, pointAt, type JointMap } from './map.ts';
14
+ import type { Joint } from './pose.ts';
15
+ import type { Retarget } from './retarget.ts';
16
+ import { pictureAngle, pointOn, wrap } from './retarget.ts';
17
+
18
+ export interface Landing {
19
+ bone: string;
20
+ /** The largest |reached − asked| over the frames, degrees (rotate) or world units (translate). */
21
+ worst: number;
22
+ frame: number;
23
+ kind: 'angle' | 'origin';
24
+ }
25
+
26
+ /**
27
+ * Tolerances, from what the writer does to a value: a rotate key is rounded to
28
+ * 0.001° and a chain accumulates one rounding per link, rig-c stores numbers as
29
+ * float32 (≈ 1e-5° at these magnitudes), and a key time is stored float32
30
+ * rounded down; 0.01° and 0.01 world units sit an order above that sum for
31
+ * chains up to ten links.
32
+ */
33
+ export const ANGLE_TOLERANCE = 0.01;
34
+ export const ORIGIN_TOLERANCE = 0.01;
35
+
36
+ /** Every bone, world, on every key frame of the animation, by rig-c's poser. */
37
+ export function poseFrames(doc: CompiledDocument, animation: string, times: readonly number[]): Array<Map<string, RawBone>> {
38
+ const steps = times.slice(1).map((t, i) => t - times[i]!);
39
+ // Pose 0 is the animation applied at t = 0 only under reset 'animation' (render's walk); under 'setup' it is the setup pose (rig-c src/core/track.ts).
40
+ const poses = poseRawAnimation(doc, animation, steps, {}, 'animation');
41
+ return poses.map((p) => new Map(p.bones.map((b) => [b.name, b])));
42
+ }
43
+
44
+ export function measureLanding(doc: CompiledDocument, animation: string, map: JointMap, r: Retarget, posed = poseFrames(doc, animation, r.times)): Landing[] {
45
+ const out: Landing[] = [];
46
+ const lengths = new Map(doc.bones.map((b) => [b.name, b.length ?? 0]));
47
+ for (const c of map.chains) {
48
+ c.bones.forEach((b, k) => {
49
+ const aim = c.aim[k]!;
50
+ const asked = r.targets.get(b)!;
51
+ let worst = -1;
52
+ let frame = 0;
53
+ posed.forEach((bones, i) => {
54
+ const reached = aimAngle(bones, lengths, b, aim);
55
+ const e = Math.abs(wrap(reached - asked[i]!));
56
+ if (e > worst) {
57
+ worst = e;
58
+ frame = i;
59
+ }
60
+ });
61
+ out.push({ bone: b, worst, frame, kind: 'angle' });
62
+ });
63
+ }
64
+ if (r.origin !== null) {
65
+ let worst = -1;
66
+ let frame = 0;
67
+ posed.forEach((bones, i) => {
68
+ const o = bones.get(r.origin!.bone)!;
69
+ const [x, y] = r.origin!.at[i]!;
70
+ const e = Math.hypot(o.worldX - x, o.worldY - y);
71
+ if (e > worst) {
72
+ worst = e;
73
+ frame = i;
74
+ }
75
+ });
76
+ out.push({ bone: r.origin.bone, worst, frame, kind: 'origin' });
77
+ }
78
+ return out;
79
+ }
80
+
81
+ export function landed(l: Landing): boolean {
82
+ return l.worst <= (l.kind === 'angle' ? ANGLE_TOLERANCE : ORIGIN_TOLERANCE);
83
+ }
84
+
85
+ export interface Range {
86
+ min: number;
87
+ max: number;
88
+ range: number;
89
+ }
90
+ export interface Posture {
91
+ /** The chain read as the pelvis (joints r_hip → l_hip), and the rig points the reading used. */
92
+ pelvis: string;
93
+ rigHip: string;
94
+ rigShoulder: string | null;
95
+ /** Reference frames on which the hips (tilt) and the hips and shoulders (sway) are all good. */
96
+ frames: { tilt: number; sway: number; of: number };
97
+ /** Hip-line tilt, Spine degrees (r_hip → l_hip; the rig: the pelvis chain's aim). */
98
+ tilt: { reference: Range; rig: Range; ratio: number };
99
+ /**
100
+ * Sway: the x-range of mid_hip − mid_shoulder, in rig units (the reference's
101
+ * picture px × the figure scale) and in each body's own shoulder widths.
102
+ */
103
+ sway: {
104
+ reference: Range & { shoulderWidth: number; rangeSw: number };
105
+ rig: (Range & { shoulderWidth: number | null; rangeSw: number | null }) | null;
106
+ ratio: number | null;
107
+ ratioSw: number | null;
108
+ } | null;
109
+ }
110
+
111
+ function rangeOf(xs: number[]): Range {
112
+ let min = Infinity;
113
+ let max = -Infinity;
114
+ for (const x of xs) {
115
+ if (x < min) min = x;
116
+ if (x > max) max = x;
117
+ }
118
+ return { min, max, range: max - min };
119
+ }
120
+ const unwrapped = (xs: number[]): number[] => xs.map((x) => xs[0]! + wrap(x - xs[0]!));
121
+ const medianOf = (xs: number[]): number => {
122
+ const s = [...xs].sort((a, b) => a - b);
123
+ const m = s.length >> 1;
124
+ return s.length % 2 === 1 ? s[m]! : (s[m - 1]! + s[m]!) / 2;
125
+ };
126
+
127
+ /**
128
+ * The pelvis, the reference beside the rig. A reading, not a gate: the
129
+ * reference is the pose as read (no hold, no smoothing; a frame counts where
130
+ * the joints it needs are good), the rig is the gated build posed by rig-c.
131
+ * Null when the map has no pelvis chain (joints exactly r_hip, l_hip) or no
132
+ * translate.
133
+ */
134
+ export function measurePosture(posed: Array<Map<string, RawBone>>, lengths: ReadonlyMap<string, number>, map: JointMap, r: Retarget, frames: ReadonlyArray<Partial<Record<Body18, Joint>>>, minScore: number): Posture | null {
135
+ const pelvis = map.chains.find((c) => c.joints.length === 2 && c.joints[0] === 'r_hip' && c.joints[1] === 'l_hip');
136
+ if (pelvis === undefined || map.translate === null || r.origin === null || r.scale === null) return null;
137
+ const s = r.scale.factor;
138
+ const at = (i: number, j: Body18): [number, number] | null => {
139
+ const v = pointOn(frames[i]!, j, minScore);
140
+ return typeof v === 'string' ? null : v;
141
+ };
142
+ const refTilt: number[] = [];
143
+ const refSway: number[] = [];
144
+ const refSw: number[] = [];
145
+ frames.forEach((_, i) => {
146
+ const rh = at(i, 'r_hip');
147
+ const lh = at(i, 'l_hip');
148
+ if (rh === null || lh === null) return;
149
+ refTilt.push(pictureAngle(rh, lh));
150
+ const rs = at(i, 'r_shoulder');
151
+ const ls = at(i, 'l_shoulder');
152
+ if (rs === null || ls === null) return;
153
+ refSway.push(((rh[0] + lh[0]) / 2 - (rs[0] + ls[0]) / 2) * s);
154
+ refSw.push(Math.hypot(ls[0] - rs[0], ls[1] - rs[1]) * s);
155
+ });
156
+ const bone = pelvis.bones[0]!;
157
+ const aim = pelvis.aim[0]!;
158
+ const rigTilt = posed.map((b) => aimAngle(b, lengths, bone, aim));
159
+ const tilt = { reference: rangeOf(unwrapped(refTilt)), rig: rangeOf(unwrapped(rigTilt)), ratio: 0 };
160
+ tilt.ratio = tilt.rig.range / tilt.reference.range;
161
+
162
+ // The rig's mid-shoulder: the point a reach aims at, else the midpoint of the arms' first bones.
163
+ const reach = map.chains.find((c) => c.reach);
164
+ const first = (j: string) => map.chains.find((c) => c.joints[0] === j)?.bones[0];
165
+ const armR = first('r_shoulder');
166
+ const armL = first('l_shoulder');
167
+ const rigShoulder = reach !== undefined ? reach.aim[0]! : armR !== undefined && armL !== undefined ? `${armR}+${armL}` : null;
168
+ let sway: Posture['sway'] = null;
169
+ if (refSway.length > 0) {
170
+ const refR = rangeOf(refSway);
171
+ const refWidth = medianOf(refSw);
172
+ const reference = { ...refR, shoulderWidth: refWidth, rangeSw: refR.range / refWidth };
173
+ let rig: NonNullable<Posture['sway']>['rig'] = null;
174
+ if (rigShoulder !== null) {
175
+ const xs = posed.map((b) => b.get(r.origin!.bone)!.worldX - pointAt(b, lengths, rigShoulder)![0]);
176
+ const rr = rangeOf(xs);
177
+ const width = armR !== undefined && armL !== undefined ? medianOf(posed.map((b) => {
178
+ const p = pointAt(b, lengths, armR)!;
179
+ const q = pointAt(b, lengths, armL)!;
180
+ return Math.hypot(q[0] - p[0], q[1] - p[1]);
181
+ })) : null;
182
+ rig = { ...rr, shoulderWidth: width, rangeSw: width === null ? null : rr.range / width };
183
+ }
184
+ sway = {
185
+ reference,
186
+ rig,
187
+ ratio: rig === null ? null : rig.range / reference.range,
188
+ ratioSw: rig === null || rig.rangeSw === null ? null : rig.rangeSw / reference.rangeSw,
189
+ };
190
+ }
191
+ return { pelvis: pelvis.name, rigHip: r.origin.bone, rigShoulder, frames: { tilt: refTilt.length, sway: refSway.length, of: frames.length }, tilt, sway };
192
+ }
193
+
194
+ const f1 = (x: number) => x.toFixed(1);
195
+ const f3 = (x: number) => x.toFixed(3);
196
+ const sg = (x: number) => `${x >= 0 ? '+' : '−'}${Math.abs(x).toFixed(1)}`;
197
+
198
+ /** The POSTURE lines. */
199
+ export function postureLines(q: Posture): string[] {
200
+ const over = (k: number) => (k === q.frames.of ? '' : ` over ${k} of ${q.frames.of} frames`);
201
+ const out = [
202
+ `hip tilt range (chain "${q.pelvis}", r_hip → l_hip, Spine degrees): reference ${f1(q.tilt.reference.range)}° (${sg(q.tilt.reference.min)}° … ${sg(q.tilt.reference.max)}°${over(q.frames.tilt)}), rig ${f1(q.tilt.rig.range)}° (${sg(q.tilt.rig.min)}° … ${sg(q.tilt.rig.max)}°) — rig/reference ${f3(q.tilt.ratio)}`,
203
+ ];
204
+ const w = q.sway;
205
+ if (w === null) out.push('sway: the reference has no frame with both hips and both shoulders good; not measured');
206
+ else {
207
+ const ref = `reference ${f1(w.reference.range)} units = picture px × scale (${f3(w.reference.rangeSw)} shoulder widths${over(q.frames.sway)})`;
208
+ if (w.rig === null) out.push(`sway, x-range of mid_hip − mid_shoulder: ${ref}; the rig has no mid-shoulder to read (no reach, no chains from r_shoulder and l_shoulder)`);
209
+ else {
210
+ const sw = w.rig.rangeSw === null ? 'no shoulder width' : `${f3(w.rig.rangeSw)} shoulder widths`;
211
+ const ratioSw = w.ratioSw === null ? '' : `, ${f3(w.ratioSw)} in shoulder widths`;
212
+ 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}`);
213
+ }
214
+ }
215
+ return out;
216
+ }