@rig-c/pose 0.17.0 → 0.19.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
@@ -74,36 +74,63 @@ export interface CleanSpike {
74
74
  * pelvis line off the picture (`tools/pelvis.py`, after clean) and turned the
75
75
  * hip pair to a measured angle on every frame. The exact shape:
76
76
  *
77
- * { by: "measured", method: string,
78
- * read: Run[], interpolated: Run[], held: Run[],
77
+ * { by: "measured", method: string, fit?: { frames, gain, corr },
78
+ * read: Run[], interpolated: Run[], scaled?: Run[], held: Run[], unmeasured?: Run[],
79
79
  * declined: Array<{ frames: Run, why: string }> } Run = [first, last]
80
80
  *
81
81
  * `method` names the reading. Runs are integer frame indices inside the
82
82
  * sequence, first ≤ last, increasing and not overlapping within each list.
83
83
  * Every frame of the sequence is in exactly one of `read` (the frame's own
84
84
  * reading), `interpolated` (a declined frame between two reads close in time,
85
- * the angle interpolated) and `held` (any other declined frame, the earlier
86
- * read carried to the end of its run; at the clip's start, the later read). `declined` lists, with the reason, exactly the frames that are
87
- * interpolated or held. Anything else is refused by name (POSE_CLEANED).
85
+ * the angle interpolated), `scaled` (#106: a longer declined run taking the
86
+ * detector hip line scaled by `fit.gain`), `held` (the earlier read carried to
87
+ * the end of its run; at the clip's start, the later read) and `unmeasured`
88
+ * (#106: a longer declined run when `fit.corr` < 0.9). `declined` lists, with
89
+ * the reason, exactly the non-read frames. Anything else is refused by name
90
+ * (POSE_CLEANED).
88
91
  */
89
92
  export type Run = [number, number];
93
+ export interface PelvisFit {
94
+ frames: number;
95
+ gain: number | null;
96
+ corr: number | null;
97
+ }
90
98
  export type HipsInferred =
91
99
  | { by: 'declared' }
92
- | { by: 'measured'; method: string; read: Run[]; interpolated: Run[]; held: Run[]; declined: Array<{ frames: Run; why: string }> };
100
+ | {
101
+ by: 'measured';
102
+ method: string;
103
+ fit?: PelvisFit;
104
+ read: Run[];
105
+ interpolated: Run[];
106
+ scaled?: Run[];
107
+ held: Run[];
108
+ unmeasured?: Run[];
109
+ declined: Array<{ frames: Run; why: string }>;
110
+ };
93
111
 
94
112
  /** A copy of the record, in the writer's key order. */
95
113
  export function copyHipsInferred(h: HipsInferred): HipsInferred {
96
114
  const runs = (rs: Run[]): Run[] => rs.map(([a, b]) => [a, b]);
97
- return h.by === 'declared'
98
- ? { by: 'declared' }
99
- : { by: 'measured', method: h.method, read: runs(h.read), interpolated: runs(h.interpolated), held: runs(h.held), declined: h.declined.map((d) => ({ frames: [d.frames[0], d.frames[1]], why: d.why })) };
115
+ if (h.by === 'declared') return { by: 'declared' };
116
+ return {
117
+ by: 'measured',
118
+ method: h.method,
119
+ ...(h.fit !== undefined ? { fit: { frames: h.fit.frames, gain: h.fit.gain, corr: h.fit.corr } } : {}),
120
+ read: runs(h.read),
121
+ interpolated: runs(h.interpolated),
122
+ ...(h.scaled !== undefined ? { scaled: runs(h.scaled) } : {}),
123
+ held: runs(h.held),
124
+ ...(h.unmeasured !== undefined ? { unmeasured: runs(h.unmeasured) } : {}),
125
+ declined: h.declined.map((d) => ({ frames: [d.frames[0], d.frames[1]], why: d.why })),
126
+ };
100
127
  }
101
128
 
102
- /** The frames a `measured` record gives an angle on (read, interpolated, held), as a set; null for any other record or none. */
129
+ /** The frames a `measured` record gives an angle on (read, interpolated, scaled, held), as a set; null for any other record or none. */
103
130
  export function measuredFrames(h: HipsInferred | undefined): Set<number> | null {
104
131
  if (h === undefined || h.by !== 'measured') return null;
105
132
  const out = new Set<number>();
106
- for (const rs of [h.read, h.interpolated, h.held]) for (const [a, b] of rs) for (let f = a; f <= b; f++) out.add(f);
133
+ for (const rs of [h.read, h.interpolated, h.scaled ?? [], h.held]) for (const [a, b] of rs) for (let f = a; f <= b; f++) out.add(f);
107
134
  return out;
108
135
  }
109
136
 
@@ -180,11 +207,22 @@ export interface PoseSequence {
180
207
  camera?: CameraFrame[];
181
208
  /** Present once `rig-pose clean` has run on the sequence (#67). */
182
209
  cleaned?: CleanRecord;
210
+ /**
211
+ * Optional (rig-c/pose#92): the clip's frame of reference. "in-place" — a
212
+ * gait filmed without the figure travelling (a treadmill or reel walk, a
213
+ * loop) — is the one value; `clean --loop` writes it, and it may be written
214
+ * by hand. Absent: the figure's travel in the picture is its travel.
215
+ */
216
+ travel?: Travel;
183
217
  frames: PoseFrame[];
184
218
  }
185
219
 
220
+ /** The one value of a sequence's `travel` (rig-c/pose#92). */
221
+ export const TRAVEL_IN_PLACE = 'in-place';
222
+ export type Travel = typeof TRAVEL_IN_PLACE;
223
+
186
224
  const TOP_KEYS = ['spec', 'space', 'width', 'height', 'source', 'frames'];
187
- const OPTIONAL_KEYS = ['cameraSource', 'camera', 'cleaned'];
225
+ const OPTIONAL_KEYS = ['cameraSource', 'camera', 'cleaned', 'travel'];
188
226
  const CAMERA_KEYS = ['s', 'tx', 'ty', 'inliers'];
189
227
 
190
228
  /**
@@ -269,10 +307,11 @@ export function readPoseSequence(text: string, where: string): PoseSequence {
269
307
  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
308
  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
309
  }
310
+ 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
311
  const cleaned = 'cleaned' in doc ? readCleaned(p, where, doc.cleaned, Array.isArray(doc.frames) ? doc.frames.length : null) : undefined;
273
312
  p.throwIfAny();
274
313
  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 };
314
+ 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
315
  }
277
316
 
278
317
  /** `camera`: one `{ s > 0, tx, ty, inliers ≥ 0 }` per frame, as many as `frames`. */
@@ -477,10 +516,22 @@ function readHipsInferred(p: Problems, hw: string, h: unknown, frames: number |
477
516
  return;
478
517
  }
479
518
  const before = p.list.length;
480
- const CLASSES = ['read', 'interpolated', 'held'] as const;
481
- refuseUnknownKeys(p, 'POSE_CLEANED', hw, h, ['by', 'method', ...CLASSES, 'declined']);
482
- for (const k of ['method', ...CLASSES, 'declined']) if (!(k in h)) p.add('POSE_CLEANED', `${hw} has no "${k}"; a measured record is ${measuredShape}`);
519
+ const REQ_CLASSES = ['read', 'interpolated', 'held'] as const;
520
+ const ALL_CLASSES = ['read', 'interpolated', 'scaled', 'held', 'unmeasured'] as const;
521
+ refuseUnknownKeys(p, 'POSE_CLEANED', hw, h, ['by', 'method', 'fit', ...ALL_CLASSES, 'declined']);
522
+ for (const k of ['method', ...REQ_CLASSES, 'declined']) if (!(k in h)) p.add('POSE_CLEANED', `${hw} has no "${k}"; a measured record is ${measuredShape}`);
483
523
  if ('method' in h && !(typeof h.method === 'string' && h.method.trim() !== '')) p.add('POSE_CLEANED', `${hw} "method" is ${typeName(h.method)}; it must be a non-empty string naming the reading`);
524
+ if ('fit' in h) {
525
+ const f = h.fit;
526
+ const fw = `${hw} "fit"`;
527
+ if (!isObject(f)) p.add('POSE_CLEANED', `${fw} is ${typeName(f)}; it must be { frames, gain, corr }`);
528
+ else {
529
+ refuseUnknownKeys(p, 'POSE_CLEANED', fw, f, ['frames', 'gain', 'corr']);
530
+ if (!(Number.isInteger(f.frames) && (f.frames as number) >= 0)) p.add('POSE_CLEANED', `${fw} "frames" is ${typeName(f.frames)}; it must be a whole number of frames ≥ 0`);
531
+ if (!(f.gain === null || (typeof f.gain === 'number' && Number.isFinite(f.gain)))) p.add('POSE_CLEANED', `${fw} "gain" is ${typeName(f.gain)}; it must be a finite number or null`);
532
+ if (!(f.corr === null || (typeof f.corr === 'number' && Number.isFinite(f.corr) && f.corr >= -1 && f.corr <= 1))) p.add('POSE_CLEANED', `${fw} "corr" is ${typeName(f.corr)}; it must be a correlation in [-1, 1] or null`);
533
+ }
534
+ }
484
535
  const range = `[0, ${frames === null ? 'the frame count' : frames - 1}]`;
485
536
  /** A run [first, last], or null after naming what is wrong with it. */
486
537
  const run = (v: unknown, rw: string): Run | null => {
@@ -512,8 +563,8 @@ function readHipsInferred(p: Problems, hw: string, h: unknown, frames: number |
512
563
  if (cur.r[0] <= prev.r[1]) p.add('POSE_CLEANED', `${cur.w} [${cur.r[0]}, ${cur.r[1]}] starts at or before the end of ${prev.w} [${prev.r[0]}, ${prev.r[1]}]; ${what} runs must increase and not overlap`);
513
564
  }
514
565
  };
515
- const classes: Record<(typeof CLASSES)[number], Named[]> = { read: [], interpolated: [], held: [] };
516
- for (const c of CLASSES) {
566
+ const classes: Record<(typeof ALL_CLASSES)[number], Named[]> = { read: [], interpolated: [], scaled: [], held: [], unmeasured: [] };
567
+ for (const c of ALL_CLASSES) {
517
568
  if (!(c in h)) continue;
518
569
  const list = h[c];
519
570
  if (!Array.isArray(list)) {
@@ -548,12 +599,15 @@ function readHipsInferred(p: Problems, hw: string, h: unknown, frames: number |
548
599
  });
549
600
  ordered(declined, 'declined');
550
601
  }
551
- // Across lists: the classes are disjoint, they cover every frame, and declined is exactly interpolated ∪ held.
552
- for (let x = 0; x < CLASSES.length; x++) {
553
- for (let y = x + 1; y < CLASSES.length; y++) {
554
- for (const a of classes[CLASSES[x]!]) {
555
- for (const b of classes[CLASSES[y]!]) {
556
- if (a.r[0] <= b.r[1] && b.r[0] <= a.r[1]) p.add('POSE_CLEANED', `${a.w} [${a.r[0]}, ${a.r[1]}] and ${b.w} [${b.r[0]}, ${b.r[1]}] share frame(s) ${Math.max(a.r[0], b.r[0])}–${Math.min(a.r[1], b.r[1])}; a frame is one of read, interpolated and held`);
602
+ const has106 = 'scaled' in h || 'unmeasured' in h || 'fit' in h;
603
+ const classListLabel = has106 ? 'read, interpolated, scaled, held and unmeasured' : 'read, interpolated and held';
604
+ const filledLabel = has106 ? 'interpolated, scaled, held or unmeasured' : 'interpolated or held';
605
+ // Across lists: the classes are disjoint, they cover every frame, and declined is exactly the non-read classes.
606
+ for (let x = 0; x < ALL_CLASSES.length; x++) {
607
+ for (let y = x + 1; y < ALL_CLASSES.length; y++) {
608
+ for (const a of classes[ALL_CLASSES[x]!]) {
609
+ for (const b of classes[ALL_CLASSES[y]!]) {
610
+ if (a.r[0] <= b.r[1] && b.r[0] <= a.r[1]) p.add('POSE_CLEANED', `${a.w} [${a.r[0]}, ${a.r[1]}] and ${b.w} [${b.r[0]}, ${b.r[1]}] share frame(s) ${Math.max(a.r[0], b.r[0])}–${Math.min(a.r[1], b.r[1])}; a frame is one of ${classListLabel}`);
557
611
  }
558
612
  }
559
613
  }
@@ -574,18 +628,18 @@ function readHipsInferred(p: Problems, hw: string, h: unknown, frames: number |
574
628
  return out.map(([a, b]) => (a === b ? `${a}` : `${a}–${b}`)).join(', ');
575
629
  };
576
630
  const read = set(classes.read);
577
- const filled = set([...classes.interpolated, ...classes.held]);
631
+ const nonRead = set([...classes.interpolated, ...classes.scaled, ...classes.held, ...classes.unmeasured]);
578
632
  const dec = set(declined);
579
633
  if (frames !== null) {
580
- const none = Array.from({ length: frames }, (_, f) => f).filter((f) => !read.has(f) && !filled.has(f));
581
- if (none.length > 0) p.add('POSE_CLEANED', `${hw}: frame(s) ${spans(none)} are in none of read, interpolated and held; every frame of the sequence is one of them`);
634
+ const none = Array.from({ length: frames }, (_, f) => f).filter((f) => !read.has(f) && !nonRead.has(f));
635
+ if (none.length > 0) p.add('POSE_CLEANED', `${hw}: frame(s) ${spans(none)} are in none of ${classListLabel}; every frame of the sequence is one of them`);
582
636
  }
583
637
  const both = [...dec].filter((f) => read.has(f)).sort((a, b) => a - b);
584
638
  if (both.length > 0) p.add('POSE_CLEANED', `${hw}: frame(s) ${spans(both)} are both read and declined; a frame is read or declined, not both`);
585
- const unfilled = [...dec].filter((f) => !read.has(f) && !filled.has(f)).sort((a, b) => a - b);
586
- if (unfilled.length > 0) p.add('POSE_CLEANED', `${hw}: frame(s) ${spans(unfilled)} are declined but neither interpolated nor held; a declined frame takes one of them`);
587
- const undeclared = [...filled].filter((f) => !dec.has(f)).sort((a, b) => a - b);
588
- if (undeclared.length > 0) p.add('POSE_CLEANED', `${hw}: frame(s) ${spans(undeclared)} are interpolated or held but not declined; only a frame the reading declined is filled, and "declined" says why`);
639
+ const unfilled = [...dec].filter((f) => !read.has(f) && !nonRead.has(f)).sort((a, b) => a - b);
640
+ if (unfilled.length > 0) p.add('POSE_CLEANED', `${hw}: frame(s) ${spans(unfilled)} are declined but neither ${filledLabel}; a declined frame takes one of them`);
641
+ const undeclared = [...nonRead].filter((f) => !dec.has(f)).sort((a, b) => a - b);
642
+ if (undeclared.length > 0) p.add('POSE_CLEANED', `${hw}: frame(s) ${spans(undeclared)} are ${filledLabel} but not declined; only a frame the reading declined is filled, and "declined" says why`);
589
643
  }
590
644
  const holdParamsOf = (h: HoldParams | 'off'): HoldParams | 'off' => (h === 'off' ? 'off' : { speed: h.speed, reach: h.reach, minSeconds: h.minSeconds, blend: h.blend });
591
645
  const copyHold = (h: CleanHold): CleanHold => ({ joint: h.joint, frames: [h.frames[0], h.frames[1]], jitter: { before: h.jitter.before, after: h.jitter.after } });
@@ -786,5 +840,5 @@ export function writePoseSequence(seq: PoseSequence): string {
786
840
  ...(c.seams !== undefined ? { seams: c.seams.map((x) => ({ joint: x.joint, frame: x.frame, step: round(x.step, 4), gap: x.gap })) } : {}),
787
841
  ...(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
842
  };
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`;
843
+ 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
844
  }
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
  }