@rig-c/pose 0.31.1 → 0.33.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/clean.ts CHANGED
@@ -807,7 +807,7 @@ export function untestedText(frames: readonly number[]): string {
807
807
  }
808
808
 
809
809
  /** The record as one line: retarget's CLEAN line, and clean's own summary. */
810
- export function cleanLine(r: CleanRecord): string {
810
+ export function cleanLine(r: CleanRecord, hold: (r: CleanRecord) => string = holdText): string {
811
811
  const { loop, legs, rest } = r.params;
812
812
  const windows = legs === 1 && rest === 1 ? 'legs 1, rest 1 — no smoothing' : `legs ${legs}, rest ${rest}`;
813
813
  const found = r.spikes.found;
@@ -815,7 +815,28 @@ export function cleanLine(r: CleanRecord): string {
815
815
  const spikes = `${found.length === 0 ? 'no spike' : `${found.length} spike(s) replaced (${shown}${found.length > 8 ? `, … ${found.length - 8} more` : ''})`}, ${untestedText(r.spikes.untested)}`;
816
816
  const jit = (v: number | null) => (v === null ? 'not measured' : v.toFixed(2));
817
817
  const hips = r.params.hipsInferred === undefined ? '' : `; hips inferred (${inferredSource(r)})`;
818
- return `${r.spec}: ${windows}, ${loop ? 'loop' : 'no loop'}; ${spikes}; jitter (mean |second difference| of y) ${jit(r.jitter.before)} → ${jit(r.jitter.after)} px/f²${holdText(r)}${hips}`;
818
+ return `${r.spec}: ${windows}, ${loop ? 'loop' : 'no loop'}; ${spikes}; jitter (mean |second difference| of y) ${jit(r.jitter.before)} → ${jit(r.jitter.after)} px/f²${hold(r)}${hips}`;
819
+ }
820
+
821
+ /**
822
+ * The CLEAN line as retarget prints it by default (rig-c/pose#238): `cleanLine`
823
+ * with the stillness hold's per-joint record replaced by its count — holds,
824
+ * joints, regions and the frames they span. The record itself is in the
825
+ * sequence's `cleaned` (`holds`, `unheld`, `seams`), which `--report` writes
826
+ * whole as its `cleaned`; `--verbose` prints `cleanLine`.
827
+ */
828
+ export function cleanSummaryLine(r: CleanRecord): string {
829
+ return cleanLine(r, holdCount);
830
+ }
831
+
832
+ function holdCount(r: CleanRecord): string {
833
+ const h = r.params.hold;
834
+ const holds = r.holds ?? [];
835
+ if (h === undefined || h === 'off' || holds.length === 0) return holdText(r);
836
+ const regions = holdRegions(holds);
837
+ const span = `${regions[0]![0]}–${regions[regions.length - 1]![1]}`;
838
+ const unheld = (r.unheld ?? []).length, seams = (r.seams ?? []).length;
839
+ return `; ${holds.length} HOLD(s) over ${new Set(holds.map((x) => x.joint)).size} joint(s) in ${regions.length} region(s), frames ${span}, ${unheld} joint span(s) not held, ${seams} seam(s) (per joint: --report's cleaned.holds, cleaned.unheld, cleaned.seams; --verbose)`;
819
840
  }
820
841
 
821
842
  /**
package/src/legs.ts CHANGED
@@ -321,6 +321,16 @@ export interface LegScale {
321
321
  scale: number[];
322
322
  /** The scale the pose asked for on each frame before the clamp floor (`min(ratio, limits[1])`, rig-c/pose#107). */
323
323
  asked: number[];
324
+ /** The picture's scale on each frame, before either limit: picture length ÷ straight (rig-c/pose#237). */
325
+ picture: number[];
326
+ /**
327
+ * rig-c/pose#237: on the frames this segment was clamped (`scale ≠ picture`)
328
+ * while its side was free (the other foot supporting, or tracked), what the
329
+ * clamps on that leg did to the free ankle: its height keyed against its
330
+ * height with every segment of that side at the picture's scale, the other
331
+ * leg as keyed on both. Absent with no supports read, or no clamped frame.
332
+ */
333
+ free?: FreeAnkleShift;
324
334
  /**
325
335
  * rig-c/pose#133: under `--plant-feet`, runs of supporting frames on this
326
336
  * segment where the asked scale went under `limits[0]` and followed `asked`
@@ -357,9 +367,90 @@ export function legScaleOf(lengths: readonly number[], limits: [number, number],
357
367
  ...(clamped.length > 0 ? { clamped } : {}),
358
368
  scale,
359
369
  asked,
370
+ picture: ratio,
360
371
  };
361
372
  }
362
373
 
374
+ /** rig-c/pose#237: one mapped leg segment's picture length ÷ its straight length (the one `--leg-scale` scales against), read with or without the flag. */
375
+ export interface LegSegmentRatio {
376
+ chain: string;
377
+ bone: string;
378
+ joints: [string, string];
379
+ /** px; null where it was not measured (`why`). */
380
+ straight: number | null;
381
+ /** Before any clamp: lowest, median, highest; null where it was not measured. */
382
+ ratio: { min: number; median: number; max: number } | null;
383
+ why?: string;
384
+ }
385
+
386
+ /** A free ankle's height moved by the leg-scale clamps (rig-c/pose#237): world units, and in that side's rig leg at setup (thigh origin → ankle). */
387
+ export interface AnkleShift {
388
+ units: number;
389
+ legLengths: number;
390
+ }
391
+
392
+ /** What the clamps on a free leg did to its ankle's height over a set of frames (rig-c/pose#237). */
393
+ export interface FreeAnkleShift {
394
+ side: Side;
395
+ /** Runs of the frames counted. */
396
+ frames: Array<[number, number]>;
397
+ /** How many frames. */
398
+ count: number;
399
+ /** The largest height above the picture's, and the largest below it (both ≥ 0); null where no frame was that way. */
400
+ above: AnkleShift | null;
401
+ below: AnkleShift | null;
402
+ }
403
+
404
+ /**
405
+ * rig-c/pose#237: per side and frame, the free ankle's world y as keyed less
406
+ * its world y with that side's leg segments at the picture's scale — on a frame
407
+ * the side is free (`freeOf`) and at least one of its segments' keyed scale is
408
+ * not the picture's; null on every other frame. `tipY` poses the legs by a
409
+ * scale lookup and returns that side's ankle world y; both poses share every
410
+ * other bone's scale, so the supporting leg cancels.
411
+ */
412
+ export function freeAnkleShiftOf(n: number, legScale: readonly LegScale[], freeOf: (i: number) => Side | null, tipY: (i: number, side: Side, scaleOf: (bone: string) => number | null) => number): Record<Side, Array<number | null>> {
413
+ const out: Record<Side, Array<number | null>> = { r: new Array<number | null>(n).fill(null), l: new Array<number | null>(n).fill(null) };
414
+ const sideOf = (ls: LegScale): Side => (ls.joints[0].startsWith('r_') ? 'r' : 'l');
415
+ const byBone = new Map(legScale.map((ls) => [ls.bone, ls]));
416
+ for (let i = 0; i < n; i++) {
417
+ const f = freeOf(i);
418
+ if (f === null) continue;
419
+ const own = legScale.filter((ls) => sideOf(ls) === f);
420
+ if (!own.some((ls) => ls.scale[i] !== ls.picture[i])) continue;
421
+ const keyed = (b: string): number | null => byBone.get(b)?.scale[i] ?? null;
422
+ const picture = (b: string): number | null => {
423
+ const ls = byBone.get(b);
424
+ if (ls === undefined) return null;
425
+ return sideOf(ls) === f ? ls.picture[i]! : ls.scale[i]!;
426
+ };
427
+ out[f][i] = tipY(i, f, keyed) - tipY(i, f, picture);
428
+ }
429
+ return out;
430
+ }
431
+
432
+ /** The frames of `frames` with a shift on `side`, as a `FreeAnkleShift` in that side's rig leg `leg` (world units); null when none has one. */
433
+ export function shiftOver(side: Side, frames: readonly number[], shift: Record<Side, ReadonlyArray<number | null>>, leg: number): FreeAnkleShift | null {
434
+ const hit = frames.filter((i) => shift[side][i] !== null && shift[side][i] !== undefined);
435
+ if (hit.length === 0) return null;
436
+ const vs = hit.map((i) => shift[side][i]!);
437
+ const up = Math.max(0, ...vs);
438
+ const down = Math.max(0, ...vs.map((v) => -v));
439
+ const of = (u: number): AnkleShift => ({ units: u, legLengths: u / leg });
440
+ return { side, frames: runsOf(hit), count: hit.length, above: vs.some((v) => v > 0) ? of(up) : null, below: vs.some((v) => v < 0) ? of(down) : null };
441
+ }
442
+
443
+ /** Consecutive runs of a sorted list of frames. */
444
+ function runsOf(xs: readonly number[]): Array<[number, number]> {
445
+ const out: Array<[number, number]> = [];
446
+ for (const x of xs) {
447
+ const last = out[out.length - 1];
448
+ if (last !== undefined && last[1] === x - 1) last[1] = x;
449
+ else out.push([x, x]);
450
+ }
451
+ return out;
452
+ }
453
+
363
454
  function runs(flags: readonly boolean[]): Array<[number, number]> {
364
455
  const out: Array<[number, number]> = [];
365
456
  for (let i = 0; i < flags.length; i++) {
@@ -1129,6 +1220,8 @@ export interface StanceRun {
1129
1220
  k: { min: number; max: number } | null;
1130
1221
  /** #87: a run read in `state`, shorter than `STANCE_MIN_SECONDS` between two runs of the other state, which took their state; its frames are in their run too, and `k` is null. */
1131
1222
  absorbed?: true;
1223
+ /** rig-c/pose#237, on a `lifted` or `below` run under `--leg-scale`: the frames whose free ankle the clamps moved off the pose's height, and by how much. Absent when the pose's height was kept on every frame. */
1224
+ moved?: FreeAnkleShift;
1132
1225
  }
1133
1226
 
1134
1227
  /** A run of planted-both frames whose stretch needed a factor past the bounds (or could not reach: `needed` null). */
package/src/map.ts CHANGED
@@ -43,6 +43,11 @@
43
43
  * well. Outside a set's span the retarget clamps the key there, and lists the
44
44
  * overflow as demand (`RANGE`).
45
45
  *
46
+ * The chain whose joints carry the eye line (`r_eye → l_eye`) keys the head's
47
+ * nod; `"nod": false` on it keys none and couples none (rig-c/pose#241). "nod"
48
+ * on any other chain is refused (`MAP_NOD`): a switch with nothing to turn off
49
+ * would be read as having turned something off.
50
+ *
46
51
  * `alternates` lists the rig's alternate drawings as sets: `{ name, slots,
47
52
  * hides, chains, pose, drawn?, offsets? }`. While a set shows, every slot in
48
53
  * `slots` shows its attachment and every slot in `hides` is empty; `pose` is the
@@ -73,6 +78,8 @@ export interface Chain {
73
78
  reach: boolean;
74
79
  /** Per bone of the chain: the rotate key's range, degrees relative to setup, [min, max]. */
75
80
  range: Record<string, [number, number]>;
81
+ /** On the chain carrying the eye line (`r_eye → l_eye`): false keys no head nod and couples none (rig-c/pose#241); absent or true, the nod is read. */
82
+ nod?: boolean;
76
83
  }
77
84
  export interface Translate {
78
85
  bone: string;
@@ -160,6 +167,23 @@ export interface RestBone extends RawBone {
160
167
  }
161
168
  export type Rest = ReadonlyMap<string, RestBone>;
162
169
 
170
+ /** The body-18 points past which nothing is read: no toe past an ankle, no hand point past a wrist. */
171
+ const LIMB_ENDS: Readonly<Record<string, string>> = { r_ankle: 'toe', l_ankle: 'toe', r_wrist: 'hand point', l_wrist: 'hand point' };
172
+
173
+ /**
174
+ * MAP_CHAIN_JOINTS's remedy (rig-c/pose#240): a chain one joint short whose last
175
+ * joint ends a limb — a three-bone leg on hip, knee, ankle — has a bone body-18
176
+ * gives no segment to, so the remedy is to leave it out, not to find a joint.
177
+ */
178
+ function endOfLimb(bones: readonly string[], joints: readonly string[]): string {
179
+ const last = joints[joints.length - 1];
180
+ if (joints.length !== bones.length || last === undefined) return '';
181
+ const what = LIMB_ENDS[last];
182
+ if (what === undefined) return '';
183
+ const bone = bones[bones.length - 1]!;
184
+ return ` — body-18 has no ${what} past "${last}", so nothing aims bone "${bone}": list the chain's bones up to the one ending at "${last}" (${bones.slice(0, -1).map((b) => `"${b}"`).join(', ')}) and leave "${bone}" out of "bones"; it stays unkeyed and turns with its parent`;
185
+ }
186
+
163
187
  export function readMap(text: string, where: string): JointMap {
164
188
  const doc = parseJson(text, where, 'MAP_JSON');
165
189
  const p = new Problems();
@@ -178,10 +202,10 @@ export function readMap(text: string, where: string): JointMap {
178
202
  doc.chains.forEach((c: unknown, i: number) => {
179
203
  const cw = `${where} chains[${i}]`;
180
204
  if (!isObject(c)) {
181
- p.add('MAP_CHAIN_SHAPE', `${cw} is ${typeName(c)}; a chain is { name, bones, joints, aim?, bend?, reach?, range? }`);
205
+ p.add('MAP_CHAIN_SHAPE', `${cw} is ${typeName(c)}; a chain is { name, bones, joints, aim?, bend?, reach?, range?, nod? }`);
182
206
  return;
183
207
  }
184
- refuseUnknownKeys(p, 'MAP_KEYS', cw, c, ['name', 'bones', 'joints', 'aim', 'bend', 'reach', 'range']);
208
+ refuseUnknownKeys(p, 'MAP_KEYS', cw, c, ['name', 'bones', 'joints', 'aim', 'bend', 'reach', 'range', 'nod']);
185
209
  const name = typeof c.name === 'string' && c.name !== '' ? c.name : null;
186
210
  if (name === null) p.add('MAP_CHAIN_NAME', `${cw} "name" is ${typeName(c.name)}; it must be a non-empty string`);
187
211
  else if (names.has(name)) p.add('MAP_CHAIN_NAME', `${cw} "name" "${name}" names two chains`);
@@ -192,7 +216,7 @@ export function readMap(text: string, where: string): JointMap {
192
216
  if (bones === null || bones.length === 0) p.add('MAP_CHAIN_BONES', `${label} "bones" is ${typeName(c.bones)}; it must be a non-empty array of bone names`);
193
217
  if (joints === null || joints.length < 2) p.add('MAP_CHAIN_JOINTS', `${label} "joints" is ${typeName(c.joints)}; it must be an array of at least two joint names`);
194
218
  if (bones !== null && joints !== null && bones.length > 0 && joints.length !== bones.length + 1) {
195
- p.add('MAP_CHAIN_JOINTS', `${label} has ${bones.length} bone(s) and ${joints.length} joint(s); bone k carries the segment from joint k to joint k+1, so it needs ${bones.length + 1}`);
219
+ p.add('MAP_CHAIN_JOINTS', `${label} has ${bones.length} bone(s) and ${joints.length} joint(s); bone k carries the segment from joint k to joint k+1, so it needs ${bones.length + 1}${endOfLimb(bones, joints)}`);
196
220
  }
197
221
  for (const j of joints ?? []) {
198
222
  if (!POINT_NAMES.includes(j)) p.add('MAP_JOINT_NAME', `${label} names joint "${j}", which is not a point this package reads; they are ${POINT_NAMES.join(', ')}`);
@@ -228,7 +252,14 @@ export function readMap(text: string, where: string): JointMap {
228
252
  }
229
253
  }
230
254
  }
231
- chains.push({ name: name ?? cw, bones: bones ?? [], joints: joints ?? [], aim, bend, reach, range });
255
+ let nod: boolean | undefined;
256
+ if ('nod' in c) {
257
+ const eyeLine = (joints ?? []).some((j, k, js) => (j === 'r_eye' && js[k + 1] === 'l_eye') || (j === 'l_eye' && js[k + 1] === 'r_eye'));
258
+ if (typeof c.nod !== 'boolean') p.add('MAP_NOD', `${label} "nod" is ${typeName(c.nod)}; it must be true or false (false keys no head nod and couples none into the head's rotate or the face pitch)`);
259
+ else if (!eyeLine) p.add('MAP_NOD', `${label} has "nod" but its joints (${(joints ?? []).join(' → ')}) carry no eye line; the nod is read on the chain whose joints run r_eye → l_eye (or l_eye → r_eye), so "nod" belongs there or nowhere`);
260
+ else nod = c.nod;
261
+ }
262
+ chains.push({ name: name ?? cw, bones: bones ?? [], joints: joints ?? [], aim, bend, reach, range, ...(nod !== undefined ? { nod } : {}) });
232
263
  });
233
264
  }
234
265
  let translate: Translate | null = null;
package/src/problems.ts CHANGED
@@ -10,6 +10,13 @@ export interface Problem {
10
10
  code: string;
11
11
  /** The object, the value found, the value required. */
12
12
  text: string;
13
+ /**
14
+ * The per-frame problems this one collapses (rig-c/pose#238): a refusal of one
15
+ * kind that fires on every frame prints one line with its count, worst value
16
+ * and frame range; these are the lines it stands for, printed under --verbose.
17
+ * `detail.length` is the count the collapsed line states.
18
+ */
19
+ detail?: readonly Problem[];
13
20
  }
14
21
 
15
22
  export class Refusal extends Error {
@@ -28,8 +35,8 @@ export function lineOf(p: Problem): string {
28
35
  /** A collector: `add` while reading, `throwIfAny` once at the end. */
29
36
  export class Problems {
30
37
  readonly list: Problem[] = [];
31
- add(code: string, text: string): void {
32
- this.list.push({ code, text });
38
+ add(code: string, text: string, detail?: readonly Problem[]): void {
39
+ this.list.push(detail === undefined ? { code, text } : { code, text, detail });
33
40
  }
34
41
  get any(): boolean {
35
42
  return this.list.length > 0;
package/src/report.ts CHANGED
@@ -56,10 +56,12 @@ export function enrichReport(input:Record<string,unknown>,map:JointMap,clock:Rep
56
56
  if(Array.isArray(value)){value.forEach((row,i)=>visit(row,`${where}[${i}]`));return;}
57
57
  if(!isObject(value))return;
58
58
  // Preserve uncovered's discrete renderer times; they do not index the pose.
59
- if(where.startsWith('report.uncovered[')||where.startsWith('report.folds[')){
59
+ if(where.startsWith('report.uncovered[')||where.startsWith('report.folds[')||where.startsWith('report.seams[')){
60
60
  if(!render)fail(where,'render-domain event requires declared renderer metadata');
61
61
  add(value,'domain','render',where);return;
62
62
  }
63
+ // seamPairs' frame lists are the seam render's indices (#216); they carry no pose coordinate.
64
+ if(where.startsWith('report.seamPairs['))return;
63
65
  let coordinates=false;
64
66
  if(poseEvent(where)&&value.frame!==undefined&&typeof value.frame!=='number')fail(where+'.frame','expected nonnegative integer pose frame');
65
67
  if(typeof value.frame==='number'){add(value,'t',time(value.frame,where+'.frame'),where);coordinates=true;}
@@ -85,4 +87,26 @@ export function enrichReport(input:Record<string,unknown>,map:JointMap,clock:Rep
85
87
  if(inputs!==undefined){if(!isObject(inputs))throw new Refusal([{code:'REPORT_INPUTS',text:'inputs: expected rig, map and pose file identities'}]);for(const key of ['rig','map','pose'] as const){const entry=inputs[key];if(!isObject(entry)||typeof entry.path!=='string'||!entry.path||entry.path.startsWith('/')||entry.path.charCodeAt(0)===92||/^[A-Za-z]:/.test(entry.path)||typeof entry.sha256!=='string'||! /^[a-f0-9]{64}$/.test(entry.sha256))throw new Refusal([{code:'REPORT_INPUTS',text:`inputs.${key}: expected invocation-relative consumed file path and exact SHA-256`}]);}add(report,'inputs',clone(inputs),'report');}
86
88
  problems.throwIfAny();return report;
87
89
  }
88
- export function writeReport(input:Record<string,unknown>,map:JointMap,clock:ReportClock,render:RenderDomain|null,inputs:ReportInputs):string {if(inputs===undefined)throw new Refusal([{code:'REPORT_INPUTS',text:'inputs: expected rig, map and pose file identities'}]);return JSON.stringify(enrichReport(input,map,clock,render,inputs),null,2)+'\n';}
90
+ /**
91
+ * rig-c/pose#216: `seams` and `seamPairs` are declared fields, not optional by
92
+ * accident — a report without them is a run whose seam reading never ran, and
93
+ * the writer refuses it by name. Each `seams` row is one span of one kind:
94
+ * `{ pair, kind, frames: [first, last], times: [first, last], maxPx }`.
95
+ */
96
+ export function checkSeams(input:Record<string,unknown>):void {
97
+ const p=new Problems();
98
+ if(!Array.isArray(input.seams))p.add('REPORT_SEAMS',`seams: found ${input.seams===undefined?'no field':JSON.stringify(input.seams)}; the report declares the seam reading's spans (rig-c/pose#216), [] when none opens or no pair is declared, and seamPairs says which`);
99
+ if(!Array.isArray(input.seamPairs))p.add('REPORT_SEAMS',`seamPairs: found ${input.seamPairs===undefined?'no field':JSON.stringify(input.seamPairs)}; the report declares what the seam reading said of each declared pair (rig-c/pose#216), [] when none is declared`);
100
+ p.throwIfAny();
101
+ const int=(v:unknown)=>Number.isSafeInteger(v)&&(v as number)>=0;
102
+ (input.seams as unknown[]).forEach((row,i)=>{
103
+ const ok=isObject(row)&&typeof row.pair==='string'&&row.pair.includes('|')&&(row.kind==='gap'||row.kind==='ghost')&&Array.isArray(row.frames)&&row.frames.length===2&&row.frames.every(int)&&(row.frames[1] as number)>=(row.frames[0] as number)&&Array.isArray(row.times)&&row.times.length===2&&row.times.every(t=>typeof t==='number'&&Number.isFinite(t)&&t>=0)&&(row.times[1] as number)>=(row.times[0] as number)&&int(row.maxPx)&&(row.maxPx as number)>0;
104
+ if(!ok)p.add('REPORT_SEAMS',`seams[${i}]: found ${JSON.stringify(row)}; expected { pair: "<a>|<b>", kind: "gap"|"ghost", frames: [first, last], times: [first, last], maxPx > 0 }`);
105
+ });
106
+ (input.seamPairs as unknown[]).forEach((row,i)=>{
107
+ const ok=isObject(row)&&typeof row.pair==='string'&&(row.status==='read'||row.status==='skip'||row.status==='unread')&&(row.status!=='unread'||typeof row.reason==='string');
108
+ if(!ok)p.add('REPORT_SEAMS',`seamPairs[${i}]: found ${JSON.stringify(row)}; expected { pair, status: "read"|"skip"|"unread", reason when unread }`);
109
+ });
110
+ p.throwIfAny();
111
+ }
112
+ export function writeReport(input:Record<string,unknown>,map:JointMap,clock:ReportClock,render:RenderDomain|null,inputs:ReportInputs):string {checkSeams(input);if(inputs===undefined)throw new Refusal([{code:'REPORT_INPUTS',text:'inputs: expected rig, map and pose file identities'}]);return JSON.stringify(enrichReport(input,map,clock,render,inputs),null,2)+'\n';}