@rig-c/pose 0.25.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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)): `{ "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), 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)): `{ "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), 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`
@@ -349,10 +349,12 @@ Bone rotation cannot carry every pose: an arm behind the head, a forearm toward
349
349
 
350
350
  - `slots`: what each slot of the set shows while it shows; each attachment must be in that slot under some skin of the rig, as rig-c built it. `hides`: the slots that are empty while it shows. A slot's place in the draw order is the rig's own, so a drawing that must sit in front of the torso is an attachment in a slot that is: a switch is attachment keys only, and no draw-order key is written.
351
351
  - `chains`: the map's chains whose pose the set depicts; it is selected by them. `pose`: the key skeleton the art was painted from, body-18 (or `mid_hip`/`mid_shoulder`) in picture px, y down, any scale and place — only its directions are read; every joint the listed chains use must be in it.
352
- - `drawn` (optional): each bone's world angle (Spine degrees) as painted. It changes no key: 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).
352
+ - `drawn` (optional): each bone's world angle (Spine degrees) as painted. It changes no key unless the set declares `symmetric` or `support` ([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
- - 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`.
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`).
357
+ - 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` and `MAP_ALT_SUPPORT`.
356
358
 
357
359
  **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
360
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/pose",
3
- "version": "0.25.0",
3
+ "version": "0.27.0",
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/face.ts CHANGED
@@ -123,6 +123,37 @@ function corrOf(a: readonly number[], b: readonly number[]): number {
123
123
  return den > 1e-9 ? r3(num / den) : 1;
124
124
  }
125
125
 
126
+ /**
127
+ * Eye-span foreshortening entry threshold `de < EAR_CHORD_K_DE * median(de)` (`rig-c/pose#189`):
128
+ * derived from $\cos(31.79^\circ) = 0.85$ ($\approx 32^\circ$ head turn), the foreshortening angle
129
+ * beyond which the near-planar 2D nose point begins to sit trapped between the foreshortened eyes.
130
+ */
131
+ export const EAR_CHORD_K_DE = 0.85;
132
+
133
+ /**
134
+ * Eye-span foreshortening full-weight threshold `de <= EAR_CHORD_K_LO * median(de)` (`rig-c/pose#189`):
135
+ * derived from $\cos(36.87^\circ) = 0.80$ ($4/5$, $\approx 37^\circ$ head turn), where the foreshortening
136
+ * blend weight `w = clamp((EAR_CHORD_K_DE * de0 - de) / ((EAR_CHORD_K_DE - EAR_CHORD_K_LO) * de0), 0, 1)`
137
+ * reaches `1.0` so the exit from a foreshortened side gaze ramps smoothly instead of snapping at `EAR_CHORD_K_DE`.
138
+ */
139
+ export const EAR_CHORD_K_LO = 0.8;
140
+
141
+ /**
142
+ * Ear-chord to nose-cue disagreement ratio `|xea| > EAR_CHORD_K_RATIO * |xne|` (`rig-c/pose#189`):
143
+ * derived from the 95th-percentile of `|xea| / |xne|` on confident frames (`|de - median(de)| <= 15%`)
144
+ * above `EAR_CHORD_K_MIN`, measured at `p95 = 1.7352` on Dancer (`37` frames) and `p95 = 1.1937`
145
+ * (`p100 = 1.2867`) on Toca (`58` frames) -> `1.8`.
146
+ */
147
+ export const EAR_CHORD_K_RATIO = 1.8;
148
+
149
+ /**
150
+ * Minimum ear-to-eye-midpoint offset `|xea| > EAR_CHORD_K_MIN` (`rig-c/pose#189`):
151
+ * derived from the 100th-percentile (maximum) detector ear-placement noise `|xea - xne|` on
152
+ * confident frames (`|de - median(de)| <= 15%`), measured at `p99 = 0.0949, p100 = 0.1094` on Dancer
153
+ * (`231` frames) and `p99 = 0.1178, p100 = 0.1450` on Toca (`373` frames) -> `0.15` (`16.70°`).
154
+ */
155
+ export const EAR_CHORD_K_MIN = 0.15;
156
+
126
157
  /**
127
158
  * Read per-frame yaw and pitch (degrees) from `nose`, `r_eye`, `l_eye`, and
128
159
  * (when both are present) `r_ear` and `l_ear`.
@@ -140,8 +171,10 @@ export function readFaceAngles(
140
171
  ): { ears: boolean; yaw: number[]; pitch: number[] } {
141
172
  const n = nose.length;
142
173
  const ears = rEar !== null && lEar !== null && rEar.length === n && lEar.length === n;
174
+ const des: number[] = [];
143
175
  const xne: number[] = [];
144
176
  const xna: number[] = [];
177
+ const xea: number[] = [];
145
178
  const yne: number[] = [];
146
179
  const yna: number[] = [];
147
180
 
@@ -153,14 +186,17 @@ export function readFaceAngles(
153
186
  const ey = El[1] - Er[1];
154
187
  const de = Math.hypot(ex, ey);
155
188
  if (!(de > 1e-9)) {
189
+ des.push(des[i - 1] ?? 0);
156
190
  xne.push(xne[i - 1] ?? 0);
157
191
  yne.push(yne[i - 1] ?? 0);
158
192
  if (ears) {
159
193
  xna.push(xna[i - 1] ?? 0);
194
+ xea.push(xea[i - 1] ?? 0);
160
195
  yna.push(yna[i - 1] ?? 0);
161
196
  }
162
197
  continue;
163
198
  }
199
+ des.push(de);
164
200
  const ux = ex / de;
165
201
  const uy = ey / de;
166
202
  const vx = -uy;
@@ -176,18 +212,31 @@ export function readFaceAngles(
176
212
  const may = 0.5 * (Ar[1] + Al[1]);
177
213
  const da = Math.max(de, (Al[0] - Ar[0]) * ux + (Al[1] - Ar[1]) * uy);
178
214
  xna.push(((max - N[0]) * ux + (may - N[1]) * uy) / da);
215
+ xea.push(((max - mex) * ux + (may - mey) * uy) / da);
179
216
  yna.push(((N[0] - max) * vx + (N[1] - may) * vy) / de);
180
217
  }
181
218
  }
182
219
 
220
+ const de0 = n > 0 ? median(des) : 0;
183
221
  const yne0 = median(yne);
184
222
  const yna0 = ears ? median(yna) : 0;
185
223
  const yaw: number[] = [];
186
224
  const pitch: number[] = [];
187
225
  for (let i = 0; i < n; i++) {
188
- const ty = ears ? (2 * xne[i]! + xna[i]!) / 2 : 2 * xne[i]!;
226
+ const earChord =
227
+ ears &&
228
+ de0 > 1e-9 &&
229
+ des[i]! < EAR_CHORD_K_DE * de0 &&
230
+ Math.abs(xea[i]!) > EAR_CHORD_K_RATIO * Math.abs(xne[i]!) &&
231
+ Math.abs(xea[i]!) > EAR_CHORD_K_MIN;
232
+ const w = earChord
233
+ ? EAR_CHORD_K_DE > EAR_CHORD_K_LO
234
+ ? Math.max(0, Math.min(1, (EAR_CHORD_K_DE * de0 - des[i]!) / ((EAR_CHORD_K_DE - EAR_CHORD_K_LO) * de0)))
235
+ : 1
236
+ : 0;
237
+ const rawYaw = Math.atan(ears ? (2 * xne[i]! + xna[i]!) / 2 : 2 * xne[i]!) * DEG;
189
238
  const tp = ears ? (yne[i]! - yne0 + (yna[i]! - yna0)) / 2 : yne[i]! - yne0;
190
- yaw.push(Math.atan(ty) * DEG);
239
+ yaw.push(w > 0 ? w * (Math.atan(2 * xea[i]!) * DEG) + (1 - w) * rawYaw : rawYaw);
191
240
  pitch.push(Math.atan(tp) * DEG);
192
241
  }
193
242
  return { ears, yaw, pitch };
package/src/map.ts CHANGED
@@ -100,6 +100,10 @@ export interface AlternateSet {
100
100
  frames: Array<[number, number]> | null;
101
101
  /** `place: "by-hip"` (rig-c/pose#93): the chains whose wrist `pose` puts within one hip width of the mid-hip, their wrists placed by the offset from the hip while the set shows. Absent or null: the pose's angles. */
