@rig-c/pose 0.16.0 → 0.18.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/reduce.ts CHANGED
@@ -18,6 +18,13 @@
18
18
  * target is its world error: errors do not pile up down a chain. A knot's value
19
19
  * is the target's, not the dense key's.
20
20
  *
21
+ * Scaled bones (rig-c/pose#94). A `scalex` track is thinned in value space
22
+ * within `SCALE_REDUCE` (`reduceScale`). A bone under a scaled bone is not
23
+ * keyed by the formula above but through its parent's posed matrix (legs.ts
24
+ * `posePath`), so `reduceUnderScale` solves its keys again through its parent
25
+ * as reduced and reads each error as the world aim, and the world length, it
26
+ * lands on.
27
+ *
21
28
  * Every error is read after the numbers are rounded as written and evaluated as
22
29
  * rig-c's poser evaluates them (`curveAt`): the curve is the poser's ten-piece
23
30
  * polyline, not the cubic. The poser is the judge — run.ts measures the landing
@@ -52,12 +59,13 @@ export interface ReduceHip {
52
59
  }
53
60
  export interface ReduceRow {
54
61
  bone: string;
55
- kind: 'angle' | 'origin';
62
+ /** `scale`: a `scalex` track (#94), its error the bone's world x-axis length against its setup length times the scale asked. */
63
+ kind: 'angle' | 'origin' | 'scale';
56
64
  /** Dense key count. */
57
65
  before: number;
58
66
  /** Reduced key count. */
59
67
  after: number;
60
- /** The largest error over all frames: degrees of world aim for a bone, world units for the hip. */
68
+ /** The largest error over all frames: degrees of world aim for a bone, world units for the hip, a fraction of the setup length for a scale. */
61
69
  worst: number;
62
70
  /** The first frame where it is. */
63
71
  frame: number;
@@ -67,6 +75,8 @@ export interface Reduced {
67
75
  hip: ReducedKey[] | null;
68
76
  /** The bones in the order given, then the hip. */
69
77
  rows: ReduceRow[];
78
+ /** Each bone's world-aim error on every frame (reduced less dense, degrees), as the poser evaluates the reduced keys. */
79
+ errors: Map<string, number[]>;
70
80
  }
71
81
 
72
82
  // ---------------------------------------------------------------------------
@@ -186,6 +196,11 @@ const r3 = (v: number): number => {
186
196
  const r = Math.round(v * 1000) / 1000;
187
197
  return Object.is(r, -0) ? 0 : r;
188
198
  };
199
+ /** A scale as the retarget writes one (retarget.ts `r6`). */
200
+ const r6 = (v: number): number => {
201
+ const r = Math.round(v * 1e6) / 1e6;
202
+ return Object.is(r, -0) ? 0 : r;
203
+ };
189
204
 
190
205
  /** One track to reduce: its targets per frame (one array per frame, a value per channel) and how a frame's error is read. */
191
206
  interface Fit {
@@ -194,6 +209,8 @@ interface Fit {
194
209
  tolerance: number;
195
210
  /** The error at frame `i` of `values` against the target. */
196
211
  error: (i: number, values: readonly number[]) => number;
212
+ /** How a written value is rounded: 0.001 (an angle, a translate) unless given. */
213
+ round?: (v: number) => number;
197
214
  }
198
215
 
199
216
  /** The frame with the largest error over `frames` (the first, on a tie), or null when there is none. */
@@ -242,7 +259,8 @@ function solveHandles(a: ReducedKey, b: ReducedKey, channel: number, hx1: number
242
259
  function fitTrack(times: readonly number[], samples: readonly number[], fit: Fit): ReducedKey[] {
243
260
  const n = times.length;
244
261
  const channels = fit.targets[0]!.length;
245
- const keyOn = (i: number): ReducedKey => ({ t: times[i]!, v: fit.targets[i]!.map(r3) });
262
+ const round = fit.round ?? r3;
263
+ const keyOn = (i: number): ReducedKey => ({ t: times[i]!, v: fit.targets[i]!.map(round) });
246
264
  const knots = [...new Set([0, n - 1, ...fit.keep])].sort((x, y) => x - y);
247
265
  const curves = new Map<number, number[]>();
248
266
  const done: number[] = [];
@@ -266,7 +284,7 @@ function fitTrack(times: readonly number[], samples: readonly number[], fit: Fit
266
284
  const hx2 = times[k0]! + (2 * (times[k1]! - times[k0]!)) / 3;
267
285
  const handles = Array.from({ length: channels }, (_, c) => solveHandles(a, b, c, hx1, hx2, interior, samples, (i) => fit.targets[i]![c]!));
268
286
  if (handles.every((h) => h !== null)) {
269
- const curve = handles.flatMap(([cy1, cy2]) => [hx1, r3(cy1), hx2, r3(cy2)]);
287
+ const curve = handles.flatMap(([cy1, cy2]) => [hx1, round(cy1), hx2, round(cy2)]);
270
288
  const bent = errorWith({ ...a, curve });
271
289
  if (bent.worst <= fit.tolerance) {
272
290
  curves.set(k0, curve);
@@ -304,8 +322,7 @@ export function reduceKeys(times: readonly number[], bones: readonly ReduceBone[
304
322
  if (!(Number.isFinite(tolerance) && tolerance > 0)) throw new Error(`reduce: the tolerance is ${tolerance}°, not a positive number`);
305
323
  if (hip !== null && !(Number.isFinite(hip.tolerance) && hip.tolerance > 0)) throw new Error(`reduce: the hip's tolerance is ${hip.tolerance} units, not a positive number`);
306
324
  // The poser samples at the running sum of the frame steps (verify.ts `poseFrames`).
307
- const samples: number[] = [times[0]!];
308
- for (let i = 1; i < n; i++) samples.push(samples[i - 1]! + (times[i]! - times[i - 1]!));
325
+ const samples = samplesOf(times);
309
326
 
310
327
  const byName = new Map<string, ReduceBone>();
311
328
  for (const b of bones) {
@@ -341,6 +358,7 @@ export function reduceKeys(times: readonly number[], bones: readonly ReduceBone[
341
358
  rowOf.set(b.bone, { bone: b.bone, kind: 'angle', before: n, after: keys.length, worst: worst.worst, frame: worst.frame });
342
359
  }
343
360
  const rows = bones.map((b) => rowOf.get(b.bone)!);
361
+ const errorsOrdered = new Map(bones.map((b) => [b.bone, errors.get(b.bone)!]));
344
362
  const ordered = new Map(bones.map((b) => [b.bone, rotate.get(b.bone)!]));
345
363
 
346
364
  let hipKeys: ReducedKey[] | null = null;
@@ -357,5 +375,147 @@ export function reduceKeys(times: readonly number[], bones: readonly ReduceBone[
357
375
  const worst = worstOf(at.map((_, i) => i), (i) => world(i, at[i]!))!;
358
376
  rows.push({ bone: hip.bone, kind: 'origin', before: n, after: hipKeys.length, worst: worst.worst, frame: worst.frame });
359
377
  }
360
- return { rotate: ordered, hip: hipKeys, rows };
378
+ return { rotate: ordered, hip: hipKeys, rows, errors: errorsOrdered };
379
+ }
380
+
381
+ /** The times the poser samples a track at: the running sum of the frame steps (verify.ts `poseFrames`). */
382
+ export function samplesOf(times: readonly number[]): number[] {
383
+ const samples: number[] = [times[0]!];
384
+ for (let i = 1; i < times.length; i++) samples.push(samples[i - 1]! + (times[i]! - times[i - 1]!));
385
+ return samples;
386
+ }
387
+
388
+ // ---------------------------------------------------------------------------
389
+ // scales, and bones keyed through a scaled parent (rig-c/pose#94)
390
+ // ---------------------------------------------------------------------------
391
+
392
+ /**
393
+ * The tolerance a reduced `scalex` track is held to: half of the landing
394
+ * check's scale tolerance (verify.ts `SCALE_TOLERANCE`, 1e-4 of the setup
395
+ * length), so a reduced curve cannot fail the check it is re-run against.
396
+ */
397
+ export const SCALE_REDUCE = 5e-5;
398
+
399
+ /**
400
+ * One `scalex` track of a bone with no scaled bone above it, reduced: its world
401
+ * x-axis length over setup is its own scale, so the error is the value's
402
+ * distance from the scale asked.
403
+ */
404
+ export function reduceScale(times: readonly number[], bone: string, asked: readonly number[], keep: ReadonlySet<number>, tolerance: number): { keys: ReducedKey[]; at: number[]; row: ReduceRow } {
405
+ const n = times.length;
406
+ if (asked.length !== n) throw new Error(`reduce: bone "${bone}" asks ${asked.length} scale(s) for ${n} frame(s)`);
407
+ const samples = samplesOf(times);
408
+ const keys = fitTrack(times, samples, { targets: asked.map((v) => [v]), keep: keptFrames(keep, n, `bone "${bone}" scalex`), tolerance, error: (i, v) => Math.abs(v[0]! - asked[i]!), round: r6 });
409
+ const at = trackAt(keys, samples, 1).map((v) => v[0]!);
410
+ const worst = worstOf(at.map((_, i) => i), (i) => Math.abs(at[i]! - asked[i]!))!;
411
+ return { keys, at, row: { bone, kind: 'scale', before: n, after: keys.length, worst: worst.worst, frame: worst.frame } };
412
+ }
413
+
414
+ /** A world 2×2: columns (a, c) — the x axis — and (b, d) — the y axis (legs.ts `M2`). */
415
+ export type M2 = [number, number, number, number];
416
+ const DEG = 180 / Math.PI;
417
+ const mul = (p: M2, q: M2): M2 => [p[0] * q[0] + p[1] * q[2], p[0] * q[1] + p[1] * q[3], p[2] * q[0] + p[3] * q[2], p[2] * q[1] + p[3] * q[3]];
418
+ const inv = (m: M2): M2 => {
419
+ const det = m[0] * m[3] - m[1] * m[2];
420
+ return [m[3] / det, -m[1] / det, -m[2] / det, m[0] / det];
421
+ };
422
+ const apply = (m: M2, v: [number, number]): [number, number] => [m[0] * v[0] + m[1] * v[1], m[2] * v[0] + m[3] * v[1]];
423
+ const rot = (deg: number): M2 => {
424
+ const r = deg / DEG;
425
+ return [Math.cos(r), -Math.sin(r), Math.sin(r), Math.cos(r)];
426
+ };
427
+ const scaleX = (s: number): M2 => [s, 0, 0, 1];
428
+ /** A bone's setup local 2×2 in its parent's axes: inv(parent's setup world) · its setup world (legs.ts `posePath`'s L0). */
429
+ export const setupLocal = (parentWorld0: M2, world0: M2): M2 => mul(inv(parentWorld0), world0);
430
+ /** `p · q`. */
431
+ export const mulM2 = mul;
432
+ const IDENTITY: M2 = [1, 0, 0, 1];
433
+ export { IDENTITY as M2_IDENTITY };
434
+ const wrap180 = (d: number): number => {
435
+ const x = ((d + 180) % 360 + 360) % 360 - 180;
436
+ return x === -180 ? 180 : x;
437
+ };
438
+
439
+ /**
440
+ * A mapped bone under a scaled bone (legs.ts `posePath`: keyed through its
441
+ * parent's posed matrix), reduced once the bones above it are. Its parent's
442
+ * world 2×2 is posed from keys already reduced — a bone the key formula holds
443
+ * for (`rigid`: R(turn) · its setup world · diag(scale, 1), its turn and scale
444
+ * the reduced ones), or another bone of the same call given before it — times
445
+ * the setup locals of the unmapped bones between (`via`).
446
+ */
447
+ export interface UnderBone {
448
+ bone: string;
449
+ parent: { rigid: { turn: readonly number[]; world0: M2; scale: readonly number[] }; via: M2 } | { under: string; via: M2 };
450
+ /** Its setup local 2×2 in its parent's axes: inv(parent's setup world) · its setup world, by rig-c's setup. */
451
+ local: M2;
452
+ /** Its setup world x-axis length. */
453
+ len0: number;
454
+ /** The world angle its x axis is asked on every frame (degrees), and the scale of it asked (1: none). */
455
+ target: readonly number[];
456
+ asked: readonly number[];
457
+ /** The dense rotate keys (unwrapped, as written). */
458
+ dense: readonly number[];
459
+ keep: ReadonlySet<number>;
460
+ }
461
+
462
+ /**
463
+ * Each bone under a scaled bone, in the order given (parents first): its
464
+ * rotate and scalex keys re-solved through its parent's reduced posed matrix
465
+ * (the key that puts its x axis on the target, and the scale that gives it the
466
+ * length asked), then reduced — the rotate with its error read as the world aim
467
+ * it lands on through that matrix, the scale with its error read as the world
468
+ * length over setup against the scale asked, on the rotate as reduced. Its
469
+ * world error is its own fit error, as down a chain the key formula holds for.
470
+ */
471
+ export function reduceUnderScale(times: readonly number[], bones: readonly UnderBone[], tolerance: number, scaleTolerance: number): { rotate: Map<string, ReducedKey[]>; scale: Map<string, ReducedKey[]>; rows: ReduceRow[] } {
472
+ const n = times.length;
473
+ const samples = samplesOf(times);
474
+ const world = new Map<string, M2[]>();
475
+ const rotate = new Map<string, ReducedKey[]>();
476
+ const scale = new Map<string, ReducedKey[]>();
477
+ const rows: ReduceRow[] = [];
478
+ for (const b of bones) {
479
+ for (const [what, xs] of [['target', b.target], ['asked', b.asked], ['dense', b.dense]] as const) {
480
+ if (xs.length !== n) throw new Error(`reduce: bone "${b.bone}" has ${xs.length} ${what} value(s) for ${n} frame(s)`);
481
+ }
482
+ const p = b.parent;
483
+ let parent: M2[];
484
+ if ('under' in p) {
485
+ const above = world.get(p.under);
486
+ if (above === undefined) throw new Error(`reduce: bone "${b.bone}" hangs from bone "${p.under}", which is not given before it`);
487
+ parent = above.map((m) => mul(m, p.via));
488
+ } else {
489
+ parent = Array.from({ length: n }, (_, i) => mul(mul(mul(rot(p.rigid.turn[i]!), p.rigid.world0), scaleX(p.rigid.scale[i]!)), p.via));
490
+ }
491
+ const l0: [number, number] = [b.local[0], b.local[2]];
492
+ const a0 = Math.atan2(l0[1], l0[0]) * DEG;
493
+ /** The x axis's world vector through the parent on frame i, at rotate key k, before its own scale. */
494
+ const axis = (i: number, k: number) => apply(parent[i]!, apply(rot(k), l0));
495
+ // The dense keys re-solved through the reduced parent (posePath's solve).
496
+ const solved = b.target.map((t, i) => {
497
+ const v = apply(inv(parent[i]!), [b.asked[i]! * b.len0 * Math.cos(t / DEG), b.asked[i]! * b.len0 * Math.sin(t / DEG)]);
498
+ const k = Math.atan2(v[1], v[0]) * DEG - a0;
499
+ return b.dense[i]! + wrap180(k - b.dense[i]!);
500
+ });
501
+ const aimError = (i: number, k: number) => {
502
+ const w = axis(i, k);
503
+ return Math.abs(wrap180(Math.atan2(w[1], w[0]) * DEG - b.target[i]!));
504
+ };
505
+ const keep = keptFrames(b.keep, n, `bone "${b.bone}"`);
506
+ const rk = fitTrack(times, samples, { targets: solved.map((k) => [k]), keep, tolerance, error: (i, v) => aimError(i, v[0]!) });
507
+ const kAt = trackAt(rk, samples, 1).map((v) => v[0]!);
508
+ const ae = worstOf(kAt.map((_, i) => i), (i) => aimError(i, kAt[i]!))!;
509
+ rows.push({ bone: b.bone, kind: 'angle', before: n, after: rk.length, worst: ae.worst, frame: ae.frame });
510
+ // The scale that gives it the length asked on the rotate as reduced.
511
+ const reach = kAt.map((k, i) => Math.hypot(...axis(i, k)) / b.len0);
512
+ const sk = fitTrack(times, samples, { targets: reach.map((r, i) => [b.asked[i]! / r]), keep, tolerance: scaleTolerance, error: (i, v) => Math.abs(v[0]! * reach[i]! - b.asked[i]!), round: r6 });
513
+ const sAt = trackAt(sk, samples, 1).map((v) => v[0]!);
514
+ const se = worstOf(sAt.map((_, i) => i), (i) => Math.abs(sAt[i]! * reach[i]! - b.asked[i]!))!;
515
+ rows.push({ bone: b.bone, kind: 'scale', before: n, after: sk.length, worst: se.worst, frame: se.frame });
516
+ world.set(b.bone, parent.map((m, i) => mul(mul(mul(m, rot(kAt[i]!)), b.local), scaleX(sAt[i]!))));
517
+ rotate.set(b.bone, rk);
518
+ scale.set(b.bone, sk);
519
+ }
520
+ return { rotate, scale, rows };
361
521
  }
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 { Feet, LegScale, PathPose, Posing, Side, Stance } from './legs.ts';
80
- import { 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
 
@@ -108,8 +113,12 @@ export interface RetargetOptions extends SwitchOptions {
108
113
  plantFeet?: boolean;
109
114
  /** 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. */
110
115
  stance?: number | 'off';
116
+ /** With plantFeet: the floor's world y every support span is planted on (#85); absent, the median of the spans' planted heights. */
117
+ floor?: number;
111
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. */
112
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;
113
122
  }
114
123
 
115
124
  export interface Hold {
@@ -288,12 +297,20 @@ export interface Retarget {
288
297
  planted: boolean;
289
298
  /** With --stance: the frames planted both and lifted, and the free leg's stretch (#60). */
290
299
  stance: Stance | null;
300
+ /** With --plant-feet: the one floor every support span was planted on, and each span's shift onto it (#85). */
301
+ floor: Floor | null;
302
+ /** With --plant-feet: the supporting leg stretched on the blend frames so its ankle stays on the floor (#85). */
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;
291
306
  /** Each ankle's world position on every frame, as the keys put it, when legs are scaled or feet planted. */
292
307
  ankles: { bones: Record<Side, string>; at: Record<Side, Pt[]> } | null;
293
308
  /** The translated bone's origin against its thigh roots' midpoint, at setup. */
294
309
  pivot: Pivot | null;
295
310
  /** With --raised-by-head: each raised set's span on each raised chain, its wrist placed against the head. */
296
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[];
297
314
  }
298
315
 
299
316
  /** The hip's pivot against the thigh roots, at setup (rig-c/pose#47). */
@@ -394,6 +411,9 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
394
411
  // eye line with an "a>b" aim, whose two ends are the rig's eyes.
395
412
  const head = opts.raisedByHead === true ? headOf(map) : null;
396
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);
397
417
 
398
418
  // Every point the map reads, held where it is not good.
399
419
  const needed = new Set<string>();
@@ -606,9 +626,14 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
606
626
  // which are aimed from where the hip is.
607
627
  const legScaleOn = opts.legScale !== undefined && opts.legScale !== 'off';
608
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;
609
632
  const legScale: LegScale[] = [];
610
633
  const scales = new Map<string, number[]>();
611
634
  let stance: Stance | null = null;
635
+ let floor: Floor | null = null;
636
+ let blendHold: BlendHold | null = null;
612
637
  let feet: Feet | null = null;
613
638
  let legs: ReturnType<typeof legsOf> | null = null;
614
639
  let legPath: string[] | null = null;
@@ -635,7 +660,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
635
660
  const hipOf = (s: Side) => series.get(`${s}_hip`);
636
661
  const ankleOf = (s: Side) => series.get(`${s}_ankle`);
637
662
  const hr = hipOf('r'), hl = hipOf('l'), ar = ankleOf('r'), al = ankleOf('l');
638
- 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
639
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`);
640
665
  }
641
666
  }
@@ -664,6 +689,8 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
664
689
  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');
665
690
  }
666
691
  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`);
692
+ 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`);
693
+ if (opts.floor !== undefined && !Number.isFinite(opts.floor)) p.add('FLOOR', `--floor is ${opts.floor}; it must be a finite world y`);
667
694
  p.throwIfAny();
668
695
  /** Every bone on the legs' path posed on frame i, by the targets as they stand. */
669
696
  const poseLegs = (i: number): PathPose => {
@@ -684,32 +711,56 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
684
711
  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 : '') };
685
712
  });
686
713
  const tb = rest.get(t!.bone)!;
687
- const hip = plantHip(feet!, (i, s) => offsets[i]![s], (i) => poseTravel[i]!, [tb.worldX, tb.worldY]);
714
+ // #85: every support span on one floor — the median of their planted heights, or --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);
718
+ const hip = planted.hip;
719
+ floor = planted.floor;
720
+ closure = planted.closure;
688
721
  for (let i = 0; i < n; i++) travel[i] = [hip[i]![0] - tb.worldX, hip[i]![1] - tb.worldY];
689
- // --stance (#60): with the hip placed, a free ankle within the tolerance of the floor has its leg
690
- // stretched (every bone of its chain's scalex, about the chain's root) until it reaches it.
691
- if (opts.stance !== undefined && opts.stance !== 'off') {
692
- const L = (legs as { legs: Record<Side, { chain: Chain; ankleBone: string }> }).legs;
693
- const world = (i: number, v: Pt): Pt => [hip[i]![0] + v[0], hip[i]![1] + v[1]];
694
- const ankleSeries = { r: series.get('r_ankle')!, l: series.get('l_ankle')! };
695
- stance = stanceOf(feet!, ankleSeries, opts.stance, (i) => {
696
- const lp = poseLegs(i);
697
- return {
698
- root: { r: world(i, lp.origin.get(L.r.chain.bones[0]!)!), l: world(i, lp.origin.get(L.l.chain.bones[0]!)!) },
699
- ankle: { r: world(i, tipOf(rest, lp, L.r.ankleBone)), l: world(i, tipOf(rest, lp, L.l.ankleBone)) },
700
- };
701
- });
722
+ const L = (legs as { legs: Record<Side, { chain: Chain; ankleBone: string }> }).legs;
723
+ const world = (i: number, v: Pt): Pt => [hip[i]![0] + v[0], hip[i]![1] + v[1]];
724
+ /** Both legs posed on frame i by the scales as they stand, world (y up): each chain's root and ankle. */
725
+ const posedFeet = (i: number) => {
726
+ const lp = poseLegs(i);
727
+ return {
728
+ root: { r: world(i, lp.origin.get(L.r.chain.bones[0]!)!), l: world(i, lp.origin.get(L.l.chain.bones[0]!)!) },
729
+ ankle: { r: world(i, tipOf(rest, lp, L.r.ankleBone)), l: world(i, tipOf(rest, lp, L.l.ankleBone)) },
730
+ };
731
+ };
732
+ /** Every bone of a side's chain has its scalex multiplied by the stretch keyed on that side. */
733
+ const stretchChain = (stretch: ReadonlyArray<{ side: Side; k: number } | null>) => {
702
734
  for (const side of SIDES) {
703
- if (!stance.stretch.some((x) => x?.side === side)) continue;
735
+ if (!stretch.some((x) => x?.side === side)) continue;
704
736
  for (const b of L[side].chain.bones) {
705
737
  const own = scales.get(b);
706
738
  const s = own === undefined ? new Array<number>(n).fill(1) : [...own];
707
- stance.stretch.forEach((x, i) => {
739
+ stretch.forEach((x, i) => {
708
740
  if (x?.side === side) s[i] = s[i]! * x.k;
709
741
  });
710
742
  scales.set(b, s);
711
743
  }
712
744
  }
745
+ };
746
+ // #85: on a blend frame the hip is between two derivations; the supporting leg is stretched
747
+ // (stanceOf's mechanism, its bounds) so its ankle stays on the floor. Before --stance, whose
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);
752
+ stretchChain(blendHold.stretch);
753
+ // --stance (#60): with the hip placed, a free ankle within the tolerance of the floor has its leg
754
+ // stretched (every bone of its chain's scalex, about the chain's root) until it reaches it.
755
+ if (opts.stance !== undefined && opts.stance !== 'off') {
756
+ stance = stanceOf(feet!, ankleSeries, opts.stance, posedFeet, secondsOf, lower);
757
+ // The supporting leg (the blend's) and the free leg (the stance's) are different chains on a frame.
758
+ for (let i = 0; i < n; i++) {
759
+ const b = blendHold.stretch[i];
760
+ 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`);
761
+ }
762
+ p.throwIfAny();
763
+ stretchChain(stance.stretch);
713
764
  }
714
765
  }
715
766
  if (t !== null && scale !== null) {
@@ -851,44 +902,60 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
851
902
  // neither. A target out of reach is placed at the reach limit along the head-relative direction
852
903
  // (`reachLimit`, a REACH line). The set's `turn` is part of the solve: a held upper arm has its forearm
853
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.
854
909
  const raised: Raised[] = [];
910
+ const byHip: ByHip[] = [];
855
911
  const raisedArt: Array<{ sw: Switch; entries: ArtRange[] }> = [];
856
- 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] }> = [];
857
- if (head !== null && !('problem' in head)) {
858
- const eyesOn = (i: number): { centre: Pt; dist: number } => {
859
- const a = posedPoint(head.points[0], i);
860
- 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);
861
920
  return { centre: [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2], dist: Math.hypot(b[0] - a[0], b[1] - a[1]) };
862
921
  };
863
- const headBones = new Set([...head.points.flatMap((x) => pointParts(x).map(partBone))]);
864
- const ra = series.get(head.joints[0])!;
865
- const rb = series.get(head.joints[1])!;
866
- const refCentre = (i: number): Pt => [(ra[i]![0] + rb[i]![0]) / 2, (ra[i]![1] + rb[i]![1]) / 2];
867
922
  for (const s of sw.switches) {
868
923
  const set = map.alternates.find((a) => a.name === s.set)!;
869
924
  const art: ArtRange[] = [];
870
925
  for (const cn of s.chains) {
871
926
  const c = map.chains.find((x) => x.name === cn)!;
872
927
  const drawn = set.drawn[c.bones[0]!];
873
- if (drawn === undefined || !isRaised(drawn)) continue;
874
- 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';
875
942
  if (c.bones.length !== 2) {
876
- 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)`);
877
944
  continue;
878
945
  }
879
946
  if (c.reach || c.aim.some((x) => x.includes('>'))) {
880
- 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`);
881
948
  continue;
882
949
  }
883
950
  const [b0, b1] = c.bones as [string, string];
884
- 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));
885
952
  if (moved.length > 0) {
886
- 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`);
887
954
  continue;
888
955
  }
889
956
  const wristSpec = c.aim[1] === 'x' ? `${b1}:tip` : c.aim[1]!;
890
957
  if (c.aim[1] === 'x' && !(rest.get(b1)!.length > 1e-9)) {
891
- 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`);
892
959
  continue;
893
960
  }
894
961
  const wristOf = (i: number): Pt => posedPoint(wristSpec, i);
@@ -897,9 +964,10 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
897
964
  const span: number[] = [];
898
965
  for (let i = s.frames[0]; i <= s.frames[1]; i++) span.push(i);
899
966
  const refEye = median(span.map((i) => Math.hypot(rb[i]![0] - ra[i]![0], rb[i]![1] - ra[i]![1])));
900
- const rigEye = median(span.map((i) => eyesOn(i).dist));
967
+ const rigEye = median(span.map((i) => pairOn(anchor, i).dist));
901
968
  if (!(refEye > 1e-9) || !(rigEye > 1e-9)) {
902
- 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`);
903
971
  continue;
904
972
  }
905
973
  // The rig's own lengths, and each bone's aim against the vector it carries, at setup.
@@ -914,7 +982,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
914
982
  const t1 = targets.get(b1)!;
915
983
  const turn0 = set.turn[b0];
916
984
  const turn1 = set.turn[b1];
917
- 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 };
918
986
  /** Per frame of the span: the forearm's art turn the re-aim asked, and how far past its turn. */
919
987
  const foreAsked: number[] = [];
920
988
  const foreOver: number[] = [];
@@ -923,7 +991,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
923
991
  const upperOver: number[] = [];
924
992
  const heldMiss: number[] = [];
925
993
  for (const i of span) {
926
- const h = eyesOn(i);
994
+ const h = pairOn(anchor, i);
927
995
  const rc = refCentre(i);
928
996
  // The reference's wrist from its head centre, in its eye distances, y turned up.
929
997
  const ref: Pt = [(refWrist[i]![0] - rc[0]) / refEye, -(refWrist[i]![1] - rc[1]) / refEye];
@@ -988,7 +1056,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
988
1056
  for (let k = a; k <= b; k++) if (heldMiss[k]! > heldMiss[worst]!) worst = k;
989
1057
  const first = s.frames[0] + a;
990
1058
  const last = s.frames[0] + b;
991
- 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 }) });
992
1060
  }
993
1061
  }
994
1062
  if (turn1 !== undefined) {
@@ -1020,8 +1088,8 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1020
1088
  for (const job of raisedJobs) {
1021
1089
  const after: Pt[] = [];
1022
1090
  for (let i = job.sw.frames[0]; i <= job.sw.frames[1]; i++) {
1023
- const a = posedPoint(job.eyePoints[0], i);
1024
- 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);
1025
1093
  const w = job.wrist(i);
1026
1094
  after.push([(w[0] - (a[0] + b[0]) / 2) / job.rigEye, (w[1] - (a[1] + b[1]) / 2) / job.rigEye]);
1027
1095
  }
@@ -1035,12 +1103,12 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1035
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)) } });
1036
1104
  k = m + 1;
1037
1105
  }
1038
- raised.push({
1106
+ const unit = { rig: r6(job.rigEye), reference: r6(job.refEye) };
1107
+ const entry = {
1039
1108
  set: job.sw.set,
1040
1109
  chain: job.chain.name,
1041
1110
  frames: job.sw.frames,
1042
1111
  seconds: secondsOf(job.sw.frames),
1043
- eyes: { rig: r6(job.rigEye), reference: r6(job.refEye) },
1044
1112
  reference: round(job.reference),
1045
1113
  placed: round(job.placed),
1046
1114
  before: round(job.before),
@@ -1049,7 +1117,11 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1049
1117
  short,
1050
1118
  upperHeld: job.upperHeld,
1051
1119
  reach: { min: r6(Math.abs(job.l0 - job.l1)), max: r6(job.l0 + job.l1), farthest: r6(job.farthest) },
1052
- });
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 });
1053
1125
  }
1054
1126
 
1055
1127
  // The legs on their final targets: a bone under a scaled bone is keyed through
@@ -1110,10 +1182,49 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1110
1182
  animation: { duration: keyTimes[n - 1]!, tracks },
1111
1183
  times: keyTimes, targets, origin, holds, bends, short, endOn, flips, scale,
1112
1184
  switches: sw.switches, shown: sw.shown, distances: sw.distances, turns, turnsUnread, range, artRange,
1113
- legScale, scales, feet, planted: plantOn, stance, 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,
1114
1186
  };
1115
1187
  }
1116
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
+
1117
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. */
1118
1229
  interface Head {
1119
1230
  chain: string;