@rig-c/pose 0.40.0 → 0.42.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/pelvis.ts CHANGED
@@ -31,6 +31,11 @@
31
31
  * scan runs down to where the colour was found. The first patch that holds
32
32
  * and is not the background is the garment; with none, the midpoint's median
33
33
  * is read as it is.
34
+ * The line is fitted through the band's top edge (rig-c/pose#318): where a
35
+ * strip of the garment is too thin for the colour test (a waistband drawn as
36
+ * two ink lines), the tops step between the strip's lines, and each column
37
+ * is followed up to its topmost garment pixel; an edge that cuts across the
38
+ * band under it (a fold) is declined.
34
39
  *
35
40
  * The forearms are not read: pixels near the segments elbow → wrist (and the
36
41
  * hands) are masked, since a hanging arm passes the hips in a walk. A frame
@@ -76,13 +81,17 @@ export const MAX_RMS = 2.5;
76
81
  export const ARM_RADIUS = 0.15;
77
82
  /** Hips closer than this in the picture, px, have no span to read across (a figure seen side-on). */
78
83
  export const MIN_SPAN = 6;
84
+ /** The band's top is taken above the scan's tops only when their climbs to it differ by more than this many rows (rig-c/pose#318): each end of a clean edge's climb is found to a row. */
85
+ export const CLIMB_SPREAD = 2;
86
+ /** The band's top is climbed against at most this many garment colours (rig-c/pose#318): a paletted garment's own, or a painted one's fullest cells. */
87
+ export const MAX_CLOTH = 16;
79
88
 
80
89
  /**
81
90
  * One frame's reading: the tilt (Spine degrees, r_hip → l_hip), or why it was declined. A waistband
82
91
  * read through the window mean says so (`mean`), and one whose colour was found below the hips says
83
- * how far (`below`, px).
92
+ * how far (`below`, px); one whose band top lay above the colour's edge in some columns says how many (`raised`, #318).
84
93
  */
85
- export type Reading = { tilt: number; cover: number; rms: number; mean?: true; below?: number } | { kind: string; why: string };
94
+ export type Reading = { tilt: number; cover: number; rms: number; mean?: true; below?: number; raised?: number } | { kind: string; why: string };
86
95
 
87
96
  /** The frame as RGB over white, its background colour, and which pixels are masked (forearms, hands). */
88
97
  interface Frame {
@@ -321,6 +330,7 @@ function waistband(f: Frame, c: Pt, span: number, x0: number, x1: number, cols:
321
330
  }
322
331
  }
323
332
  }
333
+ const raised = toBandTop(f, tops, g, top, colourAt);
324
334
  const share = tops.length / cols;