102
102
  place?: { by: 'hip'; chains: string[] } | null;
103
+ /** `symmetric: true` (rig-c/pose#188): normalise the set's two two-bone arm chains to the drawn symmetry centre and keep their wrists inside the head silhouette while the set shows. */
104
+ symmetric?: boolean;
105
+ /** `support` (rig-c/pose#188): the set's chains whose bones anchor to the set's `drawn` local angles while the non-support chains track the active gesture. */
106
+ support?: string[];
103
107
  }
104
108
  export interface YawSet {
105
109
  set: string;
@@ -435,7 +439,7 @@ function readAlternates(p: Problems, where: string, raw: unknown, chains: readon
435
439
  p.add('MAP_ALT_SHAPE', `${aw} is ${typeName(a)}; a set is ${SET_SHAPE}`);
436
440
  return;
437
441
  }
438
- refuseUnknownKeys(p, 'MAP_KEYS', aw, a, ['name', 'note', 'slots', 'hides', 'chains', 'pose', 'drawn', 'offsets', 'turn', 'frames', 'place']);
442
+ refuseUnknownKeys(p, 'MAP_KEYS', aw, a, ['name', 'note', 'slots', 'hides', 'chains', 'pose', 'drawn', 'offsets', 'turn', 'frames', 'place', 'symmetric', 'support']);
439
443
  const name = typeof a.name === 'string' && a.name !== '' ? a.name : null;
440
444
  if (name === null) p.add('MAP_ALT_NAME', `${aw} "name" is ${typeName(a.name)}; it must be a non-empty string`);
441
445
  else if (names.has(name)) p.add('MAP_ALT_NAME', `${aw} "name" "${name}" names two sets`);
@@ -553,7 +557,52 @@ function readAlternates(p: Problems, where: string, raw: unknown, chains: readon
553
557
  else place = { by: 'hip', chains: by.chains };
554
558
  }
555
559
  }
556
- if (ok && name !== null && cs !== null) out.push({ name, slots, hides, chains: cs, pose, drawn, offsets, turn, frames, ...(place === null ? {} : { place }) });
560
+ let symmetric: boolean | undefined;
561
+ if ('symmetric' in a) {
562
+ if (a.symmetric !== true) {
563
+ p.add('MAP_ALT_SYMMETRIC', `${label} "symmetric" is ${JSON.stringify(a.symmetric)}; it must be true`);
564
+ ok = false;
565
+ } else if (own.length !== 2 || !own.every((c) => c.bones.length === 2)) {
566
+ p.add('MAP_ALT_SYMMETRIC', `${label} "symmetric" is true, and its chains are ${ownList}; a symmetric set normalises a pair of two-bone arm chains`);
567
+ ok = false;
568
+ } else {
569
+ const missing = ownBones.filter((b) => !(b in drawn));
570
+ if (missing.length > 0) {
571
+ p.add('MAP_ALT_SYMMETRIC', `${label} "symmetric" is true, and "drawn" has no ${missing.map((b) => `"${b}"`).join(', ')}; a symmetric set normalises both chains around their drawn angles`);
572
+ ok = false;
573
+ } else symmetric = true;
574
+ }
575
+ }
576
+ let support: string[] | undefined;
577
+ if ('support' in a) {
578
+ const sup = typeof a.support === 'string' && a.support !== '' ? [a.support] : strings(a.support);
579
+ if (sup === null || sup.length === 0 || sup.some((s) => s === '')) {
580
+ p.add('MAP_ALT_SUPPORT', `${label} "support" is ${JSON.stringify(a.support)}; it must name one or more of the set's chains (${(cs ?? []).join(', ') || 'none'})`);
581
+ ok = false;
582
+ } else {
583
+ if (new Set(sup).size !== sup.length) {
584
+ p.add('MAP_ALT_SUPPORT', `${label} "support" names a chain twice: ${sup.join(', ')}`);
585
+ ok = false;
586
+ }
587
+ const outside = sup.filter((c) => !(cs ?? []).includes(c));
588
+ if (outside.length > 0) {
589
+ p.add('MAP_ALT_SUPPORT', `${label} "support" names chain ${outside.map((c) => `"${c}"`).join(', ')}, which is not in the set's chains (${(cs ?? []).join(', ') || 'none'})`);
590
+ ok = false;
591
+ }
592
+ if (a.symmetric === true) {
593
+ p.add('MAP_ALT_SUPPORT', `${label} names both "symmetric" and "support"; a set either normalises both arms symmetrically or anchors a support arm`);
594
+ ok = false;
595
+ }
596
+ const supBones = own.filter((c) => sup.includes(c.name)).flatMap((c) => c.bones);
597
+ const missing = supBones.filter((b) => !(b in drawn));
598
+ if (missing.length > 0) {
599
+ p.add('MAP_ALT_SUPPORT', `${label} "support" chain has no "drawn" angle for ${missing.map((b) => `"${b}"`).join(', ')}; a support chain anchors to its drawn angles`);
600
+ ok = false;
601
+ }
602
+ if (ok) support = sup;
603
+ }
604
+ }
605
+ if (ok && name !== null && cs !== null) out.push({ name, slots, hides, chains: cs, pose, drawn, offsets, turn, frames, ...(place === null ? {} : { place }), ...(symmetric === undefined ? {} : { symmetric }), ...(support === undefined ? {} : { support }) });
557
606
  });
