@rig-c/pose 0.31.1 → 0.32.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/retarget.ts CHANGED
@@ -81,8 +81,8 @@ import { TRAVEL_IN_PLACE } from './pose.ts';
81
81
  import { Problems, Refusal } from './problems.ts';
82
82
  import type { ArtRange, AttachmentKey, ByHip, Raised, ReachShort, SlotTrack, Switch, SwitchOptions } from './switch.ts';
83
83
  import { clampArt, coveredChains, isRaised, measureArt, overflow, RAISED_WRIST_EYES, RANGE_SLACK, reachLimit, runsOf, selectSwitches, twoBone } from './switch.ts';
84
- import type { BlendHold, Closure, Feet, Floor, LegScale, PathPose, Posing, Side, Stance } from './legs.ts';
85
- import { figureScaleOf, holdBlend, isLegSegment, legScaleOf, legsOf, pathFrom, percentile, plantHip, posePath, rootOf, scaleSupport, SIDES, stanceOf, stanceRangeOf, STRAIGHT_PERCENTILE, supportsOf, tipOf, unshortenStanceShin } from './legs.ts';
84
+ import type { BlendHold, Closure, Feet, Floor, LegScale, LegSegmentRatio, PathPose, Posing, Side, Stance } from './legs.ts';
85
+ 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
86
  import type { BodyReading, FaceReading, YawSetSwitch } from './face.ts';
87
87
  import { readBody, readFace, readFaceAngles, selectYawSets } from './face.ts';
88
88
 
@@ -124,12 +124,29 @@ export const NOD_RESIDUAL_HEAD_GAIN = 1.6;
124
124
  */
125
125
  export const HEAD_ANCHOR_FRAME: 'head-local' | 'world-fixed' | 'off' = 'head-local';
126
126
 
127
+ /** How the nod's baseline was read (rig-c/pose#241): none on an in-place sequence (frame 0, no clamp), else the ±0.5 s detrend window, wrapped on a `clean --loop` sequence or cut at the clip's ends. */
128
+ export type NodWindow = 'in-place' | 'wrapped' | 'cut';
129
+
127
130
  export interface HeadNodReading {
128
131
  bone: string;
132
+ /** The chain that carries the eye line (rig-c/pose#241). */
133
+ chain: string;
129
134
  maxNod: number;
130
135
  rawY: number[];
131
136
  clampedY: number[];
132
137
  residual: number[];
138
+ /** The baseline's window (#241), and its half width in frames (0 in place). */
139
+ window: NodWindow;
140
+ halfWin: number;
141
+ /** Whether a translate track was keyed (false: the eyes did not move against the neck past the writer's rounding). */
142
+ keyed: boolean;
143
+ }
144
+
145
+ /** Why the eye-line chain keys no nod (rig-c/pose#241): switched off in the map, or a reading it cannot take. */
146
+ export interface NodSkipped {
147
+ bone: string;
148
+ chain: string;
149
+ why: string;
133
150
  }
134
151
 