325
335
  if (share < MIN_COVER) {
326
336
  return above > cols / 2 ? { kind: 'garment above the band', why: `the garment runs above the band read (${above} of ${cols} columns carry its colour ${(1.5 * span).toFixed(0)} px above the hips); no waistband edge` } : { kind: 'waistband edge too short', why: `the garment's top edge is found in ${tops.length} of the ${cols} columns between the hips (${(100 * share).toFixed(0)}% < ${100 * MIN_COVER}%)` };
@@ -329,7 +339,197 @@ function waistband(f: Frame, c: Pt, span: number, x0: number, x1: number, cols:
329
339
  if (fit === null) return { kind: 'no line', why: 'the garment\'s top edge does not fit a line (fewer than three columns)' };
330
340
  if (fit.rms > MAX_RMS) return { kind: 'not a straight waistband', why: `the garment's top edge lies ${fit.rms.toFixed(2)} px from its line (root mean square > ${MAX_RMS}); not a straight waistband` };
331
341
  if (Math.abs(Math.atan(fit.m)) * (180 / Math.PI) > 45) return { kind: 'waistband too steep', why: `the garment's top edge runs ${(Math.atan(fit.m) * (180 / Math.PI)).toFixed(1)}° from level; a waistband is within 45°` };
332
- return { tilt: tiltOf(fit.m, rHip, lHip), cover: share, rms: fit.rms, ...(isMean ? { mean: true as const } : {}), ...(below > 0 ? { below } : {}) };
342
+ const off = notTheBand(f, fit, tops, top, colourAt);
343
+ if (off !== null) return off;
344
+ return { tilt: tiltOf(fit.m, rHip, lHip), cover: share, rms: fit.rms, ...(isMean ? { mean: true as const } : {}), ...(below > 0 ? { below } : {}), ...(raised > 0 ? { raised } : {}) };
345
+ }
346
+
347
+ /**
348
+ * rig-c/pose#318: the band's top edge, the topmost garment boundary in each column. The three-row test misses garment
349
+ * above the row it finds when a strip of it is too thin to carry the colour three rows deep (through the window mean,
350
+ * a strip between two ink lines never does), so the tops step between a strip's top line and the cloth under its lower
351
+ * line. The garment's colours are the picture's own: the pixels of three rows in each column `DITHER_RADIUS + 1` below
352
+ * the row the scan found, clear of the edge's blend (an outline there would lend the colour above to the garment). The
353
+ * thing above has one colour, `a`: the median, over the columns read, of the rows from the scan's top down to
354
+ * `3 · (DITHER_RADIUS + 1)` above the topmost top — clear of the edge's blend, an outline and a window, and by area, so
355
+ * a line crossing it or a tilted band's outline in a few of its rows does not move it. From each top
356
+ * the column is followed up, pixel by pixel, while a pixel is no nearer to `a` than to the nearest garment pixel (cloth
357
+ * of any shade, and the ink lines on it, are); a climb that reaches the scan's top or a forearm does not see the top.
358
+ * The band's top is the topmost cloth pixel on the path (within `GARMENT_DIFF` of a garment pixel), from where the climb
359
+ * stopped down to the scan's three rows: an outline above it, or a line leaving the band upward, is ink, not garment;
360
+ * a thin strip's cloth between its two lines is garment. A clean edge climbs
361
+ * about the same rows in every column — the distance between two boundaries each found to a row — so only when the
362
+ * climbs differ by more than `CLIMB_SPREAD` rows (90th less 10th percentile) do the columns take their topmost garment
363
+ * row — and a column whose top was not seen is then not read, since its scan top is the lower level; otherwise the
364
+ * tops stay exactly as read. Nothing is done when the colour above is the garment's (its median
365
+ * within `GARMENT_DIFF`). Rewrites `tops` in place; returns how many moved.
366
+ */
367
+ function toBandTop(f: Frame, tops: Pt[], g: RGB, top: number, colourAt: (x: number, y: number) => RGB | null): number {
368
+ if (tops.length === 0) return 0;
369
+ const lift = DITHER_RADIUS + 1;
370
+ const ya = Math.min(...tops.map((p) => p[1])) - 3 * lift;
371
+ if (ya < top) return 0;
372
+ // The garment's colours, at most `MAX_CLOTH`: a paletted garment's few, as they are; a painted one's many, as the mean
373
+ // colours of the `MAX_CLOTH` fullest cells of a `GARMENT_DIFF / 2` grid (first filled first among equals). The climb
374
+ // compares each pixel against them all, so their number is bounded whatever the span.
375
+ const exact = new Map<string, RGB>();
376
+ const cells = new Map<string, { s: RGB; n: number }>();
377
+ const step = GARMENT_DIFF / 2;
378
+ for (const [x, y0] of tops) {
379
+ for (let k = 0; k < 3; k++) {
380
+ const y = y0 + lift + k;
381
+ if (y < 0 || y >= f.h || f.masked(x, y)) continue;
382
+ const p = f.rgb(x, y);
383
+ if (exact.size <= MAX_CLOTH) exact.set(p.join(','), p);
384
+ const key = p.map((v) => Math.floor(v / step)).join(',');
385
+ const c = cells.get(key);
386
+ if (c === undefined) cells.set(key, { s: [p[0], p[1], p[2]], n: 1 });
387
+ else {
388
+ c.s[0] += p[0];
389
+ c.s[1] += p[1];
390
+ c.s[2] += p[2];
391
+ c.n++;
392
+ }
393
+ }
394
+ }
395
+ const cloth: RGB[] = exact.size <= MAX_CLOTH ? [...exact.values()] : [...cells.values()].sort((u, v) => v.n - u.n).slice(0, MAX_CLOTH).map((c) => [c.s[0] / c.n, c.s[1] / c.n, c.s[2] / c.n]);
396
+ if (cloth.length === 0) return 0;
397
+ // The colour above: the median of the region, channel by channel, read twice over it rather than held.
398
+ const a = regionMedian((visit) => {
399
+ for (const [x] of tops) {
400
+ for (let y = top; y <= ya; y++) {
401
+ const p = f.masked(x, y) ? null : colourAt(x, y);
402
+ if (p !== null) visit(p);
403
+ }
404
+ }
405
+ });
406
+ if (a === null) return 0;
407
+ if (diff(a, g) <= GARMENT_DIFF) return 0;
408
+ const nearest = (p: RGB, cs: RGB[]) => Math.min(...cs.map((q) => diff(p, q)));
409
+ const climbs = tops.map(([x, y0]) => {
410
+ let y = y0;
411
+ while (true) {
412
+ if (y - 1 < top || f.masked(x, y - 1)) return null;
413
+ const p = f.rgb(x, y - 1);
414
+ if (diff(p, a) < nearest(p, cloth)) break;
415
+ y--;
416
+ }
417
+ // The band's top: the topmost cloth pixel on the path, from where the climb stopped down to the scan's three rows.
418
+ for (let v = y; v <= y0 + 2; v++) if (v < f.h && nearest(f.rgb(x, v), cloth) <= GARMENT_DIFF) return y0 - v;
419
+ return null;
420
+ });
421
+ const ks = climbs.filter((k): k is number => k !== null).sort((p, q) => p - q);
422
+ if (ks.length === 0) return 0;
423
+ const rank = (q: number) => ks[Math.min(ks.length - 1, Math.max(0, Math.ceil(q * ks.length) - 1))]!;
424
+ if (rank(0.9) - rank(0.1) <= CLIMB_SPREAD) return 0;
425
+ let moved = 0;
426
+ const read = tops.flatMap((t, i): Pt[] => {
427
+ const k = climbs[i];
428
+ if (k === null || k === undefined) return [];
429
+ if (k !== 0) moved++;
430
+ return [[t[0], t[1] - k]];
431
+ });
432
+ tops.splice(0, tops.length, ...read);
433
+ return moved;
434
+ }
435
+
436
+ /**
437
+ * The median, channel by channel, of the colours a region yields (`median`'s value: the middle one, or the mean of the
438
+ * two middle ones), without holding them: a first pass counts each channel's values by whole step (0–255), which names
439
+ * the step the middle rank falls in; a second keeps only the values in that step, as distinct values and counts. Memory
440
+ * is the 256 counts and the distinct values of one step. Null for an empty region.
441
+ */
442
+ function regionMedian(each: (visit: (p: RGB) => void) => void): RGB | null {
443
+ const bin = (v: number) => Math.max(0, Math.min(255, Math.floor(v)));
444
+ const hist = [new Float64Array(256), new Float64Array(256), new Float64Array(256)];
445
+ let n = 0;
446
+ each((p) => {
447
+ n++;
448
+ for (let k = 0; k < 3; k++) hist[k]![bin(p[k]!)]!++;
449
+ });
450
+ if (n === 0) return null;
451
+ const m = n >> 1;
452
+ const ranks = n % 2 === 1 ? [m] : [m - 1, m];
453
+ // For each channel and rank: the step it falls in and its rank within the step.
454
+ const want = [0, 1, 2].map((k) => ranks.map((r) => {
455
+ let below = 0;
456
+ for (let b = 0; b < 256; b++) {
457
+ const c = hist[k]![b]!;
458
+ if (r < below + c) return { b, r: r - below };
459
+ below += c;
460
+ }
461
+ throw new Error('unreachable: a rank beyond the count');
462
+ }));
463
+ const kept = [0, 1, 2].map(() => new Map<number, Map<number, number>>());
464
+ for (let k = 0; k < 3; k++) for (const w of want[k]!) kept[k]!.set(w.b, new Map());
465
+ each((p) => {
466
+ for (let k = 0; k < 3; k++) {
467
+ const vs = kept[k]!.get(bin(p[k]!));
468
+ if (vs !== undefined) vs.set(p[k]!, (vs.get(p[k]!) ?? 0) + 1);
469
+ }
470
+ });
471
+ const at = (k: number, w: { b: number; r: number }) => {
472
+ let below = 0;
473
+ for (const [v, c] of [...kept[k]!.get(w.b)!.entries()].sort((u, z) => u[0] - z[0])) {
474
+ if (w.r < below + c) return v;
475
+ below += c;
476
+ }
477
+ throw new Error('unreachable: a rank beyond the step');
478
+ };
479
+ return [0, 1, 2].map((k) => {
480
+ const vs = want[k]!.map((w) => at(k, w));
481
+ return vs.length === 1 ? vs[0]! : (vs[0]! + vs[1]!) / 2;
482
+ }) as RGB;
483
+ }
484
+
485
+ /**
486
+ * rig-c/pose#318: the edge the waistband reader fitted is declined when it cuts across the band instead of running
487
+ * along it — a fold's edge under a band the colour test passed over. Above the edge, in each column read, the colour
488
+ * `DITHER_RADIUS + 1` rows up (past the outline and the window's blend) is followed up while it holds within
489
+ * `GARMENT_DIFF`; where it stops is the band's top. When at least `MIN_COVER` of the columns read show such a band (not
490
+ * the ground, not open up to the scan's top), its top is fitted as a line. The two lines bound a strip of thickness
491
+ * `ta` and `tb` at the read columns' ends. A line inside a band of thickness t across a width W departs from the band's
492
+ * direction by at most atan(t / W); an edge whose strip more than doubles across the hips (|ta − tb| > min(ta, tb)), or
493
+ * crosses the band's top, cuts across the garment instead of running along the band.
494
+ *
495
+ * Null when the edge is the band's (or nothing above it is a band to hold it to).
496
+ */
497
+ function notTheBand(f: Frame, fit: { m: number; q: number }, tops: Pt[], top: number, colourAt: (x: number, y: number) => RGB | null): Reading | null {
498
+ const edgeAt = (x: number) => fit.m * x + fit.q;
499
+ const bandTops: Pt[] = [];
500
+ for (const [x, y] of tops) {
501
+ const s = y - (DITHER_RADIUS + 1);
502
+ if (s < top || f.masked(x, s)) continue;
503
+ const c0 = colourAt(x, s);
504
+ if (c0 === null || diff(c0, f.bg) <= FIGURE_DIFF) continue;
505
+ let v = s;
506
+ let closed = false;
507
+ while (v - 1 >= top) {
508
+ if (f.masked(x, v - 1)) break;
509
+ const p = colourAt(x, v - 1);
510
+ if (p === null) break;
511
+ if (diff(p, c0) > GARMENT_DIFF) {
512
+ closed = true;
513
+ break;
514
+ }
515
+ v--;
516
+ }
517
+ if (closed) bandTops.push([x, v]);
518
+ }
519
+ if (bandTops.length < MIN_COVER * tops.length) return null;
520
+ const band = fitLine(bandTops);
521
+ if (band === null || band.rms > MAX_RMS) return null;
522
+ const xa = Math.min(...tops.map((p) => p[0]));
523
+ const xb = Math.max(...tops.map((p) => p[0]));
524
+ const ta = edgeAt(xa) - (band.m * xa + band.q);
525
+ const tb = edgeAt(xb) - (band.m * xb + band.q);
526
+ if (Math.abs(ta - tb) <= Math.min(ta, tb)) return null;
527
+ const deg = (m: number) => Math.atan(m) * (180 / Math.PI);
528
+ const allow = Math.max(0, Math.min(ta, tb));
529
+ return {
530
+ kind: 'edge leaves the band',
531
+ why: `the garment's edge fitted across the hips runs ${Math.abs(deg(fit.m) - deg(band.m)).toFixed(1)}° from the top of the band above it (${bandTops.length} of ${tops.length} columns; the band ${ta.toFixed(1)} px thick at x ${xa} and ${tb.toFixed(1)} px at x ${xb}); an edge inside a band ${allow.toFixed(1)} px thick at its thinnest departs from it by at most ${deg(allow / (xb - xa)).toFixed(1)}° across the ${xb - xa} px read, so this edge cuts across the garment (a fold), not along the band`,
532
+ };
333
533
  }
334
534
 
335
535
  /**
@@ -420,6 +620,8 @@ function viaNote(readings: Reading[]): string {
420
620
  const parts: string[] = [];
421
621
  if (mean > 0) parts.push(`${mean} through the ${2 * DITHER_RADIUS + 1}×${2 * DITHER_RADIUS + 1} window mean (the garment's colour does not hold pixel by pixel: a dithered or paletted picture)`);
422
622
  if (below > 0) parts.push(`${below} with the colour found below the hips (up to ${Math.max(...ok.map((r) => r.below ?? 0)).toFixed(1)} px; the hips on the band's edge)`);
623
+ const raised = ok.filter((r) => r.raised !== undefined);
624
+ if (raised.length > 0) parts.push(`${raised.length} with the band's top read above the colour's edge in some columns (up to ${Math.max(...raised.map((r) => r.raised ?? 0))} columns: a strip too thin to carry the colour, read as its top line)`);
423
625
  return parts.length === 0 ? '' : `; of the frames read, ${parts.join(', ')}`;
424
626
  }
425
627
 
package/src/pose.ts CHANGED
@@ -74,7 +74,7 @@ export interface SegmentDepth {
74
74
  source: DepthSource;
75
75
  }
76
76
  export type FrameDepth = Partial<Record<DepthSegment, SegmentDepth>>;
77
- /** The gait `clean --depth` was told the clip is: it decides the sign of a foreshortened shin. */
77
+ /** The gait `clean --depth` was told the clip is (rig-c/pose#336): the signs are the same for both; it decides the joint `--draw-order depth` orders the legs by. */
78
78
  export const DEPTH_GAITS = ['walk', 'run'] as const;
79
79
  export type DepthGait = (typeof DEPTH_GAITS)[number];
80
80
  /**
package/src/reduce.ts CHANGED
@@ -80,6 +80,12 @@ export interface Reduced {
80
80
  rows: ReduceRow[];
81
81
  /** Each bone's world-aim error on every frame (reduced less dense, degrees), as the poser evaluates the reduced keys. */
82
82
  errors: Map<string, number[]>;
83
+ /**
84
+ * rig-c/pose#320: each trunk root whose smoothing (`smoothTrunk`, #116) was not kept because it left a bone it
85
+ * manages past that bone's own tolerance — the bone, its smoothed worst error and its tolerance (degrees); the
86
+ * fitted keys stand. Absent when every smoothing was kept.
87
+ */
88
+ unsmoothed?: Array<{ root: string; bone: string; worst: number; tolerance: number }>;
83
89
  }