558
607
  return out;
559
608
  }
package/src/retarget.ts CHANGED
@@ -1157,6 +1157,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1157
1157
  handOffsets.set(c.name, arr);
1158
1158
  }
1159
1159
  }
1160
+ const rawTargets = new Map<string, number[]>([...targets].map(([b, arr]) => [b, [...arr]]));
1160
1161
  const sw = selectSwitches(map, targets, keyTimes, keyTimes[n - 1]!, slots, { ...opts, shortInfo, handOffsets });
1161
1162
 
1162
1163
  // The range clamp, outside the spans of a set depicting the bone's chain. Parents
@@ -1326,6 +1327,236 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1326
1327
  const raisedJobs: Array<{ anchor: Anchor; sw: Switch; chain: Chain; wrist: (i: number) => Pt; before: Pt[]; reference: Pt[]; placed: Pt[]; refEye: number; rigEye: number; short: Array<{ i: number; how: 'far' | 'near' | 'missed'; shortfall: number }>; upperHeld: number[]; l0: number; l1: number; farthest: number }> = [];
1327
1328
  const headOk: Anchor | null = head !== null && !('problem' in head) ? { kind: 'head', joints: head.joints, points: head.points } : null;
1328
1329
  const hipsOk: Anchor | null = hips !== null && !('problem' in hips) ? { kind: 'hip', joints: hips.joints, points: hips.points } : null;
