@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/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
- if (ok && name !== null && cs !== null) out.push({ name, slots, hides, chains: cs, pose, drawn, offsets, turn, frames });
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
- kind: 'angle' | 'origin';
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 keyOn = (i: number): ReducedKey => ({ t: times[i]!, v: fit.targets[i]!.map(r3) });
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, r3(cy1), hx2, r3(cy2)]);
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: number[] = [times[0]!];
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
  }