@rig-c/pose 0.29.0 → 0.29.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
@@ -107,7 +107,7 @@ A retarget of the cleaned dancer, with `--flip 90 --short 0.35 --bends clamp --l
107
107
  rig-pose retarget --rig out/rig --map map.json --pose dance.pose.json --out motion.json --name dance
108
108
  ```
109
109
 
110
- For every frame of the pose and every bone the map names, the segment that bone carries (an upper arm: shoulder → elbow) points somewhere in the picture. The bone is turned so its segment points the same way: the picture angle, flipped into rig-c's y-up world, minus the bone's angle at the rig's setup pose (read through rig-c's own poser), less the turn of the mapped bone above it — because a `rotate` key in rig-c adds to the setup rotation and a child turns with its parent. The travel of the pelvis midpoint, scaled by a length measured on both the rig and the pose, moves the hip. When a head chain carries the eye line (`r_eye → l_eye`), the eyes' vertical travel against `neck` in the reference, scaled into the rig's units, keys a vertical `translate` nod channel on the head bone ([#111](https://github.com/rig-c/pose/issues/111)) clamped within `±2%` of the setup neck length (`head − neck`, [#181](https://github.com/rig-c/pose/issues/181)), while any vertical residual removed by that clamp (`rawNod − dy`) is coupled in phase into `face.pitch`'s dial (`+2.8°/unit`, mapped through `face.pitch.range`) and the head bone's `rotate` track (`+1.6°/unit`, [#190](https://github.com/rig-c/pose/issues/190); gains calibrated against the hand-polished reference `p1`), adding `0°` on frames where the clamp does not act. The result is one animation in rig-c's motion format (`rigc-motion/1`): a `rotate` track per mapped bone, a `translate` track on the hip (and on the head when the reference nods), one key per pose frame — and, when the map lists alternate drawings, an `attachment` track per slot they switch (and a `translate` track per bone a set offsets).
110
+ For every frame of the pose and every bone the map names, the segment that bone carries (an upper arm: shoulder → elbow) points somewhere in the picture. The bone is turned so its segment points the same way: the picture angle, flipped into rig-c's y-up world, minus the bone's angle at the rig's setup pose (read through rig-c's own poser), less the turn of the mapped bone above it — because a `rotate` key in rig-c adds to the setup rotation and a child turns with its parent. The travel of the pelvis midpoint, scaled by a length measured on both the rig and the pose, moves the hip. When a head chain carries the eye line (`r_eye → l_eye`), the eyes' vertical travel against `neck` in the reference, scaled into the rig's units, keys a vertical `translate` nod channel on the head bone ([#111](https://github.com/rig-c/pose/issues/111)) clamped within `±2%` of the setup neck length (`head − neck`, [#181](https://github.com/rig-c/pose/issues/181)), while on sequences where that clamp acts, the clamped vertical nod (`clampedY`) is coupled in phase into `face.pitch`'s dial (`+2.8°/unit`, mapped through `face.pitch.range`) and the head bone's `rotate` track (`+1.6°/unit`, [#190](https://github.com/rig-c/pose/issues/190), [#205](https://github.com/rig-c/pose/issues/205); gains calibrated against the hand-polished reference `p1`). The result is one animation in rig-c's motion format (`rigc-motion/1`): a `rotate` track per mapped bone, a `translate` track on the hip (and on the head when the reference nods), one key per pose frame — and, when the map lists alternate drawings, an `attachment` track per slot they switch (and a `translate` track per bone a set offsets).
111
111
 
112
112
  Nothing is written until every check passes:
113
113
 
@@ -315,7 +315,7 @@ Which bones carry which joints. You write it for your rig:
315
315
  ⚠️ **Seen from the front, an elbow's side is not a joint limit.** An elbow flexes toward the camera, and its picture falls on either side of the upper arm depending on how the arm is turned. On a real 13 s front-facing dance (313 frames, RTMPose), the right elbow sat on the "wrong" side of the rig's rest on 165 frames (median 23°), and the left on 200 (median 29°, up to 179° with the hands behind the head). Every one was a real elbow bend. The knees strayed at most 8.8° and 14.7°, which is pose noise around a straight leg. So for front-facing material, arms go `"free"`. Legs on `"rest"` with `--bends clamp` hold only while the knees stay near straight. On a second dance with knee bends (Toca Toca, rig-c/pose#27), that setting straightened the left knee for most of the dance: `leg_l1` was 48.3° off at p90, against 0.0–0.3° with free legs. Leave legs `"free"` unless the dance keeps its knees straight.
316
316
  - `mid_hip` and `mid_shoulder` are the midpoints of the two hips and the two shoulders.
317
317
  - `translate.scale` is one length measured on both: two points on the rig, two points in the pose (its median over the frames where both are good). Pick a long segment that does not foreshorten much.
318
- - `face` (optional, [#138](https://github.com/rig-c/pose/issues/138), [#164](https://github.com/rig-c/pose/issues/164), [#173](https://github.com/rig-c/pose/issues/173), [#189](https://github.com/rig-c/pose/issues/189), [#190](https://github.com/rig-c/pose/issues/190)): `{ "yaw"?: { "bone": "yaw", "range": [-15, 15], "sets"?: [{ "set": "T1", "above": 45 }] }, "pitch"?: { "bone": "pitch", "range": [-10, 15] }, "sets"?: [{ "set": "T1", "above": 45 }] }` (`min ≤ 0 ≤ max`, `min < max`). Reads per-frame yaw and pitch from `nose` against `r_eye + l_eye` (and `r_ear + l_ear` when present — where the eye span is foreshortened below `0.85 × median(de)` and the ear-chord offset `xea = (mid_ears − mid_eyes) · u / da` exceeds both `1.8 × |xne|` and `0.15`, yaw blends toward the ear chord `atan(2 × xea)` with weight `w = clamp((0.85 × median(de) − de) / ((0.85 − 0.80) × median(de)), 0, 1)` so a side gaze whose 2D nose sits near the eye midpoint reaches `atan(2 × xea)` below `0.80 × median(de)` and exits without a step), couples any vertical residual removed by the head nod channel's `±2%` neck-length clamp (`+2.8°/unit`, `0°` where un-clamped) into `pitch` in phase ([#190](https://github.com/rig-c/pose/issues/190)), maps 1:1 inside `0.8 × range` (`[-12, 12]°` on `[-15, 15]°`) and compresses only the overshoot above the knee into the remaining 20% of the range so a single outlier frame does not crush the rest of the clip, and keys the named dial bone's `rotate` track (`FACE`). When `sets` (`[{ "set": "<name>", "above": <deg> }]`, `0 < above ≤ 90`) is declared on `face.yaw.sets` or `face.sets`, each set enters on frames where `|yaw| ≥ above` (or the highest `above` satisfied when multiple sets are declared) and leaves only when `|yaw| < above − 10°` (`10°` hysteresis), emitting stepped slot `attachment` keys for the set's `slots` and `hides` (resolved from `alternates` in the map or `sets.json` (`rig-parts-sets/1`) beside `rig.json`; an unknown set is refused by name as `MAP_SET_EXISTS`).
318
+ - `face` (optional, [#138](https://github.com/rig-c/pose/issues/138), [#164](https://github.com/rig-c/pose/issues/164), [#173](https://github.com/rig-c/pose/issues/173), [#189](https://github.com/rig-c/pose/issues/189), [#190](https://github.com/rig-c/pose/issues/190), [#205](https://github.com/rig-c/pose/issues/205)): `{ "yaw"?: { "bone": "yaw", "range": [-15, 15], "sets"?: [{ "set": "T1", "above": 45 }] }, "pitch"?: { "bone": "pitch", "range": [-10, 15] }, "sets"?: [{ "set": "T1", "above": 45 }] }` (`min ≤ 0 ≤ max`, `min < max`). Reads per-frame yaw and pitch from `nose` against `r_eye + l_eye` (and `r_ear + l_ear` when present — where the eye span is foreshortened below `0.85 × median(de)` and the ear-chord offset `xea = (mid_ears − mid_eyes) · u / da` exceeds both `1.8 × |xne|` and `0.15`, yaw blends toward the ear chord `atan(2 × xea)` with weight `w = clamp((0.85 × median(de) − de) / ((0.85 − 0.80) × median(de)), 0, 1)` so a side gaze whose 2D nose sits near the eye midpoint reaches `atan(2 × xea)` below `0.80 × median(de)` and exits without a step), couples the head nod channel's clamped vertical travel (`clampedY`, `+2.8°/unit` when the `±2%` neck-length clamp acts on the sequence) into `pitch` in phase ([#190](https://github.com/rig-c/pose/issues/190), [#205](https://github.com/rig-c/pose/issues/205)), maps 1:1 inside `0.8 × range` (`[-12, 12]°` on `[-15, 15]°`) and compresses only the overshoot above the knee into the remaining 20% of the range so a single outlier frame does not crush the rest of the clip, and keys the named dial bone's `rotate` track (`FACE`). When `sets` (`[{ "set": "<name>", "above": <deg> }]`, `0 < above ≤ 90`) is declared on `face.yaw.sets` or `face.sets`, each set enters on frames where `|yaw| ≥ above` (or the highest `above` satisfied when multiple sets are declared) and leaves only when `|yaw| < above − 10°` (`10°` hysteresis), emitting stepped slot `attachment` keys for the set's `slots` and `hides` (resolved from `alternates` in the map or `sets.json` (`rig-parts-sets/1`) beside `rig.json`; an unknown set is refused by name as `MAP_SET_EXISTS`).
319
319
  - `body` (optional, [#173](https://github.com/rig-c/pose/issues/173)): `{ "yaw"?: { "bone": "body_yaw", "range": [-30, 30], "sets"?: [{ "set": "T8", "above": 45 }] }, "sets"?: [{ "set": "T8", "above": 45 }] }` (`min ≤ 0 ≤ max`, `min < max`). Maps the per-frame body yaw read from the camera-normalised shoulder and hip width (`BODY`) into `range` with the same `0.8 × range` knee mapping as `face`, keying the named dial bone's `rotate` track, and switches any declared `sets` (`body.sets` or `body.yaw.sets`) with the same `above` / `above − 10°` hysteresis (overriding `face` sets and arm `alternates` on slots they share while active). Without `body` in the map, `BODY` and `--report`'s `body` still report the reading while leaving every track in `motion.json` unchanged.
320
320
 
321
321
  ### A joint's range: `range`
@@ -352,8 +352,8 @@ Bone rotation cannot carry every pose: an arm behind the head, a forearm toward
352
352
  - `drawn` (optional): each bone's world angle (Spine degrees) as painted. It changes no key unless the set declares `symmetric`, `support` or `anchor` ([below](#alternate-drawings-alternates)): otherwise, while a set shows, a bone is keyed to the pose as always, and the art turns with it. What it gives is a figure: the art turn, how far each bone is from the angle its art was painted at (`artTurn`, and `ART-RANGE` past the bone's range).
353
353
  - `offsets` (optional): a translate per bone, in its parent's axes, held while the set shows and 0 elsewhere (stepped keys), so the joints as painted are the pivots. A bone `translate` already keys (the hip) cannot take one.
354
354
  - `place` (optional): `"by-hip"` places a hand that meets the body by its offset from the hip ([below](#alternate-drawings-alternates)).
355
- - `symmetric` (optional, [#188](https://github.com/rig-c/pose/issues/188)): `true` normalises a two-arm symmetric gesture set (such as hands behind the head) to its `drawn` symmetry centre so both elbows sit at the same world height and neither wrist extends past the head silhouette ([below](#alternate-drawings-alternates)). Requires two 2-bone chains with `drawn` angles on all four bones (`MAP_ALT_SYMMETRIC`).
356
- - `support` (optional, [#188](https://github.com/rig-c/pose/issues/188)): a chain name or non-empty list of chain names of the set (`"support": "arm_l"` or `["arm_l"]`) whose bones carry a stationary support arm (such as a waist-resting hand while the other arm gestures); while the set shows, each support chain's bones anchor to `drawn` within `±2.5°` (`≤ ±3°`) of the pose's delta relative to the set's reference pose, and an unplaced active raised arm in the same set clears the head/bun outward along the 2D shoulder→wrist direction (`MAP_ALT_SUPPORT`).
355
+ - `symmetric` (optional, [#188](https://github.com/rig-c/pose/issues/188), [#206](https://github.com/rig-c/pose/issues/206)): `true` normalises a two-arm symmetric gesture set (such as hands behind the head) to its `drawn` symmetry centre so both elbows sit at the same world height and neither wrist extends past the head silhouette ([below](#alternate-drawings-alternates)), ramping in and out in joint space across any adjacent bridge frames where the set's slots remain shown. Requires two 2-bone chains with `drawn` angles on all four bones (`MAP_ALT_SYMMETRIC`).
356
+ - `support` (optional, [#188](https://github.com/rig-c/pose/issues/188), [#206](https://github.com/rig-c/pose/issues/206)): a chain name or non-empty list of chain names of the set (`"support": "arm_l"` or `["arm_l"]`) whose bones carry a stationary support arm (such as a waist-resting hand while the other arm gestures); while the set's slots show (`loShow..hiShow`), each support chain's bones anchor to `drawn` within `±2.5°` (`≤ ±3°`) of the pose's delta relative to the set's reference pose (bridging in joint space across any preceding gap frames where the leaving set's slots still show), and an unplaced active raised arm in the same set clears the head/bun outward along the 2D shoulder→wrist direction (`MAP_ALT_SUPPORT`).
357
357
  - `anchor` (optional, [#191](https://github.com/rig-c/pose/issues/191)): a chain name or non-empty list of chain names of the set (`"anchor": "arm_l"` or `["arm_l"]`) whose hand is anchored to the set's drawn hand-tip-to-head offset in the `head` bone's local frame (`MAP_ALT_ANCHOR`, `ANCHOR_HEAD`); while the set's slots for that chain show, the target hand tip is `head_posed_origin + rotate(W_drawn − H_setup, head_posed_rot − head_drawn_rot)` and the 2-bone arm is solved by two-circle intersection (`twoBone` / `reachLimit`) inside the set's `turn`, listing any frame where `turn` saturates and leaves a residual hand-tip shortfall (`saturated: [{ frame, residual }]` on `raised`, printed as `; residual f46 9.68u, …` on `RAISED`).
358
358
  - 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), `MAP_ALT_PLACE`, `MAP_ALT_SYMMETRIC`, `MAP_ALT_SUPPORT` and `MAP_ALT_ANCHOR`.
359
359
 
@@ -365,7 +365,7 @@ The bound exists because speed alone rewarded showing art far from its pose. Unb
365
365
 
366
366
  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`.
367
367
 
368
- **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.
368
+ **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), with any adjacent bridge or `SHORT` ramp interpolated in joint space (parent-local angle for child bones) and any extended hold of a tight set included in `chainHolds`/`covered` so the held art is clamped and exempted from `capShortSteps` and `range`; 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), [#187](https://github.com/rig-c/pose/issues/187), [#206](https://github.com/rig-c/pose/issues/206)). 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.
369
369
 
370
370
  **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.
371
371
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/pose",
3
- "version": "0.29.0",
3
+ "version": "0.29.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
@@ -99,18 +99,20 @@ const DEG = 180 / Math.PI;
99
99
  export const HELD_MISS = 0.01;
100
100
 
101
101
  /**
102
- * Beat-nod phase-coupling gain (`°/unit`) from the vertical residual that the
103
- * `±2%` neck-length clamp removes from `head.translate.y` (`rawNod - dy`) onto
104
- * `map.face.pitch` (`pitch_dial.rotate`, `rig-c/pose#190`). Measured on the
105
- * dancer reference hand pass (`hand/p1` HP2: `+2.8°/unit`).
102
+ * Beat-nod phase-coupling gain (`°/unit`) from the clamped `head.translate.y`
103
+ * offset (`clampedY`, bounded by `±2%` of neck length) onto `map.face.pitch`
104
+ * (`pitch_dial.rotate`, `rig-c/pose#190`, `#205`) when the neck-length clamp
105
+ * acts on the sequence. Measured on the dancer reference hand pass (`hand/p1`
106
+ * HP2: `+2.8°/unit`).
106
107
  */
107
108
  export const NOD_RESIDUAL_PITCH_GAIN = 2.8;
108
109
 
109
110
  /**
110
- * Beat-nod phase-coupling gain (`°/unit`) from the vertical residual that the
111
- * `±2%` neck-length clamp removes from `head.translate.y` (`rawNod - dy`) onto
112
- * the head chain bone's `rotate` track (`head.rotate`, `rig-c/pose#190`).
113
- * Measured on the dancer reference hand pass (`hand/p1` HP2: `+1.6°/unit`).
111
+ * Beat-nod phase-coupling gain (`°/unit`) from the clamped `head.translate.y`
112
+ * offset (`clampedY`, bounded by `±2%` of neck length) onto the head chain
113
+ * bone's `rotate` track (`head.rotate`, `rig-c/pose#190`, `#205`) when the
114
+ * neck-length clamp acts on the sequence. Measured on the dancer reference
115
+ * hand pass (`hand/p1` HP2: `+1.6°/unit`).
114
116
  */
115
117
  export const NOD_RESIDUAL_HEAD_GAIN = 1.6;
116
118
 
@@ -1396,14 +1398,14 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1396
1398
  }
1397
1399
  }
1398
1400
  let rotDeg = targets.get(hb)?.[i] ?? restOf.get(hb) ?? 0;
1399
- if (headNodReading !== null && headNodReading.bone === hb) {
1400
- const res = headNodReading.residual[i] ?? 0;
1401
- if (res !== 0) {
1401
+ if (headNodReading !== null && headNodReading.bone === hb && headNodReading.residual.some((r) => r !== 0)) {
1402
+ const nodY = headNodReading.clampedY[i] ?? 0;
1403
+ if (nodY !== 0) {
1402
1404
  const above = mappedAbove(hb);
1403
1405
  const hc = map.chains.find((c) => c.bones.includes(hb));
1404
1406
  const limits = hc?.range[hb];
1405
1407
  const cov = hc !== undefined ? covered.get(hc.name) : undefined;
1406
- let next = wrap(rotDeg + NOD_RESIDUAL_HEAD_GAIN * res);
1408
+ let next = wrap(rotDeg + NOD_RESIDUAL_HEAD_GAIN * nodY);
1407
1409
  if (limits !== undefined && cov?.[i] !== true) {
1408
1410
  const key = wrap((next - restOf.get(hb)!) - (above === null ? 0 : turn(above, i)));
1409
1411
  if (overflow(key, limits) > RANGE_SLACK) {
@@ -2145,15 +2147,38 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
2145
2147
  const [lo, hi] = s.frames;
2146
2148
  if (lo <= 0 || hi + 1 >= n) continue;
2147
2149
  if (cov?.[lo - 1] !== true && cov?.[hi + 1] !== true) continue;
2150
+ if (cov?.[lo - 1] === true && cov?.[hi + 1] === true && hi - lo + 1 <= 10) continue;
2151
+ const ch = map.chains.find((c) => c.name === s.chain);
2152
+ const bIdx = ch !== undefined ? ch.bones.indexOf(s.bone) : -1;
2153
+ const parentTg = ch !== undefined && bIdx > 0 ? targets.get(ch.bones[bIdx - 1]!) : undefined;
2148
2154
  const tg = targets.get(s.bone)!;
2149
- const startA = tg[lo - 1]!;
2150
- const total = wrap(tg[hi + 1]! - startA);
2155
+ const startA = parentTg !== undefined ? wrap(tg[lo - 1]! - parentTg[lo - 1]!) : tg[lo - 1]!;
2156
+ const endA = parentTg !== undefined ? wrap(tg[hi + 1]! - parentTg[hi + 1]!) : tg[hi + 1]!;
2157
+ const total = wrap(endA - startA);
2151
2158
  const steps = hi - lo + 2;
2152
2159
  for (let k = lo; k <= hi; k++) {
2153
- tg[k] = wrap(startA + ((k - lo + 1) / steps) * total);
2160
+ const localA = wrap(startA + ((k - lo + 1) / steps) * total);
2161
+ tg[k] = parentTg !== undefined ? wrap(parentTg[k]! + localA) : localA;
2154
2162
  }
2155
2163
  s.step = r3(total / steps);
2156
2164
  }
2165
+ const interpolateChainInJointSpace = (ch: Chain, i0: number, i1: number) => {
2166
+ const steps = i1 - i0;
2167
+ if (steps < 2 || i0 < 0 || i1 >= n) return;
2168
+ for (let bIdx = 0; bIdx < ch.bones.length; bIdx++) {
2169
+ const b = ch.bones[bIdx]!;
2170
+ const tg = targets.get(b);
2171
+ if (!tg) continue;
2172
+ const parentTg = bIdx > 0 ? targets.get(ch.bones[bIdx - 1]!) : undefined;
2173
+ const startA = parentTg !== undefined ? wrap(tg[i0]! - parentTg[i0]!) : tg[i0]!;
2174
+ const endA = parentTg !== undefined ? wrap(tg[i1]! - parentTg[i1]!) : tg[i1]!;
2175
+ const total = wrap(endA - startA);
2176
+ for (let k = i0 + 1; k < i1; k++) {
2177
+ const localA = wrap(startA + ((k - i0) / steps) * total);
2178
+ tg[k] = parentTg !== undefined ? wrap(parentTg[k]! + localA) : localA;
2179
+ }
2180
+ }
2181
+ };
2157
2182
  const holds = sw.chainHolds ?? [];
2158
2183
  for (const h of holds) {
2159
2184
  const swParent = sw.switches.find((s) => s.set === h.set && s.frames[0] === h.frames[0] && s.frames[1] > h.frames[1]);
@@ -2165,19 +2190,10 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
2165
2190
  if (lo > hi || lo <= 0 || hi + 1 >= n) continue;
2166
2191
  const ch = map.chains.find((c) => c.name === h.chain);
2167
2192
  if (!ch) continue;
2168
- const steps = hi - lo + 2;
2169
- for (const b of ch.bones) {
2170
- const tg = targets.get(b);
2171
- if (!tg) continue;
2172
- const startA = tg[lo - 1]!;
2173
- const total = wrap(tg[hi + 1]! - startA);
2174
- for (let k = lo; k <= hi; k++) {
2175
- tg[k] = wrap(startA + ((k - lo + 1) / steps) * total);
2176
- }
2177
- }
2193
+ interpolateChainInJointSpace(ch, lo - 1, hi + 1);
2178
2194
  }
2179
2195
  }
2180
- // #188 / #191: Cover the full shown/bridged span of symmetric sets, support-arm sets, and head-anchored chains after short interpolation.
2196
+ // #188 / #191 / #206: Cover the full shown/bridged span of symmetric sets, support-arm sets, and head-anchored chains after short interpolation.
2181
2197
  for (let sIdx = 0; sIdx < sw.switches.length; sIdx++) {
2182
2198
  const s = sw.switches[sIdx]!;
2183
2199
  const set = map.alternates.find((a) => a.name === s.set)!;
@@ -2237,12 +2253,16 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
2237
2253
  for (const cn of set.chains) {
2238
2254
  const c = map.chains.find((x) => x.name === cn);
2239
2255
  if (!c) continue;
2240
- for (const b of c.bones) {
2256
+ for (let bIdx = 0; bIdx < c.bones.length; bIdx++) {
2257
+ const b = c.bones[bIdx]!;
2241
2258
  const tg = targets.get(b)!;
2242
- const startA = tg[loRamp]!;
2243
- const total = wrap(tg[loShow]! - startA);
2259
+ const parentTg = bIdx > 0 ? targets.get(c.bones[bIdx - 1]!) : undefined;
2260
+ const startA = parentTg !== undefined ? wrap(tg[loRamp]! - parentTg[loRamp]!) : tg[loRamp]!;
2261
+ const endA = parentTg !== undefined ? wrap(tg[loShow]! - parentTg[loShow]!) : tg[loShow]!;
2262
+ const total = wrap(endA - startA);
2244
2263
  for (let k = loRamp + 1; k < loShow; k++) {
2245
- tg[k] = wrap(startA + ((k - loRamp) / steps) * total);
2264
+ const localA = wrap(startA + ((k - loRamp) / steps) * total);
2265
+ tg[k] = parentTg !== undefined ? wrap(parentTg[k]! + localA) : localA;
2246
2266
  }
2247
2267
  }
2248
2268
  }
@@ -2257,23 +2277,43 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
2257
2277
  const [cR, cL] = S0_0[0] <= S0_1[0] ? [c0, c1] : [c1, c0];
2258
2278
  const bR0 = cR.bones[0]!;
2259
2279
  const bL0 = cL.bones[0]!;
2280
+ const bR1 = cR.bones[1]!;
2281
+ const bL1 = cL.bones[1]!;
2260
2282
  const SR0 = rigPoint(rest, bR0)!;
2261
- const ER0 = rigPoint(rest, cR.bones[1]!)!;
2283
+ const ER0 = rigPoint(rest, bR1)!;
2262
2284
  const SL0 = rigPoint(rest, bL0)!;
2263
- const EL0 = rigPoint(rest, cL.bones[1]!)!;
2285
+ const EL0 = rigPoint(rest, bL1)!;
2264
2286
  const lR0 = Math.hypot(ER0[0] - SR0[0], ER0[1] - SR0[1]);
2265
2287
  const lL0 = Math.hypot(EL0[0] - SL0[0], EL0[1] - SL0[1]);
2266
2288
  const dR0 = wrap(Math.atan2(ER0[1] - SR0[1], ER0[0] - SR0[0]) * DEG - restOf.get(bR0)!);
2267
2289
  const dL0 = wrap(Math.atan2(EL0[1] - SL0[1], EL0[0] - SL0[0]) * DEG - restOf.get(bL0)!);
2268
- for (let i = s.frames[1] + 1; i <= hiShow; i++) {
2290
+ const iEnd = s.frames[1];
2291
+ const iNext = Math.min(n - 1, hiShow + 1);
2292
+ const spanOut = Math.max(1, iNext - iEnd);
2293
+ const startR0 = targets.get(bR0)![iEnd]!;
2294
+ const startL0 = targets.get(bL0)![iEnd]!;
2295
+ const drawnR0 = set.drawn[bR0] ?? startR0;
2296
+ const drawnL0 = set.drawn[bL0] ?? startL0;
2297
+ const drawnR1 = set.drawn[bR1] ?? targets.get(bR1)![iEnd]!;
2298
+ const drawnL1 = set.drawn[bL1] ?? targets.get(bL1)![iEnd]!;
2299
+ const inR1 = wrap(drawnR1 + (set.turn[bR1]?.[0] ?? 0));
2300
+ const inL1 = wrap(drawnL1 + 0.6 * (set.turn[bL1]?.[1] ?? 0));
2301
+ const endR0 = targets.get(bR0)![iNext]!;
2302
+ const endL0 = targets.get(bL0)![iNext]!;
2303
+ for (let i = iEnd + 1; i <= hiShow; i++) {
2304
+ const u = (i - iEnd) / spanOut;
2305
+ const w = 1 - u;
2306
+ const alpha = 0.25 * u + 0.75 * u * u;
2269
2307
  const sr = posedOrigin(bR0, i);
2270
2308
  const sl = posedOrigin(bL0, i);
2271
- const baseR = targets.get(bR0)![i]!;
2272
- const baseL = targets.get(bL0)![i]!;
2273
- let lo = -20;
2274
- let hi = 20;
2309
+ const baseR = wrap(startR0 + alpha * wrap(endR0 - startR0));
2310
+ const baseL = wrap(startL0 + alpha * wrap(endL0 - startL0));
2311
+ let lo = -30;
2312
+ let hi = 30;
2313
+ const dR = (deg: number) => (deg < 0 ? 0.25 * deg : deg);
2314
+ const dL = (deg: number) => (deg > 0 ? 0.25 * deg : deg);
2275
2315
  const evalD = (deg: number) =>
2276
- sl[1] + lL0 * Math.sin((baseL + deg + dL0) / DEG) - (sr[1] + lR0 * Math.sin((baseR + deg + dR0) / DEG));
2316
+ 0.4 * w * (sl[1] - sr[1]) + lL0 * Math.sin((baseL + dL(deg) + dL0) / DEG) - lR0 * Math.sin((baseR + dR(deg) + dR0) / DEG);
2277
2317
  let fLo = evalD(lo);
2278
2318
  for (let it = 0; it < 32; it++) {
2279
2319
  const mid = 0.5 * (lo + hi);
@@ -2284,24 +2324,50 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
2284
2324
  } else hi = mid;
2285
2325
  }
2286
2326
  const deg = 0.5 * (lo + hi);
2287
- targets.get(bR0)![i] = wrap(baseR + deg);
2288
- targets.get(bL0)![i] = wrap(baseL + deg);
2327
+ const aR0 = wrap(baseR + dR(deg));
2328
+ const aL0 = wrap(baseL + dL(deg));
2329
+ targets.get(bR0)![i] = aR0;
2330
+ targets.get(bL0)![i] = aL0;
2331
+ targets.get(bR1)![i] = wrap(inR1 + 0.45 * wrap(aR0 - drawnR0));
2332
+ targets.get(bL1)![i] = wrap(inL1 + 0.45 * wrap(aL0 - drawnL0));
2289
2333
  }
2290
2334
  }
2291
2335
  }
2292
2336
  }
2337
+ if (set.anchor === undefined && set.symmetric !== true && set.support === undefined && loShow < s.frames[0]) {
2338
+ for (const cn of s.chains) {
2339
+ const c = map.chains.find((x) => x.name === cn);
2340
+ if (!c || c.bones.length < 2) continue;
2341
+ const b1 = c.bones[1]!;
2342
+ const t1 = targets.get(b1)!;
2343
+ const entryWorld1 = t1[s.frames[0]]!;
2344
+ for (let i = loShow; i < s.frames[0]; i++) {
2345
+ t1[i] = entryWorld1;
2346
+ }
2347
+ }
2348
+ }
2293
2349
  if (set.support !== undefined) {
2294
2350
  const prevSw = sIdx > 0 ? sw.switches[sIdx - 1] : undefined;
2295
2351
  const loGap = prevSw !== undefined && s.frames[0] - prevSw.frames[1] <= 10 ? prevSw.frames[1] + 1 : loShow;
2296
2352
  for (const cn of s.chains) {
2297
2353
  const c = map.chains.find((x) => x.name === cn)!;
2298
2354
  if (set.support.includes(c.name)) {
2299
- for (let i = Math.min(loGap, loShow); i <= hiShow; i++) solveSupportChainFrame(set, c, i);
2300
- const anchorStart = Math.min(loGap, loShow);
2301
- if (anchorStart >= 2) {
2302
- for (const b of c.bones) {
2355
+ for (let i = loShow; i <= hiShow; i++) solveSupportChainFrame(set, c, i);
2356
+ if (loGap < loShow && loGap >= 1) {
2357
+ const i0 = loGap - 1;
2358
+ const i1 = loShow;
2359
+ const steps = i1 - i0;
2360
+ for (let bIdx = 0; bIdx < c.bones.length; bIdx++) {
2361
+ const b = c.bones[bIdx]!;
2303
2362
  const tg = targets.get(b)!;
2304
- tg[anchorStart - 1] = wrap(tg[anchorStart - 2]! + 0.5 * wrap(tg[anchorStart]! - tg[anchorStart - 2]!));
2363
+ const parentTg = bIdx > 0 ? targets.get(c.bones[bIdx - 1]!) : undefined;
2364
+ const startA = parentTg !== undefined ? wrap(tg[i0]! - parentTg[i0]!) : tg[i0]!;
2365
+ const endA = parentTg !== undefined ? wrap(tg[i1]! - parentTg[i1]!) : tg[i1]!;
2366
+ const total = wrap(endA - startA);
2367
+ for (let k = loGap; k < loShow; k++) {
2368
+ const localA = wrap(startA + ((k - i0) / steps) * total);
2369
+ tg[k] = parentTg !== undefined ? wrap(parentTg[k]! + localA) : localA;
2370
+ }
2305
2371
  }
2306
2372
  }
2307
2373
  } else if (c.bones.length >= 2) {
@@ -2325,6 +2391,18 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
2325
2391
  if (i >= s.frames[0] || outSign * wrap(t0[i]! - pair[0]) < 0) t0[i] = pair[0];
2326
2392
  if (i >= s.frames[0] || outSign * wrap(t1[i]! - pair[1]) < 0) t1[i] = pair[1];
2327
2393
  }
2394
+ if (loGap <= loShow && loGap >= 1 && loShow + 1 <= hiShow) {
2395
+ const i0 = loGap - 1;
2396
+ const i1 = loShow + 1;
2397
+ const steps = i1 - i0;
2398
+ const startLocal = wrap(t1[i0]! - t0[i0]!);
2399
+ const endLocal = wrap(t1[i1]! - t0[i1]!);
2400
+ const totalLocal = wrap(endLocal - startLocal);
2401
+ for (let k = loGap; k <= loShow; k++) {
2402
+ const localA = wrap(startLocal + ((k - i0) / steps) * totalLocal);
2403
+ t1[k] = wrap(t0[k]! + localA);
2404
+ }
2405
+ }
2328
2406
  }
2329
2407
  }
2330
2408
  }
@@ -2350,9 +2428,11 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
2350
2428
  }
2351
2429
  const solvedKey = (b: string, i: number) => legPoses?.[i]!.solved.get(b);
2352
2430
 
2353
- // #190: couple the vertical residual removed by the neck-length clamp into head.rotate
2354
- // in phase (+NOD_RESIDUAL_HEAD_GAIN °/unit), adding 0 on frames where the clamp did not act.
2355
- if (headNodReading !== null) {
2431
+ // #190 / #205: when the neck-length clamp acts on the sequence, couple the clamped
2432
+ // head.translate.y (`clampedY`, the signal the gains were calibrated against) into
2433
+ // head.rotate (+NOD_RESIDUAL_HEAD_GAIN °/unit) and pitch_dial.rotate (+NOD_RESIDUAL_PITCH_GAIN °/unit).
2434
+ const nodCoupled = headNodReading !== null && headNodReading.residual.some((r) => r !== 0) ? headNodReading.clampedY : null;
2435
+ if (headNodReading !== null && nodCoupled !== null) {
2356
2436
  const hb = headNodReading.bone;
2357
2437
  const tg = targets.get(hb);
2358
2438
  if (tg !== undefined) {
@@ -2361,9 +2441,9 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
2361
2441
  const limits = hc?.range[hb];
2362
2442
  const cov = hc !== undefined ? covered.get(hc.name) : undefined;
2363
2443
  for (let i = 0; i < n; i++) {
2364
- const res = headNodReading.residual[i]!;
2365
- if (res === 0) continue;
2366
- let next = wrap(tg[i]! + NOD_RESIDUAL_HEAD_GAIN * res);
2444
+ const nodY = nodCoupled[i]!;
2445
+ if (nodY === 0) continue;
2446
+ let next = wrap(tg[i]! + NOD_RESIDUAL_HEAD_GAIN * nodY);
2367
2447
  if (limits !== undefined && cov?.[i] !== true) {
2368
2448
  const key = wrap((next - restOf.get(hb)!) - (above === null ? 0 : turn(above, i)));
2369
2449
  if (overflow(key, limits) > RANGE_SLACK) {
@@ -2410,7 +2490,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
2410
2490
  const h = heldOf('l_ear');
2411
2491
  return 'never' in h ? null : h.at;
2412
2492
  })();
2413
- face = readFace(map.face, series.get('nose')!, series.get('r_eye')!, series.get('l_eye')!, rEar, lEar, headNodReading?.residual ?? null, NOD_RESIDUAL_PITCH_GAIN);
2493
+ face = readFace(map.face, series.get('nose')!, series.get('r_eye')!, series.get('l_eye')!, rEar, lEar, nodCoupled, NOD_RESIDUAL_PITCH_GAIN);
2414
2494
  for (const axis of ['yaw', 'pitch'] as const) {
2415
2495
  const ax = face[axis];
2416
2496
  if (ax === undefined) continue;
package/src/switch.ts CHANGED
@@ -787,14 +787,14 @@ export function selectSwitches(map: JointMap, targets: ReadonlyMap<string, reado
787
787
  }
788
788
  const tight0 = isTightSet(set0, cn);
789
789
  const tight1 = isTightSet(set1, cn);
790
- if (tight0 && set0 !== set1) {
790
+ if (tight0 && set0 !== set1 && g1 - g0 + 1 <= 10) {
791
791
  let i = g0;
792
792
  while (i <= g1 && rawChainMax(set0, cn, i) <= opts.switchTolerance + hyst) {
793
793
  cClamp.get(cn)![i] = set0;
794
794
  i++;
795
795
  }
796
796
  }
797
- if (tight1 && set0 !== set1) {
797
+ if (tight1 && set0 !== set1 && g1 - g0 + 1 <= 10) {
798
798
  let enter1 = g1 + 1;
799
799
  for (let i = g0; i <= g1; i++) {
800
800
  if (rawChainMax(set1, cn, i) <= opts.switchTolerance) {
@@ -809,63 +809,53 @@ export function selectSwitches(map: JointMap, targets: ReadonlyMap<string, reado
809
809
  if (arr[i] !== null) cClamp.get(cn)![i] = arr[i]!;
810
810
  }
811
811
  }
812
- if (!tight0 && set0 !== set1) {
813
- const c = map.chains.find((x) => x.name === cn);
814
- if (c !== undefined) {
815
- let k = sw0.frames[1];
816
- while (
817
- k >= sw0.frames[0] &&
818
- c.bones.some((b) => {
819
- const lim = set0.turn[b];
820
- const dr = set0.drawn[b];
821
- const ndr = set1.drawn[b];
822
- if (lim === undefined || dr === undefined || ndr === undefined) return false;
823
- const t = wrap(targets.get(b)![k]! - dr);
824
- return overflow(t, lim) > RANGE_SLACK && Math.abs(wrap(targets.get(b)![k + 1]! - ndr)) < Math.abs(wrap(targets.get(b)![k]! - ndr));
825
- })
826
- ) {
827
- cClamp.get(cn)![k] = null;
828
- k--;
829
- }
830
- }
831
- }
832
812
  cPick.set(cn, arr);
833
813
  }
834
- // #187 Rule 1 & Rule 2: when two gesture sets are adjacent (gap <= 10 frames, or the arm is moving
835
- // toward the next set's pose), unwrap any bone that turned the long way (> 180° through inverted angles)
836
- // onto the short arc and pick one shared switch frame t_switch where the measured path crosses halfway
814
+ // #187 Rule 1 & Rule 2 / #206: when two gesture sets are adjacent (gap <= 10 frames),
815
+ // unwrap any bone that turned the long way (> 180° through inverted angles in its own joint
816
+ // space — world angle for a chain's root bone, parent-local angle for a child bone) onto the
817
+ // short arc and pick one shared switch frame t_switch where the measured path crosses halfway
837
818
  // between set0.drawn and set1.drawn.
838
- const movingTowardNext =
839
- set0 !== set1 &&
840
- distances.get(set1.name)![g1]! < distances.get(set1.name)![g0]! &&
841
- distances.get(set0.name)![g1]! > distances.get(set0.name)![g0]!;
842
- const bridgeDirect = set0 !== set1 && (g1 - g0 + 1 <= 10 || movingTowardNext);
819
+ const bridgeDirect = set0 !== set1 && g1 - g0 + 1 <= 10;
843
820
  let tSwitch = g1 + 1;
844
821
  if (bridgeDirect && switchingChains.length > 0) {
845
- const swBones = switchingChains.flatMap((cn) => map.chains.find((c) => c.name === cn)?.bones ?? []);
846
- for (const b of swBones) {
847
- const tg = targets.get(b) as number[] | undefined;
848
- const d0 = set0.drawn[b];
849
- const d1 = set1.drawn[b];
850
- if (tg === undefined || d0 === undefined || d1 === undefined) continue;
851
- const i0 = sw0.frames[1];
852
- const i1 = sw1.frames[0];
853
- const startA = tg[i0]!;
854
- const endA = tg[i1]!;
855
- const unwrapped: number[] = [startA];
856
- for (let i = i0 + 1; i <= i1; i++) {
857
- unwrapped.push(unwrapped[unwrapped.length - 1]! + wrap(tg[i]! - tg[i - 1]!));
858
- }
859
- const totalTurn = unwrapped[unwrapped.length - 1]! - startA;
860
- const shortTurn = wrap(endA - startA);
861
- const invertsBetween = Array.from({ length: g1 - g0 + 1 }, (_, k) => {
862
- const a = tg[g0 + k]!;
863
- return Math.abs(wrap(a - d0)) > 90 && Math.abs(wrap(a - d1)) > 90;
864
- }).some(Boolean);
865
- if (Math.abs(totalTurn) > 180 && Math.abs(totalTurn - shortTurn) > 1e-6 && invertsBetween && Math.abs(shortTurn) <= 150) {
866
- for (let i = g0; i <= g1; i++) {
867
- const p = (unwrapped[i - i0]! - startA) / totalTurn;
868
- tg[i] = wrap(startA + p * shortTurn);
822
+ for (const cn of switchingChains) {
823
+ const ch = map.chains.find((c) => c.name === cn);
824
+ if (!ch) continue;
825
+ for (let bIdx = 0; bIdx < ch.bones.length; bIdx++) {
826
+ const b = ch.bones[bIdx]!;
827
+ const parentBone = bIdx > 0 ? ch.bones[bIdx - 1] : undefined;
828
+ const tg = targets.get(b) as number[] | undefined;
829
+ const parentTg = parentBone !== undefined ? targets.get(parentBone) : undefined;
830
+ const d0World = set0.drawn[b];
831
+ const d1World = set1.drawn[b];
832
+ if (tg === undefined || d0World === undefined || d1World === undefined) continue;
833
+ const pd0 = parentBone !== undefined ? set0.drawn[parentBone] : undefined;
834
+ const pd1 = parentBone !== undefined ? set1.drawn[parentBone] : undefined;
835
+ const jointAngle = (i: number): number =>
836
+ parentTg !== undefined ? wrap(tg[i]! - parentTg[i]!) : tg[i]!;
837
+ const d0 = pd0 !== undefined ? wrap(d0World - pd0) : d0World;
838
+ const d1 = pd1 !== undefined ? wrap(d1World - pd1) : d1World;
839
+ const i0 = sw0.frames[1];
840
+ const i1 = sw1.frames[0];
841
+ const startA = jointAngle(i0);
842
+ const endA = jointAngle(i1);
843
+ const unwrapped: number[] = [startA];
844
+ for (let i = i0 + 1; i <= i1; i++) {
845
+ unwrapped.push(unwrapped[unwrapped.length - 1]! + wrap(jointAngle(i) - jointAngle(i - 1)));
846
+ }
847
+ const totalTurn = unwrapped[unwrapped.length - 1]! - startA;
848
+ const shortTurn = wrap(endA - startA);
849
+ const invertsBetween = Array.from({ length: g1 - g0 + 1 }, (_, k) => {
850
+ const a = jointAngle(g0 + k);
851
+ return Math.abs(wrap(a - d0)) > 90 && Math.abs(wrap(a - d1)) > 90;
852
+ }).some(Boolean);
853
+ if (Math.abs(totalTurn) > 180 && Math.abs(totalTurn - shortTurn) > 1e-6 && invertsBetween && Math.abs(shortTurn) <= 150) {
854
+ for (let i = g0; i <= g1; i++) {
855
+ const p = (unwrapped[i - i0]! - startA) / totalTurn;
856
+ const localA = wrap(startA + p * shortTurn);
857
+ tg[i] = parentTg !== undefined ? wrap(parentTg[i]! + localA) : localA;
858
+ }
869
859
  }
870
860
  }
871
861
  }
@@ -904,7 +894,8 @@ export function selectSwitches(map: JointMap, targets: ReadonlyMap<string, reado
904
894
  ? pairs.reduce((s, b) => s + Math.abs(wrap(effAngle(b, i) - set1.drawn[b]!)), 0) / pairs.length
905
895
  : (distances.get(set1.name)?.[i] ?? Infinity);
906
896
  const rootsCrossed =
907
- activeSwChains.length === switchingChains.length ||
897
+ switchingChains.length === 1 ||
898
+ rootBones.length === 0 ||
908
899
  rootBones.every((b) => Math.abs(wrap(effAngle(b, i) - set1.drawn[b]!)) <= Math.abs(wrap(effAngle(b, i) - set0.drawn[b]!)));
909
900
  if ((d1 <= d0 || (span > 1e-6 && d0 >= 0.5 * span)) && rootsCrossed) {
910
901
  tSwitch = i;
@@ -912,6 +903,9 @@ export function selectSwitches(map: JointMap, targets: ReadonlyMap<string, reado
912
903
  }
913
904
  }
914
905
  }
906
+ const syncBridgeClamp = (cn: string, i: number, picked: AlternateSet) => {
907
+ if (isTightSet(picked, cn)) cClamp.get(cn)![i] = picked;
908
+ };
915
909
  const anyHeldBehind = switchingChains.some((cn) => {
916
910
  const arr = cPick.get(cn)!;
917
911
  for (let i = g0; i <= g1; i++) if (arr[i] === null) return false;
@@ -924,7 +918,11 @@ export function selectSwitches(map: JointMap, targets: ReadonlyMap<string, reado
924
918
  });
925
919
  if (bridgeDirect) {
926
920
  for (const cn of switchingChains) {
927
- for (let i = g0; i <= g1; i++) cPick.get(cn)![i] = i < tSwitch ? set0 : set1;
921
+ for (let i = g0; i <= g1; i++) {
922
+ const picked = i < tSwitch ? set0 : set1;
923
+ cPick.get(cn)![i] = picked;
924
+ syncBridgeClamp(cn, i, picked);
925
+ }
928
926
  }
929
927
  } else if (!frontNear) for (const cn of chains) for (let i = g0; i <= g1; i++) cPick.get(cn)![i] = set0;
930
928
  } else {
@@ -980,7 +978,11 @@ export function selectSwitches(map: JointMap, targets: ReadonlyMap<string, reado
980
978
  }
981
979
  if (bridgeDirect) {
982
980
  for (const cn of switchingChains) {
983
- for (let i = g0; i <= g1; i++) cPick.get(cn)![i] = i < tSwitch ? set0 : set1;
981
+ for (let i = g0; i <= g1; i++) {
982
+ const picked = i < tSwitch ? set0 : set1;
983
+ cPick.get(cn)![i] = picked;
984
+ syncBridgeClamp(cn, i, picked);
985
+ }
984
986
  }
985
987
  }
986
988
  }
@@ -1224,7 +1226,7 @@ export const ART_EASE_STEP = 3;
1224
1226
  export function clampArt(switches: readonly Switch[], map: JointMap, targets: Map<string, number[]>, chainHolds?: ReadonlyArray<{ set: string; chain: string; frames: [number, number] }>): void {
1225
1227
  const boneChain = new Map<string, string>();
1226
1228
  for (const c of map.chains) for (const b of c.bones) boneChain.set(b, c.name);
1227
- const clampSpan = (set: AlternateSet, bone: string, [a, b0]: [number, number]) => {
1229
+ const clampSpan = (set: AlternateSet, bone: string, [a, b]: [number, number]) => {
1228
1230
  const limits = set.turn[bone];
1229
1231
  if (limits === undefined) return;
1230
1232
  const drawn = set.drawn[bone]!;
@@ -1236,28 +1238,14 @@ export function clampArt(switches: readonly Switch[], map: JointMap, targets: Ma
1236
1238
  return Math.abs(c - t) > RANGE_SLACK ? c - t : 0;
1237
1239
  };
1238
1240
  const tightHold = limits[1] - limits[0] <= 10;
1239
- let b = b0;
1240
- let trimmedExit = false;
1241
- if (!tightHold && b + 1 < n && delta(b) !== 0) {
1242
- const nextSw = switches.find((s) => s.frames[0] > b && s.frames[0] - b <= 10 && s.set !== set.name);
1243
- const nextDrawn = nextSw !== undefined ? map.alternates.find((x) => x.name === nextSw.set)?.drawn[bone] : undefined;
1244
- if (nextDrawn !== undefined) {
1245
- const distNext = (i: number) => Math.abs(wrap(target[i]! - nextDrawn));
1246
- if (distNext(b + 1) < distNext(b)) {
1247
- while (b >= a && delta(b) !== 0 && (b === a || distNext(b) < distNext(b - 1))) {
1248
- b--;
1249
- trimmedExit = true;
1250
- }
1251
- }
1252
- }
1253
- }
1254
- if (a > b) return;
1241
+ const prevNearSw = switches.some((s) => s.frames[1] < a && a - s.frames[1] <= 11);
1242
+ const nextNearSw = switches.some((s) => s.frames[0] > b && s.frames[0] - b <= 11);
1255
1243
  const da = delta(a);
1256
1244
  const db = delta(b);
1257
- const easeIn = a > 0 && da !== 0 && Math.sign(delta(a - 1)) === Math.sign(da) && !tightHold
1245
+ const easeIn = a > 0 && da !== 0 && Math.sign(delta(a - 1)) === Math.sign(da) && !(tightHold && prevNearSw)
1258
1246
  ? Math.max(ART_EASE_FRAMES, Math.ceil(Math.abs(da) / ART_EASE_STEP) - 1)
1259
1247
  : 0;
1260
- const easeOut = b + 1 < n && db !== 0 && Math.sign(delta(b + 1)) === Math.sign(db) && !tightHold && !trimmedExit
1248
+ const easeOut = b + 1 < n && db !== 0 && Math.sign(delta(b + 1)) === Math.sign(db) && !(tightHold && nextNearSw)
1261
1249
  ? Math.max(ART_EASE_FRAMES, Math.ceil(Math.abs(db) / ART_EASE_STEP) - 1)
1262
1250
  : 0;
1263
1251
  for (let i = a; i <= b; i++) {