@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/src/legs.ts ADDED
@@ -0,0 +1,488 @@
1
+ /**
2
+ * The legs (rig-c/pose#47): foreshortening by length, and the hip derived from
3
+ * the supporting foot. Pure — no file, no clock, no process.
4
+ *
5
+ * **Leg scale.** A knee bending toward the camera is seen from the front only
6
+ * as a shorter leg. Each mapped leg segment (two of one side's hip, knee and
7
+ * ankle) is scaled along its own x axis (`scalex`): its picture length on the
8
+ * frame over its *straight length*, the 95th percentile of its picture length
9
+ * over the sequence, clamped to a declared range. The frames clamped are listed.
10
+ *
11
+ * **A scaled parent turns its child.** rig-c's `scalex` multiplies the bone's
12
+ * x axis, and a child with the `normal` inherit mode takes the parent's whole
13
+ * world matrix — so under a thigh scaled by s, a shin at angle φ in the thigh's
14
+ * axes points at atan2(sin φ, s·cos φ), not φ, and its own length is scaled by
15
+ * |(s·cos φ, sin φ)|. The rotate key `turn − turn above` assumes unit scale. A
16
+ * mapped bone below a scaled bone is therefore keyed through its parent's
17
+ * posed matrix (`posePath`): with rig-c's semantics, a `rotate` key k and a
18
+ * `scalex` key s' make the bone's local matrix R(k) · L · diag(s', 1), L its
19
+ * setup local matrix (from rig-c's setup world matrices), so k and s' are the
20
+ * ones that put its world x axis on the target angle at the asked length. Its
21
+ * art is still sheared by the parent's scale; that is measured, not hidden
22
+ * (`shear` in the report).
23
+ *
24
+ * **Planted feet.** The hip is no longer the pose's mid_hip travel scaled. On
25
+ * every frame one foot supports: the one whose reference ankle is lower in the
26
+ * picture, changing only when the other ankle has been ≥ `PLANT_SWITCH` leg
27
+ * lengths lower for ≥ `PLANT_SWITCH_SECONDS`. The supporting foot is held where
28
+ * it was on the frame it took support, and the hip is placed so it stays there,
29
+ * through the posed (scaled) legs. A change of support blends the two
30
+ * derivations over `PLANT_BLEND` frames. A jump — both ankles and mid_hip more
31
+ * than `JUMP_RISE` leg lengths above their rolling floor (the 95th-percentile
32
+ * picture y over ± `FLOOR_SECONDS`), for ≥ `JUMP_FRAMES` frames — releases the
33
+ * plant: the hip follows the pose's travel from where it was, and re-plants on
34
+ * landing. A step does too: while the supporting ankle moves more than
35
+ * `STEP_SPEED` leg lengths a frame its plant is released, the other foot takes
36
+ * support if it is planted (not released, not moving) and otherwise the hip
37
+ * follows the pose; the released ankle may support again once it has moved
38
+ * less than `STILL_SPEED` for `STILL_FRAMES` frames (rig-c/pose#47, round 2).
39
+ * The floor is rolling because an ankle's picture y drifts with depth: under
40
+ * one floor for the sequence, HQ's dancer standing still read as jumping.
41
+ */
42
+ import type { Chain, JointMap, Rest } from './map.ts';
43
+
44
+ type Pt = [number, number];
45
+ /** A world 2×2: columns (a, c) — the x axis — and (b, d) — the y axis. */
46
+ export type M2 = [number, number, number, number];
47
+
48
+ const DEG = 180 / Math.PI;
49
+
50
+ /** A change of support needs the other ankle this many leg lengths lower … */
51
+ export const PLANT_SWITCH = 0.02;
52
+ /** … for at least this long, in seconds (first frame's time to the end of the last, as END-ON times a run). */
53
+ export const PLANT_SWITCH_SECONDS = 0.1;
54
+ /** A change of support (and a landing) blends the hip over this many frames. */
55
+ export const PLANT_BLEND = 3;
56
+ /** Both ankles and mid_hip more than this many leg lengths above their rolling floors: a jump. */
57
+ export const JUMP_RISE = 0.05;
58
+ /** A rolling floor: this percentile of the picture y around the frame. */
59
+ export const FLOOR_PERCENTILE = 0.95;
60
+ /** A segment's straight length: this percentile of its picture length over the sequence. */
61
+ export const STRAIGHT_PERCENTILE = 0.95;
62
+
63
+ export type Side = 'r' | 'l';
64
+ export const SIDES: readonly Side[] = ['r', 'l'];
65
+
66
+ /** A segment of one side's hip, knee and ankle. */
67
+ export function isLegSegment(a: string, b: string): boolean {
68
+ const side = (x: string) => (x.startsWith('r_') ? 'r' : x.startsWith('l_') ? 'l' : null);
69
+ if (a === b || side(a) === null || side(a) !== side(b)) return false;
70
+ const LEG = ['hip', 'knee', 'ankle'];
71
+ return LEG.includes(a.slice(2)) && LEG.includes(b.slice(2));
72
+ }
73
+
74
+ /** Linear-interpolated percentile (numpy's default) of the finite values. */
75
+ export function percentile(xs: readonly number[], q: number): number {
76
+ const s = xs.filter((x) => Number.isFinite(x)).sort((a, b) => a - b);
77
+ if (s.length === 0) return NaN;
78
+ const pos = q * (s.length - 1);
79
+ const lo = Math.floor(pos);
80
+ const hi = Math.min(s.length - 1, lo + 1);
81
+ return s[lo]! + (s[hi]! - s[lo]!) * (pos - lo);
82
+ }
83
+
84
+ export interface Leg {
85
+ side: Side;
86
+ chain: Chain;
87
+ /** The chain's last bone: its x axis ends at the ankle (`<bone>:tip`). */
88
+ ankleBone: string;
89
+ }
90
+
91
+ /**
92
+ * Each side's leg: the (non-reach) chain whose last joint is that side's
93
+ * ankle. Every refusal names what it found.
94
+ */
95
+ export function legsOf(map: JointMap, why: string): { legs: Record<Side, Leg> } | { problems: string[] } {
96
+ const problems: string[] = [];
97
+ const legs: Partial<Record<Side, Leg>> = {};
98
+ for (const side of SIDES) {
99
+ const ankle = `${side}_ankle`;
100
+ const found = map.chains.filter((c) => !c.reach && c.joints[c.joints.length - 1] === ankle);
101
+ if (found.length !== 1) {
102
+ problems.push(`${why} needs one chain ending at "${ankle}" (its last bone's x axis ends at the ankle); the map has ${found.length === 0 ? 'none' : found.map((c) => `"${c.name}"`).join(', ')}`);
103
+ continue;
104
+ }
105
+ const chain = found[0]!;
106
+ const k = chain.bones.length - 1;
107
+ if (chain.aim[k] !== 'x') problems.push(`${why}: chain "${chain.name}" ends at "${ankle}" with bone "${chain.bones[k]}" aimed ${JSON.stringify(chain.aim[k])}; the ankle is read as that bone's tip, so it must be aimed along its own x axis ("x")`);
108
+ legs[side] = { side, chain, ankleBone: chain.bones[k]! };
109
+ }
110
+ return problems.length > 0 ? { problems } : { legs: legs as Record<Side, Leg> };
111
+ }
112
+
113
+ // --- 2×2 -------------------------------------------------------------------
114
+
115
+ const mul = (p: M2, q: M2): M2 => [p[0] * q[0] + p[1] * q[2], p[0] * q[1] + p[1] * q[3], p[2] * q[0] + p[3] * q[2], p[2] * q[1] + p[3] * q[3]];
116
+ const inv = (m: M2): M2 => {
117
+ const det = m[0] * m[3] - m[1] * m[2];
118
+ return [m[3] / det, -m[1] / det, -m[2] / det, m[0] / det];
119
+ };
120
+ const apply = (m: M2, v: Pt): Pt => [m[0] * v[0] + m[1] * v[1], m[2] * v[0] + m[3] * v[1]];
121
+ const rot = (deg: number): M2 => {
122
+ const r = deg / DEG;
123
+ return [Math.cos(r), -Math.sin(r), Math.sin(r), Math.cos(r)];
124
+ };
125
+ const scaleX = (s: number): M2 => [s, 0, 0, 1];
126
+ export const restMatrix = (rest: Rest, bone: string): M2 => {
127
+ const b = rest.get(bone)!;
128
+ return [b.a, b.b, b.c, b.d];
129
+ };
130
+
131
+ /**
132
+ * How one mapped bone is posed on a frame: its world turn from setup (the
133
+ * target less the rest angle), its target (the world angle of its aim), whether
134
+ * its aim is its own x axis, and the scale asked of its x axis (1: none).
135
+ */
136
+ export interface Posing {
137
+ turn: number;
138
+ target: number;
139
+ aimX: boolean;
140
+ }
141
+
142
+ export interface PathPose {
143
+ /** Each bone's posed origin, relative to the first bone's origin. */
144
+ origin: Map<string, Pt>;
145
+ /** Each bone's posed world 2×2. */
146
+ m: Map<string, M2>;
147
+ /** For a mapped bone under a scaled bone: the rotate key (degrees, wrapped) and the scalex key that land it. */
148
+ solved: Map<string, { rotate: number; scale: number }>;
149
+ }
150
+
151
+ /**
152
+ * Pose a path of bones, parents first, the first the path's top (its origin is
153
+ * (0, 0)). A mapped bone with no scaled bone above it turns rigidly from setup
154
+ * (R(turn) · setup world · diag(scale, 1)); a mapped bone under a scaled bone
155
+ * is solved through its parent's posed matrix (the header); an unmapped bone
156
+ * keeps its setup local matrix. Every setup matrix and origin is rig-c's.
157
+ */
158
+ export function posePath(rest: Rest, path: readonly string[], posing: (b: string) => Posing | null, scaleOf: (b: string) => number | null): PathPose | string {
159
+ const origin = new Map<string, Pt>();
160
+ const m = new Map<string, M2>();
161
+ const solved = new Map<string, { rotate: number; scale: number }>();
162
+ const scaledAbove = new Set<string>();
163
+ for (const b of path) {
164
+ const rb = rest.get(b)!;
165
+ const W0 = restMatrix(rest, b);
166
+ const pz = posing(b);
167
+ const s = scaleOf(b) ?? 1;
168
+ const p = rb.parent;
169
+ const top = origin.size === 0;
170
+ if (top || p === null || !m.has(p)) {
171
+ if (!top) return `bone "${b}" is on the path but its parent "${p}" is not`;
172
+ origin.set(b, [0, 0]);
173
+ m.set(b, mul(mul(rot(pz?.turn ?? 0), W0), scaleX(s)));
174
+ if (scaleOf(b) !== null) scaledAbove.add(b);
175
+ continue;
176
+ }
177
+ const Wp0 = restMatrix(rest, p);
178
+ const Wp = m.get(p)!;
179
+ const rp = rest.get(p)!;
180
+ const local = apply(inv(Wp0), [rb.worldX - rp.worldX, rb.worldY - rp.worldY]);
181
+ const po = origin.get(p)!;
182
+ const o = apply(Wp, local);
183
+ origin.set(b, [po[0] + o[0], po[1] + o[1]]);
184
+ const L0 = mul(inv(Wp0), W0);
185
+ const under = scaledAbove.has(p);
186
+ if (pz === null) m.set(b, mul(Wp, L0));
187
+ else if (!under) m.set(b, mul(mul(rot(pz.turn), W0), scaleX(s)));
188
+ else {
189
+ if (!pz.aimX) return `bone "${b}" is mapped under a scaled bone and aimed other than along its x axis; only an "x" aim is solved through a scaled parent`;
190
+ const len0 = Math.hypot(W0[0], W0[2]);
191
+ const X: Pt = [s * len0 * Math.cos(pz.target / DEG), s * len0 * Math.sin(pz.target / DEG)];
192
+ const v = apply(inv(Wp), X);
193
+ const l0: Pt = [L0[0], L0[2]];
194
+ const k = (Math.atan2(v[1], v[0]) - Math.atan2(l0[1], l0[0])) * DEG;
195
+ const sk = Math.hypot(v[0], v[1]) / Math.hypot(l0[0], l0[1]);
196
+ m.set(b, mul(mul(mul(Wp, rot(k)), L0), scaleX(sk)));
197
+ solved.set(b, { rotate: k, scale: sk });
198
+ }
199
+ if (under || scaleOf(b) !== null) scaledAbove.add(b);
200
+ }
201
+ return { origin, m, solved };
202
+ }
203
+
204
+ /** Where `bone`'s x axis ends on a posed path: its origin plus its world x column times its length. */
205
+ export function tipOf(rest: Rest, pose: PathPose, bone: string): Pt {
206
+ const o = pose.origin.get(bone)!;
207
+ const w = pose.m.get(bone)!;
208
+ const len = rest.get(bone)!.length;
209
+ return [o[0] + w[0] * len, o[1] + w[2] * len];
210
+ }
211
+
212
+ /** The bones from `top` down to each of `ends` (inclusive), parents first; null when an end does not descend from top. */
213
+ export function pathFrom(rest: Rest, top: string, ends: readonly string[]): string[] | null {
214
+ const set = new Set<string>([top]);
215
+ for (const e of ends) {
216
+ const up: string[] = [];
217
+ let b: string | null = e;
218
+ while (b !== null && b !== top) {
219
+ up.push(b);
220
+ b = rest.get(b)!.parent;
221
+ }
222
+ if (b === null) return null;
223
+ for (const x of up) set.add(x);
224
+ }
225
+ const depth = (b: string): number => {
226
+ let d = 0;
227
+ for (let a = rest.get(b)!.parent; a !== null; a = rest.get(a)!.parent) d++;
228
+ return d;
229
+ };
230
+ return [...set].sort((x, y) => depth(x) - depth(y) || (x < y ? -1 : x > y ? 1 : 0));
231
+ }
232
+
233
+ /** The root of `b`'s ancestry. */
234
+ export function rootOf(rest: Rest, b: string): string {
235
+ let x = b;
236
+ for (let p = rest.get(x)!.parent; p !== null; p = rest.get(x)!.parent) x = p;
237
+ return x;
238
+ }
239
+
240
+ // --- leg scale -------------------------------------------------------------
241
+
242
+ export interface LegScale {
243
+ chain: string;
244
+ bone: string;
245
+ joints: [string, string];
246
+ /** The straight length: the 95th percentile of the segment's picture length, px. */
247
+ straight: number;
248
+ /** The picture length over the straight length, before the clamp: lowest, median, highest. */
249
+ ratio: { min: number; median: number; max: number };
250
+ /** The range the scale is clamped to. */
251
+ limits: [number, number];
252
+ /** Runs of frames clamped at the low and at the high limit. */
253
+ low: Array<[number, number]>;
254
+ high: Array<[number, number]>;
255
+ /** The scale keyed on each frame (clamped). */
256
+ scale: number[];
257
+ }
258
+
259
+ /** One segment's scale per frame: picture length ÷ its 95th percentile, clamped; and the runs clamped. */
260
+ export function legScaleOf(lengths: readonly number[], limits: [number, number]): Omit<LegScale, 'chain' | 'bone' | 'joints'> {
261
+ const straight = percentile(lengths, STRAIGHT_PERCENTILE);
262
+ const ratio = lengths.map((l) => l / straight);
263
+ const scale = ratio.map((r) => Math.min(Math.max(r, limits[0]), limits[1]));
264
+ const sorted = [...ratio].sort((a, b) => a - b);
265
+ const mid = sorted.length >> 1;
266
+ const median = sorted.length % 2 === 1 ? sorted[mid]! : (sorted[mid - 1]! + sorted[mid]!) / 2;
267
+ return {
268
+ straight,
269
+ ratio: { min: sorted[0]!, median, max: sorted[sorted.length - 1]! },
270
+ limits,
271
+ low: runs(ratio.map((r) => r < limits[0])),
272
+ high: runs(ratio.map((r) => r > limits[1])),
273
+ scale,
274
+ };
275
+ }
276
+
277
+ function runs(flags: readonly boolean[]): Array<[number, number]> {
278
+ const out: Array<[number, number]> = [];
279
+ for (let i = 0; i < flags.length; i++) {
280
+ if (!flags[i]) continue;
281
+ let j = i;
282
+ while (j + 1 < flags.length && flags[j + 1]) j++;
283
+ out.push([i, j]);
284
+ i = j;
285
+ }
286
+ return out;
287
+ }
288
+
289
+ // --- supports, jumps and steps ---------------------------------------------
290
+
291
+ /** The points a jump reads, each above its rolling floor by more than `JUMP_RISE` leg lengths. */
292
+ export const JUMP_POINTS = ['r_ankle', 'l_ankle', 'mid_hip'] as const;
293
+ /** A jump lasts at least this many frames. */
294
+ export const JUMP_FRAMES = 2;
295
+ /** A rolling floor: `FLOOR_PERCENTILE` of the picture y over ± this many seconds around the frame. */
296
+ export const FLOOR_SECONDS = 1.0;
297
+ /** A supporting ankle moving more than this many leg lengths a frame is stepping: its plant is released. */
298
+ export const STEP_SPEED = 0.03;
299
+ /** A released ankle re-plants once it moves less than this many leg lengths a frame … */
300
+ export const STILL_SPEED = 0.01;
301
+ /** … for this many frames. */
302
+ export const STILL_FRAMES = 2;
303
+
304
+ export interface Step {
305
+ side: Side;
306
+ /** First frame the ankle moved past `STEP_SPEED`, and the last frame before it re-planted (or the last frame). */
307
+ frames: [number, number];
308
+ /** The frame it re-planted on (still for `STILL_FRAMES`), or null if it never did. */
309
+ replant: number | null;
310
+ /** The side that took support when it released, or null: the hip followed the pose's travel. */
311
+ to: Side | null;
312
+ }
313
+
314
+ export interface Feet {
315
+ /** Each side's median picture length hip → ankle, px; the unit of every threshold below (mid_hip: their mean). */
316
+ legLength: Record<Side, number>;
317
+ /** Per frame: the side that supports, or null (a jump, or a step with no other foot planted). */
318
+ support: Array<Side | null>;
319
+ /** Runs of one supporting side: [first, last] frames and the side. */
320
+ supports: Array<{ side: Side; frames: [number, number] }>;
321
+ /** Runs of jump frames. */
322
+ jumps: Array<[number, number]>;
323
+ /** Every release of a supporting foot by a step. */
324
+ steps: Step[];
325
+ /** The frames a change of support (or a landing, or a re-plant) was keyed on: the blend starts there. */
326
+ changes: Array<{ frame: number; from: Side | null; to: Side }>;
327
+ }
328
+
329
+ /** Each frame's rolling floor of a picture-y series: its `FLOOR_PERCENTILE` over the frames within ± `FLOOR_SECONDS`. */
330
+ export function rollingFloor(ys: readonly number[], times: readonly number[]): number[] {
331
+ return ys.map((_, i) => {
332
+ const w: number[] = [];
333
+ for (let j = 0; j < ys.length; j++) if (Math.abs(times[j]! - times[i]!) <= FLOOR_SECONDS + 1e-9) w.push(ys[j]!);
334
+ return percentile(w, FLOOR_PERCENTILE);
335
+ });
336
+ }
337
+
338
+ /**
339
+ * Which foot supports on each frame, from the reference ankles and hips (picture
340
+ * px, y down) — the header's rules. `times` are the key times; `secondsOf`
341
+ * times a run of frames as the rest of the retarget does.
342
+ */
343
+ export function supportsOf(ankle: Record<Side, readonly Pt[]>, hip: Record<Side, readonly Pt[]>, times: readonly number[], secondsOf: (run: [number, number]) => number): Feet {
344
+ const n = ankle.r.length;
345
+ const legLength = {} as Record<Side, number>;
346
+ for (const s of SIDES) legLength[s] = percentile(ankle[s].map((a, i) => Math.hypot(a[0] - hip[s][i]![0], a[1] - hip[s][i]![1])), 0.5);
347
+ const mean = (legLength.r + legLength.l) / 2;
348
+ // A jump: both ankles and mid_hip above their rolling floors, for ≥ JUMP_FRAMES frames.
349
+ const midY = hip.r.map((h, i) => (h[1] + hip.l[i]![1]) / 2);
350
+ const rises: Array<{ ys: number[]; unit: number }> = [
351
+ { ys: ankle.r.map((a) => a[1]), unit: legLength.r },
352
+ { ys: ankle.l.map((a) => a[1]), unit: legLength.l },
353
+ { ys: midY, unit: mean },
354
+ ];
355
+ const up = rises.map(({ ys, unit }) => {
356
+ const floor = rollingFloor(ys, times);
357
+ return ys.map((y, i) => floor[i]! - y > JUMP_RISE * unit);
358
+ });
359
+ const lifted = Array.from({ length: n }, (_, i) => up.every((u) => u[i]));
360
+ const jumps = runs(lifted).filter(([a, b]) => b - a + 1 >= JUMP_FRAMES);
361
+ const jump = new Array<boolean>(n).fill(false);
362
+ for (const [a, b] of jumps) for (let i = a; i <= b; i++) jump[i] = true;
363
+ // Ankle speeds, leg lengths a frame (0 on the first frame).
364
+ const speed = {} as Record<Side, number[]>;
365
+ for (const s of SIDES) speed[s] = ankle[s].map((a, i) => (i === 0 ? 0 : Math.hypot(a[0] - ankle[s][i - 1]![0], a[1] - ankle[s][i - 1]![1]) / legLength[s]));
366
+ const moving = (s: Side, i: number) => speed[s][i]! > STEP_SPEED;
367
+ const still = (s: Side, i: number) => {
368
+ for (let k = i - STILL_FRAMES + 1; k <= i; k++) if (k >= 0 && !(speed[s][k]! < STILL_SPEED)) return false;
369
+ return true;
370
+ };
371
+ const support: Array<Side | null> = new Array<Side | null>(n).fill(null);
372
+ const changes: Feet['changes'] = [];
373
+ const steps: Step[] = [];
374
+ const open: Partial<Record<Side, Step>> = {};
375
+ let cur: Side | null = null;
376
+ let lowerFrom = -1;
377
+ const lower = (cands: Side[], i: number): Side => (cands.length === 2 ? (ankle.l[i]![1] > ankle.r[i]![1] ? 'l' : 'r') : cands[0]!);
378
+ for (let i = 0; i < n; i++) {
379
+ // A released ankle re-plants once it has been still for STILL_FRAMES frames.
380
+ for (const s of SIDES) {
381
+ const st = open[s];
382
+ if (st !== undefined && still(s, i)) {
383
+ st.replant = i;
384
+ st.frames[1] = i - 1;
385
+ delete open[s];
386
+ }
387
+ }
388
+ if (jump[i]) {
389
+ cur = null;
390
+ lowerFrom = -1;
391
+ continue;
392
+ }
393
+ const free = (s: Side) => open[s] === undefined && !moving(s, i);
394
+ if (cur !== null && moving(cur, i)) {
395
+ const other: Side = cur === 'r' ? 'l' : 'r';
396
+ const to: Side | null = free(other) ? other : null;
397
+ const st: Step = { side: cur, frames: [i, n - 1], replant: null, to };
398
+ steps.push(st);
399
+ open[cur] = st;
400
+ if (to !== null) changes.push({ frame: i, from: cur, to });
401
+ cur = to;
402
+ lowerFrom = -1;
403
+ } else if (cur === null) {
404
+ const cands = SIDES.filter(free);
405
+ if (cands.length > 0) {
406
+ cur = lower(cands, i);
407
+ changes.push({ frame: i, from: null, to: cur });
408
+ lowerFrom = -1;
409
+ }
410
+ } else {
411
+ const other: Side = cur === 'r' ? 'l' : 'r';
412
+ if (free(other) && ankle[other][i]![1] - ankle[cur][i]![1] >= PLANT_SWITCH * mean) {
413
+ if (lowerFrom < 0) lowerFrom = i;
414
+ if (secondsOf([lowerFrom, i]) >= PLANT_SWITCH_SECONDS - 1e-9) {
415
+ changes.push({ frame: i, from: cur, to: other });
416
+ cur = other;
417
+ lowerFrom = -1;
418
+ }
419
+ } else lowerFrom = -1;
420
+ }
421
+ support[i] = cur;
422
+ }
423
+ const supports: Feet['supports'] = [];
424
+ for (let i = 0; i < n; i++) {
425
+ const s = support[i];
426
+ if (s === null || s === undefined) continue;
427
+ const last = supports[supports.length - 1];
428
+ if (last !== undefined && last.side === s && last.frames[1] === i - 1) last.frames[1] = i;
429
+ else supports.push({ side: s, frames: [i, i] });
430
+ }
431
+ return { legLength, support, supports, jumps, steps, changes };
432
+ }
433
+
434
+ /**
435
+ * The hip's world origin on every frame, from the supporting foot (header).
436
+ * `offset(i, side)` is the ankle's posed position relative to the hip's origin
437
+ * on frame i; `travel(i)` the pose's own travel of the hip from frame 0, world.
438
+ * `start` is the hip's origin on frame 0 (its setup origin).
439
+ */
440
+ export function plantHip(feet: Feet, offset: (i: number, s: Side) => Pt, travel: (i: number) => Pt, start: Pt): Pt[] {
441
+ const n = feet.support.length;
442
+ const hip: Pt[] = [];
443
+ /** How the hip is derived on a frame: held over a foot's anchor, or carried by the pose's travel from a frame's hip. */
444
+ type Mode = { kind: 'plant'; side: Side; anchor: Pt } | { kind: 'travel'; from: number; at: Pt };
445
+ const of = (mode: Mode, i: number): Pt => {
446
+ if (mode.kind === 'plant') {
447
+ const o = offset(i, mode.side);
448
+ return [mode.anchor[0] - o[0], mode.anchor[1] - o[1]];
449
+ }
450
+ const a = travel(i);
451
+ const b = travel(mode.from);
452
+ return [mode.at[0] + a[0] - b[0], mode.at[1] + a[1] - b[1]];
453
+ };
454
+ let mode: Mode | null = null;
455
+ let from: Mode | null = null;
456
+ let blendAt = -1;
457
+ for (let i = 0; i < n; i++) {
458
+ const s = feet.support[i] ?? null;
459
+ if (i === 0) {
460
+ if (s === null) mode = { kind: 'travel', from: 0, at: start };
461
+ else {
462
+ const o = offset(0, s);
463
+ mode = { kind: 'plant', side: s, anchor: [start[0] + o[0], start[1] + o[1]] };
464
+ }
465
+ } else if (s === null && mode!.kind === 'plant') {
466
+ mode = { kind: 'travel', from: i - 1, at: hip[i - 1]! };
467
+ from = null;
468
+ blendAt = -1;
469
+ } else if (s !== null && (mode!.kind === 'travel' || (mode as { side: Side }).side !== s)) {
470
+ // The new support is held where it is on this frame, under the hip the old derivation gives here.
471
+ const here = of(mode!, i);
472
+ const o = offset(i, s);
473
+ from = mode;
474
+ mode = { kind: 'plant', side: s, anchor: [here[0] + o[0], here[1] + o[1]] };
475
+ blendAt = i;
476
+ }
477
+ const to = of(mode!, i);
478
+ if (from !== null && i - blendAt < PLANT_BLEND) {
479
+ const w = (i - blendAt + 1) / (PLANT_BLEND + 1);
480
+ const f = of(from, i);
481
+ hip.push([f[0] + (to[0] - f[0]) * w, f[1] + (to[1] - f[1]) * w]);
482
+ } else {
483
+ from = null;
484
+ hip.push(to);
485
+ }
486
+ }
487
+ return hip;
488
+ }
package/src/pose.ts CHANGED
@@ -50,6 +50,16 @@ export interface PoseSequence {
50
50
 
51
51
  const TOP_KEYS = ['spec', 'space', 'width', 'height', 'source', 'frames'];
52
52
 
53
+ /**
54
+ * The older name the same file went by: `rig-c-pose-frames/0`, written by the
55
+ * extraction jobs this package's own demos were made from. It is this format
56
+ * under another spec string, with `space.units` "painting-px" and an `fps` key
57
+ * (the time is in each frame's `t`, which wins). It is read as it is; nothing
58
+ * else about it differs.
59
+ */
60
+ export const POSE_SPEC_FRAMES0 = 'rig-c-pose-frames/0';
61
+ const FRAMES0_SPACE = { units: 'painting-px', origin: 'top-left', y: 'down' } as const;
62
+
53
63
  /** Read and check a pose sequence; every problem is collected and thrown once. */
54
64
  export function readPoseSequence(text: string, where: string): PoseSequence {
55
65
  const doc = parseJson(text, where, 'POSE_JSON');
@@ -59,12 +69,15 @@ export function readPoseSequence(text: string, where: string): PoseSequence {
59
69
  p.throwIfAny();
60
70
  throw new Error('unreachable');
61
71
  }
62
- refuseUnknownKeys(p, 'POSE_KEYS', where, doc, TOP_KEYS);
72
+ const frames0 = doc.spec === POSE_SPEC_FRAMES0;
73
+ refuseUnknownKeys(p, 'POSE_KEYS', where, doc, frames0 ? [...TOP_KEYS, 'fps'] : TOP_KEYS);
63
74
  for (const k of TOP_KEYS) if (!(k in doc)) p.add('POSE_KEYS', `${where} has no "${k}"; a ${POSE_SPEC} document carries ${TOP_KEYS.join(', ')}`);
64
- if ('spec' in doc && doc.spec !== POSE_SPEC) p.add('POSE_SPEC', `${where} "spec" is ${typeName(doc.spec)}; this reader reads "${POSE_SPEC}"`);
65
- if ('space' in doc && JSON.stringify(doc.space) !== JSON.stringify(POSE_SPACE)) {
66
- p.add('POSE_SPACE', `${where} "space" is ${JSON.stringify(doc.space)}; it must be exactly ${JSON.stringify(POSE_SPACE)}`);
75
+ if ('spec' in doc && doc.spec !== POSE_SPEC && !frames0) p.add('POSE_SPEC', `${where} "spec" is ${typeName(doc.spec)}; this reader reads "${POSE_SPEC}" (and the older name "${POSE_SPEC_FRAMES0}")`);
76
+ const space = frames0 ? FRAMES0_SPACE : POSE_SPACE;
77
+ if ('space' in doc && JSON.stringify(doc.space) !== JSON.stringify(space)) {
78
+ p.add('POSE_SPACE', `${where} "space" is ${JSON.stringify(doc.space)}; for "${frames0 ? POSE_SPEC_FRAMES0 : POSE_SPEC}" it must be exactly ${JSON.stringify(space)}`);
67
79
  }
80
+ if (frames0 && 'fps' in doc && !(typeof doc.fps === 'number' && Number.isFinite(doc.fps) && doc.fps > 0)) p.add('POSE_FPS', `${where} "fps" is ${typeName(doc.fps)}; it must be a positive number (each frame's "t" is the time read)`);
68
81
  for (const k of ['width', 'height'] as const) {
69
82
  if (k in doc && !(Number.isInteger(doc[k]) && (doc[k] as number) > 0)) p.add('POSE_SIZE', `${where} "${k}" is ${typeName(doc[k])}; it must be a positive integer (picture pixels)`);
70
83
  }