84
90
 
85
91
  // ---------------------------------------------------------------------------
@@ -607,7 +613,9 @@ function solveLimbPreservingJitter(
607
613
  * above the sequence's own. Optimizing knot values and handle y-coordinates
608
614
  * jointly within the ±tolerance world-aim tube removes those C0 breaks while
609
615
  * keeping any limb chains hanging from the trunk within 0.04 of their landing
610
- * and jitter.
616
+ * and jitter. The tube's bounds are a penalty, not a constraint: a root whose
617
+ * smoothing leaves any bone it manages past that bone's own tolerance keeps its
618
+ * fitted keys instead, recorded in `out.unsmoothed` (rig-c/pose#320).
611
619
  */
612
620
  export function smoothTrunk(
613
621
  times: readonly number[],
@@ -737,6 +745,16 @@ export function smoothTrunk(
737
745
  }
738
746
  };
739
747
 
748
+ // rig-c/pose#320: the smoothing is kept only within every managed bone's own tolerance; past it, the fitted keys
749
+ // (within it by construction) stand, and the run says so.
750
+ const past = allManaged.map((b) => {
751
+ const e = b === root ? eRoot : eSub.get(b)!;
752
+ return { b, worst: Math.max(...e.map(Math.abs)) };
753
+ }).find((x) => x.worst > tolOf(x.b));
754
+ if (past !== undefined) {
755
+ (out.unsmoothed ??= []).push({ root, bone: past.b, worst: past.worst, tolerance: tolOf(past.b) });
756
+ continue;
757
+ }
740
758
  updateBone(root, smRoot.keys, eRoot);
741
759
  for (const b of [...headBones, ...arm0Bones, ...arm1Bones]) {
742
760
  updateBone(b, smSub.get(b)!.keys, eSub.get(b)!);
package/src/retarget.ts CHANGED
@@ -80,8 +80,9 @@ import type { Joint, Travel } from './pose.ts';
80
80
  import { TRAVEL_IN_PLACE } from './pose.ts';
81
81
  import { Problems, Refusal } from './problems.ts';
82
82
  import type { ArtRange, AttachmentKey, ByHip, Raised, ReachShort, SetRuns, SlotTrack, Switch, SwitchOptions } from './switch.ts';
83
- import { clampArt, coveredChains, isRaised, measureArt, overflow, phaseGroupProblems, RAISED_WRIST_EYES, RANGE_SLACK, reachLimit, runsOf, selectSwitches, twoBone } from './switch.ts';
83
+ import { clampArt, coveredChains, isRaised, keyAtFrame, measureArt, overflow, phaseGroupProblems, RAISED_WRIST_EYES, RANGE_SLACK, reachLimit, runsOf, selectSwitches, twoBone } from './switch.ts';
84
84
  import type { BlendHold, Closure, Feet, Floor, LegScale, LegSegmentRatio, PathPose, Posing, Side, Stance } from './legs.ts';
85
+ import { contactKeysOf, type ContactKeys } from './contacts.ts';
85
86
  import { figureScaleOf, freeAnkleShiftOf, holdBlend, isLegSegment, legScaleOf, legsOf, pathFrom, percentile, plantHip, posePath, rootOf, scaleSupport, shiftOver, SIDES, stanceOf, stanceRangeOf, STRAIGHT_PERCENTILE, supportsOf, tipOf, unshortenStanceShin } from './legs.ts';
86
87
  import type { BodyReading, FaceReading, YawSetSwitch } from './face.ts';
87
88
  import { readBody, readFace, readFaceAngles, selectYawSets } from './face.ts';
@@ -181,6 +182,12 @@ export interface RetargetOptions extends SwitchOptions {
181
182
  * the hip line on the frames it does not; absent, from the hip line.
182
183
  */
183
184
  pelvis?: { source: string; tilt: ReadonlyArray<number | null> };
185
+ /**
186
+ * rig-c/pose#303: the hand contact keys (`--contact-keys`), read against the
187
+ * sequence. Applied last over the supports read (`contactKeysOf`): on every
188
+ * frame they cover they decide the support. Absent, nothing changes.
189
+ */
190
+ contactKeys?: ContactKeys;
184
191
  }
185
192
 
186
193
  /**
@@ -1020,6 +1027,11 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1020
1027
  else if (plantOn) p.add('LEGS_MAP', `--plant-feet reads both hips and both ankles from the pose; the map's chains use ${['r_hip', 'l_hip', 'r_ankle', 'l_ankle'].filter((j) => !series.has(j)).join(', ')} nowhere`);
1021
1028
  }
1022
1029
  }
1030
+ // rig-c/pose#303: the contact keys decide last, over every reading above; with no support read they have nothing to decide, so they are refused.
1031
+ if (opts.contactKeys !== undefined) {
1032
+ if (feet !== null) feet = contactKeysOf(feet, opts.contactKeys);
1033
+ else p.add('CONTACT_KEYS', `--contact-keys sets which foot supports, and no support is read: ${legs !== null && 'problems' in legs ? legs.problems.join('; ') : `the map's chains use ${['r_hip', 'l_hip', 'r_ankle', 'l_ankle'].filter((j) => !series.has(j)).join(', ')} nowhere`}`);
1034
+ }
1023
1035
  const heldPt = (name: string): Pt[] | null => {
1024
1036
  const known = series.get(name);
1025
1037
  if (known !== undefined) return known;
@@ -3048,7 +3060,6 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
3048
3060
  if (!slotOrder.includes(sl)) slotOrder.push(sl);
3049
3061
  }
3050
3062
  }
3051
- const yawExactAt = new Map<string, Set<number>>();
3052
3063
  for (const s of activeYawSets) {
3053
3064
  const def = known.get(s.set);
3054
3065
  if (def === undefined) continue;
@@ -3058,10 +3069,6 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
3058
3069
  const row = sw.shown.get(sl)!;
3059
3070
  for (const [lo, hi] of s.spans) {
3060
3071
  for (let i = lo; i <= hi; i++) row[i] = v;
3061
- const exact = yawExactAt.get(sl) ?? new Set<number>();
3062
- exact.add(lo);
3063
- if (hi + 1 < n) exact.add(hi + 1);
3064
- yawExactAt.set(sl, exact);
3065
3072
  }
3066
3073
  }
3067
3074
  }
@@ -3071,11 +3078,12 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
3071
3078
  if (row === undefined) continue;
3072
3079
  let prev = slots.get(sl)?.setup ?? null;
3073
3080
  const keys: AttachmentKey[] = [];
3074
- const exact = yawExactAt.get(sl);
3075
3081
  row.forEach((v, i) => {
3076
3082
  if (v === prev) return;
3077
- const t = i === 0 ? 0 : exact?.has(i) === true ? keyTimes[i]! : r6((keyTimes[i - 1]! + keyTimes[i]!) / 2);
3078
- keys.push({ t, v });
3083
+ // Half a frame before the frame it shows on, as every discrete key pose writes (`keyAtFrame`, rig-c/pose#334):
3084
+ // written on their frames' exact times, 7 of the 10 yaw swaps of the 24 fps head turn in test/retarget.test.ts
3085
+ // showed a frame late under the render's clock.
3086
+ keys.push({ t: keyAtFrame(keyTimes, i), v });
3079
3087
  prev = v;
3080
3088
  });
3081
3089
  if (keys.length > 0) rebuilt.push({ slot: sl, property: 'attachment', keys });
package/src/run.ts CHANGED
@@ -10,8 +10,8 @@ import { tmpdir } from 'node:os';
10
10
  import { dirname, join, relative, resolve } from 'node:path';
11
11
  import { knownSetsOf, readMap, resolveMap, type JointMap, type Rest } from './map.ts';
12
12
  import { cleanLine, cleanSummaryLine, inferredSource } from './clean.ts';
13
- import { depthLine, layersOf, type FrameLayers } from './depth.ts';
14
- import { drawOrderFromDepth, drawOrderKeys, drawOrderLines, type DrawOrderReading } from './draworder.ts';
13
+ import { depthLine, depthRecleanText, forwardShinFrames, layersOf, type FrameLayers } from './depth.ts';
14
+ import { drawGapLines, drawOrderFromDepth, drawOrderKeys, drawOrderLines, type DrawOrderReading } from './draworder.ts';
15
15
  import { convertCoco17, personTrack, readPoseSequence, TRAVEL_IN_PLACE, type CameraFrame } from './pose.ts';
16
16
  import { isObject, lineOf, parseJson, Problems, Refusal, type Problem } from './problems.ts';
17
17
  import { boneIdentity, droppedLine, keptOutLine, mergeProblems, readFragment, readIdle, targetOf, trackLabel, type IdleReading, type RigcTrack, type Target } from './idle.ts';
@@ -27,6 +27,7 @@ import { reportRenderDomain, writeReport, type ReportInput } from './report.ts';
27
27
  import { PROVENANCE_FILE, provenanceText } from './provenance.ts';
28
28
  import { madeLine, planOutDirs } from './outdir.ts';
29
29
  import { bodyLine, faceLine } from './face.ts';
30
+ import { contactKeysLines, readContactKeys } from './contacts.ts';
30
31
 
31
32
  export interface RetargetArgs extends RetargetOptions {
32
33
  rig: string;
@@ -81,6 +82,8 @@ export interface RetargetArgs extends RetargetOptions {
81
82
  output?: 'default' | 'verbose' | 'summary';
82
83
  /** `--draw-order depth` (rig-c/pose#304): key the animation's draw order from the sequence's depth. Absent: no draw-order key, the output as before. */
83
84
  drawOrder?: 'depth';
85
+ /** `--contact-keys` (rig-c/pose#303): the hand contact keys' file, `rig-pose-contact-keys/1`. Absent: the support as read, the output as before. */
86
+ contactKeysFile?: string;
84
87
  }
85
88
 
86
89
  export interface RunResult {
@@ -104,7 +107,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
104
107
  throw new Refusal(problems);
105
108
  };
106
109
  const args = new Problems();
107
- for (const [flag, path] of [['--map', a.map], ['--pose', a.pose], ['--merge', a.merge]] as const) {
110
+ for (const [flag, path] of [['--map', a.map], ['--pose', a.pose], ['--merge', a.merge], ['--contact-keys', a.contactKeysFile]] as const) {
108
111
  if (path !== undefined && !existsSync(path)) args.add('ARG_PATH', `${flag} ${path} does not exist`);
109
112
  }
110
113
  // rig-c/pose#239: the report is written unless asked elsewhere — beside the motion, as <out>.report.json.
@@ -134,13 +137,22 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
134
137
  const mapSnapshot = readReportInput(a.map), poseSnapshot = readReportInput(a.pose);
135
138
  const map = readMap(mapSnapshot.bytes.toString('utf8'), a.map);
136
139
  // #283: each chain --reduce names must be the map's; a bone takes its chain's tolerance (the tighter, if two chains name it).
137
- const reduceOf = a.reduce === undefined ? null : reduceTolerances(map, a.reduce, a.reduceChains, a.map);
140
+ let reduceOf = a.reduce === undefined ? null : reduceTolerances(map, a.reduce, a.reduceChains, a.map);
138
141
  const poseText = poseSnapshot.bytes.toString('utf8');
139
142
  const seq = a.coco ? convertCoco17(poseText, a.pose) : readPoseSequence(poseText, a.pose);
140
143
  if (seq.cleaned === undefined) refuse([{ code: 'POSE_NOT_CLEANED', text: notCleaned(a.pose, a.coco) }]);
141
144
  // #304: the draw order is read off the sequence's depth; asked for on a sequence without it, refused by name.
142
145
  if (a.drawOrder === 'depth' && !seq.frames.some((x) => x.depth !== undefined)) refuse([{ code: 'DRAWORDER_NO_DEPTH', text: `--draw-order depth keys the draw order from the sequence's depth, and ${a.pose} carries none (rig-pose-sequence/${seq.cleaned!.params.depth === undefined ? '1 or 1.1, no "depth" on any frame' : '1.2 with no frame naming a segment'}); clean it with --depth walk|run, or retarget without --draw-order` }]);
143
146
  const frames = personTrack(seq, a.person, a.pose);
147
+ // #336: a derived depth whose shins lean the way the trunk faces was cleaned with the table before #336 (a walk's lifted
148
+ // foot forward); the draw order now reads a walk at the ankle, and would draw that foot in front. Refused by name.
149
+ if (a.drawOrder === 'depth') {
150
+ const old = forwardShinFrames(frames, seq.frames);
151
+ if (old.length > 0) refuse([{ code: 'DEPTH_RECLEAN', text: depthRecleanText(a.pose, seq.cleaned!.params.depth?.gait ?? null, old) }]);
152
+ }
153
+ // rig-c/pose#303: the contact keys, read against the sequence's frames before any gate runs.
154
+ const contactKeysSnapshot = a.contactKeysFile === undefined ? null : readFileSync(a.contactKeysFile);
155
+ const contactKeys = contactKeysSnapshot === null ? null : readContactKeys(contactKeysSnapshot.toString('utf8'), a.contactKeysFile!, seq.frames.length);
144
156
  const times = seq.frames.map((f) => f.t);
145
157
 
146
158
  const scratch = mkdtempSync(join(tmpdir(), 'rig-pose-'));
@@ -156,7 +168,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
156
168
  const slots = slotsOf(doc0);
157
169
  resolveMap(map, rest, a.map, slots, input.sets);
158
170
 
159
- const r = retarget(rest, map, frames, times, { ...a, ...(seq.travel !== undefined ? { travel: seq.travel } : {}), ...(seq.cleaned!.params.loop ? { loop: true } : {}), ...(seq.pelvisSource !== undefined ? { pelvis: { source: seq.pelvisSource, tilt: seq.frames.map((f) => f.pelvisTilt ?? null) } } : {}) }, slots, input.sets);
171
+ const r = retarget(rest, map, frames, times, { ...a, ...(seq.travel !== undefined ? { travel: seq.travel } : {}), ...(seq.cleaned!.params.loop ? { loop: true } : {}), ...(seq.pelvisSource !== undefined ? { pelvis: { source: seq.pelvisSource, tilt: seq.frames.map((f) => f.pelvisTilt ?? null) } } : {}), ...(contactKeys !== null ? { contactKeys } : {}) }, slots, input.sets);
160
172
  if (fragment.length > 0 && ((r.face?.sets?.length ?? 0) > 0 || (r.body?.sets?.length ?? 0) > 0)) {
161
173
  const known = knownSetsOf(map, input.sets);
162
174
  for (const s of [...(r.face?.sets ?? []), ...(r.body?.sets ?? [])]) {
@@ -227,9 +239,9 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
227
239
  // rig-c/pose#281: the limbs' front-to-back order from the sequence's depth, only when it carries depth.
228
240
  const layers: FrameLayers[] | null = seq.frames.some((x) => x.depth !== undefined) ? frames.map((j, i) => layersOf(j, seq.frames[i]!.depth ?? {})) : null;
229
241
  if (layers !== null) say(` ${depthLine(layers, seq.frames, seq.cleaned!.params.depth?.gait ?? null)}`);
230
- // rig-c/pose#304: with --draw-order depth, the legs' draw order keyed from those layers.
231
- const drawOrder: DrawOrderReading | null = a.drawOrder === 'depth' && layers !== null ? drawOrderFromDepth(layers, frames, seq.frames.map((x) => x.depth), r.times, map, rest, doc0.slots, input.sets, seq.cleaned!.params.loop) : null;
232
- if (drawOrder !== null) for (const l of drawOrderLines(drawOrder)) say(` ${l}`);
242
+ // rig-c/pose#304: with --draw-order depth, the legs' draw order keyed from the depth (#336: at the joint the recorded gait names).
243
+ const drawOrder: DrawOrderReading | null = a.drawOrder === 'depth' && layers !== null ? drawOrderFromDepth(layers, frames, seq.frames.map((x) => x.depth), r.times, map, rest, doc0.slots, input.sets, seq.cleaned!.params.loop, seq.cleaned!.params.depth?.gait ?? null) : null;
244
+ if (drawOrder !== null) for (const l of [...drawOrderLines(drawOrder), ...drawGapLines(drawOrder)]) say(` ${l}`);
233
245
  if (r.pivot !== null) say(` PIVOT bone "${r.pivot.bone}"'s origin is ${r.pivot.distance.toFixed(2)} units (${r.pivot.hipWidths.toFixed(3)} hip widths) from its thigh roots' midpoint (${r.pivot.roots.join(' + ')}): dx ${r.pivot.offset[0].toFixed(2)}, dy ${r.pivot.offset[1].toFixed(2)}, at setup — the pivot every hip turn swings the legs about`);
234
246
  if (r.feet !== null) {
235
247
  const f = r.feet;
@@ -237,7 +249,9 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
237
249
  say(` FEET ${r.planted ? 'planted' : 'read, not planted (no --plant-feet)'}: leg length (median hip → ankle) r ${f.legLength.r.toFixed(1)}px, l ${f.legLength.l.toFixed(1)}px; ${f.supports.length} support span(s), ${f.changes.filter((c) => c.from !== null).length} change(s) of support, ${f.jumps.length} jump(s), ${f.steps.length} step(s)`);
238
250
  for (const sg of r.segments) say(` FEET ${segmentLine(sg)}`);
239
251
  say(` FEET supports: ${f.supports.map((x) => `${x.side} ${span(x.frames)}`).join(', ') || 'none'}`);
240
- if (f.signal !== undefined) for (const l of signalLines(f.signal, f.supports)) say(` FEET ${l}`);
252
+ // rig-c/pose#303: with contact keys, their stride lines stand in for #280's (the keys decide the frames they cover).
253
+ const signal = f.signal === undefined ? [] : signalLines(f.signal, f.supports);
254
+ for (const l of f.contactKeys === undefined ? signal : [...signal.filter((x) => !x.startsWith('stride ')), ...contactKeysLines(f)]) say(` FEET ${l}`);
241
255
  say(` FEET jumps (both ankles and mid_hip > 0.05 leg lengths above their rolling floor, ≥ 2 frames): ${f.jumps.map(span).join(', ') || 'none'}`);
242
256
  if (f.lowered !== undefined) say(` FEET ${loweredLine(f.lowered)}`);
243
257
  if (f.tracked !== undefined) say(` FEET tracked (the lower foot supports on the floor, following its scaled ankle track until still, rig-c/pose#119): ${f.tracked.map((x) => `${x.side} ${span(x.frames)}`).join(', ')}`);
@@ -256,14 +270,23 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
256
270
  if (r.face !== null) say(` FACE ${faceLine(r.face)}`);
257
271
  if (r.body !== null) say(` BODY ${bodyLine(r.body)}`);
258
272
 
273
+ // #320: a mapped bone above a planted or aimed chain --reduce names is held to that chain's tolerance when its own is looser.
274
+ const held = reduceOf === null || a.reduceChains === undefined ? [] : holdParents(map, rest, new Set(r.targets.keys()), a.reduceChains, reduceOf, r.ankles === null ? [] : [r.ankles.bones.r, r.ankles.bones.l]);
275
+ if (held.length > 0) {
276
+ const first = reduceOf!;
277
+ reduceOf = (bone) => held.find((h) => h.bone === bone)?.to ?? first(bone);
278
+ for (const h of held) say(` REDUCE ${heldLine(h)}`);
279
+ }
259
280
  // --reduce (#32): the dense keys thinned top-down, before anything reads the tracks.
260
281
  const reduced = a.reduce === undefined ? null : a.reduceChains === undefined ? reduceTracks(r, map, rest, a.reduce, a.reduceHip, a.reduceKeepPlanted === true) : reduceTracks(r, map, rest, a.reduce, a.reduceHip, a.reduceKeepPlanted === true, reduceOf!);
261
282
  if (reduced !== null) {
262
283
  for (const row of reduced.rows) {
263
284
  const [who, track, what, unit] = row.kind === 'angle' ? ['bone', 'rotate', 'aim', '°'] : row.kind === 'origin' ? ['hip', 'translate', 'origin', ' units'] : ['bone', 'scalex', 'scale', ' of its setup length'];
264
- const own = row.kind === 'angle' && reduceOf!(row.bone) !== a.reduce ? ` — within its chain's ${reduceOf!(row.bone)}°` : '';
285
+ const hold = held.find((h) => h.bone === row.bone && row.kind === 'angle');
286
+ const own = row.kind === 'angle' && reduceOf!(row.bone) !== a.reduce ? (hold === undefined ? ` — within its chain's ${reduceOf!(row.bone)}°` : ` — held to ${hold.to}° for ${hold.for.map((c) => `chain "${c.chain}"`).join(', ')} below it`) : '';
265
287
  say(` REDUCE ${who} "${row.bone}" ${track}: ${row.before} → ${row.after} key(s); worst ${what} error ${row.worst.toPrecision(3)}${unit} at frame ${row.frame} (t=${r.times[row.frame]!.toFixed(3)}s)${own}`);
266
288
  }
289
+ for (const u of reduced.unsmoothed ?? []) say(` REDUCE ${unsmoothedLine(u)}`);
267
290
  const sum = (kind: ReduceRow['kind'], k: 'before' | 'after') => reduced.rows.filter((x) => x.kind === kind).reduce((t, x) => t + x[k], 0);
268
291
  const scales = reduced.rows.some((x) => x.kind === 'scale') ? `; scalex keys ${sum('scale', 'before')} → ${sum('scale', 'after')} within ${SCALE_REDUCE} of the setup length` : '';
269
292
  say(` REDUCE rotate keys ${sum('angle', 'before')} → ${sum('angle', 'after')} (${(sum('angle', 'before') / sum('angle', 'after')).toFixed(2)}×) within ${a.reduce}°${chainsText(a.reduceChains)}${scales}${a.reduceHip === undefined ? '; the hip stays dense (no --reduce-hip)' : ''}`);
@@ -414,7 +437,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
414
437
 
415
438
  assertReportInputs([rigSnapshot,mapSnapshot,poseSnapshot]);
416
439
  // Every retarget option as resolved: the report's `options`, and with the name the build record's `flags` (#270).
417
- const options = { minScore: a.minScore, flip: a.flip, short: a.short, bends: a.bends, bendTolerance: a.bendTolerance, switchTolerance: a.switchTolerance, switchMin: a.switchMin, switchAt: a.switchAt, profile: a.profile, legScale: a.legScale ?? 'off', plantFeet: a.plantFeet === true, stance: a.stance ?? 'off', floor: a.floor ?? null, raisedByHead: a.raisedByHead === true, idle: idleName, carryIdle: a.carryIdle === true, merge: a.merge ?? null, reduce: a.reduce ?? 'off', ...(a.reduceChains === undefined ? {} : { reduceChains: a.reduceChains }), reduceHip: a.reduceHip ?? 'off', ...(a.reduceKeepPlanted === true ? { reduceKeepPlanted: true } : {}), ...(a.drawOrder !== undefined ? { drawOrder: a.drawOrder } : {}) };
440
+ const options = { minScore: a.minScore, flip: a.flip, short: a.short, bends: a.bends, bendTolerance: a.bendTolerance, switchTolerance: a.switchTolerance, switchMin: a.switchMin, switchAt: a.switchAt, profile: a.profile, legScale: a.legScale ?? 'off', plantFeet: a.plantFeet === true, stance: a.stance ?? 'off', floor: a.floor ?? null, raisedByHead: a.raisedByHead === true, idle: idleName, carryIdle: a.carryIdle === true, merge: a.merge ?? null, reduce: a.reduce ?? 'off', ...(a.reduceChains === undefined ? {} : { reduceChains: a.reduceChains }), reduceHip: a.reduceHip ?? 'off', ...(a.reduceKeepPlanted === true ? { reduceKeepPlanted: true } : {}), ...(a.drawOrder !== undefined ? { drawOrder: a.drawOrder } : {}), ...(a.contactKeysFile !== undefined ? { contactKeys: a.contactKeysFile } : {}) };
418
441
  const reportText = writeReport({
419
442
  spec: 'rig-pose-report/1',
420
443
  rigc,
@@ -476,15 +499,18 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
476
499
  pelvis: r.pelvis,
477
500
  // Only when the sequence carries depth (#281): a sequence without it writes the report it always has.
478
501
  ...(layers !== null ? { layers: layers.map((l, frame) => ({ frame, order: l.order, depth: l.depth })) } : {}),
479
- // Only with --draw-order depth (#304).
480
- ...(drawOrder !== null ? { drawOrder: { source: 'depth', pairs: drawOrder.pairs, keys: drawOrder.keys, spans: drawOrder.spans } } : {}),
502
+ // Only with --draw-order depth (#304). A key's `t` here is its frame's time, the report's clock (#214); the
503
+ // motion's key is written half a frame before it (#325).
504
+ ...(drawOrder !== null ? { drawOrder: { source: 'depth', pairs: drawOrder.pairs, keys: drawOrder.keys.map((k) => ({ ...k, t: r.times[k.frame]! })), spans: drawOrder.spans, gaps: drawOrder.gaps ?? [] } } : {}),
505
+ // Only with --contact-keys (#303).
506
+ ...(r.feet?.contactKeys !== undefined && contactKeysSnapshot !== null ? { contactKeys: { path: a.contactKeysFile, sha256: createHash('sha256').update(contactKeysSnapshot).digest('hex'), keys: r.feet.contactKeys.keys.length, from: r.feet.contactKeys.from, changed: r.feet.contactKeys.changed, kept: r.feet.contactKeys.kept, unreached: r.feet.contactKeys.unreached } } : {}),
481
507
  stance: r.stance === null ? null : { tolerance: r.stance.tolerance, release: r.stance.release, minSeconds: r.stance.minSeconds, bounds: r.stance.bounds, ...(r.stance.range === undefined ? {} : { range: r.stance.range }), runs: r.stance.runs, over: r.stance.over },
482
508
  feet: r.feet === null ? null : { planted: r.planted, legLength: r.feet.legLength, straight: r.legStraight, rigLeg: r.rigLeg, segments: r.segments, supports: r.feet.supports, jumps: r.feet.jumps, steps: r.feet.steps, changes: r.feet.changes, ...(r.feet.lowered === undefined ? {} : { lowered: r.feet.lowered }), ...(r.feet.tracked === undefined ? {} : { tracked: r.feet.tracked }), floor: r.floor?.y ?? null, shifts: r.floor?.shifts ?? [], blend: r.blendHold === null ? null : { bounds: r.blendHold.bounds, ...(r.blendHold.range === undefined ? {} : { range: r.blendHold.range }), runs: r.blendHold.runs, over: r.blendHold.over }, reading: feetReading, closure: r.closure },
483
509
  shear,
484
510
  idleDropped: idle === null ? null : idle.dropped,
485
511
  idleKeptOut: idle === null ? null : idle.keptOut,
486
512
  merged: fragment.map((tr) => ({ track: trackLabel(tr, groups), keys: tr.keys.length })),
487
- reduce: reduced === null ? null : { tolerance: a.reduce, ...(a.reduceChains === undefined ? {} : { chains: a.reduceChains }), hipTolerance: a.reduceHip ?? null, rows: reduced.rows, ankleDrift: drift },
513
+ reduce: reduced === null ? null : { tolerance: a.reduce, ...(a.reduceChains === undefined ? {} : { chains: a.reduceChains }), ...(held.length === 0 ? {} : { held }), ...(reduced.unsmoothed === undefined ? {} : { unsmoothed: reduced.unsmoothed }), hipTolerance: a.reduceHip ?? null, rows: reduced.rows, ankleDrift: drift },
488
514
  }, map, { frameTimes: r.times }, f.meshes !== null && doc.slots.length > 0 ? reportRenderDomain(JSON.parse(readFileSync(join(f.frames, '..', 'frames.json'), 'utf8')), a.name) : seamRead.frames === null ? null : reportRenderDomain(seamRead.frames, a.name), {rig:rigSnapshot.identity,map:mapSnapshot.identity,pose:poseSnapshot.identity});
489
515
  if (a.build !== undefined) {
490
516
  // Built again where it will live, not copied: rigc's page paths are relative to the build (#18).
@@ -1375,6 +1401,80 @@ export function reduceTolerances(map: JointMap, tolerance: number, chains: Recor
1375
1401
  return (bone) => by.get(bone) ?? tolerance;
1376
1402
  }
1377
1403
 
1404
+ /** rig-c/pose#320: a mapped bone held to the tolerance of a planted or aimed chain below it. */
1405
+ export interface ReduceHeld {
1406
+ /** The held bone, and the map chain it is a bone of (null for a mapped bone no chain lists). */
1407
+ bone: string;
1408
+ chain: string | null;
1409
+ /** Its tolerance as --reduce gave it (the run's, or its own chain's), degrees. */
1410
+ from: number;
1411
+ /** The tolerance it is held to: the tightest planted or aimed chain --reduce names below it, degrees. */
1412
+ to: number;
1413
+ /**
1414
+ * Each such chain below it reduced within `to`, in the map's order: its name, what holds it (`ankle`: it carries
1415
+ * a planted ankle's landing; `aim`: the map aims it), its first bone, and the lever — that bone's origin's setup
1416
+ * distance from the held bone's origin, rig units.
1417
+ */
1418
+ for: Array<{ chain: string; by: 'ankle' | 'aim'; bone: string; lever: number }>;
1419
+ }
1420
+
1421
+ /**
1422
+ * rig-c/pose#320: every mapped bone above a planted or aimed chain `chains`
1423
+ * names, whose own tolerance (`tolOf`) is looser than that chain's, held to it
1424
+ * — to the tightest such chain when several hang below it. A chain is planted
1425
+ * when it carries an ankle the landing check holds (`ankles`), aimed when the
1426
+ * map gives it an `aim` other than "x".
1427
+ *
1428
+ * The lever. A turn error θ on a bone turns everything under it rigidly: a
1429
+ * chain below has its first bone moved by θ·L (L = `lever`) and its aim turned
1430
+ * by θ. The reduction refits the chain against the turn (`target = dense −
1431
+ * e_above`), so the aim is the chain's own error, but not the move: the ankle
1432
+ * allowance (`footReduce`) sums every segment from the legs' top down — the
1433
+ * held bone's lever among them — times the leg's own degrees. θ·L ≤ θ_leg·L is
1434
+ * θ ≤ θ_leg whatever L is. An aimed chain is refitted, and on the trunk
1435
+ * smoothed (#116), against its parent's error as it stands: held to the
1436
+ * chain's degrees, that error never asks the chain to absorb more than its own
1437
+ * tolerance. A bent chain (an arm, a leg with no ankle check) has only its aim
1438
+ * checked, which the refit holds, so it holds nothing above it. Only a
1439
+ * per-chain entry can be tighter than a bone above it: with none, nothing is held.
1440
+ */
1441
+ export function holdParents(map: JointMap, rest: Rest, mapped: ReadonlySet<string>, chains: Record<string, number>, tolOf: (bone: string) => number, ankles: readonly string[]): ReduceHeld[] {
1442
+ const held: ReduceHeld[] = [];
1443
+ for (const c of map.chains) {
1444
+ const t = Object.hasOwn(chains, c.name) ? chains[c.name] : undefined;
1445
+ const top = c.bones[0];
1446
+ const by = c.bones.some((b) => ankles.includes(b)) ? ('ankle' as const) : c.aim.some((x) => x !== 'x') ? ('aim' as const) : null;
1447
+ if (t === undefined || top === undefined || by === null || !rest.has(top)) continue;
1448
+ const at = rest.get(top)!;
1449
+ for (let p = at.parent; p !== null; p = rest.get(p)?.parent ?? null) {
1450
+ if (!mapped.has(p) || c.bones.includes(p) || !(tolOf(p) > t)) continue;
1451
+ const was = held.findIndex((h) => h.bone === p);
1452
+ if (was >= 0 && held[was]!.to < t) continue;
1453
+ const x = rest.get(p)!;
1454
+ const child = { chain: c.name, by, bone: top, lever: Number(Math.hypot(at.worldX - x.worldX, at.worldY - x.worldY).toPrecision(6)) };
1455
+ if (was >= 0 && held[was]!.to === t) held[was]!.for.push(child);
1456
+ else if (was >= 0) held[was] = { ...held[was]!, to: t, for: [child] };
1457
+ else held.push({ bone: p, chain: map.chains.find((m) => m.bones.includes(p))?.name ?? null, from: tolOf(p), to: t, for: [child] });
1458
+ }
1459
+ }
1460
+ return held;
1461
+ }
1462
+
1463
+ /** rig-c/pose#320: the REDUCE line naming a held bone, the chains it was held for, and why. */
1464
+ export function heldLine(h: ReduceHeld): string {
1465
+ const below = h.for.map((c) => `chain "${c.chain}" (${c.by === 'ankle' ? 'a planted ankle' : 'aimed'}; L = ${c.lever} units to bone "${c.bone}")`).join(', ');
1466
+ const why = [
1467
+ ...(h.for.some((c) => c.by === 'ankle') ? ['the ankle allowance counts L at the leg\'s own degrees'] : []),
1468
+ ...(h.for.some((c) => c.by === 'aim') ? ['an aimed chain refits against its parent\'s error within its own'] : []),
1469
+ ].join(' and ');
1470
+ return `held: bone "${h.bone}"${h.chain === null ? '' : ` (chain "${h.chain}")`} reduced within ${h.to}°, not ${h.from}° — for ${below} below it, reduced within ${h.to}°: a turn error θ on bone "${h.bone}" moves a chain below it by θ·L and turns it by θ, and ${why}, so θ ≤ ${h.to}°`;
1471
+ }
1472
+
1473
+ /** rig-c/pose#320: the REDUCE line for a trunk smoothing (#116) not kept. */
1474
+ export function unsmoothedLine(u: { root: string; bone: string; worst: number; tolerance: number }): string {
1475
+ return `smoothing (rig-c/pose#116) from bone "${u.root}" down not kept: it left bone "${u.bone}" at ${u.worst.toPrecision(3)}°, past its ${u.tolerance}°; the fitted keys stand, each within its tolerance`;
1476
+ }
1477
+
1378
1478
  /** rig-c/pose#283: the per-chain tolerances as the run and REDUCE lines print them; empty without. */
1379
1479
  export function chainsText(chains: Record<string, number> | undefined): string {
1380
1480
  if (chains === undefined) return '';