135
152
  export interface RetargetOptions extends SwitchOptions {
@@ -153,8 +170,10 @@ export interface RetargetOptions extends SwitchOptions {
153
170
  floor?: number;
154
171
  /** While a set whose arm is drawn raised shows, its wrist is placed against the rig's head in eye distances (rig-c/pose#62); false (the default) keeps the pose's angles. */
155
172
  raisedByHead?: boolean;
156
- /** The sequence's `travel` (rig-c/pose#92): "in-place" makes --plant-feet let the supporting foot follow its own track and close the loop; absent, nothing changes. */
173
+ /** The sequence's `travel` (rig-c/pose#92, #242): "in-place" reads the supports, the camera-zoom normalisations and the head nod as a loop, with or without --plant-feet, and makes --plant-feet let the supporting foot follow its own track and close the loop; absent, nothing changes. */
157
174
  travel?: Travel;
175
+ /** The sequence was cleaned with `--loop` (its `cleaned.params.loop`, rig-c/pose#241): the head nod's detrend window wraps, the last frame running into the first. */
176
+ loop?: boolean;
158
177
  }
159
178
 
160
179
  export interface Hold {
@@ -296,6 +315,51 @@ export interface RangeDemand {
296
315
  limits: [number, number];
297
316
  }
298
317
 
318
+ /**
319
+ * A `reach` chain whose aim departs from its pose pair's own picture angle
320
+ * (rig-c/pose#236), and the two things that make it: the bone's origin sits off
321
+ * the anchor's (a lever: the target is the pair's end, so a pair turning by θ
322
+ * about the anchor turns the aim from an origin partway up it by more than θ),
323
+ * or a bone above it is turned, or translated other than by the anchor's own
324
+ * travel (which the target carries too, so it cancels). A reading — the keys
325
+ * are the reach's, unchanged.
326
+ */
327
+ export interface ReachReading {
328
+ chain: string;
329
+ bone: string;
330
+ /** The pair: the chain's first joint (the translated joint) and its second. */
331
+ joints: [string, string];
332
+ /** The anchor (the translated bone), and the setup distance from its origin to the bone's: world units, and as a fraction of the reached length (the pair's median picture length, scaled). */
333
+ anchor: string;
334
+ offset: { units: number; ofLength: number };
335
+ /** The bones above it the animation moves: by a rotate track, or a translate track other than the anchor's. */
336
+ above: Array<{ bone: string; property: 'rotate' | 'translate'; chain: string | null }>;
337
+ /** aim − the pair's angle, degrees: the largest |·| over the frames and its frame, and the mean |second difference| of it, °/frame². */
338
+ departure: { max: number; frame: number; meanD2: number | null };
339
+ /** Mean |second difference|, °/frame², of the reach's aim and of the pair's own angle, over every frame; and aim ÷ pair (null when the pair is still: its figure rounds to 0). */
340
+ aimD2: number | null;
341
+ pairD2: number | null;
342
+ ratio: number | null;
343
+ }
344
+
345
+ /**
346
+ * Mean |second difference| of a series of angles, degrees: |wrap(v[i+1] − v[i])
347
+ * − wrap(v[i] − v[i−1])| over every i whose three frames `ok` admits (every
348
+ * frame when it is omitted), and how many triples that was. null on none.
349
+ */
350
+ export function meanSecondDiff(series: ReadonlyArray<readonly number[]>, ok?: (i: number) => boolean): { value: number | null; triples: number } {
351
+ let sum = 0;
352
+ let triples = 0;
353
+ for (const v of series) {
354
+ for (let i = 1; i + 1 < v.length; i++) {
355
+ if (ok !== undefined && !(ok(i - 1) && ok(i) && ok(i + 1))) continue;
356
+ sum += Math.abs(wrap(v[i + 1]! - v[i]!) - wrap(v[i]! - v[i - 1]!));
357
+ triples++;
358
+ }
359
+ }
360
+ return { value: triples === 0 ? null : sum / triples, triples };
361
+ }
362
+
299
363
  /** The lines across the body a `TURN` is read on: the subject's right joint, then its left twin. */
300
364
  export const TURN_LINES: ReadonlyArray<[string, string, string]> = [['hip', 'r_hip', 'l_hip'], ['shoulder', 'r_shoulder', 'l_shoulder'], ['eye', 'r_eye', 'l_eye']];
301
365
  /** A line shorter in the picture than this fraction of its median is turned (rig-c/pose#6: between the front-facing dance's floor, 0.81, and a side-on body's 0.17–0.49). */
@@ -324,6 +388,8 @@ export interface Retarget {
324
388
  flips: Flip[];
325
389
  /** The figure scale, rig units per picture pixel, and the lengths it came from. */
326
390
  scale: { factor: number; pose: number; rig: number } | null;
391
+ /** rig-c/pose#240: what the figure scale is read against — its segment's foreshortening, the legs' and hips' ratios, the hip's travel; null without a scale. */
392
+ scaleFit: ScaleFit | null;
327
393
  /** The spans each alternate set shows, with their art turn. */
328
394
  switches: Switch[];
329
395
  /** Per switched or hidden slot, what it shows on each frame (null: nothing). */
@@ -340,6 +406,12 @@ export interface Retarget {
340
406
  artRange: ArtRange[];
341
407
  /** With --leg-scale: each leg segment's scale, straight length and clamped runs. */
342
408
  legScale: LegScale[];
409
+ /** rig-c/pose#237: every mapped leg segment's picture length ÷ straight (min, median, max), with or without --leg-scale. */
410
+ segments: LegSegmentRatio[];
411
+ /** rig-c/pose#237: each side's straight leg, the 95th percentile of its picture length hip → ankle, px (LEG-SCALE's percentile, against FEET's median); null without both legs read. */
412
+ legStraight: Record<Side, number> | null;
413
+ /** rig-c/pose#237: each side's rig leg at setup, thigh origin → ankle, world units — the leg length a clamp's shift of the ankle is read in; null without both legs. */
414
+ rigLeg: Record<Side, number> | null;
343
415
  /** Per scaled bone, the scale its x axis must reach on each frame (relative to setup); the scalex key of a bone under a scaled bone differs from it. */
344
416
  scales: Map<string, number[]>;
345
417
  /** The supports and jumps read off the reference ankles, when the map has both legs (whether or not --plant-feet used them). */
@@ -354,6 +426,8 @@ export interface Retarget {
354
426
  blendHold: BlendHold | null;
355
427
  /** With --plant-feet on an in-place sequence: the hip's x drift closed over the loop (#92). */
356
428
  closure: Closure | null;
429
+ /** On an in-place sequence without --plant-feet (#242): the translated bone's world x on the last frame less its x on the first, which nothing closes; null otherwise, and when no bone is translated. */
430
+ openDrift: number | null;
357
431
  /** Each ankle's world position on every frame, as the keys put it, when legs are scaled or feet planted. */
358
432
  ankles: { bones: Record<Side, string>; at: Record<Side, Pt[]> } | null;
359
433
  /** The translated bone's origin against its thigh roots' midpoint, at setup. */
@@ -370,6 +444,10 @@ export interface Retarget {
370
444
  figureScale: FigureScaleReading;
371
445
  /** Per-frame vertical head nod reading (`rawY`, `clampedY`, and clamped `residual`, `#111`, `#181`, `#190`). */
372
446
  headNod: HeadNodReading | null;
447
+ /** The eye-line chain keys no nod, and why (rig-c/pose#241); null when it does or when no chain carries the eye line. */
448
+ nodSkipped: NodSkipped | null;
449
+ /** Each `reach` chain hanging below a bone the animation turns or translates (#236): its aim's departure from the pose pair's angle. */
450
+ reach: ReachReading[];
373
451
  }
374
452
 
375
453
  export interface FigureScaleFrame {
@@ -403,6 +481,46 @@ export interface Pivot {
403
481
 
404
482
  type Pt = [number, number];
405
483
 
484
+ /** A ratio band, rig units per picture px: the rig's length over the picture's straight (95th percentile) length, to over its median. */
485
+ export interface ScaleBand {
486
+ /** rig / picture 95th percentile — the factor that matches the length where the picture shows it longest. */
487
+ lo: number;
488
+ /** rig / picture median — the factor that matches it on the median frame. */
489
+ hi: number;
490
+ }
491
+
492
+ /** rig-c/pose#240: the figure scale beside what it could have been read off instead. */
493
+ export interface ScaleFit {
494
+ /** The `translate.scale.pose` segment: its median picture length (what the factor divides by) against its 95th percentile. */
495
+ segment: { joints: [string, string]; median: number; straight: number; ratio: number };
496
+ /** Each side's leg, hip → ankle: the rig's at setup (thigh origin → ankle, units), the picture's median and 95th percentile (px), and the band rig ÷ those; null without both legs. */
497
+ leg: { rig: Record<Side, number>; median: Record<Side, number>; straight: Record<Side, number>; band: ScaleBand; within: boolean } | null;
498
+ /** The hips' width: the rig's thigh roots apart at setup (units), the picture's r_hip → l_hip median and 95th percentile (px), and the band; null without both legs and both hips read. */
499
+ hip: { rig: number; median: number; straight: number; band: ScaleBand; within: boolean } | null;
500
+ /** Whether `scale.factor` lies outside the leg band and outside the hip band (`withinPx`); null unless both are read. */
501
+ outside: boolean | null;
502
+ /** The translated joint's range over the frames, x and y: the pose's in px, the keyed bone's world origin in units — each also in leg lengths (the pose's straight leg, the rig's at setup, the two sides' mean) and hip widths (the pose's 95th percentile, the rig's at setup); null where the unit is not read. */
503
+ travel: {
504
+ joint: string;
505
+ bone: string;
506
+ pose: { x: number; y: number; legs: [number, number] | null; hips: [number, number] | null };
507
+ rig: { x: number; y: number; legs: [number, number] | null; hips: [number, number] | null };
508
+ };
509
+ }
510
+
511
+ /**
512
+ * Whether `scale.factor` sizes a length as the picture shows it: the rig's
513
+ * length carried into the picture by the factor (rig ÷ factor, px) lies between
514
+ * the picture's median and 95th percentile, give or take one picture px — the
515
+ * picture's own unit, so a band the picture never widened (a length it shows the
516
+ * same on every frame) is not missed by a rounding the picture cannot show.
517
+ */
518
+ export function withinPx(factor: number, rig: number, median: number, straight: number): boolean {
519
+ const px = rig / factor;
520
+ return px >= Math.min(median, straight) - 1 && px <= Math.max(median, straight) + 1;
521
+ }
522
+
523
+
406
524
  /** The value of a point on one frame, or why it is not good. */
407
525
  export function pointOn(joints: Partial<Record<Body18, Joint>>, name: string, minScore: number): Pt | string {
408
526
  if (isBody18(name)) {
@@ -467,6 +585,57 @@ function pivotOf(rest: Rest, bone: string | null, legs: ReturnType<typeof legsOf
467
585
  return { bone, roots, offset, distance, hipWidths: width > 1e-9 ? distance / width : NaN };
468
586
  }
469
587
 
588
+ /** Two bones' setup origins apart, world units: the rig's hip width when they are the thigh roots. */
589
+ function rootsApart(rest: Rest, a: string, b: string): number {
590
+ const x = rest.get(a)!;
591
+ const y = rest.get(b)!;
592
+ return Math.hypot(y.worldX - x.worldX, y.worldY - x.worldY);
593
+ }
594
+
595
+ const rangeOf = (xs: readonly number[]): number => (xs.length === 0 ? 0 : Math.max(...xs) - Math.min(...xs));
596
+
597
+ /** rig-c/pose#240: the SCALE reading, from fields the retarget already read — nothing here changes a key. */
598
+ function scaleFitOf(o: {
599
+ joints: [string, string]; joint: string; bone: string; lengths: number[]; factor: number;
600
+ series: ReadonlyMap<string, Pt[]>; good: ReadonlyMap<string, boolean[]>;
601
+ legLength: Record<Side, number> | null; legStraight: Record<Side, number> | null; rigLeg: Record<Side, number> | null;
602
+ rigHip: number | null; origin: readonly Pt[];
603
+ }): ScaleFit {
604
+ const segMedian = median(o.lengths);
605
+ const segStraight = percentile(o.lengths, STRAIGHT_PERCENTILE);
606
+ const leg = o.legLength === null || o.legStraight === null || o.rigLeg === null ? null : (() => {
607
+ const rig = o.rigLeg!, med = o.legLength!, str = o.legStraight!;
608
+ const at = (q: Record<Side, number>) => SIDES.map((s) => rig[s] / q[s]);
609
+ return { rig, median: med, straight: str, band: { lo: Math.min(...at(str)), hi: Math.max(...at(med)) }, within: SIDES.some((s) => withinPx(o.factor, rig[s], med[s], str[s])) };
610
+ })();
611
+ const hipWidths: number[] = [];
612
+ const R = o.series.get('r_hip'), L = o.series.get('l_hip'), gr = o.good.get('r_hip'), gl = o.good.get('l_hip');
613
+ if (R !== undefined && L !== undefined && gr !== undefined && gl !== undefined) {
614
+ for (let i = 0; i < R.length; i++) if (gr[i] && gl[i]) hipWidths.push(Math.hypot(L[i]![0] - R[i]![0], L[i]![1] - R[i]![1]));
615
+ }
616
+ const hipMedian = hipWidths.length === 0 ? NaN : median(hipWidths);
617
+ const hipStraight = percentile(hipWidths, STRAIGHT_PERCENTILE);
618
+ const hip = o.rigHip === null || !(hipMedian > 1e-6) || !(hipStraight > 1e-6) || !(o.rigHip > 1e-6) ? null
619
+ : { rig: o.rigHip, median: hipMedian, straight: hipStraight, band: { lo: o.rigHip / hipStraight, hi: o.rigHip / hipMedian }, within: withinPx(o.factor, o.rigHip, hipMedian, hipStraight) };
620
+ const P = o.series.get(o.joint)!;
621
+ const px = rangeOf(P.map((p) => p[0])), py = rangeOf(P.map((p) => p[1]));
622
+ const ux = rangeOf(o.origin.map((p) => p[0])), uy = rangeOf(o.origin.map((p) => p[1]));
623
+ const mean = (q: Record<Side, number>) => (q.r + q.l) / 2;
624
+ const per = (x: number, y: number, unit: number | null): [number, number] | null => (unit === null || !(unit > 1e-6) ? null : [x / unit, y / unit]);
625
+ return {
626
+ segment: { joints: o.joints, median: segMedian, straight: segStraight, ratio: segMedian / segStraight },
627
+ leg,
628
+ hip,
629
+ outside: leg === null || hip === null ? null : !leg.within && !hip.within,
630
+ travel: {
631
+ joint: o.joint,
632
+ bone: o.bone,
633
+ pose: { x: px, y: py, legs: per(px, py, o.legStraight === null ? null : mean(o.legStraight)), hips: per(px, py, hip === null ? null : hip.straight) },
634
+ rig: { x: ux, y: uy, legs: per(ux, uy, o.rigLeg === null ? null : mean(o.rigLeg)), hips: per(ux, uy, hip === null ? null : hip.rig) },
635
+ },
636
+ };
637
+ }
638
+
470
639
  /**
471
640
  * Retarget. Refuses (every reason at once) when a point the map needs is never
472
641
  * good, when a hinge's side cannot be read off a straight rest, and — with
@@ -600,6 +769,8 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
600
769
  // first, because a reach is aimed from where the hip has put its bone.
601
770
  let origin: Retarget['origin'] = null;
602
771
  let scale: Retarget['scale'] = null;
772
+ /** The scale segment's picture length on every frame both its points are good (#240's SCALE reading). */
773
+ let scaleLengths: number[] = [];
603
774
  let translateTrack: MotionTrack | null = null;
604
775
  /** The translated bone's world travel from its setup origin, per frame. */
605
776
  const travel: Pt[] = [];
@@ -621,6 +792,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
621
792
  else {
622
793
  const s = rigLen / poseLen;
623
794
  scale = { factor: s, pose: poseLen, rig: rigLen };
795
+ scaleLengths = lengths;
624
796
  const P = series.get(t.joint)!;
625
797
  for (let i = 0; i < n; i++) {
626
798
  const wx = (P[i]![0] - P[0]![0]) * s;
@@ -720,7 +892,8 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
720
892
  for (let k = 1; k < c.bones.length; k++) checkHinge(c, k, targets, restOf, keyTimes, opts, bends, p);
721
893
  }
722
894
  if (opts.bends === 'refuse' && bends.length > 0) {
723
- for (const e of bends) p.add('POSE_BEND_ONE_WAY', bendLine(e));
895
+ // #238: one line per hinge — its count, worst angle, frame range and the remedy; the per-frame lines are its detail (--verbose).
896
+ for (const g of bendGroups(bends)) p.add('POSE_BEND_ONE_WAY', bendGroupLine(g, 'refuse'), g.events.map((e) => ({ code: 'POSE_BEND_ONE_WAY', text: bendLine(e) })));
724
897
  }
725
898
  p.throwIfAny();
726
899
 
@@ -729,9 +902,16 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
729
902
  // which are aimed from where the hip is.
730
903
  const legScaleOn = opts.legScale !== undefined && opts.legScale !== 'off';
731
904
  const plantOn = opts.plantFeet === true;
732
- /** #92: an in-place sequence under --plant-feet — the supporting foot follows its own track, no step releases, the loop closed. */
733
- const inPlace = plantOn && opts.travel === TRAVEL_IN_PLACE;
905
+ /**
906
+ * #92, #242: the sequence declares itself in place. A property of the sequence, not of --plant-feet: every reading
907
+ * below takes it with or without the flag — the supports (no step releases a foot), the camera-zoom normalisations
908
+ * (#159, #163, #173: a loop has no zoom, and their rolling windows do not wrap), the trunk scale (#172) and the head
909
+ * nod (no detrend window, no clamp). What only planting can do — the supporting foot following its own track and the
910
+ * loop closed on the planted hip — stays under `plantOn`, and without it `openDrift` says what was not closed.
911
+ */
912
+ const inPlace = opts.travel === TRAVEL_IN_PLACE;
734
913
  let closure: Closure | null = null;
914
+ let openDrift: number | null = null;
735
915
  const legScale: LegScale[] = [];
736
916
  const scales = new Map<string, number[]>();
737
917
  let stance: Stance | null = null;
@@ -903,8 +1083,12 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
903
1083
  const poseScale: number[] | null = t !== null && scale !== null && n > 25 && !inPlace ? continuousScale : null;
904
1084
  let trunkScaleBone: string | null = null;
905
1085
  let trunkScale: number[] | null = null;
906
- if (legScaleOn) {
907
- const limits = opts.legScale as [number, number];
1086
+ // #237: every mapped leg segment's picture length ÷ straight, with or without --leg-scale: the
1087
+ // straight lengths --leg-scale scales against, so the minimum read here is the one it would clamp.
1088
+ const segments: LegSegmentRatio[] = [];
1089
+ {
1090
+ /** Without --leg-scale nothing is keyed: [1, 1] only lets `legScaleOf` read the ratio. */
1091
+ const limits: [number, number] = legScaleOn ? (opts.legScale as [number, number]) : [1, 1];
908
1092
  const rawSegments: Array<{ chain: string; bone: string; joints: [string, string]; kind: string; lengths: number[]; ownStraight: number }> = [];
909
1093
  for (const c of map.chains) {
910
1094
  if (c.reach) continue;
@@ -912,7 +1096,8 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
912
1096
  const joints: [string, string] = [c.joints[k]!, c.joints[k + 1]!];
913
1097
  if (!isLegSegment(joints[0], joints[1])) return;
914
1098
  if (c.aim[k] !== 'x') {
915
- p.add('LEGS_MAP', `--leg-scale scales chain "${c.name}" bone "${b}" (${joints[0]} → ${joints[1]}) along its own x axis, and the map aims it ${JSON.stringify(c.aim[k])}; a leg segment it scales must be aimed "x"`);
1099
+ if (legScaleOn) p.add('LEGS_MAP', `--leg-scale scales chain "${c.name}" bone "${b}" (${joints[0]} → ${joints[1]}) along its own x axis, and the map aims it ${JSON.stringify(c.aim[k])}; a leg segment it scales must be aimed "x"`);
1100
+ segments.push({ chain: c.name, bone: b, joints, straight: null, ratio: null, why: `aimed ${JSON.stringify(c.aim[k])}, and --leg-scale scales a segment aimed "x"` });
916
1101
  return;
917
1102
  }
918
1103
  const from = series.get(joints[0])!;
@@ -920,10 +1105,12 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
920
1105
  const lengths = from.map((f, i) => Math.hypot(to[i]![0] - f[0], to[i]![1] - f[1]));
921
1106
  const ownStraight = percentile(lengths, STRAIGHT_PERCENTILE);
922
1107
  if (!(ownStraight > 1e-6)) {
923
- p.add('LEGS_MAP', `--leg-scale: chain "${c.name}" bone "${b}"'s segment ${joints[0]} → ${joints[1]} has a straight length (95th percentile) of ${ownStraight}px; there is nothing to scale against`);
1108
+ if (legScaleOn) p.add('LEGS_MAP', `--leg-scale: chain "${c.name}" bone "${b}"'s segment ${joints[0]} → ${joints[1]} has a straight length (95th percentile) of ${ownStraight}px; there is nothing to scale against`);
1109
+ segments.push({ chain: c.name, bone: b, joints, straight: null, ratio: null, why: `its straight length (95th percentile) is ${ownStraight}px` });
924
1110
  return;
925
1111
  }
926
1112
  rawSegments.push({ chain: c.name, bone: b, joints, kind: `${joints[0].slice(2)}:${joints[1].slice(2)}`, lengths, ownStraight });
1113
+ segments.push({ chain: c.name, bone: b, joints, straight: null, ratio: null });
927
1114
  });
928
1115
  }
929
1116
  const straightByKind = new Map<string, number>();
@@ -971,9 +1158,17 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
971
1158
  };
972
1159
  for (const seg of rawSegments) {
973
1160
  const ls = legScaleOf(seg.lengths, limits, straightByKind.get(seg.kind)! * rigShareOf(seg));
974
- legScale.push({ chain: seg.chain, bone: seg.bone, joints: seg.joints, ...ls });
975
- scales.set(seg.bone, ls.scale);
1161
+ const row = segments.find((x) => x.bone === seg.bone)!;
1162
+ row.straight = ls.straight;
1163
+ row.ratio = ls.ratio;
1164
+ if (legScaleOn) {
1165
+ legScale.push({ chain: seg.chain, bone: seg.bone, joints: seg.joints, ...ls });
1166
+ scales.set(seg.bone, ls.scale);
1167
+ }
976
1168
  }
1169
+ }
1170
+ if (legScaleOn) {
1171
+ const limits = opts.legScale as [number, number];
977
1172
  if (legScale.length === 0 && !p.any) p.add('LEGS_MAP', '--leg-scale scales the leg segments (two of one side\'s hip, knee and ankle), and the map aims none');
978
1173
  const isTrunkPair = (a: string, b: string): boolean => (a === 'mid_hip' && (b === 'neck' || b === 'mid_shoulder')) || (b === 'mid_hip' && (a === 'neck' || a === 'mid_shoulder'));
979
1174
  const trunkChain = map.chains.find((c) => c.bones.length === 1 && isTrunkPair(c.joints[0]!, c.joints[1]!));
@@ -1107,6 +1302,57 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1107
1302
  stretchChain(stance.stretch);
1108
1303
  }
1109
1304
  }
1305
+ // #237: what the clamps did to a free ankle's height — read, nothing keyed changes. Each side's rig
1306
+ // leg at setup is the unit; the clamped frames of a free leg go on LEG-SCALE, and a lifted or below
1307
+ // STANCE run says the pose's height was kept only where no clamp moved it.
1308
+ let rigLeg: Record<Side, number> | null = null;
1309
+ if (legs !== null && 'legs' in legs) {
1310
+ const L = legs.legs;
1311
+ const of = (s: Side): number => {
1312
+ const o = rest.get(L[s].chain.bones[0]!)!;
1313
+ const e = rest.get(L[s].ankleBone)!;
1314
+ return Math.hypot(e.worldX + e.a * e.length - o.worldX, e.worldY + e.c * e.length - o.worldY);
1315
+ };
1316
+ rigLeg = { r: of('r'), l: of('l') };
1317
+ }
1318
+ if (legScaleOn && feet !== null && legPath !== null && rigLeg !== null && legs !== null && 'legs' in legs) {
1319
+ const L = legs.legs;
1320
+ const F = feet;
1321
+ const legUnit = rigLeg;
1322
+ const trackedSide = new Array<Side | null>(n).fill(null);
1323
+ for (const tr of F.tracked ?? []) for (let i = tr.frames[0]; i <= tr.frames[1]; i++) trackedSide[i] = tr.side;
1324
+ const freeOf = (i: number): Side | null => {
1325
+ const s = F.support[i] ?? trackedSide[i] ?? null;
1326
+ return s === null ? null : s === 'r' ? 'l' : 'r';
1327
+ };
1328
+ const tipY = (i: number, side: Side, scaleOf: (b: string) => number | null): number => {
1329
+ const r = posePath(rest, legPath!, (b): Posing | null => (targets.has(b) ? { turn: restTurn(b, i), target: targets.get(b)![i]!, aimX: aimX.get(b)! } : null), scaleOf);
1330
+ if (typeof r === 'string') throw new Refusal([{ code: 'LEGS_MAP', text: r }]);
1331
+ return tipOf(rest, r, L[side].ankleBone)[1];
1332
+ };
1333
+ const shift = freeAnkleShiftOf(n, legScale, freeOf, tipY);
1334
+ for (const ls of legScale) {
1335
+ const side: Side = ls.joints[0].startsWith('r_') ? 'r' : 'l';
1336
+ const frames: number[] = [];
1337
+ for (let i = 0; i < n; i++) if (ls.scale[i] !== ls.picture[i] && freeOf(i) === side) frames.push(i);
1338
+ const free = shiftOver(side, frames, shift, legUnit[side]);
1339
+ if (free !== null) ls.free = free;
1340
+ }
1341
+ for (const run of stance?.runs ?? []) {
1342
+ if (run.state === 'both' || run.absorbed === true) continue;
1343
+ const frames = Array.from({ length: run.frames[1] - run.frames[0] + 1 }, (_, k) => run.frames[0] + k);
1344
+ const moved = shiftOver(run.side, frames, shift, legUnit[run.side]);
1345
+ if (moved !== null) run.moved = moved;
1346
+ }
1347
+ }
1348
+ const legStraight: Record<Side, number> | null = feet === null ? null : (() => {
1349
+ const out = {} as Record<Side, number>;
1350
+ for (const s of SIDES) {
1351
+ const h = series.get(`${s}_hip`)!;
1352
+ out[s] = percentile(series.get(`${s}_ankle`)!.map((a, i) => Math.hypot(a[0] - h[i]![0], a[1] - h[i]![1])), STRAIGHT_PERCENTILE);
1353
+ }
1354
+ return out;
1355
+ })();
1110
1356
  if (t !== null && scale !== null) {
1111
1357
  const bone = rest.get(t.bone)!;
1112
1358
  const parent = bone.parent === null ? null : rest.get(bone.parent)!;
@@ -1126,6 +1372,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1126
1372
  }
1127
1373
  translateTrack = { bone: t.bone, property: 'translate', keys };
1128
1374
  origin = { bone: t.bone, at };
1375
+ if (inPlace && !plantOn && n > 0) openDrift = r6(travel[n - 1]![0] - travel[0]![0]);
1129
1376
  }
1130
1377
 
1131
1378
  // Reaches: from the bone's posed origin at the pose's end point, anchored at
@@ -1153,6 +1400,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1153
1400
  let headNod: Pt[] | null = null;
1154
1401
  let headNodTrack: MotionTrack | null = null;
1155
1402
  let headNodReading: HeadNodReading | null = null;
1403
+ let nodSkipped: NodSkipped | null = null;
1156
1404
  const posedOrigin = (b: string, i: number): Pt => {
1157
1405
  const own = rest.get(b)!;
1158
1406
  if (t !== null && b === t.bone) return [own.worldX + (travel[i]?.[0] ?? 0), own.worldY + (travel[i]?.[1] ?? 0)];
@@ -1183,6 +1431,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1183
1431
  };
1184
1432
  const reaches = map.chains.filter((c) => c.reach).sort((x, y) => depth(x.bones[0]!) - depth(y.bones[0]!));
1185
1433
  let aimReach: ((c: Chain) => void) | null = null;
1434
+ const reachAims = new Map<string, { aim: number[]; pair: number[]; length: number }>();
1186
1435
  if (reaches.length > 0 && t !== null && scale !== null) {
1187
1436
  const s = scale.factor;
1188
1437
  const tb = rest.get(t.bone)!;
@@ -1200,6 +1449,8 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1200
1449
  }
1201
1450
  fillLeading(angles, () => p.add('POSE_SEGMENT_ZERO', `chain "${c.name}" — bone "${b}" reaches for ${c.joints[1]}, which the pose puts on the bone's posed origin on every frame; it has no direction`));
1202
1451
  targets.set(b, angles);
1452
+ // #236: the aim as reached, before its own range clamp, beside the pair's own angle (a reading).
1453
+ reachAims.set(c.name, { aim: [...angles], pair: Array.from({ length: n }, (_, i) => pictureAngle(anchor[i]!, end[i]!)), length: median(Array.from({ length: n }, (_, i) => Math.hypot(end[i]![0] - anchor[i]![0], end[i]![1] - anchor[i]![1]) * s)) });
1203
1454
  };
1204
1455
  for (const c of reaches) aimReach(c);
1205
1456
  }
@@ -1286,10 +1537,27 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1286
1537
  if (!((ja === 'r_eye' && jb === 'l_eye') || (ja === 'l_eye' && jb === 'r_eye'))) continue;
1287
1538
  const hb = c.bones[k]!;
1288
1539
  const ha = c.aim[k]!;
1289
- if (t?.bone === hb || sw.offsetTracks.some((tr) => tr.bone === hb)) break;
1290
- let underScaled = false;
1291
- for (let a = rest.get(hb)!.parent; a !== null; a = rest.get(a)!.parent) if (scales.has(a)) underScaled = true;
1292
- if (underScaled) break;
1540
+ const skip = (why: string): void => {
1541
+ nodSkipped = { bone: hb, chain: c.name, why };
1542
+ };
1543
+ if (c.nod === false) {
1544
+ skip(`the chain declares "nod": false`);
1545
+ break;
1546
+ }
1547
+ if (t?.bone === hb) {
1548
+ skip(`the map's translate moves bone "${hb}"`);
1549
+ break;
1550
+ }
1551
+ if (sw.offsetTracks.some((tr) => tr.bone === hb)) {
1552
+ skip(`a set's offsets translate bone "${hb}"`);
1553
+ break;
1554
+ }
1555
+ let underScaled: string | null = null;
1556
+ for (let a = rest.get(hb)!.parent; a !== null; a = rest.get(a)!.parent) if (scales.has(a) && underScaled === null) underScaled = a;
1557
+ if (underScaled !== null) {
1558
+ skip(`bone "${hb}" hangs below the scaled bone "${underScaled}"`);
1559
+ break;
1560
+ }
1293
1561
  const rEye = series.get('r_eye')!;
1294
1562
  const lEye = series.get('l_eye')!;
1295
1563
  const neckSeries = series.get('neck') ?? (() => {
@@ -1305,6 +1573,8 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1305
1573
  const refEye = median(rEye.map((re, i) => Math.hypot(lEye[i]![0] - re[0], lEye[i]![1] - re[1])));
1306
1574
  if (refEye > 1e-6 && rigEye > 1e-6) nodScale = rigEye / refEye;
1307
1575
  }
1576
+ if (neckSeries === undefined) skip('neck is good on no frame');
1577
+ else if (nodScale === null) skip(`no scale: the map translates no bone and aim ${JSON.stringify(ha)} names no two points to measure the eye distance on`);
1308
1578
  if (neckSeries !== undefined && nodScale !== null) {
1309
1579
  const bone = rest.get(hb)!;
1310
1580
  const parent = bone.parent === null ? null : rest.get(bone.parent)!;
@@ -1325,12 +1595,20 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1325
1595
  const maxNod = !inPlace && neckRestLen > 1e-6 ? 0.02 * neckRestLen : Infinity;
1326
1596
  const refEN = rEye.map((re, i) => -((re[1] + lEye[i]![1]) / 2 - neckSeries[i]![1]));
1327
1597
  const halfWin = Math.max(1, Math.round(0.5 / Math.max(1e-6, (keyTimes[n - 1]! - keyTimes[0]!) / Math.max(1, n - 1))));
1328
- const nodMed = maxNod < Infinity && n > 2 * halfWin
1329
- ? refEN.map((_, i) => median(refEN.slice(Math.max(0, i - halfWin), Math.min(n, i + halfWin + 1))))
1330
- : refEN.map(() => refEN[0]!);
1331
- const nodBase = maxNod < Infinity && n > 2 * halfWin
1598
+ const windowed = maxNod < Infinity && n > 2 * halfWin;
1599
+ // #241: on a `clean --loop` sequence the window wraps (the last frame runs into the first), as clean's own
1600
+ // windows do; cut at the ends instead, the two ends of the loop are read against different baselines.
1601
+ const nodWindow: NodWindow = !windowed ? 'in-place' : opts.loop === true ? 'wrapped' : 'cut';
1602
+ const around = (xs: readonly number[], i: number): number[] => {
1603
+ if (nodWindow !== 'wrapped') return xs.slice(Math.max(0, i - halfWin), Math.min(n, i + halfWin + 1));
1604
+ const out: number[] = [];
1605
+ for (let j = i - halfWin; j <= i + halfWin; j++) out.push(xs[((j % n) + n) % n]!);
1606
+ return out;
1607
+ };
1608
+ const nodMed = windowed ? refEN.map((_, i) => median(around(refEN, i))) : refEN.map(() => refEN[0]!);
1609
+ const nodBase = windowed
1332
1610
  ? nodMed.map((_, i) => {
1333
- const sl = nodMed.slice(Math.max(0, i - halfWin), Math.min(n, i + halfWin + 1));
1611
+ const sl = around(nodMed, i);
1334
1612
  return sl.reduce((a, b) => a + b, 0) / sl.length;
1335
1613
  })
1336
1614
  : nodMed;
@@ -1345,8 +1623,9 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1345
1623
  return Math.max(-maxNod, Math.min(maxNod, v));
1346
1624
  });
1347
1625
  const residual = rawNod.map((v, i) => v - dy[i]!);
1348
- headNodReading = { bone: hb, maxNod, rawY: rawNod, clampedY: dy, residual };
1349
- if (dy.some((v) => Math.abs(r3(v)) > 0)) {
1626
+ const keyed = dy.some((v) => Math.abs(r3(v)) > 0);
1627
+ headNodReading = { bone: hb, chain: c.name, maxNod, rawY: rawNod, clampedY: dy, residual, window: nodWindow, halfWin: windowed ? halfWin : 0, keyed };
1628
+ if (keyed) {
1350
1629
  const keys: MotionKey[] = [];
1351
1630
  const nod: Pt[] = [];
1352
1631
  const ref0 = refEN[0]! / (poseScale?.[0] ?? 1);
@@ -2753,15 +3032,95 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
2753
3032
  if (translateTrack !== null) tracks.push(translateTrack);
2754
3033
  if (headNodTrack !== null) tracks.push(headNodTrack);
2755
3034
  tracks.push(...sw.offsetTracks, ...sw.slotTracks);
2756
-
3035
+ const reach = reachReadings(rest, map, tracks, reachAims, t?.bone ?? null);
3036
+
3037
+ const pivot = pivotOf(rest, t?.bone ?? null, legs);
3038
+ const scaleFit = t === null || scale === null || origin === null ? null : scaleFitOf({
3039
+ joints: t.scale.pose, joint: t.joint, bone: t.bone, lengths: scaleLengths, factor: scale.factor,
3040
+ series, good, legLength: feet?.legLength ?? null, legStraight, rigLeg,
3041
+ rigHip: legs !== null && 'legs' in legs ? rootsApart(rest, legs.legs.r.chain.bones[0]!, legs.legs.l.chain.bones[0]!) : null,
3042
+ origin: origin.at,
3043
+ });
2757
3044
  return {
2758
3045
  animation: { duration: keyTimes[n - 1]!, tracks },
2759
- times: keyTimes, targets, origin, holds, bends, short, endOn, flips, scale,
3046
+ times: keyTimes, targets, origin, holds, bends, short, endOn, flips, scale, scaleFit,
2760
3047
  switches: sw.switches, shown: sw.shown, distances: sw.distances, turns, turnsUnread, range, artRange,
2761
- legScale, scales, feet, planted: plantOn, stance, floor, blendHold, closure, ankles, pivot: pivotOf(rest, t?.bone ?? null, legs), raised, byHip, face, body, figureScale, headNod: headNodReading,
3048
+ legScale, segments, legStraight, rigLeg, scales, feet, planted: plantOn, stance, floor, blendHold, closure, openDrift, ankles, pivot, raised, byHip, face, body, figureScale, headNod: headNodReading, nodSkipped, reach,
2762
3049
  };
2763
3050
  }
2764
3051
 
3052
+ /** A reach bone's origin within this many world units of the anchor's, with nothing above it moved, is aimed along the pair: no REACH line. */
3053
+ export const REACH_OFFSET = 1e-6;
3054
+
3055
+ /** Round a second-difference figure, °/frame², to 1e-4. */
3056
+ const r4 = (v: number) => {
3057
+ const r = Math.round(v * 10000) / 10000;
3058
+ return Object.is(r, -0) ? 0 : r;
3059
+ };
3060
+
3061
+ /**
3062
+ * #236: for each reach chain, the bones above it the animation moves — a rotate
3063
+ * track, or a translate track other than the anchor's (its travel is in the
3064
+ * target too) — and, when there are any, its aim's departure from the pair.
3065
+ */
3066
+ function reachReadings(rest: Rest, map: JointMap, tracks: ReadonlyArray<MotionTrack | SlotTrack>, aims: ReadonlyMap<string, { aim: number[]; pair: number[]; length: number }>, anchor: string | null): ReachReading[] {
3067
+ const out: ReachReading[] = [];
3068
+ if (anchor === null) return out;
3069
+ const a0 = rest.get(anchor)!;
3070
+ const moved = new Map<string, Set<'rotate' | 'translate'>>();
3071
+ for (const tr of tracks) {
3072
+ if (!('bone' in tr) || (tr.property !== 'rotate' && tr.property !== 'translate')) continue;
3073
+ if (tr.property === 'translate' && tr.bone === anchor) continue;
3074
+ const s = moved.get(tr.bone) ?? new Set();
3075
+ s.add(tr.property);
3076
+ moved.set(tr.bone, s);
3077
+ }
3078
+ for (const c of map.chains) {
3079
+ const got = aims.get(c.name);
3080
+ if (!c.reach || got === undefined) continue;
3081
+ const b = c.bones[0]!;
3082
+ const above: ReachReading['above'] = [];
3083
+ for (let a = rest.get(b)?.parent ?? null; a !== null; a = rest.get(a)?.parent ?? null) {
3084
+ for (const property of ['rotate', 'translate'] as const) {
3085
+ if (moved.get(a)?.has(property) !== true) continue;
3086
+ above.push({ bone: a, property, chain: property === 'rotate' ? (map.chains.find((x) => x.bones.includes(a))?.name ?? null) : null });
3087
+ }
3088
+ }
3089
+ const bo = rest.get(b)!;
3090
+ const units = Math.hypot(bo.worldX - a0.worldX, bo.worldY - a0.worldY);
3091
+ if (above.length === 0 && units <= REACH_OFFSET) continue;
3092
+ const dep = got.aim.map((v, i) => wrap(v - got.pair[i]!));
3093
+ let frame = 0;
3094
+ for (let i = 1; i < dep.length; i++) if (Math.abs(dep[i]!) > Math.abs(dep[frame]!)) frame = i;
3095
+ const d2 = meanSecondDiff([dep]).value;
3096
+ const aimD2 = meanSecondDiff([got.aim]).value;
3097
+ const pairD2 = meanSecondDiff([got.pair]).value;
3098
+ out.push({
3099
+ chain: c.name,
3100
+ bone: b,
3101
+ joints: [c.joints[0]!, c.joints[1]!],
3102
+ anchor,
3103
+ offset: { units: r3(units), ofLength: got.length > 0 ? r3(units / got.length) : 0 },
3104
+ above,
3105
+ departure: { max: r3(Math.abs(dep[frame] ?? 0)), frame, meanD2: d2 === null ? null : r4(d2) },
3106
+ aimD2: aimD2 === null ? null : r4(aimD2),
3107
+ pairD2: pairD2 === null ? null : r4(pairD2),
3108
+ ratio: aimD2 === null || pairD2 === null || r4(pairD2) === 0 ? null : Number((aimD2 / pairD2).toFixed(3)),
3109
+ });
3110
+ }
3111
+ return out;
3112
+ }
3113
+
3114
+ /** The `REACH` line of a reach chain whose aim departs from its pair (#236). */
3115
+ export function reachCostLine(e: ReachReading, times: readonly number[]): string {
3116
+ const f = (v: number | null) => (v === null ? 'not measured' : `${v.toFixed(4)}°/f²`);
3117
+ const why: string[] = [];
3118
+ if (e.offset.units > REACH_OFFSET) why.push(`its origin sits ${e.offset.units.toFixed(2)} units from the anchor "${e.anchor}"'s at setup (${e.offset.ofLength.toFixed(3)} of the reached length)`);
3119
+ if (e.above.length > 0) why.push(`it hangs below ${e.above.map((x) => `"${x.bone}" (${x.property === 'rotate' ? (x.chain === null ? 'rotate keys' : `turned by chain "${x.chain}"`) : 'translate keys'})`).join(', ')}`);
3120
+ const ratio = e.ratio === null ? (e.pairD2 === 0 ? ' — the pair is still' : '') : ` (${e.ratio.toFixed(2)}×)`;
3121
+ return `chain "${e.chain}" bone "${e.bone}" (${e.joints[0]} → ${e.joints[1]}) is aimed from its posed origin at the pair's end — ${why.join(', and ')}: the aim departs from the pair's own angle by up to ${e.departure.max.toFixed(3)}° (frame ${e.departure.frame}, t=${times[e.departure.frame]!.toFixed(3)}s), mean |Δ²| of the departure ${f(e.departure.meanD2)}; mean |Δ²| aim ${f(e.aimD2)} against the pair's ${f(e.pairD2)}${ratio} — rotation the pose pair does not have, the cost of "reach"; a reading`;
3122
+ }
3123
+
2765
3124
  /** What a placed wrist is measured against: the head (#62, the eyes) or the hip (#93, the hips) — two pose joints and the rig's two points. */
2766
3125
  interface Anchor {
2767
3126
  kind: 'head' | 'hip';
@@ -3192,7 +3551,7 @@ function checkHinge(c: Chain, k: number, targets: Map<string, number[]>, restOf:
3192
3551
  else if (c.bend === 'cw') side = -1;
3193
3552
  else {
3194
3553
  if (Math.abs(restInner) < 0.5) {
3195
- p.add('MAP_BEND_SIDE', `chain "${c.name}" — the hinge at ${joint} is straight at rest (inner angle ${restInner.toFixed(3)}°), so the rest pose does not say which way it bends; state "bend": "ccw", "cw" or "free"`);
3554
+ p.add('MAP_BEND_SIDE', `chain "${c.name}" — the hinge at ${joint} is straight at rest (inner angle ${restInner.toFixed(3)}°), so the rest pose does not say which way it bends; state "bend": "ccw", "cw" or "free" — ${bendFreeRemedy(c.name)}`);
3196
3555
  return;
3197
3556
  }
3198
3557
  side = restInner > 0 ? 1 : -1;
@@ -3213,8 +3572,83 @@ export function bendLine(e: BendEvent): string {
3213
3572
  return `chain "${e.chain}" hinge ${e.joint}, frame ${e.frame} (t=${e.t.toFixed(3)}s) — inner angle ${e.angle.toFixed(1)}°; it bends ${sense} at rest (${e.rest.toFixed(1)}°), so the angle must stay ${e.side > 0 ? '≥' : '≤'} 0`;
3214
3573
  }
3215
3574
 
3575
+ /**
3576
+ * The remedy both bend refusals name (rig-c/pose#238), as the README's map
3577
+ * section ("bend") gives it: front-facing material goes "free", a side view
3578
+ * keeps a side.
3579
+ */
3580
+ export function bendFreeRemedy(chain: string): string {
3581
+ return `seen from the front, the side a hinge's picture falls on is not its limit, so for front-facing material state "bend": "free" on chain "${chain}"; keep a side ("rest", "ccw", "cw") only for a side view, where the picture's side is the joint's own`;
3582
+ }
3583
+
3584
+ /** One hinge's wrong-way frames (#238): what its collapsed line says. */
3585
+ export interface BendGroup {
3586
+ chain: string;
3587
+ joint: string;
3588
+ side: 1 | -1;
3589
+ rest: number;
3590
+ /** Every frame it bent the wrong way, in frame order — the per-frame lines. */
3591
+ events: BendEvent[];
3592
+ /** The event furthest on the wrong side. */
3593
+ worst: BendEvent;
3594
+ /** Runs of consecutive frames. */
3595
+ runs: number;
3596
+ }
3597
+
3598
+ /** The bend events grouped per hinge, in the order the hinges were first met (#238). */
3599
+ export function bendGroups(events: readonly BendEvent[]): BendGroup[] {
3600
+ const out = new Map<string, BendGroup>();
3601
+ for (const e of events) {
3602
+ const k = `${e.chain}\u0000${e.joint}`;
3603
+ const g = out.get(k);
3604
+ if (g === undefined) {
3605
+ out.set(k, { chain: e.chain, joint: e.joint, side: e.side, rest: e.rest, events: [e], worst: e, runs: 1 });
3606
+ continue;
3607
+ }
3608
+ if (e.frame !== g.events[g.events.length - 1]!.frame + 1) g.runs++;
3609
+ g.events.push(e);
3610
+ if (-e.side * e.angle > -g.worst.side * g.worst.angle) g.worst = e;
3611
+ }
3612
+ return [...out.values()];
3613
+ }
3614
+
3615
+ /** One hinge's collapsed line (#238): a refusal names the remedy; a clamp, that every frame was straightened. */
3616
+ export function bendGroupLine(g: BendGroup, mode: 'refuse' | 'clamp'): string {
3617
+ const sense = g.side > 0 ? 'counter-clockwise (+)' : 'clockwise (−)';
3618
+ const first = g.events[0]!, last = g.events[g.events.length - 1]!;
3619
+ const range = first.frame === last.frame ? `frame ${first.frame} (t=${first.t.toFixed(3)}s)` : `frames ${first.frame}–${last.frame} (t=${first.t.toFixed(3)}–${last.t.toFixed(3)}s)`;
3620
+ const head = `chain "${g.chain}" hinge ${g.joint}, ${g.events.length} frame(s) in ${g.runs} run(s), ${range} — worst inner angle ${g.worst.angle.toFixed(1)}° at frame ${g.worst.frame} (t=${g.worst.t.toFixed(3)}s); it bends ${sense} at rest (${g.rest.toFixed(1)}°), so the angle must stay ${g.side > 0 ? '≥' : '≤'} 0`;
3621
+ return mode === 'refuse'
3622
+ ? `${head}; ${bendFreeRemedy(g.chain)}; a near-straight knee's noise takes --bends clamp or --bend-tolerance <deg> (each frame: --verbose)`
3623
+ : `${head} — clamped straight on each (each frame: --verbose; --report's "bends")`;
3624
+ }
3625
+
3216
3626
  /** Holds grouped per point, one line each span. */
3217
3627
  export function holdLine(h: Hold, times: readonly number[]): string {
3218
3628
  const span = h.from === h.to ? `frame ${h.from} (t=${times[h.from]!.toFixed(3)}s)` : `frames ${h.from}–${h.to} (t=${times[h.from]!.toFixed(3)}–${times[h.to]!.toFixed(3)}s)`;
3219
3629
  return `point "${h.point}" ${span}: ${h.why}; held frame ${h.source}'s value`;
3220
3630
  }
3631
+
3632
+ /**
3633
+ * The `NOD` line (rig-c/pose#241): what the eye-line chain did to the head — the keyed range, the window its baseline
3634
+ * was read over, whether the neck clamp acted and so coupled the nod into the head's rotate (and the face pitch). Or,
3635
+ * when no nod is keyed, why. `pitch`: the map keys a face pitch dial.
3636
+ */
3637
+ export function nodLine(h: HeadNodReading | null, skipped: NodSkipped | null, pitch: boolean): string | null {
3638
+ if (h === null) return skipped === null ? null : `none on bone "${skipped.bone}" (chain "${skipped.chain}", r_eye → l_eye): ${skipped.why} — no translate keyed, nothing coupled into its rotate${pitch ? ' or the face pitch' : ''}`;
3639
+ const head = `bone "${h.bone}" (chain "${h.chain}", r_eye → l_eye)`;
3640
+ if (!h.keyed) return `${head}: the eyes do not move against the neck past the writer's 0.001 units — no translate keyed, nothing coupled`;
3641
+ const lo = Math.min(...h.clampedY);
3642
+ const hi = Math.max(...h.clampedY);
3643
+ const sign = (v: number): string => `${v >= 0 ? '+' : ''}${v.toFixed(3)}`;
3644
+ const window = h.window === 'in-place'
3645
+ ? 'window none (in-place): the baseline is frame 0, no clamp, nothing coupled'
3646
+ : `window ±${h.halfWin} frame(s) (0.5 s) running median, detrended, ${h.window === 'wrapped' ? 'wrapped on the loop' : 'cut at the clip\'s ends'}`;
3647
+ const clamped = h.residual.filter((r) => r !== 0).length;
3648
+ const clamp = h.window === 'in-place'
3649
+ ? ''
3650
+ : clamped === 0
3651
+ ? `; clamp ±${h.maxNod.toFixed(3)} units (2% of the neck's setup length) never acted, so nothing is coupled`
3652
+ : `; clamp ±${h.maxNod.toFixed(3)} units (2% of the neck's setup length) acted on ${clamped} frame(s), so the clamped nod is coupled in phase into bone "${h.bone}"'s rotate (+${NOD_RESIDUAL_HEAD_GAIN}°/unit)${pitch ? ` and the face pitch (+${NOD_RESIDUAL_PITCH_GAIN}°/unit)` : ''} on every frame`;
3653
+ return `${head}: translate y ${sign(lo)} … ${sign(hi)} units over ${h.clampedY.length} frame(s); ${window}${clamp}`;
3654
+ }