@rig-c/pose 0.17.0 → 0.18.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 +29 -10
- package/cli.ts +18 -12
- package/package.json +1 -1
- package/src/clean.ts +7 -3
- package/src/legs.ts +246 -60
- package/src/map.ts +49 -3
- package/src/pose.ts +15 -3
- package/src/reduce.ts +167 -7
- package/src/retarget.ts +118 -37
- package/src/run.ts +202 -27
- package/src/switch.ts +31 -8
package/src/map.ts
CHANGED
|
@@ -50,6 +50,9 @@
|
|
|
50
50
|
* directions are read), holding every joint its `chains` use; `drawn` is each
|
|
51
51
|
* bone's world angle as painted (a reading: `artTurn`); `offsets` is a
|
|
52
52
|
* translate per bone, in its parent's axes, held while the set shows.
|
|
53
|
+
* `place: "by-hip"` (rig-c/pose#93) places the wrist of each of its chains that
|
|
54
|
+
* `pose` puts within one hip width of the mid-hip by its offset from the hip
|
|
55
|
+
* (`byHipChains`; the retarget solves it).
|
|
53
56
|
* `switch.ts` decides when each one shows.
|
|
54
57
|
*/
|
|
55
58
|
import type { RawBone } from 'rig-c/core';
|
|
@@ -95,6 +98,8 @@ export interface AlternateSet {
|
|
|
95
98
|
turn: Record<string, [number, number]>;
|
|
96
99
|
/** A stopgap: the only frames the set may show on, a list of [first, last] windows; on them it is chosen over any set without windows. Null: any frame. */
|
|
97
100
|
frames: Array<[number, number]> | null;
|
|
101
|
+
/** `place: "by-hip"` (rig-c/pose#93): the chains whose wrist `pose` puts within one hip width of the mid-hip, their wrists placed by the offset from the hip while the set shows. Absent or null: the pose's angles. */
|
|
102
|
+
place?: { by: 'hip'; chains: string[] } | null;
|
|
98
103
|
}
|
|
99
104
|
export interface JointMap {
|
|
100
105
|
chains: Chain[];
|
|
@@ -228,7 +233,39 @@ export function readMap(text: string, where: string): JointMap {
|
|
|
228
233
|
return { chains, translate, alternates };
|
|
229
234
|
}
|
|
230
235
|
|
|
231
|
-
const SET_SHAPE = '{ name, slots: { "<slot>": "<attachment>" }, hides?: [slot], chains: [chain], pose: { "<joint>": [x, y] }, drawn?: { "<bone>": deg }, offsets?: { "<bone>": [dx, dy] }, turn?: { "<bone>": [min, max] }, frames?: [[first, last], …] }';
|
|
236
|
+
const SET_SHAPE = '{ name, slots: { "<slot>": "<attachment>" }, hides?: [slot], chains: [chain], pose: { "<joint>": [x, y] }, drawn?: { "<bone>": deg }, offsets?: { "<bone>": [dx, dy] }, turn?: { "<bone>": [min, max] }, frames?: [[first, last], …], place?: "by-hip" }';
|
|
237
|
+
|
|
238
|
+
/** The joints a wrist is, for `place: "by-hip"`: a chain whose last joint is one of them carries a wrist. */
|
|
239
|
+
export const WRISTS: readonly string[] = ['r_wrist', 'l_wrist'];
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* `place: "by-hip"` (rig-c/pose#93): the set's chains that carry a wrist (their
|
|
243
|
+
* last joint) which its key skeleton puts within one hip width of the mid-hip —
|
|
244
|
+
* |wrist − (r_hip + l_hip) / 2| ≤ |r_hip − l_hip| — and each wrist chain's
|
|
245
|
+
* distance in hip widths; or why the key skeleton cannot be read so.
|
|
246
|
+
*/
|
|
247
|
+
export function byHipChains(pose: Readonly<Record<string, readonly [number, number]>>, chains: readonly Chain[]): { chains: string[]; distances: Array<{ chain: string; wrist: string; hipWidths: number }> } | { problem: string } {
|
|
248
|
+
const r = pose.r_hip;
|
|
249
|
+
const l = pose.l_hip;
|
|
250
|
+
if (r === undefined || l === undefined) return { problem: `its "pose" has no ${[r === undefined ? '"r_hip"' : '', l === undefined ? '"l_hip"' : ''].filter((x) => x !== '').join(' or ')}; "by-hip" reads the hip width and the mid-hip off the key skeleton's two hips, which the map's author supplies` };
|
|
251
|
+
const noWrist = chains.filter((c) => WRISTS.includes(c.joints[c.joints.length - 1]!) && pose[c.joints[c.joints.length - 1]!] === undefined);
|
|
252
|
+
if (noWrist.length > 0) return { problem: noWrist.map((c) => `its "pose" has no "${c.joints[c.joints.length - 1]}", the wrist of chain "${c.name}"`).join('; ') + '; "by-hip" reads each wrist\'s distance from the mid-hip off the key skeleton' };
|
|
253
|
+
const width = Math.hypot(l[0] - r[0], l[1] - r[1]);
|
|
254
|
+
if (!(width > 1e-9)) return { problem: `its "pose" puts r_hip and l_hip at the same point; a hip width of 0 is no unit` };
|
|
255
|
+
const mid: [number, number] = [(r[0] + l[0]) / 2, (r[1] + l[1]) / 2];
|
|
256
|
+
const distances: Array<{ chain: string; wrist: string; hipWidths: number }> = [];
|
|
257
|
+
for (const c of chains) {
|
|
258
|
+
const wrist = c.joints[c.joints.length - 1]!;
|
|
259
|
+
if (!WRISTS.includes(wrist)) continue;
|
|
260
|
+
const w = pose[wrist];
|
|
261
|
+
if (w === undefined) continue;
|
|
262
|
+
distances.push({ chain: c.name, wrist, hipWidths: Math.hypot(w[0] - mid[0], w[1] - mid[1]) / width });
|
|
263
|
+
}
|
|
264
|
+
if (distances.length === 0) return { problem: `none of its chains (${chains.map((c) => c.name).join(', ')}) ends at a wrist (${WRISTS.join(', ')}); "by-hip" places a wrist` };
|
|
265
|
+
const near = distances.filter((d) => d.hipWidths <= 1);
|
|
266
|
+
if (near.length === 0) return { problem: `its "pose" puts no wrist within one hip width of the mid-hip (${distances.map((d) => `chain "${d.chain}" ${d.wrist} ${d.hipWidths.toFixed(2)} hip widths`).join(', ')}); "by-hip" places a hand that meets the body` };
|
|
267
|
+
return { chains: near.map((d) => d.chain), distances };
|
|
268
|
+
}
|
|
232
269
|
|
|
233
270
|
/** `alternates`: each set's shape, its chains against the map's, its key skeleton against those chains' joints, and its bones against those chains' bones. */
|
|
234
271
|
function readAlternates(p: Problems, where: string, raw: unknown, chains: readonly Chain[], translate: Translate | null): AlternateSet[] {
|
|
@@ -245,7 +282,7 @@ function readAlternates(p: Problems, where: string, raw: unknown, chains: readon
|
|
|
245
282
|
p.add('MAP_ALT_SHAPE', `${aw} is ${typeName(a)}; a set is ${SET_SHAPE}`);
|
|
246
283
|
return;
|
|
247
284
|
}
|
|
248
|
-
refuseUnknownKeys(p, 'MAP_KEYS', aw, a, ['name', 'note', 'slots', 'hides', 'chains', 'pose', 'drawn', 'offsets', 'turn', 'frames']);
|
|
285
|
+
refuseUnknownKeys(p, 'MAP_KEYS', aw, a, ['name', 'note', 'slots', 'hides', 'chains', 'pose', 'drawn', 'offsets', 'turn', 'frames', 'place']);
|
|
249
286
|
const name = typeof a.name === 'string' && a.name !== '' ? a.name : null;
|
|
250
287
|
if (name === null) p.add('MAP_ALT_NAME', `${aw} "name" is ${typeName(a.name)}; it must be a non-empty string`);
|
|
251
288
|
else if (names.has(name)) p.add('MAP_ALT_NAME', `${aw} "name" "${name}" names two sets`);
|
|
@@ -354,7 +391,16 @@ function readAlternates(p: Problems, where: string, raw: unknown, chains: readon
|
|
|
354
391
|
p.add('MAP_ALT_FRAMES', `${label} "frames" is ${JSON.stringify(f)}; it must be a non-empty list of windows [[first, last], …], whole frame numbers ≥ 0 with first ≤ last`);
|
|
355
392
|
} else frames = (f as Array<[number, number]>).map((w) => [w[0], w[1]]);
|
|
356
393
|
}
|
|
357
|
-
|
|
394
|
+
let place: AlternateSet['place'] = null;
|
|
395
|
+
if ('place' in a) {
|
|
396
|
+
if (a.place !== 'by-hip') p.add('MAP_ALT_PLACE', `${label} "place" is ${JSON.stringify(a.place)}; the one placement a set may ask for is "by-hip"`);
|
|
397
|
+
else if (isObject(a.pose) && own.length > 0) {
|
|
398
|
+
const by = byHipChains(pose, own);
|
|
399
|
+
if ('problem' in by) p.add('MAP_ALT_PLACE', `${label} "place" is "by-hip", and ${by.problem}`);
|
|
400
|
+
else place = { by: 'hip', chains: by.chains };
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
if (ok && name !== null && cs !== null) out.push({ name, slots, hides, chains: cs, pose, drawn, offsets, turn, frames, ...(place === null ? {} : { place }) });
|
|
358
404
|
});
|
|
359
405
|
return out;
|
|
360
406
|
}
|
package/src/pose.ts
CHANGED
|
@@ -180,11 +180,22 @@ export interface PoseSequence {
|
|
|
180
180
|
camera?: CameraFrame[];
|
|
181
181
|
/** Present once `rig-pose clean` has run on the sequence (#67). */
|
|
182
182
|
cleaned?: CleanRecord;
|
|
183
|
+
/**
|
|
184
|
+
* Optional (rig-c/pose#92): the clip's frame of reference. "in-place" — a
|
|
185
|
+
* gait filmed without the figure travelling (a treadmill or reel walk, a
|
|
186
|
+
* loop) — is the one value; `clean --loop` writes it, and it may be written
|
|
187
|
+
* by hand. Absent: the figure's travel in the picture is its travel.
|
|
188
|
+
*/
|
|
189
|
+
travel?: Travel;
|
|
183
190
|
frames: PoseFrame[];
|
|
184
191
|
}
|
|
185
192
|
|
|
193
|
+
/** The one value of a sequence's `travel` (rig-c/pose#92). */
|
|
194
|
+
export const TRAVEL_IN_PLACE = 'in-place';
|
|
195
|
+
export type Travel = typeof TRAVEL_IN_PLACE;
|
|
196
|
+
|
|
186
197
|
const TOP_KEYS = ['spec', 'space', 'width', 'height', 'source', 'frames'];
|
|
187
|
-
const OPTIONAL_KEYS = ['cameraSource', 'camera', 'cleaned'];
|
|
198
|
+
const OPTIONAL_KEYS = ['cameraSource', 'camera', 'cleaned', 'travel'];
|
|
188
199
|
const CAMERA_KEYS = ['s', 'tx', 'ty', 'inliers'];
|
|
189
200
|
|
|
190
201
|
/**
|
|
@@ -269,10 +280,11 @@ export function readPoseSequence(text: string, where: string): PoseSequence {
|
|
|
269
280
|
if (typeof doc.cameraSource !== 'string' || doc.cameraSource.trim() === '') p.add('POSE_CAMERA_SOURCE', `${where} "cameraSource" is ${typeName(doc.cameraSource)}; it must be a non-empty string saying where the camera came from ("tracked", "hand-labelled, N keys")`);
|
|
270
281
|
else if (!('camera' in doc)) p.add('POSE_CAMERA_SOURCE', `${where} has "cameraSource" ${JSON.stringify(doc.cameraSource)} and no "camera"; it states where a camera came from, so it needs one`);
|
|
271
282
|
}
|
|
283
|
+
if ('travel' in doc && doc.travel !== TRAVEL_IN_PLACE) p.add('POSE_TRAVEL', `${where} "travel" is ${typeName(doc.travel)}; the one value is "${TRAVEL_IN_PLACE}" (a gait filmed without the figure travelling), and without the key the picture's travel is the figure's`);
|
|
272
284
|
const cleaned = 'cleaned' in doc ? readCleaned(p, where, doc.cleaned, Array.isArray(doc.frames) ? doc.frames.length : null) : undefined;
|
|
273
285
|
p.throwIfAny();
|
|
274
286
|
const cameraSource = typeof doc.cameraSource === 'string' ? doc.cameraSource : undefined;
|
|
275
|
-
return { spec: POSE_SPEC, space: POSE_SPACE, width: doc.width as number, height: doc.height as number, source: doc.source as string, ...(cameraSource !== undefined ? { cameraSource } : {}), ...(camera !== undefined ? { camera } : {}), ...(cleaned !== undefined ? { cleaned } : {}), frames };
|
|
287
|
+
return { spec: POSE_SPEC, space: POSE_SPACE, width: doc.width as number, height: doc.height as number, source: doc.source as string, ...(cameraSource !== undefined ? { cameraSource } : {}), ...(camera !== undefined ? { camera } : {}), ...(cleaned !== undefined ? { cleaned } : {}), ...(doc.travel === TRAVEL_IN_PLACE ? { travel: TRAVEL_IN_PLACE } : {}), frames };
|
|
276
288
|
}
|
|
277
289
|
|
|
278
290
|
/** `camera`: one `{ s > 0, tx, ty, inliers ≥ 0 }` per frame, as many as `frames`. */
|
|
@@ -786,5 +798,5 @@ export function writePoseSequence(seq: PoseSequence): string {
|
|
|
786
798
|
...(c.seams !== undefined ? { seams: c.seams.map((x) => ({ joint: x.joint, frame: x.frame, step: round(x.step, 4), gap: x.gap })) } : {}),
|
|
787
799
|
...(c.unheld !== undefined ? { unheld: c.unheld.map((u) => ({ joint: u.joint, frames: [u.frames[0], u.frames[1]], test: u.test, value: u.value === null ? null : round(u.value, 4) })) } : {}),
|
|
788
800
|
};
|
|
789
|
-
return `${JSON.stringify({ spec: seq.spec, space: seq.space, width: seq.width, height: seq.height, source: seq.source, ...(seq.cameraSource !== undefined ? { cameraSource: seq.cameraSource } : {}), ...(camera !== undefined ? { camera } : {}), ...(cleaned !== undefined ? { cleaned } : {}), frames }, null, 2)}\n`;
|
|
801
|
+
return `${JSON.stringify({ spec: seq.spec, space: seq.space, width: seq.width, height: seq.height, source: seq.source, ...(seq.cameraSource !== undefined ? { cameraSource: seq.cameraSource } : {}), ...(camera !== undefined ? { camera } : {}), ...(cleaned !== undefined ? { cleaned } : {}), ...(seq.travel !== undefined ? { travel: seq.travel } : {}), frames }, null, 2)}\n`;
|
|
790
802
|
}
|
package/src/reduce.ts
CHANGED
|
@@ -18,6 +18,13 @@
|
|
|
18
18
|
* target is its world error: errors do not pile up down a chain. A knot's value
|
|
19
19
|
* is the target's, not the dense key's.
|
|
20
20
|
*
|
|
21
|
+
* Scaled bones (rig-c/pose#94). A `scalex` track is thinned in value space
|
|
22
|
+
* within `SCALE_REDUCE` (`reduceScale`). A bone under a scaled bone is not
|
|
23
|
+
* keyed by the formula above but through its parent's posed matrix (legs.ts
|
|
24
|
+
* `posePath`), so `reduceUnderScale` solves its keys again through its parent
|
|
25
|
+
* as reduced and reads each error as the world aim, and the world length, it
|
|
26
|
+
* lands on.
|
|
27
|
+
*
|
|
21
28
|
* Every error is read after the numbers are rounded as written and evaluated as
|
|
22
29
|
* rig-c's poser evaluates them (`curveAt`): the curve is the poser's ten-piece
|
|
23
30
|
* polyline, not the cubic. The poser is the judge — run.ts measures the landing
|
|
@@ -52,12 +59,13 @@ export interface ReduceHip {
|
|
|
52
59
|
}
|
|
53
60
|
export interface ReduceRow {
|
|
54
61
|
bone: string;
|
|
55
|
-
|
|
62
|
+
/** `scale`: a `scalex` track (#94), its error the bone's world x-axis length against its setup length times the scale asked. */
|
|
63
|
+
kind: 'angle' | 'origin' | 'scale';
|
|
56
64
|
/** Dense key count. */
|
|
57
65
|
before: number;
|
|
58
66
|
/** Reduced key count. */
|
|
59
67
|
after: number;
|
|
60
|
-
/** The largest error over all frames: degrees of world aim for a bone, world units for the hip. */
|
|
68
|
+
/** The largest error over all frames: degrees of world aim for a bone, world units for the hip, a fraction of the setup length for a scale. */
|
|
61
69
|
worst: number;
|
|
62
70
|
/** The first frame where it is. */
|
|
63
71
|
frame: number;
|
|
@@ -67,6 +75,8 @@ export interface Reduced {
|
|
|
67
75
|
hip: ReducedKey[] | null;
|
|
68
76
|
/** The bones in the order given, then the hip. */
|
|
69
77
|
rows: ReduceRow[];
|
|
78
|
+
/** Each bone's world-aim error on every frame (reduced less dense, degrees), as the poser evaluates the reduced keys. */
|
|
79
|
+
errors: Map<string, number[]>;
|
|
70
80
|
}
|
|
71
81
|
|
|
72
82
|
// ---------------------------------------------------------------------------
|
|
@@ -186,6 +196,11 @@ const r3 = (v: number): number => {
|
|
|
186
196
|
const r = Math.round(v * 1000) / 1000;
|
|
187
197
|
return Object.is(r, -0) ? 0 : r;
|
|
188
198
|
};
|
|
199
|
+
/** A scale as the retarget writes one (retarget.ts `r6`). */
|
|
200
|
+
const r6 = (v: number): number => {
|
|
201
|
+
const r = Math.round(v * 1e6) / 1e6;
|
|
202
|
+
return Object.is(r, -0) ? 0 : r;
|
|
203
|
+
};
|
|
189
204
|
|
|
190
205
|
/** One track to reduce: its targets per frame (one array per frame, a value per channel) and how a frame's error is read. */
|
|
191
206
|
interface Fit {
|
|
@@ -194,6 +209,8 @@ interface Fit {
|
|
|
194
209
|
tolerance: number;
|
|
195
210
|
/** The error at frame `i` of `values` against the target. */
|
|
196
211
|
error: (i: number, values: readonly number[]) => number;
|
|
212
|
+
/** How a written value is rounded: 0.001 (an angle, a translate) unless given. */
|
|
213
|
+
round?: (v: number) => number;
|
|
197
214
|
}
|
|
198
215
|
|
|
199
216
|
/** The frame with the largest error over `frames` (the first, on a tie), or null when there is none. */
|
|
@@ -242,7 +259,8 @@ function solveHandles(a: ReducedKey, b: ReducedKey, channel: number, hx1: number
|
|
|
242
259
|
function fitTrack(times: readonly number[], samples: readonly number[], fit: Fit): ReducedKey[] {
|
|
243
260
|
const n = times.length;
|
|
244
261
|
const channels = fit.targets[0]!.length;
|
|
245
|
-
const
|
|
262
|
+
const round = fit.round ?? r3;
|
|
263
|
+
const keyOn = (i: number): ReducedKey => ({ t: times[i]!, v: fit.targets[i]!.map(round) });
|
|
246
264
|
const knots = [...new Set([0, n - 1, ...fit.keep])].sort((x, y) => x - y);
|
|
247
265
|
const curves = new Map<number, number[]>();
|
|
248
266
|
const done: number[] = [];
|
|
@@ -266,7 +284,7 @@ function fitTrack(times: readonly number[], samples: readonly number[], fit: Fit
|
|
|
266
284
|
const hx2 = times[k0]! + (2 * (times[k1]! - times[k0]!)) / 3;
|
|
267
285
|
const handles = Array.from({ length: channels }, (_, c) => solveHandles(a, b, c, hx1, hx2, interior, samples, (i) => fit.targets[i]![c]!));
|
|
268
286
|
if (handles.every((h) => h !== null)) {
|
|
269
|
-
const curve = handles.flatMap(([cy1, cy2]) => [hx1,
|
|
287
|
+
const curve = handles.flatMap(([cy1, cy2]) => [hx1, round(cy1), hx2, round(cy2)]);
|
|
270
288
|
const bent = errorWith({ ...a, curve });
|
|
271
289
|
if (bent.worst <= fit.tolerance) {
|
|
272
290
|
curves.set(k0, curve);
|
|
@@ -304,8 +322,7 @@ export function reduceKeys(times: readonly number[], bones: readonly ReduceBone[
|
|
|
304
322
|
if (!(Number.isFinite(tolerance) && tolerance > 0)) throw new Error(`reduce: the tolerance is ${tolerance}°, not a positive number`);
|
|
305
323
|
if (hip !== null && !(Number.isFinite(hip.tolerance) && hip.tolerance > 0)) throw new Error(`reduce: the hip's tolerance is ${hip.tolerance} units, not a positive number`);
|
|
306
324
|
// The poser samples at the running sum of the frame steps (verify.ts `poseFrames`).
|
|
307
|
-
const samples
|
|
308
|
-
for (let i = 1; i < n; i++) samples.push(samples[i - 1]! + (times[i]! - times[i - 1]!));
|
|
325
|
+
const samples = samplesOf(times);
|
|
309
326
|
|
|
310
327
|
const byName = new Map<string, ReduceBone>();
|
|
311
328
|
for (const b of bones) {
|
|
@@ -341,6 +358,7 @@ export function reduceKeys(times: readonly number[], bones: readonly ReduceBone[
|
|
|
341
358
|
rowOf.set(b.bone, { bone: b.bone, kind: 'angle', before: n, after: keys.length, worst: worst.worst, frame: worst.frame });
|
|
342
359
|
}
|
|
343
360
|
const rows = bones.map((b) => rowOf.get(b.bone)!);
|
|
361
|
+
const errorsOrdered = new Map(bones.map((b) => [b.bone, errors.get(b.bone)!]));
|
|
344
362
|
const ordered = new Map(bones.map((b) => [b.bone, rotate.get(b.bone)!]));
|
|
345
363
|
|
|
346
364
|
let hipKeys: ReducedKey[] | null = null;
|
|
@@ -357,5 +375,147 @@ export function reduceKeys(times: readonly number[], bones: readonly ReduceBone[
|
|
|
357
375
|
const worst = worstOf(at.map((_, i) => i), (i) => world(i, at[i]!))!;
|
|
358
376
|
rows.push({ bone: hip.bone, kind: 'origin', before: n, after: hipKeys.length, worst: worst.worst, frame: worst.frame });
|
|
359
377
|
}
|
|
360
|
-
return { rotate: ordered, hip: hipKeys, rows };
|
|
378
|
+
return { rotate: ordered, hip: hipKeys, rows, errors: errorsOrdered };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** The times the poser samples a track at: the running sum of the frame steps (verify.ts `poseFrames`). */
|
|
382
|
+
export function samplesOf(times: readonly number[]): number[] {
|
|
383
|
+
const samples: number[] = [times[0]!];
|
|
384
|
+
for (let i = 1; i < times.length; i++) samples.push(samples[i - 1]! + (times[i]! - times[i - 1]!));
|
|
385
|
+
return samples;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ---------------------------------------------------------------------------
|
|
389
|
+
// scales, and bones keyed through a scaled parent (rig-c/pose#94)
|
|
390
|
+
// ---------------------------------------------------------------------------
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* The tolerance a reduced `scalex` track is held to: half of the landing
|
|
394
|
+
* check's scale tolerance (verify.ts `SCALE_TOLERANCE`, 1e-4 of the setup
|
|
395
|
+
* length), so a reduced curve cannot fail the check it is re-run against.
|
|
396
|
+
*/
|
|
397
|
+
export const SCALE_REDUCE = 5e-5;
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* One `scalex` track of a bone with no scaled bone above it, reduced: its world
|
|
401
|
+
* x-axis length over setup is its own scale, so the error is the value's
|
|
402
|
+
* distance from the scale asked.
|
|
403
|
+
*/
|
|
404
|
+
export function reduceScale(times: readonly number[], bone: string, asked: readonly number[], keep: ReadonlySet<number>, tolerance: number): { keys: ReducedKey[]; at: number[]; row: ReduceRow } {
|
|
405
|
+
const n = times.length;
|
|
406
|
+
if (asked.length !== n) throw new Error(`reduce: bone "${bone}" asks ${asked.length} scale(s) for ${n} frame(s)`);
|
|
407
|
+
const samples = samplesOf(times);
|
|
408
|
+
const keys = fitTrack(times, samples, { targets: asked.map((v) => [v]), keep: keptFrames(keep, n, `bone "${bone}" scalex`), tolerance, error: (i, v) => Math.abs(v[0]! - asked[i]!), round: r6 });
|
|
409
|
+
const at = trackAt(keys, samples, 1).map((v) => v[0]!);
|
|
410
|
+
const worst = worstOf(at.map((_, i) => i), (i) => Math.abs(at[i]! - asked[i]!))!;
|
|
411
|
+
return { keys, at, row: { bone, kind: 'scale', before: n, after: keys.length, worst: worst.worst, frame: worst.frame } };
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** A world 2×2: columns (a, c) — the x axis — and (b, d) — the y axis (legs.ts `M2`). */
|
|
415
|
+
export type M2 = [number, number, number, number];
|
|
416
|
+
const DEG = 180 / Math.PI;
|
|
417
|
+
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]];
|
|
418
|
+
const inv = (m: M2): M2 => {
|
|
419
|
+
const det = m[0] * m[3] - m[1] * m[2];
|
|
420
|
+
return [m[3] / det, -m[1] / det, -m[2] / det, m[0] / det];
|
|
421
|
+
};
|
|
422
|
+
const apply = (m: M2, v: [number, number]): [number, number] => [m[0] * v[0] + m[1] * v[1], m[2] * v[0] + m[3] * v[1]];
|
|
423
|
+
const rot = (deg: number): M2 => {
|
|
424
|
+
const r = deg / DEG;
|
|
425
|
+
return [Math.cos(r), -Math.sin(r), Math.sin(r), Math.cos(r)];
|
|
426
|
+
};
|
|
427
|
+
const scaleX = (s: number): M2 => [s, 0, 0, 1];
|
|
428
|
+
/** A bone's setup local 2×2 in its parent's axes: inv(parent's setup world) · its setup world (legs.ts `posePath`'s L0). */
|
|
429
|
+
export const setupLocal = (parentWorld0: M2, world0: M2): M2 => mul(inv(parentWorld0), world0);
|
|
430
|
+
/** `p · q`. */
|
|
431
|
+
export const mulM2 = mul;
|
|
432
|
+
const IDENTITY: M2 = [1, 0, 0, 1];
|
|
433
|
+
export { IDENTITY as M2_IDENTITY };
|
|
434
|
+
const wrap180 = (d: number): number => {
|
|
435
|
+
const x = ((d + 180) % 360 + 360) % 360 - 180;
|
|
436
|
+
return x === -180 ? 180 : x;
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* A mapped bone under a scaled bone (legs.ts `posePath`: keyed through its
|
|
441
|
+
* parent's posed matrix), reduced once the bones above it are. Its parent's
|
|
442
|
+
* world 2×2 is posed from keys already reduced — a bone the key formula holds
|
|
443
|
+
* for (`rigid`: R(turn) · its setup world · diag(scale, 1), its turn and scale
|
|
444
|
+
* the reduced ones), or another bone of the same call given before it — times
|
|
445
|
+
* the setup locals of the unmapped bones between (`via`).
|
|
446
|
+
*/
|
|
447
|
+
export interface UnderBone {
|
|
448
|
+
bone: string;
|
|
449
|
+
parent: { rigid: { turn: readonly number[]; world0: M2; scale: readonly number[] }; via: M2 } | { under: string; via: M2 };
|
|
450
|
+
/** Its setup local 2×2 in its parent's axes: inv(parent's setup world) · its setup world, by rig-c's setup. */
|
|
451
|
+
local: M2;
|
|
452
|
+
/** Its setup world x-axis length. */
|
|
453
|
+
len0: number;
|
|
454
|
+
/** The world angle its x axis is asked on every frame (degrees), and the scale of it asked (1: none). */
|
|
455
|
+
target: readonly number[];
|
|
456
|
+
asked: readonly number[];
|
|
457
|
+
/** The dense rotate keys (unwrapped, as written). */
|
|
458
|
+
dense: readonly number[];
|
|
459
|
+
keep: ReadonlySet<number>;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Each bone under a scaled bone, in the order given (parents first): its
|
|
464
|
+
* rotate and scalex keys re-solved through its parent's reduced posed matrix
|
|
465
|
+
* (the key that puts its x axis on the target, and the scale that gives it the
|
|
466
|
+
* length asked), then reduced — the rotate with its error read as the world aim
|
|
467
|
+
* it lands on through that matrix, the scale with its error read as the world
|
|
468
|
+
* length over setup against the scale asked, on the rotate as reduced. Its
|
|
469
|
+
* world error is its own fit error, as down a chain the key formula holds for.
|
|
470
|
+
*/
|
|
471
|
+
export function reduceUnderScale(times: readonly number[], bones: readonly UnderBone[], tolerance: number, scaleTolerance: number): { rotate: Map<string, ReducedKey[]>; scale: Map<string, ReducedKey[]>; rows: ReduceRow[] } {
|
|
472
|
+
const n = times.length;
|
|
473
|
+
const samples = samplesOf(times);
|
|
474
|
+
const world = new Map<string, M2[]>();
|
|
475
|
+
const rotate = new Map<string, ReducedKey[]>();
|
|
476
|
+
const scale = new Map<string, ReducedKey[]>();
|
|
477
|
+
const rows: ReduceRow[] = [];
|
|
478
|
+
for (const b of bones) {
|
|
479
|
+
for (const [what, xs] of [['target', b.target], ['asked', b.asked], ['dense', b.dense]] as const) {
|
|
480
|
+
if (xs.length !== n) throw new Error(`reduce: bone "${b.bone}" has ${xs.length} ${what} value(s) for ${n} frame(s)`);
|
|
481
|
+
}
|
|
482
|
+
const p = b.parent;
|
|
483
|
+
let parent: M2[];
|
|
484
|
+
if ('under' in p) {
|
|
485
|
+
const above = world.get(p.under);
|
|
486
|
+
if (above === undefined) throw new Error(`reduce: bone "${b.bone}" hangs from bone "${p.under}", which is not given before it`);
|
|
487
|
+
parent = above.map((m) => mul(m, p.via));
|
|
488
|
+
} else {
|
|
489
|
+
parent = Array.from({ length: n }, (_, i) => mul(mul(mul(rot(p.rigid.turn[i]!), p.rigid.world0), scaleX(p.rigid.scale[i]!)), p.via));
|
|
490
|
+
}
|
|
491
|
+
const l0: [number, number] = [b.local[0], b.local[2]];
|
|
492
|
+
const a0 = Math.atan2(l0[1], l0[0]) * DEG;
|
|
493
|
+
/** The x axis's world vector through the parent on frame i, at rotate key k, before its own scale. */
|
|
494
|
+
const axis = (i: number, k: number) => apply(parent[i]!, apply(rot(k), l0));
|
|
495
|
+
// The dense keys re-solved through the reduced parent (posePath's solve).
|
|
496
|
+
const solved = b.target.map((t, i) => {
|
|
497
|
+
const v = apply(inv(parent[i]!), [b.asked[i]! * b.len0 * Math.cos(t / DEG), b.asked[i]! * b.len0 * Math.sin(t / DEG)]);
|
|
498
|
+
const k = Math.atan2(v[1], v[0]) * DEG - a0;
|
|
499
|
+
return b.dense[i]! + wrap180(k - b.dense[i]!);
|
|
500
|
+
});
|
|
501
|
+
const aimError = (i: number, k: number) => {
|
|
502
|
+
const w = axis(i, k);
|
|
503
|
+
return Math.abs(wrap180(Math.atan2(w[1], w[0]) * DEG - b.target[i]!));
|
|
504
|
+
};
|
|
505
|
+
const keep = keptFrames(b.keep, n, `bone "${b.bone}"`);
|
|
506
|
+
const rk = fitTrack(times, samples, { targets: solved.map((k) => [k]), keep, tolerance, error: (i, v) => aimError(i, v[0]!) });
|
|
507
|
+
const kAt = trackAt(rk, samples, 1).map((v) => v[0]!);
|
|
508
|
+
const ae = worstOf(kAt.map((_, i) => i), (i) => aimError(i, kAt[i]!))!;
|
|
509
|
+
rows.push({ bone: b.bone, kind: 'angle', before: n, after: rk.length, worst: ae.worst, frame: ae.frame });
|
|
510
|
+
// The scale that gives it the length asked on the rotate as reduced.
|
|
511
|
+
const reach = kAt.map((k, i) => Math.hypot(...axis(i, k)) / b.len0);
|
|
512
|
+
const sk = fitTrack(times, samples, { targets: reach.map((r, i) => [b.asked[i]! / r]), keep, tolerance: scaleTolerance, error: (i, v) => Math.abs(v[0]! * reach[i]! - b.asked[i]!), round: r6 });
|
|
513
|
+
const sAt = trackAt(sk, samples, 1).map((v) => v[0]!);
|
|
514
|
+
const se = worstOf(sAt.map((_, i) => i), (i) => Math.abs(sAt[i]! * reach[i]! - b.asked[i]!))!;
|
|
515
|
+
rows.push({ bone: b.bone, kind: 'scale', before: n, after: sk.length, worst: se.worst, frame: se.frame });
|
|
516
|
+
world.set(b.bone, parent.map((m, i) => mul(mul(mul(m, rot(kAt[i]!)), b.local), scaleX(sAt[i]!))));
|
|
517
|
+
rotate.set(b.bone, rk);
|
|
518
|
+
scale.set(b.bone, sk);
|
|
519
|
+
}
|
|
520
|
+
return { rotate, scale, rows };
|
|
361
521
|
}
|