@rig-c/pose 0.17.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/retarget.ts CHANGED
@@ -57,6 +57,10 @@
57
57
  * are posed from the final targets of the bones above. A target out of reach
58
58
  * goes to the reach limit along the head-relative direction (REACH); the set's
59
59
  * `turn` is part of the solve, and only a forearm it still holds is ART-RANGE.
60
+ * A set with `place: "by-hip"` (rig-c/pose#93) gets the same solve against the
61
+ * hip, in the same pass: each of its chains whose key skeleton puts the wrist
62
+ * within one hip width of the mid-hip lands where the reference's wrist is from
63
+ * its mid-hip, in hip widths.
60
64
  *
61
65
  * The lines across the body — hips, shoulders, eyes — are read from the pose
62
66
  * after holds, whether the map uses them or not: a line under
@@ -72,12 +76,13 @@ import type { Body18 } from './joints.ts';
72
76
  import { isBody18, VIRTUAL } from './joints.ts';
73
77
  import type { Chain, JointMap, Rest, RigSlots } from './map.ts';
74
78
  import { aimAngle, aimEnds, descends, partBone, pointParts, rigPoint } from './map.ts';
75
- import type { Joint } from './pose.ts';
79
+ import type { Joint, Travel } from './pose.ts';
80
+ import { TRAVEL_IN_PLACE } from './pose.ts';
76
81
  import { Problems, Refusal } from './problems.ts';
77
- import type { ArtRange, Raised, ReachShort, SlotTrack, Switch, SwitchOptions } from './switch.ts';
82
+ import type { ArtRange, ByHip, Raised, ReachShort, SlotTrack, Switch, SwitchOptions } from './switch.ts';
78
83
  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';
84
+ import type { BlendHold, Closure, Feet, Floor, LegScale, PathPose, Posing, Side, Stance } from './legs.ts';
85
+ import { holdBlend, isLegSegment, legScaleOf, legsOf, pathFrom, plantHip, posePath, rootOf, SIDES, stanceOf, stanceRangeOf, supportsOf, tipOf } from './legs.ts';
81
86
 
82
87
  const DEG = 180 / Math.PI;
83
88
 
@@ -112,6 +117,8 @@ export interface RetargetOptions extends SwitchOptions {
112
117
  floor?: number;
113
118
  /** 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
119
  raisedByHead?: boolean;
120
+ /** 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. */
121
+ travel?: Travel;
115
122
  }
116
123
 
117
124
  export interface Hold {
@@ -294,12 +301,16 @@ export interface Retarget {
294
301
  floor: Floor | null;
295
302
  /** With --plant-feet: the supporting leg stretched on the blend frames so its ankle stays on the floor (#85). */
296
303
  blendHold: BlendHold | null;
304
+ /** With --plant-feet on an in-place sequence: the hip's x drift closed over the loop (#92). */
305
+ closure: Closure | null;
297
306
  /** Each ankle's world position on every frame, as the keys put it, when legs are scaled or feet planted. */
298
307
  ankles: { bones: Record<Side, string>; at: Record<Side, Pt[]> } | null;
299
308
  /** The translated bone's origin against its thigh roots' midpoint, at setup. */
300
309
  pivot: Pivot | null;
301
310
  /** With --raised-by-head: each raised set's span on each raised chain, its wrist placed against the head. */
302
311
  raised: Raised[];
312
+ /** Each span of a `place: "by-hip"` set on each chain it places, its wrist placed against the hip (#93). */
313
+ byHip: ByHip[];
303
314
  }
304
315
 
305
316
  /** The hip's pivot against the thigh roots, at setup (rig-c/pose#47). */
@@ -400,6 +411,9 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
400
411
  // eye line with an "a>b" aim, whose two ends are the rig's eyes.
401
412
  const head = opts.raisedByHead === true ? headOf(map) : null;
402
413
  if (head !== null && 'problem' in head) p.add('RAISED_HEAD', head.problem);
414
+ // `place: "by-hip"` (#93) measures against the hip in hip widths: the map must name the rig's two hip points.
415
+ const hips = map.alternates.some((a) => a.place !== undefined && a.place !== null) ? hipOf(map) : null;
416
+ if (hips !== null && 'problem' in hips) p.add('PLACE_HIP', hips.problem);
403
417
 
404
418
  // Every point the map reads, held where it is not good.
405
419
  const needed = new Set<string>();
@@ -612,6 +626,9 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
612
626
  // which are aimed from where the hip is.
613
627
  const legScaleOn = opts.legScale !== undefined && opts.legScale !== 'off';
614
628
  const plantOn = opts.plantFeet === true;
629
+ /** #92: an in-place sequence under --plant-feet — the supporting foot follows its own track, no step releases, the loop closed. */
630
+ const inPlace = plantOn && opts.travel === TRAVEL_IN_PLACE;
631
+ let closure: Closure | null = null;
615
632
  const legScale: LegScale[] = [];
616
633
  const scales = new Map<string, number[]>();
617
634
  let stance: Stance | null = null;
@@ -643,7 +660,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
643
660
  const hipOf = (s: Side) => series.get(`${s}_hip`);
644
661
  const ankleOf = (s: Side) => series.get(`${s}_ankle`);
645
662
  const hr = hipOf('r'), hl = hipOf('l'), ar = ankleOf('r'), al = ankleOf('l');
646
- if (hr !== undefined && hl !== undefined && ar !== undefined && al !== undefined) feet = supportsOf({ r: ar, l: al }, { r: hr, l: hl }, keyTimes, secondsOf);
663
+ if (hr !== undefined && hl !== undefined && ar !== undefined && al !== undefined) feet = supportsOf({ r: ar, l: al }, { r: hr, l: hl }, keyTimes, secondsOf, inPlace, plantOn); // #113: planted, the support is read from the lower ankle
647
664
  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`);
648
665
  }
649
666
  }
@@ -695,9 +712,12 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
695
712
  });
696
713
  const tb = rest.get(t!.bone)!;
697
714
  // #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);
715
+ // #92: in place, the supporting ankle's own picture x, scaled, is the track its anchor follows.
716
+ const follow = inPlace ? (i: number, s: Side) => series.get(`${s}_ankle`)![i]![0] * scale!.factor : undefined;
717
+ const planted = plantHip(feet!, (i, s) => offsets[i]![s], (i) => poseTravel[i]!, [tb.worldX, tb.worldY], opts.floor, follow);
699
718
  const hip = planted.hip;
700
719
  floor = planted.floor;
720
+ closure = planted.closure;
701
721
  for (let i = 0; i < n; i++) travel[i] = [hip[i]![0] - tb.worldX, hip[i]![1] - tb.worldY];
702
722
  const L = (legs as { legs: Record<Side, { chain: Chain; ankleBone: string }> }).legs;
703
723
  const world = (i: number, v: Pt): Pt => [hip[i]![0] + v[0], hip[i]![1] + v[1]];
@@ -725,14 +745,15 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
725
745
  };
726
746
  // #85: on a blend frame the hip is between two derivations; the supporting leg is stretched
727
747
  // (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);
748
+ // floor is that supporting ankle. #95: the lower bound on a frame is the leg's own range there.
749
+ const ankleSeries = { r: series.get('r_ankle')!, l: series.get('l_ankle')! };
750
+ const lower = stanceRangeOf(ankleSeries, { r: series.get('r_hip')!, l: series.get('l_hip')! });
751
+ blendHold = holdBlend(planted.blends, n, posedFeet, lower);
730
752
  stretchChain(blendHold.stretch);
731
753
  // --stance (#60): with the hip placed, a free ankle within the tolerance of the floor has its leg
732
754
  // stretched (every bone of its chain's scalex, about the chain's root) until it reaches it.
733
755
  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);
756
+ stance = stanceOf(feet!, ankleSeries, opts.stance, posedFeet, secondsOf, lower);
736
757
  // The supporting leg (the blend's) and the free leg (the stance's) are different chains on a frame.
737
758
  for (let i = 0; i < n; i++) {
738
759
  const b = blendHold.stretch[i];
@@ -881,44 +902,60 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
881
902
  // neither. A target out of reach is placed at the reach limit along the head-relative direction
882
903
  // (`reachLimit`, a REACH line). The set's `turn` is part of the solve: a held upper arm has its forearm
883
904
  // re-aimed at the placed wrist, and only a forearm the turn still holds is ART-RANGE.
905
+ // `place: "by-hip"` (#93) is the same solve against the hip: while a set that asks for it shows, each chain
906
+ // its key skeleton puts the wrist within one hip width of the mid-hip (`byHipChains`) has its wrist placed
907
+ // at the rig's mid-hip + the reference's offset from its mid-hip, in hip widths (each side's median over the
908
+ // span). The rig's hips are the two points `hipOf` reads off the map, posed as the eyes are.
884
909
  const raised: Raised[] = [];
910
+ const byHip: ByHip[] = [];
885
911
  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);
912
+ const raisedJobs: Array<{ anchor: Anchor; 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 }> = [];
913
+ const headOk: Anchor | null = head !== null && !('problem' in head) ? { kind: 'head', joints: head.joints, points: head.points } : null;
914
+ const hipsOk: Anchor | null = hips !== null && !('problem' in hips) ? { kind: 'hip', joints: hips.joints, points: hips.points } : null;
915
+ if (headOk !== null || hipsOk !== null) {
916
+ /** The anchor's two rig points posed on frame i: their midpoint and their distance apart. */
917
+ const pairOn = (an: Anchor, i: number): { centre: Pt; dist: number } => {
918
+ const a = posedPoint(an.points[0], i);
919
+ const b = posedPoint(an.points[1], i);
891
920
  return { centre: [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2], dist: Math.hypot(b[0] - a[0], b[1] - a[1]) };
892
921
  };
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
922
  for (const s of sw.switches) {
898
923
  const set = map.alternates.find((a) => a.name === s.set)!;
899
924
  const art: ArtRange[] = [];
900
925
  for (const cn of s.chains) {
901
926
  const c = map.chains.find((x) => x.name === cn)!;
902
927
  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}°)`;
928
+ const up = headOk !== null && drawn !== undefined && isRaised(drawn);
929
+ const low = hipsOk !== null && set.place?.chains.includes(c.name) === true;
930
+ if (up && low) {
931
+ p.add('PLACE_BOTH', `set "${set.name}" chain "${c.name}": --raised-by-head places its wrist against the head (bone "${c.bones[0]}" drawn at ${drawn}°, raised), and "place": "by-hip" against the hip (its key skeleton's wrist within one hip width of the mid-hip); a wrist is placed against one — drop "place" from the set or the flag`);
932
+ continue;
933
+ }
934
+ const anchor = up ? headOk : low ? hipsOk : null;
935
+ if (anchor === null) continue;
936
+ const ra = series.get(anchor.joints[0])!;
937
+ const rb = series.get(anchor.joints[1])!;
938
+ const refCentre = (i: number): Pt => [(ra[i]![0] + rb[i]![0]) / 2, (ra[i]![1] + rb[i]![1]) / 2];
939
+ const anchorBones = new Set([...anchor.points.flatMap((x) => pointParts(x).map(partBone))]);
940
+ const label = up ? `--raised-by-head: set "${set.name}" draws chain "${c.name}" raised (bone "${c.bones[0]}" at ${drawn}°)` : `"place": "by-hip": set "${set.name}" places chain "${c.name}"'s wrist against the hip`;
941
+ const CHAIN = up ? 'RAISED_CHAIN' : 'PLACE_CHAIN';
905
942
  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)`);
943
+ p.add(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
944
  continue;
908
945
  }
909
946
  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`);
