@rig-c/pose 0.15.0 → 0.17.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
@@ -50,6 +50,14 @@
50
50
  * Inside a span nothing is clamped, but a bone with a `drawn` angle is read
51
51
  * against it (`artTurn`, and `artRange` where it leaves its range).
52
52
  *
53
+ * With `raisedByHead` (rig-c/pose#62), after the range clamp and before the art
54
+ * turn is read: inside each span of a set that draws an arm raised, the arm is
55
+ * solved last, so its wrist lands where the reference's is against the head, in
56
+ * eye distances (each side's median over the span). The head and the shoulder
57
+ * are posed from the final targets of the bones above. A target out of reach
58
+ * goes to the reach limit along the head-relative direction (REACH); the set's
59
+ * `turn` is part of the solve, and only a forearm it still holds is ART-RANGE.
60
+ *
53
61
  * The lines across the body — hips, shoulders, eyes — are read from the pose
54
62
  * after holds, whether the map uses them or not: a line under
55
63
  * `TURN_FRACTION` of its median picture length, or reversed (pointing more
@@ -63,16 +71,26 @@ import { screenToSpineDegrees } from 'rig-c/transform';
63
71
  import type { Body18 } from './joints.ts';
64
72
  import { isBody18, VIRTUAL } from './joints.ts';
65
73
  import type { Chain, JointMap, Rest, RigSlots } from './map.ts';
66
- import { aimAngle, descends, rigPoint } from './map.ts';
74
+ import { aimAngle, aimEnds, descends, partBone, pointParts, rigPoint } from './map.ts';
67
75
  import type { Joint } from './pose.ts';
68
76
  import { Problems, Refusal } from './problems.ts';
69
- import type { ArtRange, SlotTrack, Switch, SwitchOptions } from './switch.ts';
70
- import { clampArt, coveredChains, measureArt, overflow, RANGE_SLACK, runsOf, selectSwitches } from './switch.ts';
71
- import type { Feet, LegScale, PathPose, Posing, Side, Stance } from './legs.ts';
72
- import { isLegSegment, legScaleOf, legsOf, pathFrom, plantHip, posePath, rootOf, SIDES, stanceOf, supportsOf, tipOf } from './legs.ts';
77
+ import type { ArtRange, Raised, ReachShort, SlotTrack, Switch, SwitchOptions } from './switch.ts';
78
+ import { clampArt, coveredChains, isRaised, measureArt, overflow, RANGE_SLACK, reachLimit, runsOf, selectSwitches, twoBone } from './switch.ts';
79
+ import type { BlendHold, Feet, Floor, LegScale, PathPose, Posing, Side, Stance } from './legs.ts';
80
+ import { holdBlend, isLegSegment, legScaleOf, legsOf, pathFrom, plantHip, posePath, rootOf, SIDES, stanceOf, supportsOf, tipOf } from './legs.ts';
73
81
 
74
82
  const DEG = 180 / Math.PI;
75
83
 
84
+ /**
85
+ * --raised-by-head (#62): a held upper arm whose re-aimed forearm leaves the
86
+ * wrist farther than this from its placed point, in eye distances, "cannot
87
+ * reach within turn" — ART-RANGE. It sits at the float and rounding scale, so
88
+ * any real miss counts: the solve itself closes to ~1e-12, and the writer's
89
+ * 0.001° rounding of two keys moves a wrist by ≤ 2 × 1.75e-5 × (l0 + l1), which
90
+ * is 0.0004 eye distances on the tests' stick rig (a 130-unit arm, 12-unit eyes).
91
+ */
92
+ export const HELD_MISS = 0.01;
93
+
76
94
  export interface RetargetOptions extends SwitchOptions {
77
95
  /** A joint with a score below this is not good on that frame (a joint with no score is). */
78
96
  minScore: number;
@@ -90,6 +108,10 @@ export interface RetargetOptions extends SwitchOptions {
90
108
  plantFeet?: boolean;
91
109
  /** With plantFeet: a free ankle within this many leg lengths of the floor plants both feet, its leg stretched to reach it (`legs.ts` stanceOf, #60); 'off' (the default) plants one. */
92
110
  stance?: number | 'off';
111
+ /** With plantFeet: the floor's world y every support span is planted on (#85); absent, the median of the spans' planted heights. */
112
+ floor?: number;
113
+ /** 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. */
114
+ raisedByHead?: boolean;
93
115
  }
94
116
 
95
117
  export interface Hold {
@@ -268,10 +290,16 @@ export interface Retarget {
268
290
  planted: boolean;
269
291
  /** With --stance: the frames planted both and lifted, and the free leg's stretch (#60). */
270
292
  stance: Stance | null;
293
+ /** With --plant-feet: the one floor every support span was planted on, and each span's shift onto it (#85). */
294
+ floor: Floor | null;
295
+ /** With --plant-feet: the supporting leg stretched on the blend frames so its ankle stays on the floor (#85). */
296
+ blendHold: BlendHold | null;
271
297
  /** Each ankle's world position on every frame, as the keys put it, when legs are scaled or feet planted. */
272
298
  ankles: { bones: Record<Side, string>; at: Record<Side, Pt[]> } | null;
273
299
  /** The translated bone's origin against its thigh roots' midpoint, at setup. */
274
300
  pivot: Pivot | null;
301
+ /** With --raised-by-head: each raised set's span on each raised chain, its wrist placed against the head. */
302
+ raised: Raised[];
275
303
  }
276
304
 
277
305
  /** The hip's pivot against the thigh roots, at setup (rig-c/pose#47). */
@@ -368,6 +396,11 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
368
396
  const endOf = (i: number): number => (i + 1 < n ? keyTimes[i + 1]! : n > 1 ? 2 * keyTimes[i]! - keyTimes[i - 1]! : keyTimes[i]!);
369
397
  const secondsOf = (run: [number, number]): number => Math.round((endOf(run[1]) - keyTimes[run[0]]!) * 1e6) / 1e6;
370
398
 
399
+ // --raised-by-head (#62) measures against the head in eye distances: the map's head chain must carry the
400
+ // eye line with an "a>b" aim, whose two ends are the rig's eyes.
401
+ const head = opts.raisedByHead === true ? headOf(map) : null;
402
+ if (head !== null && 'problem' in head) p.add('RAISED_HEAD', head.problem);
403
+
371
404
  // Every point the map reads, held where it is not good.
372
405
  const needed = new Set<string>();
373
406
  for (const c of map.chains) c.joints.forEach((j) => needed.add(j));
@@ -582,6 +615,8 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
582
615
  const legScale: LegScale[] = [];
583
616
  const scales = new Map<string, number[]>();
584
617
  let stance: Stance | null = null;
618
+ let floor: Floor | null = null;
619
+ let blendHold: BlendHold | null = null;
585
620
  let feet: Feet | null = null;
586
621
  let legs: ReturnType<typeof legsOf> | null = null;
587
622
  let legPath: string[] | null = null;
@@ -637,6 +672,8 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
637
672
  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');
638
673
  }
639
674
  if (opts.stance !== undefined && opts.stance !== 'off' && !plantOn) p.add('STANCE', `--stance ${opts.stance} plants the free foot beside the supporting one that --plant-feet holds, and --plant-feet is not given`);
675
+ if (opts.floor !== undefined && !plantOn) p.add('FLOOR', `--floor ${opts.floor} is the floor --plant-feet plants every support span on, and --plant-feet is not given`);
676
+ if (opts.floor !== undefined && !Number.isFinite(opts.floor)) p.add('FLOOR', `--floor is ${opts.floor}; it must be a finite world y`);
640
677
  p.throwIfAny();
641
678
  /** Every bone on the legs' path posed on frame i, by the targets as they stand. */
642
679
  const poseLegs = (i: number): PathPose => {
@@ -657,32 +694,52 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
657
694
  return { r: tipOf(rest, lp, legs !== null && 'legs' in legs ? legs.legs.r.ankleBone : ''), l: tipOf(rest, lp, legs !== null && 'legs' in legs ? legs.legs.l.ankleBone : '') };
658
695
  });
659
696
  const tb = rest.get(t!.bone)!;
660
- const hip = plantHip(feet!, (i, s) => offsets[i]![s], (i) => poseTravel[i]!, [tb.worldX, tb.worldY]);
697
+ // #85: every support span on one floor — the median of their planted heights, or --floor.
698
+ const planted = plantHip(feet!, (i, s) => offsets[i]![s], (i) => poseTravel[i]!, [tb.worldX, tb.worldY], opts.floor);
699
+ const hip = planted.hip;
700
+ floor = planted.floor;
661
701
  for (let i = 0; i < n; i++) travel[i] = [hip[i]![0] - tb.worldX, hip[i]![1] - tb.worldY];
662
- // --stance (#60): with the hip placed, a free ankle within the tolerance of the floor has its leg
663
- // stretched (every bone of its chain's scalex, about the chain's root) until it reaches it.
664
- if (opts.stance !== undefined && opts.stance !== 'off') {
665
- const L = (legs as { legs: Record<Side, { chain: Chain; ankleBone: string }> }).legs;
666
- const world = (i: number, v: Pt): Pt => [hip[i]![0] + v[0], hip[i]![1] + v[1]];
667
- const ankleSeries = { r: series.get('r_ankle')!, l: series.get('l_ankle')! };
668
- stance = stanceOf(feet!, ankleSeries, opts.stance, (i) => {
669
- const lp = poseLegs(i);
670
- return {
671
- root: { r: world(i, lp.origin.get(L.r.chain.bones[0]!)!), l: world(i, lp.origin.get(L.l.chain.bones[0]!)!) },
672
- ankle: { r: world(i, tipOf(rest, lp, L.r.ankleBone)), l: world(i, tipOf(rest, lp, L.l.ankleBone)) },
673
- };
674
- });
702
+ const L = (legs as { legs: Record<Side, { chain: Chain; ankleBone: string }> }).legs;
703
+ const world = (i: number, v: Pt): Pt => [hip[i]![0] + v[0], hip[i]![1] + v[1]];
704
+ /** Both legs posed on frame i by the scales as they stand, world (y up): each chain's root and ankle. */
705
+ const posedFeet = (i: number) => {
706
+ const lp = poseLegs(i);
707
+ return {
708
+ root: { r: world(i, lp.origin.get(L.r.chain.bones[0]!)!), l: world(i, lp.origin.get(L.l.chain.bones[0]!)!) },
709
+ ankle: { r: world(i, tipOf(rest, lp, L.r.ankleBone)), l: world(i, tipOf(rest, lp, L.l.ankleBone)) },
710
+ };
711
+ };
712
+ /** Every bone of a side's chain has its scalex multiplied by the stretch keyed on that side. */
713
+ const stretchChain = (stretch: ReadonlyArray<{ side: Side; k: number } | null>) => {
675
714
  for (const side of SIDES) {
676
- if (!stance.stretch.some((x) => x?.side === side)) continue;
715
+ if (!stretch.some((x) => x?.side === side)) continue;
677
716
  for (const b of L[side].chain.bones) {
678
717
  const own = scales.get(b);
679
718
  const s = own === undefined ? new Array<number>(n).fill(1) : [...own];
680
- stance.stretch.forEach((x, i) => {
719
+ stretch.forEach((x, i) => {
681
720
  if (x?.side === side) s[i] = s[i]! * x.k;
682
721
  });
683
722
  scales.set(b, s);
684
723
  }
685
724
  }
725
+ };
726
+ // #85: on a blend frame the hip is between two derivations; the supporting leg is stretched
727
+ // (stanceOf's mechanism, its bounds) so its ankle stays on the floor. Before --stance, whose
728
+ // floor is that supporting ankle.
729
+ blendHold = holdBlend(planted.blends, n, posedFeet);
730
+ stretchChain(blendHold.stretch);
731
+ // --stance (#60): with the hip placed, a free ankle within the tolerance of the floor has its leg
732
+ // stretched (every bone of its chain's scalex, about the chain's root) until it reaches it.
733
+ if (opts.stance !== undefined && opts.stance !== 'off') {
734
+ const ankleSeries = { r: series.get('r_ankle')!, l: series.get('l_ankle')! };
735
+ stance = stanceOf(feet!, ankleSeries, opts.stance, posedFeet, secondsOf);
736
+ // The supporting leg (the blend's) and the free leg (the stance's) are different chains on a frame.
737
+ for (let i = 0; i < n; i++) {
738
+ const b = blendHold.stretch[i];
739
+ if (b !== null && b !== undefined && stance.stretch[i]?.side === b.side) p.add('LEGS_STRETCH', `frame ${i}: chain "${L[b.side].chain.name}" is stretched both as the supporting leg of a blend (k ${b.k}) and as the free leg of --stance (k ${stance.stretch[i]!.k}); one chain takes one stretch`);
740
+ }
741
+ p.throwIfAny();
742
+ stretchChain(stance.stretch);
686
743
  }
687
744
  }
688
745
  if (t !== null && scale !== null) {
@@ -713,29 +770,52 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
713
770
  for (let a = rest.get(b)!.parent; a !== null; a = rest.get(a)!.parent) d++;
714
771
  return d;
715
772
  };
773
+ /** The world turn of `b`'s nearest mapped bone-or-ancestor, degrees, on frame i, by the targets as they stand. */
774
+ const turnAbove = (b: string, i: number): number => {
775
+ for (let a: string | null = b; a !== null; a = rest.get(a)!.parent) {
776
+ const tg = targets.get(a);
777
+ if (tg !== undefined) return tg[i]! - restOf.get(a)!;
778
+ }
779
+ return 0;
780
+ };
781
+ /**
782
+ * Where the poser will put `b`'s origin on frame i: forward from the
783
+ * translated bone (moved by its travel) or the root (at setup), each rest
784
+ * offset turned by the nearest mapped bone above — the key formula's own
785
+ * assumption (unit scale, no shear), which the landing check holds to account.
786
+ */
787
+ const posedOrigin = (b: string, i: number): Pt => {
788
+ const own = rest.get(b)!;
789
+ if (t !== null && b === t.bone) return [own.worldX + (travel[i]?.[0] ?? 0), own.worldY + (travel[i]?.[1] ?? 0)];
790
+ if (own.parent === null) return [own.worldX, own.worldY];
791
+ const parent = own.parent;
792
+ const po = posedOrigin(parent, i);
793
+ const r = turnAbove(parent, i) / DEG;
794
+ const ox = own.worldX - rest.get(parent)!.worldX;
795
+ const oy = own.worldY - rest.get(parent)!.worldY;
796
+ return [po[0] + Math.cos(r) * ox - Math.sin(r) * oy, po[1] + Math.sin(r) * ox + Math.cos(r) * oy];
797
+ };
798
+ /** A rig point (a bone, `name:tip`, `a+b`) posed on frame i the same way. */
799
+ const posedPoint = (spec: string, i: number): Pt => {
800
+ let x = 0;
801
+ let y = 0;
802
+ const parts = pointParts(spec);
803
+ for (const part of parts) {
804
+ const name = partBone(part);
805
+ const o = posedOrigin(name, i);
806
+ const bone = rest.get(name)!;
807
+ const l = part.endsWith(':tip') ? bone.length : 0;
808
+ const r = turnAbove(name, i) / DEG;
809
+ x += o[0] + l * (Math.cos(r) * bone.a - Math.sin(r) * bone.c);
810
+ y += o[1] + l * (Math.sin(r) * bone.a + Math.cos(r) * bone.c);
811
+ }
812
+ return [x / parts.length, y / parts.length];
813
+ };
716
814
  const reaches = map.chains.filter((c) => c.reach).sort((x, y) => depth(x.bones[0]!) - depth(y.bones[0]!));
717
815
  let aimReach: ((c: Chain) => void) | null = null;
718
816
  if (reaches.length > 0 && t !== null && scale !== null) {
719
817
  const s = scale.factor;
720
818
  const tb = rest.get(t.bone)!;
721
- /** The world turn of `b`'s nearest mapped bone-or-ancestor, degrees, on frame i. */
722
- const turnAbove = (b: string, i: number): number => {
723
- for (let a: string | null = b; a !== null; a = rest.get(a)!.parent) {
724
- const tg = targets.get(a);
725
- if (tg !== undefined) return tg[i]! - restOf.get(a)!;
726
- }
727
- return 0;
728
- };
729
- /** Where the poser will put `b`'s origin on frame i: forward from the translated bone. */
730
- const posedOrigin = (b: string, i: number): Pt => {
731
- if (b === t.bone) return [tb.worldX + travel[i]![0], tb.worldY + travel[i]![1]];
732
- const parent = rest.get(b)!.parent!;
733
- const po = posedOrigin(parent, i);
734
- const r = turnAbove(parent, i) / DEG;
735
- const ox = rest.get(b)!.worldX - rest.get(parent)!.worldX;
736
- const oy = rest.get(b)!.worldY - rest.get(parent)!.worldY;
737
- return [po[0] + Math.cos(r) * ox - Math.sin(r) * oy, po[1] + Math.sin(r) * ox + Math.cos(r) * oy];
738
- };
739
819
  aimReach = (c: Chain): void => {
740
820
  const b = c.bones[0]!;
741
821
  const anchor = series.get(c.joints[0]!)!;
@@ -793,8 +873,214 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
793
873
  }
794
874
  }
795
875
  p.throwIfAny();
796
- const artRange = measureArt(sw.switches, map, targets, endOf, keyTimes, RANGE_GAP);
876
+
877
+ // --raised-by-head (#62, HQ's ruling): while a set whose arm is drawn raised shows, its wrist goes where
878
+ // the reference's is against the head, in eye distances (each side's median over the span). Solved last
879
+ // of the bones: every bone above the arm, the head and the trunk's reach included, has its final target
880
+ // (range clamp done), so the head and the shoulder are posed from them frame by frame; the arm moves
881
+ // neither. A target out of reach is placed at the reach limit along the head-relative direction
882
+ // (`reachLimit`, a REACH line). The set's `turn` is part of the solve: a held upper arm has its forearm
883
+ // re-aimed at the placed wrist, and only a forearm the turn still holds is ART-RANGE.
884
+ const raised: Raised[] = [];
885
+ const raisedArt: Array<{ sw: Switch; entries: ArtRange[] }> = [];
886
+ const raisedJobs: Array<{ sw: Switch; chain: Chain; wrist: (i: number) => Pt; before: Pt[]; reference: Pt[]; placed: Pt[]; refEye: number; rigEye: number; short: Array<{ i: number; how: 'far' | 'near' | 'missed'; shortfall: number }>; upperHeld: number[]; l0: number; l1: number; farthest: number; eyePoints: [string, string] }> = [];
887
+ if (head !== null && !('problem' in head)) {
888
+ const eyesOn = (i: number): { centre: Pt; dist: number } => {
889
+ const a = posedPoint(head.points[0], i);
890
+ const b = posedPoint(head.points[1], i);
891
+ return { centre: [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2], dist: Math.hypot(b[0] - a[0], b[1] - a[1]) };
892
+ };
893
+ const headBones = new Set([...head.points.flatMap((x) => pointParts(x).map(partBone))]);
894
+ const ra = series.get(head.joints[0])!;
895
+ const rb = series.get(head.joints[1])!;
896
+ const refCentre = (i: number): Pt => [(ra[i]![0] + rb[i]![0]) / 2, (ra[i]![1] + rb[i]![1]) / 2];
897
+ for (const s of sw.switches) {
898
+ const set = map.alternates.find((a) => a.name === s.set)!;
899
+ const art: ArtRange[] = [];
900
+ for (const cn of s.chains) {
901
+ const c = map.chains.find((x) => x.name === cn)!;
902
+ const drawn = set.drawn[c.bones[0]!];
903
+ if (drawn === undefined || !isRaised(drawn)) continue;
904
+ const label = `--raised-by-head: set "${set.name}" draws chain "${c.name}" raised (bone "${c.bones[0]}" at ${drawn}°)`;
905
+ if (c.bones.length !== 2) {
906
+ p.add('RAISED_CHAIN', `${label}, and the chain has ${c.bones.length} bone(s); the wrist is placed by a two-bone solve, so it must have two (upper arm, forearm)`);
907
+ continue;
908
+ }
909
+ if (c.reach || c.aim.some((x) => x.includes('>'))) {
910
+ p.add('RAISED_CHAIN', `${label}, and it aims ${JSON.stringify(c.aim)}${c.reach ? ' with "reach"' : ''}; the solve turns each bone about its own origin toward the next point, so each must aim "x" or at one point`);
911
+ continue;
912
+ }
913
+ const [b0, b1] = c.bones as [string, string];
914
+ const moved = [...headBones].filter((h) => h === b0 || h === b1 || descends(rest, h, b0));
915
+ if (moved.length > 0) {
916
+ p.add('RAISED_CHAIN', `${label}, and the head's eye point ${moved.map((h) => `"${h}"`).join(', ')} turns with it; the wrist cannot be placed against a head the arm moves`);
917
+ continue;
918
+ }
919
+ const wristSpec = c.aim[1] === 'x' ? `${b1}:tip` : c.aim[1]!;
920
+ if (c.aim[1] === 'x' && !(rest.get(b1)!.length > 1e-9)) {
921
+ p.add('RAISED_CHAIN', `${label}, and its last bone "${b1}" aims "x" with no length; its wrist (the tip) is its origin, so there is nothing to place`);
922
+ continue;
923
+ }
924
+ const wristOf = (i: number): Pt => posedPoint(wristSpec, i);
925
+ const refWrist = series.get(c.joints[2]!)!;
926
+ // The unit, each side's median over the span (HQ's ruling).
927
+ const span: number[] = [];
928
+ for (let i = s.frames[0]; i <= s.frames[1]; i++) span.push(i);
929
+ const refEye = median(span.map((i) => Math.hypot(rb[i]![0] - ra[i]![0], rb[i]![1] - ra[i]![1])));
930
+ const rigEye = median(span.map((i) => eyesOn(i).dist));
931
+ if (!(refEye > 1e-9) || !(rigEye > 1e-9)) {
932
+ p.add('RAISED_EYES', `${label}: over frames ${s.frames[0]}–${s.frames[1]} the median eye distance is ${refEye > 1e-9 ? `${rigEye} units on the rig (points "${head.points[0]}", "${head.points[1]}")` : `${refEye}px in the reference (${head.joints[0]}, ${head.joints[1]})`}; an eye distance of 0 is no unit`);
933
+ continue;
934
+ }
935
+ // The rig's own lengths, and each bone's aim against the vector it carries, at setup.
936
+ const S0 = rigPoint(rest, b0)!;
937
+ const E0 = rigPoint(rest, b1)!;
938
+ const W0 = rigPoint(rest, wristSpec)!;
939
+ const l0 = Math.hypot(E0[0] - S0[0], E0[1] - S0[1]);
940
+ const l1 = Math.hypot(W0[0] - E0[0], W0[1] - E0[1]);
941
+ const d0 = wrap(Math.atan2(E0[1] - S0[1], E0[0] - S0[0]) * DEG - restOf.get(b0)!);
942
+ const d1 = wrap(Math.atan2(W0[1] - E0[1], W0[0] - E0[0]) * DEG - restOf.get(b1)!);
943
+ const t0 = targets.get(b0)!;
944
+ const t1 = targets.get(b1)!;
945
+ const turn0 = set.turn[b0];
946
+ const turn1 = set.turn[b1];
947
+ const job: (typeof raisedJobs)[number] = { sw: s, chain: c, wrist: wristOf, before: [], reference: [], placed: [], refEye, rigEye, short: [], upperHeld: [], l0, l1, farthest: 0, eyePoints: head.points };
948
+ /** Per frame of the span: the forearm's art turn the re-aim asked, and how far past its turn. */
949
+ const foreAsked: number[] = [];
950
+ const foreOver: number[] = [];
951
+ /** Per frame of the span: the upper arm's art turn the solve asked, how far past its turn, and — where the turn held it — how far the wrist ends from its placed point, eye distances (0 elsewhere). */
952
+ const upperAsked: number[] = [];
953
+ const upperOver: number[] = [];
954
+ const heldMiss: number[] = [];
955
+ for (const i of span) {
956
+ const h = eyesOn(i);
957
+ const rc = refCentre(i);
958
+ // The reference's wrist from its head centre, in its eye distances, y turned up.
959
+ const ref: Pt = [(refWrist[i]![0] - rc[0]) / refEye, -(refWrist[i]![1] - rc[1]) / refEye];
960
+ const w = wristOf(i);
961
+ job.reference.push(ref);
962
+ job.before.push([(w[0] - h.centre[0]) / rigEye, (w[1] - h.centre[1]) / rigEye]);
963
+ const target: Pt = [h.centre[0] + ref[0] * rigEye, h.centre[1] + ref[1] * rigEye];
964
+ const shoulder = posedOrigin(b0, i);
965
+ job.farthest = Math.max(job.farthest, Math.hypot(target[0] - shoulder[0], target[1] - shoulder[1]));
966
+ const place = reachLimit(h.centre, target, shoulder, l0, l1);
967
+ const P = place.point;
968
+ job.placed.push([(P[0] - h.centre[0]) / rigEye, (P[1] - h.centre[1]) / rigEye]);
969
+ if (place.how !== 'in') job.short.push({ i, how: place.how, shortfall: Math.hypot(target[0] - P[0], target[1] - P[1]) / rigEye });
970
+ // The pose's bend side, read on the vectors the two bones carry.
971
+ const side: 1 | -1 = wrap(t1[i]! + d1 - (t0[i]! + d0)) >= 0 ? 1 : -1;
972
+ const sol = twoBone(shoulder, P, l0, l1, side);
973
+ if (sol.upper === null) {
974
+ foreAsked.push(wrap(t1[i]! - (set.drawn[b1] ?? 0)));
975
+ foreOver.push(0);
976
+ upperAsked.push(0);
977
+ upperOver.push(0);
978
+ heldMiss.push(0);
979
+ continue;
980
+ }
981
+ let a0 = wrap(sol.upper - d0);
982
+ let a1 = wrap(sol.fore - d1);
983
+ // The set's turn holds the upper arm; the forearm is then re-aimed at the placed wrist from where the held upper arm puts the elbow.
984
+ let held = false;
985
+ const tt = turn0 === undefined ? 0 : wrap(a0 - set.drawn[b0]!);
986
+ upperAsked.push(tt);
987
+ upperOver.push(turn0 === undefined ? 0 : overflow(tt, turn0));
988
+ if (turn0 !== undefined) {
989
+ const cl = Math.min(Math.max(tt, turn0[0]), turn0[1]);
990
+ if (Math.abs(cl - tt) > RANGE_SLACK) {
991
+ held = true;
992
+ a0 = wrap(set.drawn[b0]! + cl);
993
+ job.upperHeld.push(i);
994
+ const r = (a0 + d0) / DEG;
995
+ const E: Pt = [shoulder[0] + l0 * Math.cos(r), shoulder[1] + l0 * Math.sin(r)];
996
+ a1 = wrap(Math.atan2(P[1] - E[1], P[0] - E[0]) * DEG - d1);
997
+ }
998
+ }
999
+ // A forearm the turn still holds: the re-aim failed within the art's range — ART-RANGE.
1000
+ const asked = turn1 === undefined ? 0 : wrap(a1 - set.drawn[b1]!);
1001
+ const over = turn1 === undefined ? 0 : overflow(asked, turn1);
1002
+ foreAsked.push(asked);
1003
+ foreOver.push(over);
1004
+ if (turn1 !== undefined && over > RANGE_SLACK) a1 = wrap(set.drawn[b1]! + Math.min(Math.max(asked, turn1[0]), turn1[1]));
1005
+ // Where the held arm leaves the wrist: the re-aimed forearm points at the placed point, but from a held
1006
+ // elbow its length need not reach it (rule 3, "cannot reach within turn").
1007
+ const r0 = (a0 + d0) / DEG;
1008
+ const r1 = (a1 + d1) / DEG;
1009
+ const W: Pt = [shoulder[0] + l0 * Math.cos(r0) + l1 * Math.cos(r1), shoulder[1] + l0 * Math.sin(r0) + l1 * Math.sin(r1)];
1010
+ heldMiss.push(held ? Math.hypot(W[0] - P[0], W[1] - P[1]) / rigEye : 0);
1011
+ t0[i] = a0;
1012
+ t1[i] = a1;
1013
+ }
1014
+ // A held upper arm whose re-aimed forearm leaves the wrist more than HELD_MISS short of its placed point: ART-RANGE on the upper arm, with the shortfall.
1015
+ if (turn0 !== undefined) {
1016
+ for (const [a, b] of runsOf(heldMiss.map((m) => m > HELD_MISS), RANGE_GAP)) {
1017
+ let worst = a;
1018
+ for (let k = a; k <= b; k++) if (heldMiss[k]! > heldMiss[worst]!) worst = k;
1019
+ const first = s.frames[0] + a;
1020
+ const last = s.frames[0] + b;
1021
+ art.push({ set: set.name, chain: c.name, bone: b0, frames: [first, last], seconds: r6(endOf(last) - keyTimes[first]!), maxOverflow: r3(upperOver[worst]!), turn: r3(upperAsked[worst]!), limits: turn0, clamped: true, shortfall: r3(heldMiss[worst]!) });
1022
+ }
1023
+ }
1024
+ if (turn1 !== undefined) {
1025
+ for (const [a, b] of runsOf(foreOver.map((o) => o > RANGE_SLACK), RANGE_GAP)) {
1026
+ let worst = a;
1027
+ for (let k = a; k <= b; k++) if (foreOver[k]! > foreOver[worst]!) worst = k;
1028
+ const first = s.frames[0] + a;
1029
+ const last = s.frames[0] + b;
1030
+ art.push({ set: set.name, chain: c.name, bone: b1, frames: [first, last], seconds: r6(endOf(last) - keyTimes[first]!), maxOverflow: r3(foreOver[worst]!), turn: r3(foreAsked[worst]!), limits: turn1, clamped: true });
1031
+ }
1032
+ }
1033
+ raisedJobs.push(job);
1034
+ }
1035
+ art.sort((x, y) => x.frames[0] - y.frames[0]);
1036
+ if (art.length > 0) raisedArt.push({ sw: s, entries: art });
1037
+ }
1038
+ }
1039
+ p.throwIfAny();
1040
+ // The art turn of every span; a raised chain's bones were held by the solve, and only its re-aimed forearm is listed (above).
1041
+ const measured = measureArt(sw.switches, map, targets, endOf, keyTimes, RANGE_GAP);
1042
+ const solvedBy = (e: ArtRange, s: Switch) => raisedJobs.some((j) => j.sw === s && j.chain.name === e.chain);
1043
+ const artRange: ArtRange[] = [];
1044
+ for (const s of sw.switches) {
1045
+ const own = measured.filter((e) => e.set === s.set && e.frames[0] >= s.frames[0] && e.frames[1] <= s.frames[1]);
1046
+ artRange.push(...own.filter((e) => !solvedBy(e, s)), ...(raisedArt.find((x) => x.sw === s)?.entries ?? []));
1047
+ }
797
1048
  clampArt(sw.switches, map, targets);
1049
+ // Where each raised wrist lands as keyed: after the solve and the set's `turn`.
1050
+ for (const job of raisedJobs) {
1051
+ const after: Pt[] = [];
1052
+ for (let i = job.sw.frames[0]; i <= job.sw.frames[1]; i++) {
1053
+ const a = posedPoint(job.eyePoints[0], i);
1054
+ const b = posedPoint(job.eyePoints[1], i);
1055
+ const w = job.wrist(i);
1056
+ after.push([(w[0] - (a[0] + b[0]) / 2) / job.rigEye, (w[1] - (a[1] + b[1]) / 2) / job.rigEye]);
1057
+ }
1058
+ const worst = (rig: readonly Pt[]): Pt => [Math.max(...rig.map((v, k) => Math.abs(v[0] - job.reference[k]![0]))), Math.max(...rig.map((v, k) => Math.abs(v[1] - job.reference[k]![1])))].map(r6) as Pt;
1059
+ const round = (vs: readonly Pt[]): Pt[] => vs.map((v) => [r6(v[0]), r6(v[1])]);
1060
+ const short: ReachShort[] = [];
1061
+ for (let k = 0; k < job.short.length;) {
1062
+ let m = k;
1063
+ while (m + 1 < job.short.length && job.short[m + 1]!.i === job.short[m]!.i + 1 && job.short[m + 1]!.how === job.short[k]!.how) m++;
1064
+ const fs = job.short.slice(k, m + 1).map((x) => x.shortfall);
1065
+ short.push({ frames: [job.short[k]!.i, job.short[m]!.i], how: job.short[k]!.how, shortfall: { max: r6(Math.max(...fs)), median: r6(median(fs)) } });
1066
+ k = m + 1;
1067
+ }
1068
+ raised.push({
1069
+ set: job.sw.set,
1070
+ chain: job.chain.name,
1071
+ frames: job.sw.frames,
1072
+ seconds: secondsOf(job.sw.frames),
1073
+ eyes: { rig: r6(job.rigEye), reference: r6(job.refEye) },
1074
+ reference: round(job.reference),
1075
+ placed: round(job.placed),
1076
+ before: round(job.before),
1077
+ after: round(after),
1078
+ worst: { before: worst(job.before), after: worst(after), placed: r6(Math.max(...after.map((v, k) => Math.hypot(v[0] - job.placed[k]![0], v[1] - job.placed[k]![1])))) },
1079
+ short,
1080
+ upperHeld: job.upperHeld,
1081
+ reach: { min: r6(Math.abs(job.l0 - job.l1)), max: r6(job.l0 + job.l1), farthest: r6(job.farthest) },
1082
+ });
1083
+ }
798
1084
 
799
1085
  // The legs on their final targets: a bone under a scaled bone is keyed through
800
1086
  // its parent's posed matrix (legs.ts), and each ankle's world position is read.
@@ -854,10 +1140,34 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
854
1140
  animation: { duration: keyTimes[n - 1]!, tracks },
855
1141
  times: keyTimes, targets, origin, holds, bends, short, endOn, flips, scale,
856
1142
  switches: sw.switches, shown: sw.shown, distances: sw.distances, turns, turnsUnread, range, artRange,
857
- legScale, scales, feet, planted: plantOn, stance, ankles, pivot: pivotOf(rest, t?.bone ?? null, legs),
1143
+ legScale, scales, feet, planted: plantOn, stance, floor, blendHold, ankles, pivot: pivotOf(rest, t?.bone ?? null, legs), raised,
858
1144
  };
859
1145
  }
860
1146
 
1147
+ /** The head as --raised-by-head reads it: the chain carrying the eye line, its two pose joints and the rig's two eye points. */
1148
+ interface Head {
1149
+ chain: string;
1150
+ joints: [string, string];
1151
+ points: [string, string];
1152
+ }
1153
+
1154
+ /** The map's head chain (the one carrying r_eye → l_eye, either way round) and its "a>b" aim; or why there is none to read. */
1155
+ function headOf(map: JointMap): Head | { problem: string } {
1156
+ const lead = '--raised-by-head places a raised arm\'s wrist against the head in eye distances, read on the rig between the two points the head chain\'s "a>b" aim names';
1157
+ for (const c of map.chains) {
1158
+ for (let k = 0; k + 1 < c.joints.length; k++) {
1159
+ const a = c.joints[k]!;
1160
+ const b = c.joints[k + 1]!;
1161
+ if (!((a === 'r_eye' && b === 'l_eye') || (a === 'l_eye' && b === 'r_eye'))) continue;
1162
+ const aim = c.aim[k]!;
1163
+ if (!aim.includes('>')) return { problem: `${lead}; chain "${c.name}" carries ${a} → ${b} on bone "${c.bones[k]}", and aims it ${JSON.stringify(aim)}, not "a>b" between the rig's eye points` };
1164
+ const [pa, pb] = aimEnds(c.bones[k]!, aim);
1165
+ return { chain: c.name, joints: [a, b], points: [pa, pb] };
1166
+ }
1167
+ }
1168
+ return { problem: `${lead}; no chain of the map carries the eye line (joints r_eye → l_eye), so the rig has no eye points to measure` };
1169
+ }
1170
+
861
1171
  type ShortSpan = Omit<Short, 'chain' | 'bone' | 'joints'>;
862
1172
 
863
1173
  /**