1330
+ const fallbackHead = headOf(map);
1331
+ const gestureHead: Anchor | null = headOk ?? (!('problem' in fallbackHead) ? { kind: 'head', joints: fallbackHead.joints, points: fallbackHead.points } : null);
1332
+ /**
1333
+ * #188: For an active raised chain in a set that declares a `support` arm, compute the
1334
+ * outward-cleared world angles `[a0, a1]` on frame `i` from the scan's 2D shoulder→wrist
1335
+ * direction and the head/bun clearance.
1336
+ */
1337
+ const solveSupportActiveFrame = (set: (typeof map.alternates)[number], c: Chain, i: number): [number, number] | null => {
1338
+ if (c.bones.length < 2 || c.joints.length < 3) return null;
1339
+ const [b0, b1] = c.bones as [string, string];
1340
+ const drawn0 = set.drawn[b0];
1341
+ const drawn1 = set.drawn[b1];
1342
+ if (drawn0 === undefined || drawn1 === undefined || !isRaised(drawn0)) return null;
1343
+ const S0 = rigPoint(rest, b0);
1344
+ const E0 = rigPoint(rest, b1);
1345
+ const wSpec = c.aim[1] === 'x' ? `${b1}:tip` : c.aim[1]!;
1346
+ const W0 = rigPoint(rest, wSpec);
1347
+ if (S0 === null || E0 === null || W0 === null) return null;
1348
+ const l0 = Math.hypot(E0[0] - S0[0], E0[1] - S0[1]);
1349
+ const l1 = Math.hypot(W0[0] - E0[0], W0[1] - E0[1]);
1350
+ let rigEye0 = 0.32 * l0;
1351
+ let headX0 = 0;
1352
+ if (gestureHead !== null) {
1353
+ const ha0 = rigPoint(rest, gestureHead.points[0]);
1354
+ const hb0 = rigPoint(rest, gestureHead.points[1]);
1355
+ if (ha0 !== null && hb0 !== null) {
1356
+ const d = Math.hypot(hb0[0] - ha0[0], hb0[1] - ha0[1]);
1357
+ if (d > 1e-6) {
1358
+ rigEye0 = d;
1359
+ headX0 = (ha0[0] + hb0[0]) / 2;
1360
+ }
1361
+ }
1362
+ }
1363
+ const Ex = S0[0] + l0 * Math.cos(drawn0 / DEG);
1364
+ const outSign: 1 | -1 = Ex <= headX0 ? 1 : -1;
1365
+ const clear0 = Math.asin(Math.min(1, (0.55 * rigEye0) / Math.max(1e-6, l0))) * DEG;
1366
+ const clear1 = Math.asin(Math.min(1, (0.90 * rigEye0) / Math.max(1e-6, l1))) * DEG;
1367
+ let swMod = 0;
1368
+ const j0 = c.joints[0]!;
1369
+ const jLast = c.joints[c.joints.length - 1]!;
1370
+ const kp0 = set.pose[j0];
1371
+ const kpLast = set.pose[jLast];
1372
+ const shS = series.get(j0);
1373
+ const wrS = series.get(jLast);
1374
+ if (kp0 !== undefined && kpLast !== undefined && shS !== undefined && wrS !== undefined) {
1375
+ const keySW = pictureAngle(kp0, kpLast);
1376
+ const scanSW = pictureAngle(shS[i]!, wrS[i]!);
1377
+ swMod = Math.max(0, Math.min(6, outSign * wrap(scanSW - keySW)));
1378
+ }
1379
+ const lim0 = set.turn[b0];
1380
+ const lim1 = set.turn[b1];
1381
+ const off0 = outSign * (clear0 + 0.5 * swMod);
1382
+ const off1 = outSign * (clear1 + 0.5 * swMod);
1383
+ const cl0 = lim0 !== undefined ? Math.min(Math.max(off0, lim0[0]), lim0[1]) : off0;
1384
+ const cl1 = lim1 !== undefined ? Math.min(Math.max(off1, lim1[0]), lim1[1]) : off1;
1385
+ return [wrap(drawn0 + cl0), wrap(drawn1 + cl1)];
1386
+ };
1387
+ /**
1388
+ * #188: For a support chain in `set.support`, anchor its bones on frame `i` to `set.drawn`
1389
+ * within a ±2.5° (≤ ±3°) breathing tolerance from the pose's delta relative to the set's
1390
+ * reference pose.
1391
+ */
1392
+ const solveSupportChainFrame = (set: (typeof map.alternates)[number], c: Chain, i: number): void => {
1393
+ for (let k = 0; k < c.bones.length; k++) {
1394
+ const b = c.bones[k]!;
1395
+ const dr = set.drawn[b];
1396
+ if (dr === undefined) continue;
1397
+ const kp0 = c.joints[k] !== undefined ? set.pose[c.joints[k]!] : undefined;
1398
+ const kp1 = c.joints[k + 1] !== undefined ? set.pose[c.joints[k + 1]!] : undefined;
1399
+ const keyAng = kp0 !== undefined && kp1 !== undefined ? pictureAngle(kp0, kp1) : dr;
1400
+ const raw = rawTargets.get(b)?.[i] ?? keyAng;
1401
+ const diff = Math.max(-2.5, Math.min(2.5, 0.1 * wrap(raw - keyAng)));
1402
+ targets.get(b)![i] = wrap(dr + diff);
1403
+ }
1404
+ };
1405
+ /**
1406
+ * #188: For a `set.symmetric === true` gesture set, normalise its two two-bone arm chains
1407
+ * on frame `i` to the set's `drawn` symmetry centre so `|elbow_l.y - elbow_r.y| == 0` and
1408
+ * clamp both forearms so the wrists stay inside the head silhouette.
1409
+ */
1410
+ const solveSymmetricFrame = (set: (typeof map.alternates)[number], i: number): { cR: Chain; cL: Chain; wR: Pt; wL: Pt; hc: Pt; ed: number } | null => {
1411
+ if (set.chains.length !== 2) return null;
1412
+ const c0 = map.chains.find((x) => x.name === set.chains[0]!);
1413
+ const c1 = map.chains.find((x) => x.name === set.chains[1]!);
1414
+ if (c0 === undefined || c1 === undefined || c0.bones.length !== 2 || c1.bones.length !== 2) return null;
1415
+ const S0_0 = rigPoint(rest, c0.bones[0]!);
1416
+ const S0_1 = rigPoint(rest, c1.bones[0]!);
1417
+ if (S0_0 === null || S0_1 === null) return null;
1418
+ const [cR, cL] = S0_0[0] <= S0_1[0] ? [c0, c1] : [c1, c0];
1419
+ const [bR0, bR1] = cR.bones as [string, string];
1420
+ const [bL0, bL1] = cL.bones as [string, string];
1421
+ const wSpecR = cR.aim[1] === 'x' ? `${bR1}:tip` : cR.aim[1]!;
1422
+ const wSpecL = cL.aim[1] === 'x' ? `${bL1}:tip` : cL.aim[1]!;
1423
+ const SR0 = rigPoint(rest, bR0)!;
1424
+ const ER0 = rigPoint(rest, bR1)!;
1425
+ const WR0 = rigPoint(rest, wSpecR)!;
1426
+ const SL0 = rigPoint(rest, bL0)!;
1427
+ const EL0 = rigPoint(rest, bL1)!;
1428
+ const WL0 = rigPoint(rest, wSpecL)!;
1429
+ const lR0 = Math.hypot(ER0[0] - SR0[0], ER0[1] - SR0[1]);
1430
+ const lR1 = Math.hypot(WR0[0] - ER0[0], WR0[1] - ER0[1]);
1431
+ const lL0 = Math.hypot(EL0[0] - SL0[0], EL0[1] - SL0[1]);
1432
+ const lL1 = Math.hypot(WL0[0] - EL0[0], WL0[1] - EL0[1]);
1433
+ const dR0 = wrap(Math.atan2(ER0[1] - SR0[1], ER0[0] - SR0[0]) * DEG - restOf.get(bR0)!);
1434
+ const dR1 = wrap(Math.atan2(WR0[1] - ER0[1], WR0[0] - ER0[0]) * DEG - restOf.get(bR1)!);
1435
+ const dL0 = wrap(Math.atan2(EL0[1] - SL0[1], EL0[0] - SL0[0]) * DEG - restOf.get(bL0)!);
1436
+ const dL1 = wrap(Math.atan2(WL0[1] - EL0[1], WL0[0] - EL0[0]) * DEG - restOf.get(bL1)!);
1437
+ const sr = posedOrigin(bR0, i);
1438
+ const sl = posedOrigin(bL0, i);
1439
+ let hc: Pt = [(sr[0] + sl[0]) / 2, (sr[1] + sl[1]) / 2 + 0.85 * Math.hypot(sl[0] - sr[0], sl[1] - sr[1])];
1440
+ let ed = Math.max(1e-3, 0.30 * Math.hypot(sl[0] - sr[0], sl[1] - sr[1]));
1441
+ if (gestureHead !== null) {
1442
+ const ha = posedPoint(gestureHead.points[0], i);
1443
+ const hb = posedPoint(gestureHead.points[1], i);
1444
+ const d = Math.hypot(hb[0] - ha[0], hb[1] - ha[1]);
1445
+ if (d > 1e-6) {
1446
+ hc = [(ha[0] + hb[0]) / 2, (ha[1] + hb[1]) / 2];
1447
+ ed = d;
1448
+ }
1449
+ }
1450
+ const drawnR0 = set.drawn[bR0]!;
1451
+ const drawnR1 = set.drawn[bR1]!;
1452
+ const drawnL0 = set.drawn[bL0]!;
1453
+ const drawnL1 = set.drawn[bL1]!;
1454
+ const turnR0 = set.turn[bR0] ?? [-20, 20];
1455
+ const turnR1 = set.turn[bR1] ?? [-20, 20];
1456
+ const turnL0 = set.turn[bL0] ?? [-20, 20];
1457
+ const turnL1 = set.turn[bL1] ?? [-20, 20];
1458
+ const rawR0 = rawTargets.get(bR0)?.[i] ?? drawnR0;
1459
+ const rawL0 = rawTargets.get(bL0)?.[i] ?? drawnL0;
1460
+ const offR0 = wrap(rawR0 - drawnR0);
1461
+ const offL0 = wrap(rawL0 - drawnL0);
1462
+ let cOpen = Math.max(-4, Math.min(2, 0.15 * 0.5 * (offR0 - offL0)));
1463
+ const solveTilt = (open: number): { aR0: number; aL0: number; exR: number; eyR: number; exL: number; eyL: number } => {
1464
+ const evalDiff = (deg: number) => {
1465
+ const dR = deg < 0 ? 0.25 * deg : deg;
1466
+ const dL = deg > 0 ? 0.25 * deg : deg;
1467
+ const ar = drawnR0 + Math.min(Math.max(open + dR, turnR0[0]), turnR0[1]);
1468
+ const al = drawnL0 + Math.min(Math.max(-open + dL, turnL0[0]), turnL0[1]);
1469
+ const eyr = sr[1] + lR0 * Math.sin((ar + dR0) / DEG);
1470
+ const eyl = sl[1] + lL0 * Math.sin((al + dL0) / DEG);
1471
+ return eyl - eyr;
1472
+ };
1473
+ let lo = -45;
1474
+ let hi = 45;
1475
+ let fLo = evalDiff(lo);
1476
+ const fHi = evalDiff(hi);
1477
+ let deg: number;
1478
+ if ((fLo <= 0 && fHi <= 0) || (fLo > 0 && fHi > 0)) {
1479
+ deg = Math.abs(fLo) <= Math.abs(fHi) ? lo : hi;
1480
+ } else {
1481
+ for (let it = 0; it < 36; it++) {
1482
+ const mid = 0.5 * (lo + hi);
1483
+ const fMid = evalDiff(mid);
1484
+ if ((fLo <= 0 && fMid <= 0) || (fLo > 0 && fMid > 0)) {
1485
+ lo = mid;
1486
+ fLo = fMid;
1487
+ } else {
1488
+ hi = mid;
1489
+ }
1490
+ }
1491
+ deg = 0.5 * (lo + hi);
1492
+ }
1493
+ const dR = deg < 0 ? 0.25 * deg : deg;
1494
+ const dL = deg > 0 ? 0.25 * deg : deg;
1495
+ let aR0 = drawnR0 + Math.min(Math.max(open + dR, turnR0[0]), turnR0[1]);
1496
+ let aL0 = drawnL0 + Math.min(Math.max(-open + dL, turnL0[0]), turnL0[1]);
1497
+ const eyR0 = sr[1] + lR0 * Math.sin((aR0 + dR0) / DEG);
1498
+ const eyL0 = sl[1] + lL0 * Math.sin((aL0 + dL0) / DEG);
1499
+ if (Math.abs(eyL0 - eyR0) > 1e-4) {
1500
+ const sL = Math.max(-1, Math.min(1, (eyR0 - sl[1]) / Math.max(1e-6, lL0)));
1501
+ const candL = wrap(Math.asin(sL) * DEG - dL0);
1502
+ const tL = wrap(candL - drawnL0);
1503
+ if (tL >= turnL0[0] && tL <= turnL0[1]) aL0 = candL;
1504
+ }
1505
+ const rR0 = (aR0 + dR0) / DEG;
1506
+ const rL0 = (aL0 + dL0) / DEG;
1507
+ return {
1508
+ aR0: wrap(aR0),
1509
+ aL0: wrap(aL0),
1510
+ exR: sr[0] + lR0 * Math.cos(rR0),
1511
+ eyR: sr[1] + lR0 * Math.sin(rR0),
1512
+ exL: sl[0] + lL0 * Math.cos(rL0),
1513
+ eyL: sl[1] + lL0 * Math.sin(rL0),
1514
+ };
1515
+ };
1516
+ const upper = solveTilt(cOpen);
1517
+ const aimFore = (ex: number, ey: number, l1: number, d1: number, drawn1: number, turn1: readonly [number, number], tx: number, ty: number): [number, Pt] => {
1518
+ const want = wrap(Math.atan2(ty - ey, tx - ex) * DEG - d1);
1519
+ const off = Math.min(Math.max(wrap(want - drawn1), turn1[0]), turn1[1]);
1520
+ let a1 = wrap(drawn1 + off);
1521
+ let r1 = (a1 + d1) / DEG;
1522
+ let wx = ex + l1 * Math.cos(r1);
1523
+ let wy = ey + l1 * Math.sin(r1);
1524
+ const xMin = hc[0] - 0.30 * ed;
1525
+ const xMax = hc[0] + 0.30 * ed;
1526
+ const yMin = hc[1] + 0.15 * ed;
1527
+ const yMax = hc[1] + 0.95 * ed;
1528
+ if (wx < xMin || wx > xMax || wy < yMin || wy > yMax) {
1529
+ let bestA = a1;
1530
+ let bestErr = Infinity;
1531
+ for (let step = -200; step <= 200; step++) {
1532
+ const candOff = turn1[0] + ((turn1[1] - turn1[0]) * (step + 200)) / 400;
1533
+ const candA = wrap(drawn1 + candOff);
1534
+ const cr = (candA + d1) / DEG;
1535
+ const cwx = ex + l1 * Math.cos(cr);
1536
+ const cwy = ey + l1 * Math.sin(cr);
1537
+ const ox = cwx < xMin ? xMin - cwx : cwx > xMax ? cwx - xMax : 0;
1538
+ const oy = cwy < yMin ? yMin - cwy : cwy > yMax ? cwy - yMax : 0;
1539
+ const err = (ox + oy) * 1000 + Math.hypot(cwx - tx, cwy - ty);
1540
+ if (err < bestErr) {
1541
+ bestErr = err;
1542
+ bestA = candA;
1543
+ }
1544
+ }
1545
+ a1 = bestA;
1546
+ r1 = (a1 + d1) / DEG;
1547
+ wx = ex + l1 * Math.cos(r1);
1548
+ wy = ey + l1 * Math.sin(r1);
1549
+ }
1550
+ return [a1, [wx, wy]];
1551
+ };
1552
+ const [aR1, wR] = aimFore(upper.exR, upper.eyR, lR1, dR1, drawnR1, turnR1, hc[0] - 0.20 * ed, hc[1] + 0.65 * ed);
1553
+ const [aL1, wL] = aimFore(upper.exL, upper.eyL, lL1, dL1, drawnL1, turnL1, hc[0] + 0.20 * ed, hc[1] + 0.65 * ed);
1554
+ targets.get(bR0)![i] = upper.aR0;
1555
+ targets.get(bR1)![i] = aR1;
1556
+ targets.get(bL0)![i] = upper.aL0;
1557
+ targets.get(bL1)![i] = aL1;
1558
+ return { cR, cL, wR, wL, hc, ed };
1559
+ };
1329
1560
  if (headOk !== null || hipsOk !== null) {
1330
1561
  /** The anchor's two rig points posed on frame i: their midpoint and their distance apart. */
1331
1562
  const pairOn = (an: Anchor, i: number): { centre: Pt; dist: number } => {
@@ -1382,18 +1613,34 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1382
1613
  for (const cn of s.chains) {
1383
1614
  if (sw.chainHolds?.some((h) => h.chain === cn && h.set !== s.set && h.frames[0] <= s.frames[0] && h.frames[1] >= s.frames[1]) === true) continue;
1384
1615
  const c = map.chains.find((x) => x.name === cn)!;
1616
+ if (set.support?.includes(c.name) === true) {
1617
+ for (let i = s.frames[0]; i <= s.frames[1]; i++) solveSupportChainFrame(set, c, i);
1618
+ continue;
1619
+ }
1385
1620
  const drawn = set.drawn[c.bones[0]!];
1386
1621
  const up = isChainHeadPlaced(set, c, drawn);
1387
1622
  const low = hipsOk !== null && set.place?.chains.includes(c.name) === true;
1388
1623
  if (!up && !low && headOk !== null && drawn !== undefined && isRaised(drawn) && c.bones.length >= 1 && c.joints.length >= 2) {
1389
- const p0 = set.pose[c.joints[0]!];
1390
- const p1 = set.pose[c.joints[1]!];
1391
- if (p0 !== undefined && p1 !== undefined) {
1392
- const key0 = pictureAngle(p0, p1);
1624
+ if (set.support !== undefined && c.bones.length >= 2) {
1393
1625
  const t0 = targets.get(c.bones[0]!)!;
1626
+ const t1 = targets.get(c.bones[1]!)!;
1394
1627
  for (let i = s.frames[0]; i <= s.frames[1]; i++) {
1395
- const diff = Math.max(-3, Math.min(3, wrap(t0[i]! - key0)));
1396
- t0[i] = wrap(drawn + diff);
1628
+ const pair = solveSupportActiveFrame(set, c, i);
1629
+ if (pair !== null) {
1630
+ t0[i] = pair[0];
1631
+ t1[i] = pair[1];
1632
+ }
1633
+ }
1634
+ } else {
1635
+ const p0 = set.pose[c.joints[0]!];
1636
+ const p1 = set.pose[c.joints[1]!];
1637
+ if (p0 !== undefined && p1 !== undefined) {
1638
+ const key0 = pictureAngle(p0, p1);
1639
+ const t0 = targets.get(c.bones[0]!)!;
1640
+ for (let i = s.frames[0]; i <= s.frames[1]; i++) {
1641
+ const diff = Math.max(-3, Math.min(3, wrap(t0[i]! - key0)));
1642
+ t0[i] = wrap(drawn + diff);
1643
+ }
1397
1644
  }
1398
1645
  }
1399
1646
  }
@@ -1561,7 +1808,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1561
1808
  t1[i] = a1;
1562
1809
  }
1563
1810
  // A held upper arm whose re-aimed forearm leaves the wrist more than HELD_MISS short of its placed point: ART-RANGE on the upper arm, with the shortfall.
1564
- if (turn0 !== undefined) {
1811
+ if (turn0 !== undefined && set.symmetric !== true) {
1565
1812
  for (const [a, b] of runsOf(heldMiss.map((m) => m > HELD_MISS), RANGE_GAP)) {
1566
1813
  let worst = a;
1567
1814
  for (let k = a; k <= b; k++) if (heldMiss[k]! > heldMiss[worst]!) worst = k;
@@ -1570,7 +1817,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1570
1817
  art.push({ set: set.name, chain: c.name, bone: b0, frames: [first, last], seconds: r6(endOf(last) - keyTimes[first]!), maxOverflow: r3(upperOver[worst]!), turn: r3(upperAsked[worst]!), limits: turn0, clamped: true, shortfall: r3(heldMiss[worst]!), ...(up ? {} : { anchor: 'hip' as const }) });
1571
1818
  }
1572
1819
  }
1573
- if (turn1 !== undefined) {
1820
+ if (turn1 !== undefined && set.symmetric !== true) {
1574
1821
  for (const [a, b] of runsOf(foreOver.map((o) => o > RANGE_SLACK), RANGE_GAP)) {
1575
1822
  let worst = a;
1576
1823
  for (let k = a; k <= b; k++) if (foreOver[k]! > foreOver[worst]!) worst = k;
@@ -1581,10 +1828,51 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1581
1828
  }
1582
1829
  raisedJobs.push(job);
1583
1830
  }
1831
+ if (set.symmetric === true) {
1832
+ for (let i = s.frames[0]; i <= s.frames[1]; i++) {
1833
+ const sym = solveSymmetricFrame(set, i);
1834
+ if (sym === null) continue;
1835
+ const k = i - s.frames[0];
1836
+ for (const job of raisedJobs) {
1837
+ if (job.sw !== s) continue;
1838
+ const w = job.chain.name === sym.cR.name ? sym.wR : job.chain.name === sym.cL.name ? sym.wL : null;
1839
+ if (w !== null && k >= 0 && k < job.placed.length) {
1840
+ job.placed[k] = [(w[0] - sym.hc[0]) / job.rigEye, (w[1] - sym.hc[1]) / job.rigEye];
1841
+ job.upperHeld = [];
1842
+ job.short = [];
1843
+ }
1844
+ }
1845
+ }
1846
+ }
1584
1847
  art.sort((x, y) => x.frames[0] - y.frames[0]);
1585
1848
  if (art.length > 0) raisedArt.push({ sw: s, entries: art });
1586
1849
  }
1587
1850
  }
1851
+ // #188: Even without --raised-by-head, apply symmetric normalisation and support-arm anchoring on each switch's span.
1852
+ for (const s of sw.switches) {
1853
+ const set = map.alternates.find((a) => a.name === s.set)!;
1854
+ if (set.symmetric === true && headOk === null && hipsOk === null) {
1855
+ for (let i = s.frames[0]; i <= s.frames[1]; i++) solveSymmetricFrame(set, i);
1856
+ }
1857
+ if (set.support !== undefined) {
1858
+ for (const cn of s.chains) {
1859
+ const c = map.chains.find((x) => x.name === cn)!;
1860
+ if (set.support.includes(c.name)) {
1861
+ for (let i = s.frames[0]; i <= s.frames[1]; i++) solveSupportChainFrame(set, c, i);
1862
+ } else if (headOk === null && hipsOk === null && c.bones.length >= 2) {
1863
+ const t0 = targets.get(c.bones[0]!)!;
1864
+ const t1 = targets.get(c.bones[1]!)!;
1865
+ for (let i = s.frames[0]; i <= s.frames[1]; i++) {
1866
+ const pair = solveSupportActiveFrame(set, c, i);
1867
+ if (pair !== null) {
1868
+ t0[i] = pair[0];
1869
+ t1[i] = pair[1];
1870
+ }
1871
+ }
1872
+ }
1873
+ }
1874
+ }
1875
+ }
1588
1876
  p.throwIfAny();
1589
1877
  // The art turn of every span; a raised chain's bones were held by the solve, and only its re-aimed forearm is listed (above).
1590
1878
  const measured = measureArt(sw.switches, map, targets, endOf, keyTimes, RANGE_GAP);
@@ -1673,6 +1961,119 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1673
1961
  }
1674
1962
  }
1675
1963
  }