947
+ p.add(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
948
  continue;
912
949
  }
913
950
  const [b0, b1] = c.bones as [string, string];
914
- const moved = [...headBones].filter((h) => h === b0 || h === b1 || descends(rest, h, b0));
951
+ const moved = [...anchorBones].filter((h) => h === b0 || h === b1 || descends(rest, h, b0));
915
952
  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`);
953
+ p.add(CHAIN, up ? `${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` : `${label}, and the hip point ${moved.map((h) => `"${h}"`).join(', ')} turns with it; the wrist cannot be placed against a hip the arm moves`);
917
954
  continue;
918
955
  }
919
956
  const wristSpec = c.aim[1] === 'x' ? `${b1}:tip` : c.aim[1]!;
920
957
  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`);
958
+ p.add(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
959
  continue;
923
960
  }
924
961
  const wristOf = (i: number): Pt => posedPoint(wristSpec, i);
@@ -927,9 +964,10 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
927
964
  const span: number[] = [];
928
965
  for (let i = s.frames[0]; i <= s.frames[1]; i++) span.push(i);
929
966
  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));
967
+ const rigEye = median(span.map((i) => pairOn(anchor, i).dist));
931
968
  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`);
969
+ if (up) 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 "${anchor.points[0]}", "${anchor.points[1]}")` : `${refEye}px in the reference (${anchor.joints[0]}, ${anchor.joints[1]})`}; an eye distance of 0 is no unit`);
970
+ else p.add('PLACE_HIPS', `${label}: over frames ${s.frames[0]}–${s.frames[1]} the median hip width is ${refEye > 1e-9 ? `${rigEye} units on the rig (points "${anchor.points[0]}", "${anchor.points[1]}")` : `${refEye}px in the reference (${anchor.joints[0]}, ${anchor.joints[1]})`}; a hip width of 0 is no unit`);
933
971
  continue;
934
972
  }
935
973
  // The rig's own lengths, and each bone's aim against the vector it carries, at setup.
@@ -944,7 +982,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
944
982
  const t1 = targets.get(b1)!;
945
983
  const turn0 = set.turn[b0];
946
984
  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 };
985
+ const job: (typeof raisedJobs)[number] = { anchor, sw: s, chain: c, wrist: wristOf, before: [], reference: [], placed: [], refEye, rigEye, short: [], upperHeld: [], l0, l1, farthest: 0 };
948
986
  /** Per frame of the span: the forearm's art turn the re-aim asked, and how far past its turn. */
949
987
  const foreAsked: number[] = [];
950
988
  const foreOver: number[] = [];
@@ -953,7 +991,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
953
991
  const upperOver: number[] = [];
954
992
  const heldMiss: number[] = [];
955
993
  for (const i of span) {
956
- const h = eyesOn(i);
994
+ const h = pairOn(anchor, i);
957
995
  const rc = refCentre(i);
958
996
  // The reference's wrist from its head centre, in its eye distances, y turned up.
959
997
  const ref: Pt = [(refWrist[i]![0] - rc[0]) / refEye, -(refWrist[i]![1] - rc[1]) / refEye];
@@ -1018,7 +1056,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1018
1056
  for (let k = a; k <= b; k++) if (heldMiss[k]! > heldMiss[worst]!) worst = k;
1019
1057
  const first = s.frames[0] + a;
1020
1058
  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]!) });
1059
+ 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]!), ...(up ? {} : { anchor: 'hip' as const }) });
1022
1060
  }
1023
1061
  }
1024
1062
  if (turn1 !== undefined) {
@@ -1050,8 +1088,8 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1050
1088
  for (const job of raisedJobs) {
1051
1089
  const after: Pt[] = [];
1052
1090
  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);
1091
+ const a = posedPoint(job.anchor.points[0], i);
1092
+ const b = posedPoint(job.anchor.points[1], i);
1055
1093
  const w = job.wrist(i);
1056
1094
  after.push([(w[0] - (a[0] + b[0]) / 2) / job.rigEye, (w[1] - (a[1] + b[1]) / 2) / job.rigEye]);
1057
1095
  }
@@ -1065,12 +1103,12 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1065
1103
  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
1104
  k = m + 1;
1067
1105
  }
1068
- raised.push({
1106
+ const unit = { rig: r6(job.rigEye), reference: r6(job.refEye) };
1107
+ const entry = {
1069
1108
  set: job.sw.set,
1070
1109
  chain: job.chain.name,
1071
1110
  frames: job.sw.frames,
1072
1111
  seconds: secondsOf(job.sw.frames),
1073
- eyes: { rig: r6(job.rigEye), reference: r6(job.refEye) },
1074
1112
  reference: round(job.reference),
1075
1113
  placed: round(job.placed),
1076
1114
  before: round(job.before),
@@ -1079,7 +1117,11 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1079
1117
  short,
1080
1118
  upperHeld: job.upperHeld,
1081
1119
  reach: { min: r6(Math.abs(job.l0 - job.l1)), max: r6(job.l0 + job.l1), farthest: r6(job.farthest) },
1082
- });
1120
+ };
1121
+ // The unit sits after `seconds`, as the report has always written it.
1122
+ const { set, chain, frames, seconds, ...body } = entry;
1123
+ if (job.anchor.kind === 'head') raised.push({ set, chain, frames, seconds, eyes: unit, ...body });
1124
+ else byHip.push({ set, chain, frames, seconds, hips: unit, ...body });
1083
1125
  }
1084
1126
 
1085
1127
  // The legs on their final targets: a bone under a scaled bone is keyed through
@@ -1140,10 +1182,49 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1140
1182
  animation: { duration: keyTimes[n - 1]!, tracks },
1141
1183
  times: keyTimes, targets, origin, holds, bends, short, endOn, flips, scale,
1142
1184
  switches: sw.switches, shown: sw.shown, distances: sw.distances, turns, turnsUnread, range, artRange,
1143
- legScale, scales, feet, planted: plantOn, stance, floor, blendHold, ankles, pivot: pivotOf(rest, t?.bone ?? null, legs), raised,
1185
+ legScale, scales, feet, planted: plantOn, stance, floor, blendHold, closure, ankles, pivot: pivotOf(rest, t?.bone ?? null, legs), raised, byHip,
1144
1186
  };
1145
1187
  }
1146
1188
 
1189
+ /** 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. */
1190
+ interface Anchor {
1191
+ kind: 'head' | 'hip';
1192
+ joints: [string, string];
1193
+ points: [string, string];
1194
+ }
1195
+
1196
+ /** The hips as `place: "by-hip"` reads them: two pose joints (r_hip, l_hip) and the rig's two hip points. */
1197
+ interface Hips {
1198
+ joints: [string, string];
1199
+ points: [string, string];
1200
+ }
1201
+
1202
+ /**
1203
+ * The rig's hip points for `place: "by-hip"` (#93): the two points a chain
1204
+ * carrying the hip line (r_hip → l_hip, either way round) names with an "a>b"
1205
+ * aim — a pelvis turned by its hip line; else the origins of the first bones of
1206
+ * the two chains that start at r_hip and at l_hip — the legs, whose roots are
1207
+ * the hip joints; or why the map names neither.
1208
+ */
1209
+ function hipOf(map: JointMap): Hips | { problem: string } {
1210
+ for (const c of map.chains) {
1211
+ for (let k = 0; k + 1 < c.joints.length; k++) {
1212
+ const a = c.joints[k]!;
1213
+ const b = c.joints[k + 1]!;
1214
+ if (!((a === 'r_hip' && b === 'l_hip') || (a === 'l_hip' && b === 'r_hip'))) continue;
1215
+ const aim = c.aim[k]!;
1216
+ if (!aim.includes('>')) continue;
1217
+ const [pa, pb] = aimEnds(c.bones[k]!, aim);
1218
+ return { joints: [a, b], points: [pa, pb] };
1219
+ }
1220
+ }
1221
+ const from = (j: string, other: string) => map.chains.find((c) => c.joints[0] === j && c.joints[1] !== other);
1222
+ const r = from('r_hip', 'l_hip');
1223
+ const l = from('l_hip', 'r_hip');
1224
+ if (r !== undefined && l !== undefined) return { joints: ['r_hip', 'l_hip'], points: [r.bones[0]!, l.bones[0]!] };
1225
+ return { problem: `"place": "by-hip" places a wrist against the hip in hip widths, read on the rig between two hip points: the two a chain carrying the hip line (r_hip → l_hip) names with an "a>b" aim, or else the first bones' origins of the two chains starting at r_hip and l_hip (the legs); the map has neither (${r === undefined && l === undefined ? 'no chain starts at r_hip or l_hip' : `no chain starts at ${r === undefined ? 'r_hip' : 'l_hip'}`})` };
1226
+ }
1227
+
1147
1228
  /** 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
1229
  interface Head {
1149
1230
  chain: string;