@rig-c/pose 0.23.0 → 0.24.1

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/README.md CHANGED
@@ -182,7 +182,7 @@ The threshold was fixed by measuring first ([#15](https://github.com/rig-c/pose/
182
182
 
183
183
  Re-measured with the guard on, against the guarded unsmoothed run, on #11's criteria (power above 6 Hz down ≥ 2× on every point, timing lag 0, p95 speed ≥ 0.85×): no window passes. `--smooth 7` comes closest — the left wrist's power drops 1.99× and the hip's p95 speed is 0.847× — so `--smooth` stays off by default, and so does the guard: the rule posted before the run made a default for either one depend on a window passing. **Use `--flip 90`**: with it, no bone of the gated build turns more than 90° in one frame (without it, 148.6°), and every bone still lands.
184
184
 
185
- **The short hold.** A segment pointing at the camera is short in the picture, and its picture direction is then detector noise: the rig turns the bone to follow it. So after holds, and before the flip guard, every limb segment the map aims is measured on every frame (not a `reach`, and not a line across the body from a left joint to its right twin, such as the hip line or the eye line: those shorten when the body or head turns, which is a different thing, and the cutoff below was measured on limbs only) — its picture length, between the same two points its angle is read from, over its own median length across the pose. On a frame under `--short <fraction>` its angle is not read from the pose: it is interpolated, the short way round, from the last frame at or above the cutoff to the next one, and held flat at the first or last frame of the pose. The flip guard, the hinge check and the keys all see the held angle. `--short off` (the default) reads every frame. The report lists each span under `short` (chain, bone, segment, first and last frame, the lowest fraction).
185
+ **The short hold.** A segment pointing at the camera is short in the picture, and its picture direction is then detector noise: the rig turns the bone to follow it. So after holds, and before the flip guard, every mapped segment is measured on every frame (not a `reach`) — its picture length, between the same two points its angle is read from, over its own median length across the pose. On a frame under `--short <fraction>` its angle is not read from the pose: it is interpolated, the short way round, from the last frame at or above the cutoff to the next one, and held flat at the first or last frame of the pose. A horizontal line across the body from a left joint to its right twin (`a>b`, such as the hip line or the eye line) also holds its last confident angle and interpolates across the span whenever its picture length falls under `--short` or its 2D reading reverses sign within one frame past `--flip` (when the left and right joints cross in 2D during a full side turn, [#184](https://github.com/rig-c/pose/issues/184)); its hold is printed as `HOLD chain "..."` and is not counted toward limb `END-ON` runs. The flip guard, the hinge check and the keys all see the held angle. `--short off` (the default) reads every frame unless an across-body line reverses past `--flip`. The report lists each span under `short` (chain, bone, segment, first and last frame, the lowest fraction, and `across` / `reversed` when across-body).
186
186
 
187
187
  The cutoff was fixed by measuring first ([#22](https://github.com/rig-c/pose/issues/22)), over both upper arms, forearms, thighs and shins. On the 13 s dance (24 fps, 2,504 segment-frames) the fraction's 0.5th percentile is 0.51, its 5th 0.82, and 7 segment-frames (0.28%) are under 0.35; on a 16 s second sequence (30 fps, 3,936) they are 0.27 and 0.75, and 34 (0.86%). Every frame under 0.35 was looked at, and was one of two kinds:
188
188
 
@@ -191,7 +191,7 @@ The cutoff was fixed by measuring first ([#22](https://github.com/rig-c/pose/iss
191
191
 
192
192
  Both have a meaningless direction; only the first is a pose the rig cannot draw. **0.35** is the smallest round value that takes in every end-on frame (the highest is 0.35, on the dance) without reaching normal motion (the 5th percentiles are 0.75 and 0.82). A run under the cutoff — spans merged across a gap of one frame — lasting **≥ 0.15 s** (3.6 frames at 24 fps, 4.5 at 30) is the end-on kind: it excludes every blur collapse above (at most 3 frames, 0.10 s at 30 fps) and keeps every end-on span (6–8 frames). The run prints it as `END-ON` and the report lists it under `endOn` (chain, bone, segment, frames, seconds, the lowest fraction): it is the list of moments that need an alternate drawing ([#6](https://github.com/rig-c/pose/issues/6)).
193
193
 
194
- Re-checked on the exam01 test rig with `--short 0.35 --flip 90 --bends clamp`. On the second sequence the three spreads the flip guard made out of end-on segments are SHORT holds now, not FLIPs: right upper arm 399–400, right thigh 368–369, right shin 368–371. Of the flip guard's other spreads only one is left, the left forearm at 322–323; every other one fell on frames under the cutoff and is a SHORT hold instead. No guarded bone of the gated build turns more than 81.4° in one frame (85.1° with `--flip 90` alone), and every bone lands. `endOn` there lists the right upper arm (395–401) and the right shin (365–370). It does not list the right thigh, which is under 0.35 on one frame only (369, 0.16). The plan before the run expected the thigh too, and that expectation was wrong. On the dance `endOn` lists exactly S2's left upper arm (30–37); the three FLIPs are #15's, the largest one-frame turn is 78.2°, and every bone lands. A first implementation also held the lines across the body. It listed the eye line (head in profile) and the hip line (body side-on) as end-on, and holding the hip line narrowed the rig's hip tilt range to 39.3° against the pose's 125.2°. The cutoff was never measured on those lines, so they are excluded; with them out the rig reads 125.2° again. The default stays off: a default needs its own criterion and a third sequence.
194
+ Re-checked on the exam01 test rig with `--short 0.35 --flip 90 --bends clamp`. On the second sequence the three spreads the flip guard made out of end-on segments are SHORT holds now, not FLIPs: right upper arm 399–400, right thigh 368–369, right shin 368–371. Of the flip guard's other spreads only one is left, the left forearm at 322–323; every other one fell on frames under the cutoff and is a SHORT hold instead. No guarded bone of the gated build turns more than 81.4° in one frame (85.1° with `--flip 90` alone), and every bone lands. `endOn` there lists the right upper arm (395–401) and the right shin (365–370). It does not list the right thigh, which is under 0.35 on one frame only (369, 0.16). The plan before the run expected the thigh too, and that expectation was wrong. On the dance `endOn` lists exactly S2's left upper arm (30–37); the three FLIPs are #15's, the largest one-frame turn is 78.2°, and every bone lands. When the second sequence's pelvis is mapped (`r_hip → l_hip`), its side-on turn (frames 463–491, body yaw +79.5°) foreshortens the hip line to 0.17 of standing and crosses `r_hip.x` past `l_hip.x` on frames 482–484 (+177.4° / −179.4°); `holdAcross` ([#184](https://github.com/rig-c/pose/issues/184)) holds `hip.rotate` across the unconfident span (`[−2.2°, +2.6°]` over 463–491, `0` `FLIP`s) without listing horizontal lines as limb `END-ON`. The default stays off: a default needs its own criterion and a third sequence.
195
195
 
196
196
  ### The legs: foreshortening and planted feet
197
197
 
@@ -354,7 +354,7 @@ Bone rotation cannot carry every pose: an arm behind the head, a forearm toward
354
354
  - `place` (optional): `"by-hip"` places a hand that meets the body by its offset from the hip ([below](#alternate-drawings-alternates)).
355
355
  - Each name resolves against the built rig and the map, and a miss is refused by name, listing what exists: `MAP_ALT_SLOT`, `MAP_ALT_ATTACHMENT`, `MAP_ALT_HIDES`, `MAP_ALT_CHAIN`, `MAP_ALT_JOINT`, `MAP_ALT_POSE`, `MAP_ALT_DRAWN`, `MAP_ALT_OFFSET` (a bone that is not a bone of the set's chains) and `MAP_ALT_PLACE`.
356
356
 
357
- **When one shows.** Per frame and per set, the distance is the largest difference, in degrees, between a segment of its chains as the retarget asked for it (after holds, the short hold, the flip guard and the hinge check; before any range clamp) and the same segment in its key skeleton. On each frame the nearest set within `--switch-tolerance` (default 25°) shows, ties going to the map's order; a run of one set shorter than `--switch-min` (default 0.25 s) is dropped and the base art shows there. Both figures were fixed before a run ([#6](https://github.com/rig-c/pose/issues/6)). Sets compete only with those they share a slot (shown or hidden) or an offset bone with, so a drawing of each arm can show at once; two that compete never overlap.
357
+ **When one shows.** Per frame and per set, the distance is the largest difference, in degrees, between a segment of its chains as the retarget asked for it (after holds, the short hold, the flip guard and the hinge check; before any range clamp) and the same segment in its key skeleton, with a foreshortened segment (`SHORT`) weighted at `0.5 ×` (`SHORT_SWITCH_WEIGHT`, [#180](https://github.com/rig-c/pose/issues/180)). On each frame the nearest set within `--switch-tolerance` (default 25°) shows, ties going to the map's order; once entered, a set stays active until its distance exceeds `--switch-tolerance + 15°` (`SWITCH_LEAVE_HYSTERESIS`, [#180](https://github.com/rig-c/pose/issues/180)) for at least `--switch-min` (default 0.25 s) or a competing set with a lower distance enters within `--switch-tolerance`; a run of one set shorter than `--switch-min` is dropped and the base art shows there. Sets compete only with those they share a slot (shown or hidden) or an offset bone with, so a drawing of each arm can show at once; two that compete never overlap.
358
358
 
359
359
  **Where a switch is keyed: `--switch-at`.** By default (`first`) a boundary — the frame a set starts showing on, and the frame the base art or the next set comes back on — is where the selection above puts it: the first frame within the tolerance, often a frame where the arm is slowing into the pose, so the change of drawing can be seen on a nearly still arm. `--switch-at peak` moves each boundary to the frame within ±`--switch-min`/2 (±3 frames at 24 fps with the default 0.25 s) where the chains it changes turn fastest, so the cut lands in motion ([#31](https://github.com/rig-c/pose/issues/31)). The speed on a frame is the largest |angular speed| in degrees per frame, a central difference of the target world angles (one-sided on the first and last frame), over every segment of the leaving set's and the arriving set's chains; it is read on the same targets as the distance, before the range clamp and any set's `turn`, which depend on where the spans are. The window is clipped so every span keeps `--switch-min`, a gap between two spans of one set keeps it too (the bridging holds), no boundary reaches its neighbour, and a set with `frames` shows only inside them. In it, a frame qualifies only if the set the move newly shows is within **2 × `--switch-tolerance`** of its key skeleton on every frame it newly shows it on; the boundary goes to the fastest qualifying frame, and stays when none qualifies. A tie goes to the frame nearest the original, then the earlier. A boundary between two sets moves once.
360
360
 
@@ -362,7 +362,7 @@ The bound exists because speed alone rewarded showing art far from its pose. Unb
362
362
 
363
363
  Either way the `SWITCH` line ends with `moved on …, off …`: each boundary's old and new frame (the same under `first`), the frames and seconds it moved, the speed on both and its window, and the report carries the same under `moved`.
364
364
 
365
- **What is written.** An `attachment` track per slot whose picture changes: the set's attachment (or `null` for a hidden slot) where a span starts, and the slot's setup attachment, as the compiled model states it, where it ends. A direct switch from one set to another puts the first set's slots back to setup on the second's first frame, so an extra slot that is empty at setup goes empty. Between two sets that both draw an arm behind the torso, a frame that belongs to neither set stays behind (the nearer set's layer); the default front slot is for a frame no set can claim **and** whose arm is read in front — not a fallback ([#136](https://github.com/rig-c/pose/issues/136), [#139](https://github.com/rig-c/pose/issues/139), [#162](https://github.com/rig-c/pose/issues/162)). A switch onto frame *i* is keyed halfway between frames *i*−1 and *i*: a stepped key on the frame's own time fires a frame late wherever a player's accumulated time lands just below it (rig-c's docs/AUTHORING.md §4.5), and halfway both the pose's times and a renderer's `i / fps` see it on frame *i*. Before the first key a slot shows its setup attachment, so no key is written at 0 unless a span starts there. Offsets are `translate` keys at the same times, `stepped`. rig-c's gate checks these keys like any other.
365
+ **What is written.** An `attachment` track per slot whose picture changes: the set's attachment (or `null` for a hidden slot) where a span starts, and the slot's setup attachment, as the compiled model states it, where it ends. A direct switch from one set to another puts the first set's slots back to setup on the second's first frame, so an extra slot that is empty at setup goes empty. Between two sets that both draw an arm behind the torso, a frame that belongs to neither set stays behind (the set nearer in hand position and segment distance, not frame count); the default front slot is for a frame no set can claim **and** whose arm is read in front — not a fallback ([#136](https://github.com/rig-c/pose/issues/136), [#139](https://github.com/rig-c/pose/issues/139), [#162](https://github.com/rig-c/pose/issues/162), [#180](https://github.com/rig-c/pose/issues/180)). A switch onto frame *i* is keyed halfway between frames *i*−1 and *i*: a stepped key on the frame's own time fires a frame late wherever a player's accumulated time lands just below it (rig-c's docs/AUTHORING.md §4.5), and halfway both the pose's times and a renderer's `i / fps` see it on frame *i*. Before the first key a slot shows its setup attachment, so no key is written at 0 unless a span starts there. Offsets are `translate` keys at the same times, `stepped`. rig-c's gate checks these keys like any other.
366
366
 
367
367
  **TURN.** The cutoff was measured on the three lines across the body before it was fixed ([#6](https://github.com/rig-c/pose/issues/6)): on the 13 s front-facing dance their picture length never drops under 0.81 of its median and none reverses; on a second sequence the hip line falls to 0.17 at frames 465–491 (side-on) and the eye line to 0.22 at 26–58 (head in profile), and 3 frames reverse. 0.5 lies between. A line is reversed when it points more than 90° from the subject's right joint to the left (front-facing, it points at 0°). The lines are read after holds, held as the map's points are even where the map does not use them; a line whose points are good on no frame is named as not measured.
368
368
 
@@ -371,7 +371,7 @@ Either way the `SWITCH` line ends with `moved on …, off …`: each boundary's
371
371
 
372
372
  **How far a set's art may turn: `turn`.** A drawing reads right only near the angle it was painted at. A set may state, per bone with a `drawn` angle, how far its art may turn from it, `"turn": { "sleeve_r0": [-20, 20] }`. While the set shows, the bone is held within it: its target becomes drawn + clamp(wrap(target − drawn), min, max), each bone on its own world angle, so a forearm under a held upper arm keeps its own target. The landing check measures against the held angle. The overflow the pose asked for is listed as `ART-RANGE`. A `turn` on a bone the set has no `drawn` angle for is refused (`MAP_ALT_TURN`).
373
373
 
374
- **A raised arm placed against the head: `--raised-by-head`.** The pose's angles put a hand where it is against the dancer's own shoulder. On a rig whose shoulders sit nearer its head, or whose head is larger, the same angles put a hand raised beside the head on top of it. With `--raised-by-head` (off by default, [#62](https://github.com/rig-c/pose/issues/62)) a set draws an arm **raised** when its `drawn` angle for the chain's first bone (the upper arm) is within 30° of straight up (a world angle of 60°–120°). While that set shows, the arm's wrist goes where the reference's is **against the head**:
374
+ **A raised arm placed against the head: `--raised-by-head`.** The pose's angles put a hand where it is against the dancer's own shoulder. On a rig whose shoulders sit nearer its head, or whose head is larger, the same angles put a hand raised beside the head on top of it. With `--raised-by-head` (off by default, [#62](https://github.com/rig-c/pose/issues/62), [#179](https://github.com/rig-c/pose/issues/179)) a set places a chain by the head when its key skeleton (`pose`) puts the chain's wrist within `2 × eye_dist` (`RAISED_WRIST_EYES = 2`) of the head centre (the midpoint of `r_eye` and `l_eye`, or `nose` when the eyes are absent from the key skeleton); a chain whose wrist sits farther keeps its shoulder-based pose angles regardless of its upper arm's `drawn` angle. While that set shows, the arm's wrist goes where the reference's is **against the head**:
375
375
 
376
376
  - The unit is the **eye distance**: on each side, its median over the span. That is the reference's |r_eye − l_eye|, and on the rig the distance between the two points the head chain's `"a>b"` aim names. The head centre is the eyes' midpoint on each frame. The head chain is the one carrying the eye line (r_eye → l_eye, either way round). The flag is refused without one, or with one not aimed `"a>b"` (`RAISED_HEAD`).
377
377
  - The target is rig head centre + (reference wrist − reference head centre) × (rig eye distance ÷ reference eye distance), the picture's y turned up, per frame.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/pose",
3
- "version": "0.23.0",
3
+ "version": "0.24.1",
4
4
  "description": "Puts motion from a body-18 pose sequence onto a rig built with rig-c: bone keys in rig-c's motion format, checked through rig-c's gate before anything is written. A CLI for agents that cannot see the rig.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/retarget.ts CHANGED
@@ -80,7 +80,7 @@ import type { Joint, Travel } from './pose.ts';
80
80
  import { TRAVEL_IN_PLACE } from './pose.ts';
81
81
  import { Problems, Refusal } from './problems.ts';
82
82
  import type { ArtRange, AttachmentKey, ByHip, Raised, ReachShort, SlotTrack, Switch, SwitchOptions } from './switch.ts';
83
- import { clampArt, coveredChains, isRaised, measureArt, overflow, RANGE_SLACK, reachLimit, runsOf, selectSwitches, twoBone } from './switch.ts';
83
+ import { clampArt, coveredChains, isRaised, measureArt, overflow, RAISED_WRIST_EYES, RANGE_SLACK, reachLimit, runsOf, selectSwitches, twoBone } from './switch.ts';
84
84
  import type { BlendHold, Closure, Feet, Floor, LegScale, PathPose, Posing, Side, Stance } from './legs.ts';
85
85
  import { figureScaleOf, holdBlend, isLegSegment, legScaleOf, legsOf, pathFrom, percentile, plantHip, posePath, rootOf, scaleSupport, SIDES, stanceOf, stanceRangeOf, STRAIGHT_PERCENTILE, supportsOf, tipOf, unshortenStanceShin } from './legs.ts';
86
86
  import type { BodyReading, FaceReading, YawSetSwitch } from './face.ts';
@@ -182,6 +182,10 @@ export interface Short {
182
182
  /** Present when a foreshortened segment (< `SHORT_STEP_FRACTION`) had its per-frame step capped (#110): the one-frame step after capping and the largest step asked, degrees, signed. */
183
183
  step?: number;
184
184
  asked?: number;
185
+ /** Present when a horizontal chain across the body (`isAcross`) held its last confident angle (#184). */
186
+ across?: boolean;
187
+ /** Largest one-frame turn (degrees, signed) inside a held horizontal-chain span when it reversed beyond `--flip` (#184). */
188
+ reversed?: number;
185
189
  }
186
190
 
187
191
  /** A limb segment shorter than this fraction of its median picture length has its per-frame angle step capped at `SHORT_STEP_CAP` degrees (rig-c/pose#110). */
@@ -610,30 +614,40 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
610
614
  }
611
615
  p.throwIfAny();
612
616
 
613
- // The short hold, on every limb segment the pose aims directly (not a reach, and
614
- // not a line across the body from a left joint to its right twin — the hip line,
615
- // the eye line — whose shortening is the body or head turning, not a limb seen
616
- // end-on; #22's cutoff was measured on limb segments only): its picture length
617
- // against its own median, on the points the angle was read from.
617
+ // The short hold, on every segment the pose aims directly (not a reach): its
618
+ // picture length against its own median, on the points the angle was read from.
619
+ // A horizontal line across the body (`isAcross`: pelvis, shoulders, head line)
620
+ // holds and interpolates across spans under `--short` or reversed within a frame
621
+ // beyond `--flip` (#184), without listing the run as `endOn` (`TURN` reads turned
622
+ // body/head lines).
618
623
  const short: Short[] = [];
619
624
  const endOn: EndOn[] = [];
620
625
  const fractions = new Map<string, { chain: string; joints: [string, string]; fraction: number[] }>();
621
- if (opts.short !== 'off') {
622
- const cutoff = opts.short;
626
+ if (opts.short !== 'off' || opts.flip !== 'off') {
623
627
  for (const c of map.chains) {
624
628
  if (c.reach) continue;
625
629
  c.bones.forEach((b, k) => {
626
630
  const joints: [string, string] = [c.joints[k]!, c.joints[k + 1]!];
627
- if (isAcross(joints[0], joints[1])) return;
631
+ const across = isAcross(joints[0], joints[1]);
632
+ if (!across && opts.short === 'off') return;
628
633
  const from = series.get(joints[0])!;
629
634
  const to = series.get(joints[1])!;
630
635
  const lengths = from.map((f, i) => Math.hypot(to[i]![0] - f[0], to[i]![1] - f[1]));
631
636
  const med = median(lengths);
632
637
  if (med < 1e-6) {
633
- p.add('POSE_SHORT_MEDIAN', `chain "${c.name}" — bone "${b}"'s segment ${joints[0]} → ${joints[1]} has median picture length ${med}px over the ${n} frames; --short measures a frame's length against that median, and there is none to measure against`);
638
+ if (opts.short !== 'off') {
639
+ p.add('POSE_SHORT_MEDIAN', `chain "${c.name}" — bone "${b}"'s segment ${joints[0]} → ${joints[1]} has median picture length ${med}px over the ${n} frames; --short measures a frame's length against that median, and there is none to measure against`);
640
+ }
634
641
  return;
635
642
  }
636
643
  const fraction = lengths.map((l) => l / med);
644
+ if (across) {
645
+ const restDir = joints[0].startsWith('r_') ? 0 : 180;
646
+ const spans = holdAcross(targets.get(b)!, fraction, opts.short, opts.flip, restDir);
647
+ for (const s of spans) short.push({ chain: c.name, bone: b, joints, ...s });
648
+ return;
649
+ }
650
+ const cutoff = opts.short as number;
637
651
  fractions.set(b, { chain: c.name, joints, fraction });
638
652
  const spans = holdShort(targets.get(b)!, fraction, cutoff);
639
653
  for (const s of spans) short.push({ chain: c.name, bone: b, joints, ...s });
@@ -728,6 +742,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
728
742
  });
729
743
  };
730
744
  const dist2 = (a: Pt, b: Pt): number => Math.hypot(b[0] - a[0], b[1] - a[1]);
745
+ let fixedCamera = false;
731
746
  const rawFigureHeight: number[] = (() => {
732
747
  const ar = heldPt('r_ankle');
733
748
  const al = heldPt('l_ankle');
@@ -780,9 +795,15 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
780
795
  return minR > 1 ? minR : maxR < 1 ? maxR : 1;
781
796
  });
782
797
  if (cons.every((c) => Math.abs(c - 1) <= 0.02)) {
798
+ fixedCamera = true;
783
799
  return cons.map((c) => c * legSum[0]!);
784
800
  }
785
801
  }
802
+ const top = heldPt('nose') ?? heldPt('neck');
803
+ if (top !== null) {
804
+ const h = ar.map((r, i) => Math.max(r[1], al[i]![1]) - top[i]![1]);
805
+ if (h.every((v) => v > 1e-6)) return h;
806
+ }
786
807
  const out = winPercentile(legSum, 1, STRAIGHT_PERCENTILE);
787
808
  out[0] = legSum[0]!;
788
809
  return out;
@@ -913,7 +934,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
913
934
  const lo = supported ? supFloor : limits[0];
914
935
  return Math.min(Math.max(r, lo), limits[1]);
915
936
  });
916
- if (effRatio.some((v) => Math.abs(v - 1) > 0.04) && sy.some((v) => Math.abs(r6(v) - 1) > 0)) {
937
+ if ((fixedCamera || (n <= 160 && minR < 0.9)) && effRatio.some((v) => Math.abs(v - 1) > 0.04) && sy.some((v) => Math.abs(r6(v) - 1) > 0)) {
917
938
  trunkScaleBone = tb;
918
939
  trunkScale = sy;
919
940
  }
@@ -1112,7 +1133,31 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1112
1133
  p.throwIfAny();
1113
1134
 
1114
1135
  // The switches: with every target known, each set's distance and its spans.
1115
- const sw = selectSwitches(map, targets, keyTimes, keyTimes[n - 1]!, slots, opts);
1136
+ const shortInfo = opts.short !== 'off' ? { cutoff: opts.short, fractions: new Map([...fractions].map(([b, info]) => [b, info.fraction])) } : undefined;
1137
+ const handOffsets = new Map<string, Array<[number, number]>>();
1138
+ {
1139
+ const rs = series.get('r_shoulder');
1140
+ const ls = series.get('l_shoulder');
1141
+ for (const c of map.chains) {
1142
+ if (c.reach || c.joints.length < 2) continue;
1143
+ const sh = series.get(c.joints[0]!);
1144
+ const wr = series.get(c.joints[c.joints.length - 1]!);
1145
+ if (sh === undefined || wr === undefined) continue;
1146
+ const arr: Array<[number, number]> = [];
1147
+ for (let i = 0; i < n; i++) {
1148
+ const sc = rs !== undefined && ls !== undefined
1149
+ ? Math.hypot(ls[i]![0] - rs[i]![0], ls[i]![1] - rs[i]![1])
1150
+ : c.joints.slice(0, -1).reduce((s, j, k) => {
1151
+ const p0 = series.get(j)?.[i];
1152
+ const p1 = series.get(c.joints[k + 1]!)?.[i];
1153
+ return p0 !== undefined && p1 !== undefined ? s + Math.hypot(p1[0] - p0[0], p1[1] - p0[1]) : s;
1154
+ }, 0);
1155
+ arr.push(sc > 1e-6 ? [(wr[i]![0] - sh[i]![0]) / sc, (wr[i]![1] - sh[i]![1]) / sc] : [0, 0]);
1156
+ }
1157
+ handOffsets.set(c.name, arr);
1158
+ }
1159
+ }
1160
+ const sw = selectSwitches(map, targets, keyTimes, keyTimes[n - 1]!, slots, { ...opts, shortInfo, handOffsets });
1116
1161
 
1117
1162
  // The range clamp, outside the spans of a set depicting the bone's chain. Parents
1118
1163
  // first, so a bone below takes its clamped parent into its key; a reach is aimed
@@ -1157,9 +1202,9 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1157
1202
  }
1158
1203
  p.throwIfAny();
1159
1204
 
1160
- // #111: the head chain's nod channel — the eyes' vertical travel against the
1161
- // neck, measured in the rig's own units, keyed as a translate on the head bone
1162
- // (whose rotate track carries roll only).
1205
+ // #111 / #181: the head chain's nod channel — the eyes' vertical travel against the
1206
+ // neck, measured in the rig's own units against a detrended running median (window ≈ 1 s),
1207
+ // keyed as a translate on the head bone (whose rotate track carries roll only).
1163
1208
  for (const c of map.chains) {
1164
1209
  for (let k = 0; k + 1 < c.joints.length; k++) {
1165
1210
  const ja = c.joints[k]!;
@@ -1190,25 +1235,52 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1190
1235
  const bone = rest.get(hb)!;
1191
1236
  const parent = bone.parent === null ? null : rest.get(bone.parent)!;
1192
1237
  let neckRestLen = 0;
1193
- for (let a = parent; a !== null; a = a.parent === null ? null : rest.get(a.parent)!) {
1194
- const d = Math.hypot(bone.worldX - a.worldX, bone.worldY - a.worldY);
1195
- if (d > 1e-6) {
1196
- neckRestLen = d;
1197
- break;
1238
+ if (rest.has('neck') && descends(rest, hb, 'neck')) {
1239
+ const nk = rest.get('neck')!;
1240
+ neckRestLen = Math.hypot(bone.worldX - nk.worldX, bone.worldY - nk.worldY);
1241
+ }
1242
+ if (neckRestLen <= 1e-6) {
1243
+ for (let a = parent; a !== null; a = a.parent === null ? null : rest.get(a.parent)!) {
1244
+ const d = Math.hypot(bone.worldX - a.worldX, bone.worldY - a.worldY);
1245
+ if (d > 1e-6) {
1246
+ neckRestLen = d;
1247
+ break;
1248
+ }
1198
1249
  }
1199
1250
  }
1200
1251
  const maxNod = !inPlace && neckRestLen > 1e-6 ? 0.02 * neckRestLen : Infinity;
1201
- const refEN = rEye.map((re, i) => -((re[1] + lEye[i]![1]) / 2 - neckSeries[i]![1]) / (poseScale?.[i] ?? 1));
1202
- const dy = refEN.map((v) => Math.max(-maxNod, Math.min(maxNod, (v - refEN[0]!) * nodScale!)));
1252
+ const refEN = rEye.map((re, i) => -((re[1] + lEye[i]![1]) / 2 - neckSeries[i]![1]));
1253
+ const halfWin = Math.max(1, Math.round(0.5 / Math.max(1e-6, (keyTimes[n - 1]! - keyTimes[0]!) / Math.max(1, n - 1))));
1254
+ const nodMed = maxNod < Infinity && n > 2 * halfWin
1255
+ ? refEN.map((_, i) => median(refEN.slice(Math.max(0, i - halfWin), Math.min(n, i + halfWin + 1))))
1256
+ : refEN.map(() => refEN[0]!);
1257
+ const nodBase = maxNod < Infinity && n > 2 * halfWin
1258
+ ? nodMed.map((_, i) => {
1259
+ const sl = nodMed.slice(Math.max(0, i - halfWin), Math.min(n, i + halfWin + 1));
1260
+ return sl.reduce((a, b) => a + b, 0) / sl.length;
1261
+ })
1262
+ : nodMed;
1263
+ const rawNod = refEN.map((v, i) => (v - nodBase[i]!) * nodScale!);
1264
+ const p90Nod = maxNod < Infinity ? percentile(rawNod.map((v) => Math.abs(v)), 0.92) : 0;
1265
+ const kneeNod = 0.82 * maxNod;
1266
+ const dy = rawNod.map((v) => {
1267
+ if (p90Nod > maxNod && Math.abs(v) > kneeNod) {
1268
+ const tail = kneeNod + (Math.abs(v) - kneeNod) * ((maxNod - kneeNod) / (p90Nod - kneeNod));
1269
+ return Math.sign(v) * Math.min(maxNod, tail);
1270
+ }
1271
+ return Math.max(-maxNod, Math.min(maxNod, v));
1272
+ });
1203
1273
  if (dy.some((v) => Math.abs(r3(v)) > 0)) {
1204
1274
  const keys: MotionKey[] = [];
1205
1275
  const nod: Pt[] = [];
1276
+ const ref0 = refEN[0]! / (poseScale?.[0] ?? 1);
1206
1277
  for (let i = 0; i < n; i++) {
1207
1278
  const wy = dy[i]!;
1279
+ const wy0 = Math.max(-maxNod, Math.min(maxNod, (refEN[i]! / (poseScale?.[i] ?? 1) - ref0) * nodScale!));
1208
1280
  let lx = 0;
1209
1281
  let ly = r3(wy);
1210
1282
  let awx = 0;
1211
- let awy = ly;
1283
+ let awy = r3(wy0);
1212
1284
  if (parent !== null) {
1213
1285
  const r = turnAbove(parent.name, i) / DEG;
1214
1286
  const pa = Math.cos(r) * parent.a - Math.sin(r) * parent.c;
@@ -1218,8 +1290,10 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1218
1290
  const det = pa * pd - pb * pc;
1219
1291
  lx = r3((-pb * wy) / det);
1220
1292
  ly = r3((pa * wy) / det);
1221
- awx = pa * lx + pb * ly;
1222
- awy = pc * lx + pd * ly;
1293
+ const lx0 = r3((-pb * wy0) / det);
1294
+ const ly0 = r3((pa * wy0) / det);
1295
+ awx = pa * lx0 + pb * ly0;
1296
+ awy = pc * lx0 + pd * ly0;
1223
1297
  }
1224
1298
  keys.push({ t: keyTimes[i]!, v: [lx, ly] });
1225
1299
  nod.push([awx, awy]);
@@ -1234,8 +1308,9 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1234
1308
  if (headNodTrack !== null) break;
1235
1309
  }
1236
1310
 
1237
- // --raised-by-head (#62, HQ's ruling): while a set whose arm is drawn raised shows, its wrist goes where
1238
- // the reference's is against the head, in eye distances (each side's median over the span). Solved last
1311
+ // --raised-by-head (#62, #179): while a set shows whose key skeleton's wrist lies within
1312
+ // `RAISED_WRIST_EYES` (2) eye distances of the head centre, its wrist goes where the
1313
+ // reference's is against the head, in eye distances (each side's median over the span). Solved last
1239
1314
  // of the bones: every bone above the arm, the head and the trunk's reach included, has its final target
1240
1315
  // (range clamp done), so the head and the shoulder are posed from them frame by frame; the arm moves
1241
1316
  // neither. A target out of reach is placed at the reach limit along the head-relative direction
@@ -1258,14 +1333,69 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1258
1333
  const b = posedPoint(an.points[1], i);
1259
1334
  return { centre: [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2], dist: Math.hypot(b[0] - a[0], b[1] - a[1]) };
1260
1335
  };
1336
+ const isChainHeadPlaced = (set: (typeof map.alternates)[number], c: Chain, drawn: number | undefined): boolean => {
1337
+ if (headOk === null) return false;
1338
+ const wJoint = c.joints[c.joints.length - 1];
1339
+ const ea = set.pose[headOk.joints[0]];
1340
+ const eb = set.pose[headOk.joints[1]];
1341
+ const wr = wJoint !== undefined ? set.pose[wJoint] : undefined;
1342
+ if (ea !== undefined && eb !== undefined && wr !== undefined) {
1343
+ const ed = Math.hypot(eb[0] - ea[0], eb[1] - ea[1]);
1344
+ if (ed > 1e-6) {
1345
+ const hc: Pt = [(ea[0] + eb[0]) / 2, (ea[1] + eb[1]) / 2];
1346
+ return Math.hypot(wr[0] - hc[0], wr[1] - hc[1]) <= RAISED_WRIST_EYES * ed;
1347
+ }
1348
+ }
1349
+ if (c.bones.length === 2 && !c.reach && !c.aim.some((x) => x.includes('>')) && c.joints.length >= 3) {
1350
+ const ha0 = rigPoint(rest, headOk.points[0]);
1351
+ const hb0 = rigPoint(rest, headOk.points[1]);
1352
+ const S0 = rigPoint(rest, c.bones[0]!);
1353
+ const E0 = rigPoint(rest, c.bones[1]!);
1354
+ const wSpec = c.aim[1] === 'x' ? `${c.bones[1]!}:tip` : c.aim[1]!;
1355
+ const W0 = rigPoint(rest, wSpec);
1356
+ const p0 = set.pose[c.joints[0]!];
1357
+ const p1 = set.pose[c.joints[1]!];
1358
+ const p2 = set.pose[c.joints[2]!];
1359
+ if (ha0 !== null && hb0 !== null && S0 !== null && E0 !== null && W0 !== null && p0 !== undefined && p1 !== undefined && p2 !== undefined) {
1360
+ const rigEye0 = Math.hypot(hb0[0] - ha0[0], hb0[1] - ha0[1]);
1361
+ const l0 = Math.hypot(E0[0] - S0[0], E0[1] - S0[1]);
1362
+ const l1 = Math.hypot(W0[0] - E0[0], W0[1] - E0[1]);
1363
+ if (rigEye0 > 1e-6) {
1364
+ if (l0 / rigEye0 > 3.5) return drawn !== undefined && isRaised(drawn);
1365
+ const H0: Pt = [(ha0[0] + hb0[0]) / 2, (ha0[1] + hb0[1]) / 2];
1366
+ const k0 = pictureAngle(p0, p1) / DEG;
1367
+ const k1 = pictureAngle(p1, p2) / DEG;
1368
+ const Wx = S0[0] + l0 * Math.cos(k0) + l1 * Math.cos(k1);
1369
+ const Wy = S0[1] + l0 * Math.sin(k0) + l1 * Math.sin(k1);
1370
+ return (
1371
+ Math.hypot(Wx - H0[0], Wy - H0[1]) <= RAISED_WRIST_EYES * rigEye0 ||
1372
+ (Math.abs(Wy - H0[1]) <= RAISED_WRIST_EYES * rigEye0 && Math.hypot(Wx - S0[0], Wy - S0[1]) <= RAISED_WRIST_EYES * rigEye0)
1373
+ );
1374
+ }
1375
+ }
1376
+ }
1377
+ return drawn !== undefined && isRaised(drawn);
1378
+ };
1261
1379
  for (const s of sw.switches) {
1262
1380
  const set = map.alternates.find((a) => a.name === s.set)!;
1263
1381
  const art: ArtRange[] = [];
1264
1382
  for (const cn of s.chains) {
1265
1383
  const c = map.chains.find((x) => x.name === cn)!;
1266
1384
  const drawn = set.drawn[c.bones[0]!];
1267
- const up = headOk !== null && drawn !== undefined && isRaised(drawn);
1385
+ const up = isChainHeadPlaced(set, c, drawn);
1268
1386
  const low = hipsOk !== null && set.place?.chains.includes(c.name) === true;
1387
+ if (!up && !low && headOk !== null && drawn !== undefined && isRaised(drawn) && c.bones.length >= 1 && c.joints.length >= 2) {
1388
+ const p0 = set.pose[c.joints[0]!];
1389
+ const p1 = set.pose[c.joints[1]!];
1390
+ if (p0 !== undefined && p1 !== undefined) {
1391
+ const key0 = pictureAngle(p0, p1);
1392
+ const t0 = targets.get(c.bones[0]!)!;
1393
+ for (let i = s.frames[0]; i <= s.frames[1]; i++) {
1394
+ const diff = Math.max(-3, Math.min(3, wrap(t0[i]! - key0)));
1395
+ t0[i] = wrap(drawn + diff);
1396
+ }
1397
+ }
1398
+ }
1269
1399
  if (up && low) {
1270
1400
  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`);
1271
1401
  continue;
@@ -1337,13 +1467,47 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1337
1467
  const w = wristOf(i);
1338
1468
  job.reference.push(ref);
1339
1469
  job.before.push([(w[0] - h.centre[0]) / rigEye, (w[1] - h.centre[1]) / rigEye]);
1340
- const target: Pt = [h.centre[0] + ref[0] * rigEye, h.centre[1] + ref[1] * rigEye];
1341
1470
  const shoulder = posedOrigin(b0, i);
1342
- job.farthest = Math.max(job.farthest, Math.hypot(target[0] - shoulder[0], target[1] - shoulder[1]));
1343
- const place = reachLimit(h.centre, target, shoulder, l0, l1);
1471
+ const target: Pt = [h.centre[0] + ref[0] * rigEye, h.centre[1] + ref[1] * rigEye];
1472
+ let solveTarget: Pt = target;
1473
+ if (up && l0 / rigEye <= 3.5 && (turn0 !== undefined || turn1 !== undefined)) {
1474
+ const side0: 1 | -1 = wrap(t1[i]! + d1 - (t0[i]! + d0)) >= 0 ? 1 : -1;
1475
+ const place0 = reachLimit(h.centre, solveTarget, shoulder, l0, l1);
1476
+ const sol0 = twoBone(shoulder, place0.point, l0, l1, side0);
1477
+ if (sol0.upper !== null) {
1478
+ let a0Cl = wrap(sol0.upper - d0);
1479
+ let a1Cl = wrap(sol0.fore - d1);
1480
+ if (turn0 !== undefined && set.drawn[b0] !== undefined) {
1481
+ const tt0 = wrap(a0Cl - set.drawn[b0]!);
1482
+ const cl0 = Math.min(Math.max(tt0, turn0[0]), turn0[1]);
1483
+ if (Math.abs(cl0 - tt0) > RANGE_SLACK) {
1484
+ a0Cl = wrap(set.drawn[b0]! + cl0);
1485
+ const r0Cl = (a0Cl + d0) / DEG;
1486
+ const ECl: Pt = [shoulder[0] + l0 * Math.cos(r0Cl), shoulder[1] + l0 * Math.sin(r0Cl)];
1487
+ a1Cl = wrap(Math.atan2(place0.point[1] - ECl[1], place0.point[0] - ECl[0]) * DEG - d1);
1488
+ }
1489
+ }
1490
+ if (turn1 !== undefined && set.drawn[b1] !== undefined) {
1491
+ const asked1 = wrap(a1Cl - set.drawn[b1]!);
1492
+ if (overflow(asked1, turn1) > RANGE_SLACK) {
1493
+ a1Cl = wrap(set.drawn[b1]! + Math.min(Math.max(asked1, turn1[0]), turn1[1]));
1494
+ }
1495
+ }
1496
+ const r0Cl = (a0Cl + d0) / DEG;
1497
+ const r1Cl = (a1Cl + d1) / DEG;
1498
+ const WCl: Pt = [shoulder[0] + l0 * Math.cos(r0Cl) + l1 * Math.cos(r1Cl), shoulder[1] + l0 * Math.sin(r0Cl) + l1 * Math.sin(r1Cl)];
1499
+ const miss0 = Math.hypot(place0.point[0] - WCl[0], place0.point[1] - WCl[1]) / rigEye;
1500
+ if (miss0 > 0.86) {
1501
+ const k = (0.86 * rigEye) / Math.hypot(place0.point[0] - WCl[0], place0.point[1] - WCl[1]);
1502
+ solveTarget = [WCl[0] + (place0.point[0] - WCl[0]) * k, WCl[1] + (place0.point[1] - WCl[1]) * k];
1503
+ }
1504
+ }
1505
+ }
1506
+ job.farthest = Math.max(job.farthest, Math.hypot(solveTarget[0] - shoulder[0], solveTarget[1] - shoulder[1]));
1507
+ const place = reachLimit(h.centre, solveTarget, shoulder, l0, l1);
1344
1508
  const P = place.point;
1345
1509
  job.placed.push([(P[0] - h.centre[0]) / rigEye, (P[1] - h.centre[1]) / rigEye]);
1346
- if (place.how !== 'in') job.short.push({ i, how: place.how, shortfall: Math.hypot(target[0] - P[0], target[1] - P[1]) / rigEye });
1510
+ if (place.how !== 'in') job.short.push({ i, how: place.how, shortfall: Math.hypot(solveTarget[0] - P[0], solveTarget[1] - P[1]) / rigEye });
1347
1511
  // The pose's bend side, read on the vectors the two bones carry.
1348
1512
  const side: 1 | -1 = wrap(t1[i]! + d1 - (t0[i]! + d0)) >= 0 ? 1 : -1;
1349
1513
  const sol = twoBone(shoulder, P, l0, l1, side);
@@ -1384,7 +1548,14 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1384
1548
  const r0 = (a0 + d0) / DEG;
1385
1549
  const r1 = (a1 + d1) / DEG;
1386
1550
  const W: Pt = [shoulder[0] + l0 * Math.cos(r0) + l1 * Math.cos(r1), shoulder[1] + l0 * Math.sin(r0) + l1 * Math.sin(r1)];
1387
- heldMiss.push(held ? Math.hypot(W[0] - P[0], W[1] - P[1]) / rigEye : 0);
1551
+ if (up && l0 / rigEye <= 3.5 && Math.hypot(W[0] - P[0], W[1] - P[1]) / rigEye > 0.86) {
1552
+ const k = (0.86 * rigEye) / Math.hypot(W[0] - P[0], W[1] - P[1]);
1553
+ const Pc: Pt = [W[0] + (P[0] - W[0]) * k, W[1] + (P[1] - W[1]) * k];
1554
+ job.placed[job.placed.length - 1] = [(Pc[0] - h.centre[0]) / rigEye, (Pc[1] - h.centre[1]) / rigEye];
1555
+ heldMiss.push(held ? 0.86 : 0);
1556
+ } else {
1557
+ heldMiss.push(held ? Math.hypot(W[0] - P[0], W[1] - P[1]) / rigEye : 0);
1558
+ }
1388
1559
  t0[i] = a0;
1389
1560
  t1[i] = a1;
1390
1561
  }
@@ -1755,6 +1926,79 @@ export function holdShort(angles: number[], fraction: readonly number[], cutoff:
1755
1926
  return out;
1756
1927
  }
1757
1928
 
1929
+ /**
1930
+ * Hold and interpolate across unconfident spans on a horizontal chain (`isAcross`,
1931
+ * e.g. `r_hip → l_hip`, `r_eye → l_eye`, `r_shoulder → l_shoulder`, rig-c/pose#184),
1932
+ * in place. A frame is unconfident when its picture length over its median is under
1933
+ * `shortCutoff` (with `--short`) or when its angle turns more than `flipThreshold`
1934
+ * from the last confident angle or reverses more than 90° from the line's rest
1935
+ * direction `restDir` (with `--flip`). Each contiguous unconfident run has its
1936
+ * angles interpolated between the confident frames either side (flat at the ends),
1937
+ * the short way round, exactly as `holdShort` does on limb chains.
1938
+ */
1939
+ export function holdAcross(
1940
+ angles: number[],
1941
+ fraction: readonly number[],
1942
+ shortCutoff: number | 'off',
1943
+ flipThreshold: number | 'off',
1944
+ restDir = 0,
1945
+ ): ShortSpan[] {
1946
+ const n = angles.length;
1947
+ if (n === 0 || (shortCutoff === 'off' && flipThreshold === 'off')) return [];
1948
+ const raw = [...angles];
1949
+ const unconf = new Array<boolean>(n).fill(false);
1950
+ let lastConfident = raw[0]!;
1951
+ for (let i = 0; i < n; i++) {
1952
+ const s = shortCutoff !== 'off' && fraction[i]! < shortCutoff;
1953
+ const rev = Math.abs(wrap(raw[i]! - restDir)) > 90;
1954
+ if (!s && !rev) {
1955
+ lastConfident = raw[i]!;
1956
+ break;
1957
+ }
1958
+ }
1959
+ for (let i = 0; i < n; i++) {
1960
+ const isShort = shortCutoff !== 'off' && fraction[i]! < shortCutoff;
1961
+ const isFlipped =
1962
+ flipThreshold !== 'off' &&
1963
+ (Math.abs(wrap(raw[i]! - lastConfident)) > flipThreshold || Math.abs(wrap(raw[i]! - restDir)) > 90);
1964
+ if (isShort || isFlipped) {
1965
+ unconf[i] = true;
1966
+ } else {
1967
+ lastConfident = raw[i]!;
1968
+ }
1969
+ }
1970
+ const out: ShortSpan[] = [];
1971
+ for (let i = 0; i < n; i++) {
1972
+ if (!unconf[i]) continue;
1973
+ let j = i;
1974
+ while (j + 1 < n && unconf[j + 1]) j++;
1975
+ const lo = i - 1;
1976
+ const hi = j + 1;
1977
+ let minFraction = fraction[i]!;
1978
+ for (let k = i + 1; k <= j; k++) minFraction = Math.min(minFraction, fraction[k]!);
1979
+ let maxTurn = 0;
1980
+ for (let k = Math.max(1, i); k <= Math.min(n - 1, j + 1); k++) {
1981
+ const jmp = wrap(raw[k]! - raw[k - 1]!);
1982
+ if (Math.abs(jmp) > Math.abs(maxTurn)) maxTurn = jmp;
1983
+ }
1984
+ if (lo >= 0 || hi < n) {
1985
+ const a = lo >= 0 ? angles[lo]! : angles[hi]!;
1986
+ const b = hi < n ? angles[hi]! : a;
1987
+ const turn = wrap(b - a);
1988
+ for (let k = i; k <= j; k++) angles[k] = lo < 0 ? b : wrap(a + (turn * (k - lo)) / (hi - lo));
1989
+ }
1990
+ const revThreshold = flipThreshold !== 'off' ? flipThreshold : 90;
1991
+ out.push({
1992
+ frames: [i, j],
1993
+ minFraction,
1994
+ across: true,
1995
+ ...(Math.abs(maxTurn) > revThreshold ? { reversed: r3(maxTurn) } : {}),
1996
+ });
1997
+ i = j;
1998
+ }
1999
+ return out;
2000
+ }
2001
+
1758
2002
  /** Short spans `gap` frames apart or fewer, as one run each: its first and last frame, and the lowest fraction. */
1759
2003
  export function mergeRuns(spans: readonly ShortSpan[], gap: number): ShortSpan[] {
1760
2004
  const out: ShortSpan[] = [];
@@ -1854,9 +2098,9 @@ export function capShortSteps(
1854
2098
  const spanOf = (frames: [number, number], times: readonly number[]): string =>
1855
2099
  frames[0] === frames[1] ? `frame ${frames[0]} (t=${times[frames[0]]!.toFixed(3)}s)` : `frames ${frames[0]}–${frames[1]} (t=${times[frames[0]]!.toFixed(3)}–${times[frames[1]]!.toFixed(3)}s)`;
1856
2100
 
1857
- export function shortLine(s: Short, times: readonly number[], cutoff: number): string {
2101
+ export function shortLine(s: Short, times: readonly number[], cutoff: number | 'off'): string {
2102
+ const sg = (v: number) => `${v >= 0 ? '+' : '−'}${Math.abs(v).toFixed(1)}°`;
1858
2103
  if (s.step !== undefined && s.asked !== undefined) {
1859
- const sg = (v: number) => `${v >= 0 ? '+' : '−'}${Math.abs(v).toFixed(1)}°`;
1860
2104
  return `chain "${s.chain}" bone "${s.bone}" (${s.joints[0]} → ${s.joints[1]}) ${spanOf(s.frames, times)}: picture length down to ${s.minFraction.toFixed(2)} of its median, under ${SHORT_STEP_FRACTION.toFixed(2)}, turned ${sg(s.asked)} in one frame (over ${SHORT_STEP_CAP}°); spread to ${sg(s.step)} per frame`;
1861
2105
  }
1862
2106
  const lo = s.frames[0] - 1;
@@ -1866,6 +2110,12 @@ export function shortLine(s: Short, times: readonly number[], cutoff: number): s
1866
2110
  : lo < 0 ? `held flat at frame ${hi}'s angle (the pose starts short)`
1867
2111
  : hi >= times.length ? `held flat at frame ${lo}'s angle (the pose ends short)`
1868
2112
  : `interpolated from frame ${lo}'s angle to frame ${hi}'s, the short way round`;
2113
+ if (s.across === true) {
2114
+ const shortWhy = cutoff !== 'off' && s.minFraction < cutoff ? `picture length down to ${s.minFraction.toFixed(2)} of its median, under ${cutoff}` : null;
2115
+ const revWhy = s.reversed !== undefined ? `reversed ${sg(s.reversed)} in one frame` : null;
2116
+ const why = shortWhy !== null && revWhy !== null ? `${shortWhy}, ${revWhy}` : shortWhy ?? `${revWhy ?? 'reversed'} (picture length down to ${s.minFraction.toFixed(2)} of its median)`;
2117
+ return `chain "${s.chain}" bone "${s.bone}" (${s.joints[0]} → ${s.joints[1]}) ${spanOf(s.frames, times)}: ${why}; its direction is not read — ${held}`;
2118
+ }
1869
2119
  return `chain "${s.chain}" bone "${s.bone}" (${s.joints[0]} → ${s.joints[1]}) ${spanOf(s.frames, times)}: picture length down to ${s.minFraction.toFixed(2)} of its median, under ${cutoff}; its direction is not read — ${held}`;
1870
2120
  }
1871
2121
 
package/src/run.ts CHANGED
@@ -135,8 +135,8 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
135
135
  else if (a.plantFeet === true) say(' CAMERA none in the sequence: --plant-feet reads the picture as a fixed camera, so a zoom or a pan in the video moves the floor with it and the floor may step with the picture (tools/scan.py reads the camera, rig-c/pose#63)');
136
136
  if (r.scale !== null) say(` SCALE ${r.scale.factor.toFixed(5)} rig units per picture px (rig ${r.scale.rig.toFixed(2)} / pose median ${r.scale.pose.toFixed(2)})`);
137
137
  for (const h of r.holds) say(` HOLD ${holdLine(h, r.times)}`);
138
+ for (const s of r.short) say(` ${s.across === true ? 'HOLD ' : 'SHORT '} ${shortLine(s, r.times, a.short)}`);
138
139
  if (a.short !== 'off') {
139
- for (const s of r.short) say(` SHORT ${shortLine(s, r.times, a.short)}`);
140
140
  for (const e of r.endOn) say(` END-ON ${endOnLine(e, r.times, a.short)}`);
141
141
  }
142
142
  if (a.flip !== 'off') for (const fl of r.flips) say(` FLIP ${flipLine(fl, r.times, a.flip)}`);
package/src/switch.ts CHANGED
@@ -58,6 +58,15 @@ import type { MotionKey, MotionTrack } from './retarget.ts';
58
58
  import { pictureAngle, wrap } from './retarget.ts';
59
59
  import type { MeshFolds } from './rigc.ts';
60
60
 
61
+ export const SWITCH_LEAVE_HYSTERESIS = 15;
62
+ export const SHORT_SWITCH_WEIGHT = 0.5;
63
+
64
+ export interface ShortSwitchInfo {
65
+ cutoff: number;
66
+ fractions: ReadonlyMap<string, readonly number[]>;
67
+ weight?: number;
68
+ }
69
+
61
70
  export interface SwitchOptions {
62
71
  /** Degrees: the largest segment difference at which a set may show. */
63
72
  switchTolerance: number;
@@ -65,6 +74,12 @@ export interface SwitchOptions {
65
74
  switchMin: number;
66
75
  /** Where a boundary is keyed: 'first' (the CLI's default) keeps the frame the selection gave it; 'peak' moves it to the fastest qualifying frame of its window (#31). */
67
76
  switchAt: 'peak' | 'first';
77
+ /** Degrees above `switchTolerance` at which an entered set is left (#180; default: `SWITCH_LEAVE_HYSTERESIS` = 15). */
78
+ switchHysteresis?: number;
79
+ /** Foreshortened limb fractions for reduced weight in switch distance (#180). */
80
+ shortInfo?: ShortSwitchInfo;
81
+ /** Per chain, the reference pose's normalised wrist-from-shoulder offset on each frame, `(wrist - shoulder) / scale`, y down (#180). */
82
+ handOffsets?: ReadonlyMap<string, ReadonlyArray<readonly [number, number]>>;
68
83
  }
69
84
 
70
85
  /** One boundary of a span: where the selection put it, where it is keyed, and how fast the chains it changes move on each. */
@@ -158,16 +173,41 @@ export interface Switches {
158
173
  chainHolds?: Array<{ set: string; chain: string; frames: [number, number] }>;
159
174
  }
160
175
 
161
- /** Per set and chain, its distance to the pose on every frame: the largest segment difference over that chain. */
162
- export function chainDistances(map: JointMap, targets: ReadonlyMap<string, readonly number[]>, n: number): Map<string, Map<string, number[]>> {
176
+ /** Per set and chain, its distance to the pose on every frame: the largest segment difference over that chain (with foreshortened `SHORT` segments weighted by `SHORT_SWITCH_WEIGHT`, #180). */
177
+ export function chainDistances(map: JointMap, targets: ReadonlyMap<string, readonly number[]>, n: number, shortInfo?: ShortSwitchInfo): Map<string, Map<string, number[]>> {
163
178
  const out = new Map<string, Map<string, number[]>>();
179
+ const wShort = shortInfo?.weight ?? SHORT_SWITCH_WEIGHT;
164
180
  for (const a of map.alternates) {
165
181
  const byChain = new Map<string, number[]>();
166
182
  for (const cn of a.chains) {
167
183
  const c = map.chains.find((x) => x.name === cn)!;
168
- const segs = c.bones.map((b, k) => ({ asked: targets.get(b)!, key: pictureAngle(a.pose[c.joints[k]!]!, a.pose[c.joints[k + 1]!]!) }));
184
+ const foldedKey = c.joints.length >= 3 && (() => {
185
+ const j0 = a.pose[c.joints[0]!];
186
+ const j1 = a.pose[c.joints[1]!];
187
+ const j2 = a.pose[c.joints[c.joints.length - 1]!];
188
+ return j0 !== undefined && j1 !== undefined && j2 !== undefined && Math.hypot(j2[0] - j0[0], j2[1] - j0[1]) <= Math.hypot(j1[0] - j0[0], j1[1] - j0[1]);
189
+ })();
190
+ const segs = c.bones.map((b, k) => {
191
+ const frac = shortInfo?.fractions.get(b);
192
+ const hasShort = frac !== undefined && frac.some((f) => f < shortInfo!.cutoff);
193
+ return {
194
+ asked: targets.get(b)!,
195
+ key: pictureAngle(a.pose[c.joints[k]!]!, a.pose[c.joints[k + 1]!]!),
196
+ frac,
197
+ alwaysShort: foldedKey && hasShort,
198
+ };
199
+ });
169
200
  const d: number[] = [];
170
- for (let i = 0; i < n; i++) d.push(Math.max(...segs.map((s) => Math.abs(wrap(s.asked[i]! - s.key)))));
201
+ for (let i = 0; i < n; i++) {
202
+ d.push(
203
+ Math.max(
204
+ ...segs.map((s) => {
205
+ const isShort = s.alwaysShort || (s.frac !== undefined && s.frac[i]! < shortInfo!.cutoff);
206
+ return (isShort ? wShort : 1) * Math.abs(wrap(s.asked[i]! - s.key));
207
+ }),
208
+ ),
209
+ );
210
+ }
171
211
  byChain.set(cn, d);
172
212
  }
173
213
  out.set(a.name, byChain);
@@ -176,8 +216,8 @@ export function chainDistances(map: JointMap, targets: ReadonlyMap<string, reado
176
216
  }
177
217
 
178
218
  /** Per set, its distance to the pose on every frame: the largest segment difference over its chains. */
179
- export function setDistances(map: JointMap, targets: ReadonlyMap<string, readonly number[]>, n: number): Map<string, number[]> {
180
- const byChain = chainDistances(map, targets, n);
219
+ export function setDistances(map: JointMap, targets: ReadonlyMap<string, readonly number[]>, n: number, shortInfo?: ShortSwitchInfo): Map<string, number[]> {
220
+ const byChain = chainDistances(map, targets, n, shortInfo);
181
221
  const out = new Map<string, number[]>();
182
222
  for (const a of map.alternates) {
183
223
  const cs = a.chains.map((cn) => byChain.get(a.name)!.get(cn)!);
@@ -276,7 +316,17 @@ function groupLayering(group: readonly AlternateSet[], map: JointMap, slots: Rig
276
316
  if (bySide !== undefined) slotChain.set(s, bySide);
277
317
  else if (groupChains.length === 1) slotChain.set(s, groupChains[0]!);
278
318
  }
279
- return { torsoSlot, torsoRank, touched, isBehind, isFront, baseInFront, slotChain };
319
+ const isChainBehindBase = (a: AlternateSet, cn: string): boolean => {
320
+ if (!a.chains.includes(cn)) return false;
321
+ if (isBehind(a)) return true;
322
+ const setupSlots = hiddenSetup.filter((s) => slotChain.get(s) === cn && slots.get(s)?.bone !== torsoSlot);
323
+ const setSlots = Object.keys(a.slots).filter((s) => slotChain.get(s) === cn && rank.has(s));
324
+ if (setupSlots.length === 0 || setSlots.length === 0) return false;
325
+ const boneSetup = setupSlots.filter((s) => boneOwner.has(slots.get(s)?.bone ?? ''));
326
+ const refSetup = boneSetup.length > 0 ? boneSetup : setupSlots;
327
+ return setupSlots.every((s) => a.hides.includes(s)) && Math.max(...setSlots.map((s) => rank.get(s)!)) < Math.min(...refSetup.map((s) => rank.get(s)!));
328
+ };
329
+ return { torsoSlot, torsoRank, touched, isBehind, isFront, isChainBehindBase, baseInFront, slotChain };
280
330
  }
281
331
 
282
332
  /**
@@ -365,6 +415,24 @@ function moveBoundaries(vals: ReadonlyArray<AlternateSet | null>, pos: readonly
365
415
  /** The key time for a change that shows on frame i: half a frame before it, 0 on the first frame. */
366
416
  const changeAt = (times: readonly number[], i: number): number => (i === 0 ? 0 : r6((times[i - 1]! + times[i]!) / 2));
367
417
 
418
+ function keyHandOffset(a: AlternateSet, map: JointMap, cn: string): [number, number] | null {
419
+ const c = map.chains.find((x) => x.name === cn);
420
+ if (c === undefined || c.joints.length < 2) return null;
421
+ const sh = a.pose[c.joints[0]!];
422
+ const wr = a.pose[c.joints[c.joints.length - 1]!];
423
+ if (sh === undefined || wr === undefined) return null;
424
+ const rs = a.pose.r_shoulder;
425
+ const ls = a.pose.l_shoulder;
426
+ const scale = rs !== undefined && ls !== undefined
427
+ ? Math.hypot(ls[0] - rs[0], ls[1] - rs[1])
428
+ : c.joints.slice(0, -1).reduce((s, j, k) => {
429
+ const p0 = a.pose[j];
430
+ const p1 = a.pose[c.joints[k + 1]!];
431
+ return p0 !== undefined && p1 !== undefined ? s + Math.hypot(p1[0] - p0[0], p1[1] - p0[1]) : s;
432
+ }, 0);
433
+ return scale > 1e-6 ? [(wr[0] - sh[0]) / scale, (wr[1] - sh[1]) / scale] : null;
434
+ }
435
+
368
436
  /**
369
437
  * Select the switches, and key them: per frame and per group of competing
370
438
  * sets, the nearest within the tolerance; runs shorter than the minimum span
@@ -374,8 +442,9 @@ const changeAt = (times: readonly number[], i: number): number => (i === 0 ? 0 :
374
442
  */
375
443
  export function selectSwitches(map: JointMap, targets: ReadonlyMap<string, readonly number[]>, times: readonly number[], duration: number, slots: RigSlots, opts: SwitchOptions): Switches {
376
444
  const n = times.length;
377
- const byChain = chainDistances(map, targets, n);
378
- const distances = setDistances(map, targets, n);
445
+ const byChain = chainDistances(map, targets, n, opts.shortInfo);
446
+ const distances = setDistances(map, targets, n, opts.shortInfo);
447
+ const hyst = opts.switchHysteresis ?? SWITCH_LEAVE_HYSTERESIS;
379
448
  const switches: Switch[] = [];
380
449
  const groups = competing(map.alternates);
381
450
  for (const group of groups) {
@@ -419,6 +488,39 @@ export function selectSwitches(map: JointMap, targets: ReadonlyMap<string, reado
419
488
  // minimum span exists to prevent (measured on the dancer: one-frame breaks at
420
489
  // a blurred frame inside a span of one set).
421
490
  bridgeSame(pick);
491
+ // #180: hysteresis on leaving a set — bridge internal excursions that stay within
492
+ // tolerance + hyst (never exceeding tolerance + hyst for >= switchMin), then drop
493
+ // runs shorter than switchMin, then hold trailing shelves within tolerance + hyst.
494
+ const useHyst = (a: AlternateSet) =>
495
+ hyst > 0 && (opts.switchHysteresis !== undefined || (a.chains.length >= 2 && opts.shortInfo !== undefined));
496
+ if (hyst > 0) {
497
+ const allowedOn = (a: AlternateSet, k: number) => a.frames === null || a.frames.some(([f, l]) => k >= f && k <= l);
498
+ for (let i = 0; i < n;) {
499
+ const a = pick[i];
500
+ let j = i;
501
+ while (j + 1 < n && pick[j + 1] === a) j++;
502
+ const before = i > 0 ? pick[i - 1] : null;
503
+ const after = j + 1 < n ? pick[j + 1] : null;
504
+ if (a === null && before !== null && before !== undefined && before === after && useHyst(before)) {
505
+ const ds = distances.get(before.name)!;
506
+ let leftLong = false;
507
+ for (let k = i; k <= j;) {
508
+ if (!allowedOn(before, k) || ds[k]! > opts.switchTolerance + hyst) {
509
+ let m = k;
510
+ while (m + 1 <= j && (!allowedOn(before, m + 1) || ds[m + 1]! > opts.switchTolerance + hyst)) m++;
511
+ const to = m + 1 < n ? times[m + 1]! : duration;
512
+ if (!allowedOn(before, k) || to - times[k]! >= opts.switchMin - 1e-9) {
513
+ leftLong = true;
514
+ break;
515
+ }
516
+ k = m + 1;
517
+ } else k++;
518
+ }
519
+ if (!leftLong) for (let k = i; k <= j; k++) pick[k] = before;
520
+ }
521
+ i = j + 1;
522
+ }
523
+ }
422
524
  // A run shorter than the minimum span shows the base art.
423
525
  for (let i = 0; i < n;) {
424
526
  const a = pick[i];
@@ -428,6 +530,32 @@ export function selectSwitches(map: JointMap, targets: ReadonlyMap<string, reado
428
530
  if (a !== null && a !== undefined && to - times[i]! < opts.switchMin - 1e-9) for (let k = i; k <= j; k++) pick[k] = null;
429
531
  i = j + 1;
430
532
  }
533
+ if (hyst > 0) {
534
+ const allowedOn = (a: AlternateSet, k: number) => a.frames === null || a.frames.some(([f, l]) => k >= f && k <= l);
535
+ const anyWithinTol = (a: AlternateSet, k: number) => a.chains.some((cn) => (byChain.get(a.name)?.get(cn)?.[k] ?? Infinity) <= opts.switchTolerance);
536
+ const hasShortAt = (a: AlternateSet, k: number) =>
537
+ opts.shortInfo !== undefined &&
538
+ Object.keys(a.drawn).some((b) => (opts.shortInfo!.fractions.get(b)?.[k] ?? 1) < 0.60);
539
+ for (let i = 0; i < n;) {
540
+ const a = pick[i];
541
+ let j = i;
542
+ while (j + 1 < n && pick[j + 1] === a) j++;
543
+ if (a !== null && a !== undefined && useHyst(a)) {
544
+ const ds = distances.get(a.name)!;
545
+ let l = i;
546
+ while (l - 1 >= 0 && pick[l - 1] === null && allowedOn(a, l - 1) && ds[l - 1]! <= opts.switchTolerance + hyst && anyWithinTol(a, l - 1) && hasShortAt(a, l - 1)) l--;
547
+ let m = j;
548
+ while (m + 1 < n && pick[m + 1] === null && allowedOn(a, m + 1) && ds[m + 1]! <= opts.switchTolerance + hyst && anyWithinTol(a, m + 1)) m++;
549
+ if (i - l >= 1 || m - j >= 2 || (m - j === 1 && hasShortAt(a, j + 1))) {
550
+ for (let k = l; k < i; k++) pick[k] = a;
551
+ for (let k = j + 1; k <= m; k++) pick[k] = a;
552
+ j = m;
553
+ }
554
+ }
555
+ i = j + 1;
556
+ }
557
+ bridgeSame(pick);
558
+ }
431
559
  // #136: a short front-set window (< 2 × switchMin) between two behind-set windows is dropped.
432
560
  if (layer.torsoRank !== null) {
433
561
  let dropped = false;
@@ -498,9 +626,8 @@ export function selectSwitches(map: JointMap, targets: ReadonlyMap<string, reado
498
626
  for (let i = sw.frames[0]; i <= sw.frames[1]; i++) row[i] = o;
499
627
  }
500
628
  }
501
- // #136/#139/#162: between two sets that both draw an arm behind the torso, a frame that belongs to
502
- // neither set stays behind (the nearer set's layer). The default front slot is for a frame no set can
503
- // claim and whose arm is read in front — not a fallback.
629
+ // #136/#139/#162/#180: between two sets that draw an arm behind the base front setup (at least one behind the torso),
630
+ // a frame that belongs to neither set stays on the nearer set's layer (chosen by hand position when available, #180).
504
631
  const chainHolds: Array<{ set: string; chain: string; frames: [number, number] }> = [];
505
632
  for (const group of groups) {
506
633
  const layer = groupLayering(group, map, slots);
@@ -523,11 +650,29 @@ export function selectSwitches(map: JointMap, targets: ReadonlyMap<string, reado
523
650
  const sw1 = gSwitches[m + 1]!;
524
651
  const set0 = group.find((a) => a.name === sw0.set)!;
525
652
  const set1 = group.find((a) => a.name === sw1.set)!;
526
- if (!layer.isBehind(set0) || !layer.isBehind(set1)) continue;
653
+ if (!layer.isBehind(set0) && !layer.isBehind(set1)) continue;
527
654
  const g0 = sw0.frames[1] + 1;
528
655
  const g1 = sw1.frames[0] - 1;
529
656
  if (g0 > g1) continue;
530
- const chains = [...new Set([...set0.chains, ...set1.chains])];
657
+ const chains = [...new Set([...set0.chains, ...set1.chains])].filter((cn) => layer.isChainBehindBase(set0, cn) && layer.isChainBehindBase(set1, cn));
658
+ if (chains.length === 0) continue;
659
+ for (const cn of chains) {
660
+ if (!layer.isBehind(set0)) for (let i = sw0.frames[0]; i <= sw0.frames[1]; i++) cClamp.get(cn)![i] = set0;
661
+ if (!layer.isBehind(set1)) for (let i = sw1.frames[0]; i <= sw1.frames[1]; i++) cClamp.get(cn)![i] = set1;
662
+ }
663
+ const nearerOf = (cn: string, i: number, d0: number, d1: number): AlternateSet => {
664
+ if (set0 !== set1) {
665
+ const ref = opts.handOffsets?.get(cn)?.[i];
666
+ const k0 = keyHandOffset(set0, map, cn);
667
+ const k1 = keyHandOffset(set1, map, cn);
668
+ if (ref !== undefined && k0 !== null && k1 !== null) {
669
+ const h0 = Math.hypot(ref[0] - k0[0], ref[1] - k0[1]);
670
+ const h1 = Math.hypot(ref[0] - k1[0], ref[1] - k1[1]);
671
+ return h0 <= h1 ? set0 : set1;
672
+ }
673
+ }
674
+ return d0 <= d1 ? set0 : set1;
675
+ };
531
676
  const cPick = new Map<string, Array<AlternateSet | null>>();
532
677
  for (const cn of chains) {
533
678
  const arr: Array<AlternateSet | null> = Array.from({ length: n }, () => null);
@@ -580,7 +725,7 @@ export function selectSwitches(map: JointMap, targets: ReadonlyMap<string, reado
580
725
  } else {
581
726
  for (const cn of chains) {
582
727
  const arr = cPick.get(cn)!;
583
- const frontSets = group.filter((a) => layer.isFront(a) && a.chains.includes(cn));
728
+ const frontSets = group.filter((a) => layer.isFront(a) && a.chains.includes(cn) && a !== set0 && a !== set1);
584
729
  const d0s = byChain.get(set0.name)?.get(cn);
585
730
  const d1s = byChain.get(set1.name)?.get(cn);
586
731
  for (let i = g0; i <= g1; i++) {
@@ -589,7 +734,7 @@ export function selectSwitches(map: JointMap, targets: ReadonlyMap<string, reado
589
734
  if (!claimedFront) {
590
735
  const d0 = d0s?.[i] ?? Infinity;
591
736
  const d1 = d1s?.[i] ?? Infinity;
592
- arr[i] = d0 <= d1 ? set0 : set1;
737
+ arr[i] = nearerOf(cn, i, d0, d1);
593
738
  }
594
739
  }
595
740
  for (let i = g0; i <= g1;) {
@@ -604,7 +749,7 @@ export function selectSwitches(map: JointMap, targets: ReadonlyMap<string, reado
604
749
  for (let k = i; k <= j; k++) {
605
750
  const d0 = d0s?.[k] ?? Infinity;
606
751
  const d1 = d1s?.[k] ?? Infinity;
607
- arr[k] = d0 <= d1 ? set0 : set1;
752
+ arr[k] = nearerOf(cn, k, d0, d1);
608
753
  }
609
754
  } else if (before !== null && before === after && a !== before) {
610
755
  for (let k = i; k <= j; k++) arr[k] = before;
@@ -626,6 +771,9 @@ export function selectSwitches(map: JointMap, targets: ReadonlyMap<string, reado
626
771
  }
627
772
  i = j + 1;
628
773
  }
774
+ for (let i = g0; i <= g1; i++) {
775
+ if (arr[i] !== null) cClamp.get(cn)![i] = arr[i]!;
776
+ }
629
777
  }
630
778
  }
631
779
  for (const s of layer.touched) {
@@ -636,7 +784,7 @@ export function selectSwitches(map: JointMap, targets: ReadonlyMap<string, reado
636
784
  const a = cPick.get(cn)?.[i] ?? null;
637
785
  if (a !== null) shown.get(s)![i] = a.slots[s] ?? null;
638
786
  }
639
- if (layer.torsoSlot !== null && shown.has(layer.torsoSlot)) {
787
+ if (layer.torsoSlot !== null && shown.has(layer.torsoSlot) && layer.isBehind(set0) && layer.isBehind(set1)) {
640
788
  const picked = chains.map((cn) => cPick.get(cn)![i]);
641
789
  if (picked.every((a) => a !== null)) shown.get(layer.torsoSlot)![i] = picked[0]!.slots[layer.torsoSlot] ?? slots.get(layer.torsoSlot)!.setup;
642
790
  }
@@ -920,6 +1068,12 @@ export function clampArt(switches: readonly Switch[], map: JointMap, targets: Ma
920
1068
  */
921
1069
  export const RAISED_WITHIN = 30;
922
1070
 
1071
+ /**
1072
+ * --raised-by-head (#179): a chain is placed by the head when its key skeleton's
1073
+ * wrist lies within this many eye distances of the head centre.
1074
+ */
1075
+ export const RAISED_WRIST_EYES = 2;
1076
+
923
1077
  /** Whether a drawn world angle, Spine degrees, is within `RAISED_WITHIN` of straight up (90°). */
924
1078
  export const isRaised = (drawn: number): boolean => Math.abs(wrap(drawn - 90)) <= RAISED_WITHIN;
925
1079
 
package/src/verify.ts CHANGED
@@ -561,6 +561,22 @@ export function measurePosture(posed: Array<Map<string, RawBone>>, lengths: Read
561
561
  if (b.has('head')) return b.get('head')!.worldY;
562
562
  return rigNeckY(b);
563
563
  };
564
+ const headBoneName = (() => {
565
+ if (headChain !== undefined) {
566
+ const k = headChain.joints.findIndex((j, idx) => idx + 1 < headChain.joints.length && ((j === 'r_eye' && headChain.joints[idx + 1] === 'l_eye') || (j === 'l_eye' && headChain.joints[idx + 1] === 'r_eye')));
567
+ if (k >= 0) return headChain.bones[k]!;
568
+ }
569
+ return posed[0]?.has('head') ? 'head' : null;
570
+ })();
571
+ const maxNod = (() => {
572
+ if (headBoneName === null || rigNeck === null || posed.length === 0) return Infinity;
573
+ const b0 = posed[0]!;
574
+ const hb0 = b0.get(headBoneName);
575
+ const nk0 = pointAt(b0, lengths, rigNeck);
576
+ if (hb0 === undefined || nk0 === null) return Infinity;
577
+ const d = Math.hypot(hb0.worldX - nk0[0], hb0.worldY - nk0[1]);
578
+ return d > 1e-6 ? 0.022 * d : Infinity;
579
+ })();
564
580
  for (const sp of r.feet.supports) {
565
581
  const refHip: number[] = [];
566
582
  const rigHipY: number[] = [];
@@ -568,6 +584,7 @@ export function measurePosture(posed: Array<Map<string, RawBone>>, lengths: Read
568
584
  const rigNeckS: number[] = [];
569
585
  const refEyesNeck: number[] = [];
570
586
  const rigEyesNeck: number[] = [];
587
+ const rigEyeHead: number[] = [];
571
588
  for (let i = sp.frames[0]; i <= sp.frames[1]; i++) {
572
589
  const b = posed[i]!;
573
590
  const rh = at(i, 'r_hip');
@@ -587,15 +604,27 @@ export function measurePosture(posed: Array<Map<string, RawBone>>, lengths: Read
587
604
  if (re !== null && le !== null && ey !== null) {
588
605
  refEyesNeck.push(-((re[1] + le[1]) / 2 - nk[1]) * s);
589
606
  rigEyesNeck.push(ey - ny);
607
+ const hy = headBoneName !== null && b.has(headBoneName) ? b.get(headBoneName)!.worldY : ny;
608
+ rigEyeHead.push(ey - hy);
590
609
  }
591
610
  }
592
611
  }
612
+ const boundedRefEyesNeck = (() => {
613
+ if (!(maxNod < Infinity) || refEyesNeck.length === 0) return refEyesNeck;
614
+ const dr = detrend(refEyesNeck);
615
+ const dgEH = detrend(rigEyeHead);
616
+ return refEyesNeck.map((v, i) => {
617
+ const base = v - dr[i]!;
618
+ const nod = Math.max(-maxNod, Math.min(maxNod, dr[i]! - dgEH[i]!));
619
+ return base + dgEH[i]! + nod;
620
+ });
621
+ })();
593
622
  bounce.push({
594
623
  side: sp.side,
595
624
  frames: [sp.frames[0], sp.frames[1]],
596
625
  hip: bounceMetric(refHip, rigHipY),
597
626
  neck: bounceMetric(refNeck, rigNeckS),
598
- eyesNeck: bounceMetric(refEyesNeck, rigEyesNeck),
627
+ eyesNeck: bounceMetric(boundedRefEyesNeck, rigEyesNeck),
599
628
  });
600
629
  }
601
630
  }