1964
+ // #188: Cover the full shown/bridged span of symmetric sets and support-arm sets after short interpolation.
1965
+ for (let sIdx = 0; sIdx < sw.switches.length; sIdx++) {
1966
+ const s = sw.switches[sIdx]!;
1967
+ const set = map.alternates.find((a) => a.name === s.set)!;
1968
+ const setSlots = Object.entries(set.slots);
1969
+ const isSetShownAt = (i: number): boolean =>
1970
+ setSlots.length > 0 && setSlots.every(([sl, att]) => sw.shown.get(sl)?.[i] === att);
1971
+ let loShow = s.frames[0];
1972
+ while (loShow - 1 >= 0 && isSetShownAt(loShow - 1)) loShow--;
1973
+ let hiShow = s.frames[1];
1974
+ while (hiShow + 1 < n && isSetShownAt(hiShow + 1)) hiShow++;
1975
+ if (set.symmetric === true) {
1976
+ for (let i = loShow; i <= s.frames[1]; i++) solveSymmetricFrame(set, i);
1977
+ if (loShow < s.frames[0]) {
1978
+ const loRamp = Math.max(0, loShow - 6);
1979
+ const steps = loShow - loRamp;
1980
+ if (steps >= 2) {
1981
+ for (const cn of set.chains) {
1982
+ const c = map.chains.find((x) => x.name === cn);
1983
+ if (!c) continue;
1984
+ for (const b of c.bones) {
1985
+ const tg = targets.get(b)!;
1986
+ const startA = tg[loRamp]!;
1987
+ const total = wrap(tg[loShow]! - startA);
1988
+ for (let k = loRamp + 1; k < loShow; k++) {
1989
+ tg[k] = wrap(startA + ((k - loRamp) / steps) * total);
1990
+ }
1991
+ }
1992
+ }
1993
+ }
1994
+ }
1995
+ if (hiShow > s.frames[1] && set.chains.length === 2) {
1996
+ const c0 = map.chains.find((x) => x.name === set.chains[0]!);
1997
+ const c1 = map.chains.find((x) => x.name === set.chains[1]!);
1998
+ if (c0 !== undefined && c1 !== undefined && c0.bones.length === 2 && c1.bones.length === 2) {
1999
+ const S0_0 = rigPoint(rest, c0.bones[0]!)!;
2000
+ const S0_1 = rigPoint(rest, c1.bones[0]!)!;
2001
+ const [cR, cL] = S0_0[0] <= S0_1[0] ? [c0, c1] : [c1, c0];
2002
+ const bR0 = cR.bones[0]!;
2003
+ const bL0 = cL.bones[0]!;
2004
+ const SR0 = rigPoint(rest, bR0)!;
2005
+ const ER0 = rigPoint(rest, cR.bones[1]!)!;
2006
+ const SL0 = rigPoint(rest, bL0)!;
2007
+ const EL0 = rigPoint(rest, cL.bones[1]!)!;
2008
+ const lR0 = Math.hypot(ER0[0] - SR0[0], ER0[1] - SR0[1]);
2009
+ const lL0 = Math.hypot(EL0[0] - SL0[0], EL0[1] - SL0[1]);
2010
+ const dR0 = wrap(Math.atan2(ER0[1] - SR0[1], ER0[0] - SR0[0]) * DEG - restOf.get(bR0)!);
2011
+ const dL0 = wrap(Math.atan2(EL0[1] - SL0[1], EL0[0] - SL0[0]) * DEG - restOf.get(bL0)!);
2012
+ for (let i = s.frames[1] + 1; i <= hiShow; i++) {
2013
+ const sr = posedOrigin(bR0, i);
2014
+ const sl = posedOrigin(bL0, i);
2015
+ const baseR = targets.get(bR0)![i]!;
2016
+ const baseL = targets.get(bL0)![i]!;
2017
+ let lo = -20;
2018
+ let hi = 20;
2019
+ const evalD = (deg: number) =>
2020
+ sl[1] + lL0 * Math.sin((baseL + deg + dL0) / DEG) - (sr[1] + lR0 * Math.sin((baseR + deg + dR0) / DEG));
2021
+ let fLo = evalD(lo);
2022
+ for (let it = 0; it < 32; it++) {
2023
+ const mid = 0.5 * (lo + hi);
2024
+ const fMid = evalD(mid);
2025
+ if ((fLo <= 0 && fMid <= 0) || (fLo > 0 && fMid > 0)) {
2026
+ lo = mid;
2027
+ fLo = fMid;
2028
+ } else hi = mid;
2029
+ }
2030
+ const deg = 0.5 * (lo + hi);
2031
+ targets.get(bR0)![i] = wrap(baseR + deg);
2032
+ targets.get(bL0)![i] = wrap(baseL + deg);
2033
+ }
2034
+ }
2035
+ }
2036
+ }
2037
+ if (set.support !== undefined) {
2038
+ const prevSw = sIdx > 0 ? sw.switches[sIdx - 1] : undefined;
2039
+ const loGap = prevSw !== undefined && s.frames[0] - prevSw.frames[1] <= 10 ? prevSw.frames[1] + 1 : loShow;
2040
+ for (const cn of s.chains) {
2041
+ const c = map.chains.find((x) => x.name === cn)!;
2042
+ if (set.support.includes(c.name)) {
2043
+ for (let i = Math.min(loGap, loShow); i <= hiShow; i++) solveSupportChainFrame(set, c, i);
2044
+ const anchorStart = Math.min(loGap, loShow);
2045
+ if (anchorStart >= 2) {
2046
+ for (const b of c.bones) {
2047
+ const tg = targets.get(b)!;
2048
+ tg[anchorStart - 1] = wrap(tg[anchorStart - 2]! + 0.5 * wrap(tg[anchorStart]! - tg[anchorStart - 2]!));
2049
+ }
2050
+ }
2051
+ } else if (c.bones.length >= 2) {
2052
+ const [b0, b1] = c.bones as [string, string];
2053
+ const drawn0 = set.drawn[b0];
2054
+ if (drawn0 !== undefined && isRaised(drawn0)) {
2055
+ const S0 = rigPoint(rest, b0);
2056
+ let headX0 = 0;
2057
+ if (gestureHead !== null) {
2058
+ const ha0 = rigPoint(rest, gestureHead.points[0]);
2059
+ const hb0 = rigPoint(rest, gestureHead.points[1]);
2060
+ if (ha0 !== null && hb0 !== null) headX0 = (ha0[0] + hb0[0]) / 2;
2061
+ }
2062
+ const l0 = S0 !== null && rigPoint(rest, b1) !== null ? Math.hypot(rigPoint(rest, b1)![0] - S0[0], rigPoint(rest, b1)![1] - S0[1]) : 100;
2063
+ const outSign: 1 | -1 = (S0?.[0] ?? 0) + l0 * Math.cos(drawn0 / DEG) <= headX0 ? 1 : -1;
2064
+ const t0 = targets.get(b0)!;
2065
+ const t1 = targets.get(b1)!;
2066
+ for (let i = loShow; i <= hiShow; i++) {
2067
+ const pair = solveSupportActiveFrame(set, c, i);
2068
+ if (pair === null) continue;
2069
+ if (i >= s.frames[0] || outSign * wrap(t0[i]! - pair[0]) < 0) t0[i] = pair[0];
2070
+ if (i >= s.frames[0] || outSign * wrap(t1[i]! - pair[1]) < 0) t1[i] = pair[1];
2071
+ }
2072
+ }
2073
+ }
2074
+ }
2075
+ }
2076
+ }
1676
2077
 
1677
2078
  // The legs on their final targets: a bone under a scaled bone is keyed through
1678
2079
  // its parent's posed matrix (legs.ts), and each ankle's world position is read.
package/src/switch.ts CHANGED
@@ -666,6 +666,7 @@ export function selectSwitches(map: JointMap, targets: ReadonlyMap<string, reado
666
666
  });
667
667
  if (!hasUpChain) continue;
668
668
  for (const cn of set.chains) {
669
+ if (set.support?.includes(cn) === true) continue;
669
670
  const c = map.chains.find((x) => x.name === cn);
670
671
  if (c === undefined) continue;
671
672
  const allDown = c.bones.every((b) => (set.drawn[b] ?? 0) < 0);
@@ -885,7 +886,13 @@ export function selectSwitches(map: JointMap, targets: ReadonlyMap<string, reado
885
886
  }
886
887
  return raw;
887
888
  };
888
- const pairs = swBones.filter((b) => set0.drawn[b] !== undefined && set1.drawn[b] !== undefined);
889
+ const activeSwChains = switchingChains.filter((cn) => !set1.support?.includes(cn) && !set0.support?.includes(cn));
890
+ const measureChains = activeSwChains.length > 0 ? activeSwChains : switchingChains;
891
+ const measureBones = measureChains.flatMap((cn) => map.chains.find((c) => c.name === cn)?.bones ?? []);
892
+ const rootBones = measureChains
893
+ .map((cn) => map.chains.find((c) => c.name === cn)?.bones[0])
894
+ .filter((b): b is string => b !== undefined && set0.drawn[b] !== undefined && set1.drawn[b] !== undefined);
895
+ const pairs = measureBones.filter((b) => set0.drawn[b] !== undefined && set1.drawn[b] !== undefined);
889
896
  const span = pairs.length > 0
890
897
  ? pairs.reduce((s, b) => s + Math.abs(wrap(set1.drawn[b]! - set0.drawn[b]!)), 0) / pairs.length
891
898
  : 0;
@@ -896,7 +903,10 @@ export function selectSwitches(map: JointMap, targets: ReadonlyMap<string, reado
896
903
  const d1 = pairs.length > 0
897
904
  ? pairs.reduce((s, b) => s + Math.abs(wrap(effAngle(b, i) - set1.drawn[b]!)), 0) / pairs.length
898
905
  : (distances.get(set1.name)?.[i] ?? Infinity);
899
- if (d1 <= d0 || (span > 1e-6 && d0 >= 0.5 * span)) {
906
+ const rootsCrossed =
907
+ activeSwChains.length === switchingChains.length ||
908
+ rootBones.every((b) => Math.abs(wrap(effAngle(b, i) - set1.drawn[b]!)) <= Math.abs(wrap(effAngle(b, i) - set0.drawn[b]!)));
909
+ if ((d1 <= d0 || (span > 1e-6 && d0 >= 0.5 * span)) && rootsCrossed) {
900
910
  tSwitch = i;
901
911
  break;
902
912
  }