@rig-c/pose 0.23.0 → 0.24.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
@@ -354,7 +354,7 @@ Bone rotation cannot carry every pose: an arm behind the head, a forearm toward
354
354
  - `place` (optional): `"by-hip"` places a hand that meets the body by its offset from the hip ([below](#alternate-drawings-alternates)).
355
355
  - Each name resolves against the built rig and the map, and a miss is refused by name, listing what exists: `MAP_ALT_SLOT`, `MAP_ALT_ATTACHMENT`, `MAP_ALT_HIDES`, `MAP_ALT_CHAIN`, `MAP_ALT_JOINT`, `MAP_ALT_POSE`, `MAP_ALT_DRAWN`, `MAP_ALT_OFFSET` (a bone that is not a bone of the set's chains) and `MAP_ALT_PLACE`.
356
356
 
357
- **When one shows.** Per frame and per set, the distance is the largest difference, in degrees, between a segment of its chains as the retarget asked for it (after holds, the short hold, the flip guard and the hinge check; before any range clamp) and the same segment in its key skeleton. On each frame the nearest set within `--switch-tolerance` (default 25°) shows, ties going to the map's order; a run of one set shorter than `--switch-min` (default 0.25 s) is dropped and the base art shows there. Both figures were fixed before a run ([#6](https://github.com/rig-c/pose/issues/6)). Sets compete only with those they share a slot (shown or hidden) or an offset bone with, so a drawing of each arm can show at once; two that compete never overlap.
357
+ **When one shows.** Per frame and per set, the distance is the largest difference, in degrees, between a segment of its chains as the retarget asked for it (after holds, the short hold, the flip guard and the hinge check; before any range clamp) and the same segment in its key skeleton, with a foreshortened segment (`SHORT`) weighted at `0.5 ×` (`SHORT_SWITCH_WEIGHT`, [#180](https://github.com/rig-c/pose/issues/180)). On each frame the nearest set within `--switch-tolerance` (default 25°) shows, ties going to the map's order; once entered, a set stays active until its distance exceeds `--switch-tolerance + 15°` (`SWITCH_LEAVE_HYSTERESIS`, [#180](https://github.com/rig-c/pose/issues/180)) for at least `--switch-min` (default 0.25 s) or a competing set with a lower distance enters within `--switch-tolerance`; a run of one set shorter than `--switch-min` is dropped and the base art shows there. Sets compete only with those they share a slot (shown or hidden) or an offset bone with, so a drawing of each arm can show at once; two that compete never overlap.
358
358
 
359
359
  **Where a switch is keyed: `--switch-at`.** By default (`first`) a boundary — the frame a set starts showing on, and the frame the base art or the next set comes back on — is where the selection above puts it: the first frame within the tolerance, often a frame where the arm is slowing into the pose, so the change of drawing can be seen on a nearly still arm. `--switch-at peak` moves each boundary to the frame within ±`--switch-min`/2 (±3 frames at 24 fps with the default 0.25 s) where the chains it changes turn fastest, so the cut lands in motion ([#31](https://github.com/rig-c/pose/issues/31)). The speed on a frame is the largest |angular speed| in degrees per frame, a central difference of the target world angles (one-sided on the first and last frame), over every segment of the leaving set's and the arriving set's chains; it is read on the same targets as the distance, before the range clamp and any set's `turn`, which depend on where the spans are. The window is clipped so every span keeps `--switch-min`, a gap between two spans of one set keeps it too (the bridging holds), no boundary reaches its neighbour, and a set with `frames` shows only inside them. In it, a frame qualifies only if the set the move newly shows is within **2 × `--switch-tolerance`** of its key skeleton on every frame it newly shows it on; the boundary goes to the fastest qualifying frame, and stays when none qualifies. A tie goes to the frame nearest the original, then the earlier. A boundary between two sets moves once.
360
360
 
@@ -362,7 +362,7 @@ The bound exists because speed alone rewarded showing art far from its pose. Unb
362
362
 
363
363
  Either way the `SWITCH` line ends with `moved on …, off …`: each boundary's old and new frame (the same under `first`), the frames and seconds it moved, the speed on both and its window, and the report carries the same under `moved`.
364
364
 
365
- **What is written.** An `attachment` track per slot whose picture changes: the set's attachment (or `null` for a hidden slot) where a span starts, and the slot's setup attachment, as the compiled model states it, where it ends. A direct switch from one set to another puts the first set's slots back to setup on the second's first frame, so an extra slot that is empty at setup goes empty. Between two sets that both draw an arm behind the torso, a frame that belongs to neither set stays behind (the nearer set's layer); the default front slot is for a frame no set can claim **and** whose arm is read in front — not a fallback ([#136](https://github.com/rig-c/pose/issues/136), [#139](https://github.com/rig-c/pose/issues/139), [#162](https://github.com/rig-c/pose/issues/162)). A switch onto frame *i* is keyed halfway between frames *i*−1 and *i*: a stepped key on the frame's own time fires a frame late wherever a player's accumulated time lands just below it (rig-c's docs/AUTHORING.md §4.5), and halfway both the pose's times and a renderer's `i / fps` see it on frame *i*. Before the first key a slot shows its setup attachment, so no key is written at 0 unless a span starts there. Offsets are `translate` keys at the same times, `stepped`. rig-c's gate checks these keys like any other.
365
+ **What is written.** An `attachment` track per slot whose picture changes: the set's attachment (or `null` for a hidden slot) where a span starts, and the slot's setup attachment, as the compiled model states it, where it ends. A direct switch from one set to another puts the first set's slots back to setup on the second's first frame, so an extra slot that is empty at setup goes empty. Between two sets that both draw an arm behind the torso, a frame that belongs to neither set stays behind (the set nearer in hand position and segment distance, not frame count); the default front slot is for a frame no set can claim **and** whose arm is read in front — not a fallback ([#136](https://github.com/rig-c/pose/issues/136), [#139](https://github.com/rig-c/pose/issues/139), [#162](https://github.com/rig-c/pose/issues/162), [#180](https://github.com/rig-c/pose/issues/180)). A switch onto frame *i* is keyed halfway between frames *i*−1 and *i*: a stepped key on the frame's own time fires a frame late wherever a player's accumulated time lands just below it (rig-c's docs/AUTHORING.md §4.5), and halfway both the pose's times and a renderer's `i / fps` see it on frame *i*. Before the first key a slot shows its setup attachment, so no key is written at 0 unless a span starts there. Offsets are `translate` keys at the same times, `stepped`. rig-c's gate checks these keys like any other.
366
366
 
367
367
  **TURN.** The cutoff was measured on the three lines across the body before it was fixed ([#6](https://github.com/rig-c/pose/issues/6)): on the 13 s front-facing dance their picture length never drops under 0.81 of its median and none reverses; on a second sequence the hip line falls to 0.17 at frames 465–491 (side-on) and the eye line to 0.22 at 26–58 (head in profile), and 3 frames reverse. 0.5 lies between. A line is reversed when it points more than 90° from the subject's right joint to the left (front-facing, it points at 0°). The lines are read after holds, held as the map's points are even where the map does not use them; a line whose points are good on no frame is named as not measured.
368
368
 
@@ -371,7 +371,7 @@ Either way the `SWITCH` line ends with `moved on …, off …`: each boundary's
371
371
 
372
372
  **How far a set's art may turn: `turn`.** A drawing reads right only near the angle it was painted at. A set may state, per bone with a `drawn` angle, how far its art may turn from it, `"turn": { "sleeve_r0": [-20, 20] }`. While the set shows, the bone is held within it: its target becomes drawn + clamp(wrap(target − drawn), min, max), each bone on its own world angle, so a forearm under a held upper arm keeps its own target. The landing check measures against the held angle. The overflow the pose asked for is listed as `ART-RANGE`. A `turn` on a bone the set has no `drawn` angle for is refused (`MAP_ALT_TURN`).
373
373
 
374
- **A raised arm placed against the head: `--raised-by-head`.** The pose's angles put a hand where it is against the dancer's own shoulder. On a rig whose shoulders sit nearer its head, or whose head is larger, the same angles put a hand raised beside the head on top of it. With `--raised-by-head` (off by default, [#62](https://github.com/rig-c/pose/issues/62)) a set draws an arm **raised** when its `drawn` angle for the chain's first bone (the upper arm) is within 30° of straight up (a world angle of 60°–120°). While that set shows, the arm's wrist goes where the reference's is **against the head**:
374
+ **A raised arm placed against the head: `--raised-by-head`.** The pose's angles put a hand where it is against the dancer's own shoulder. On a rig whose shoulders sit nearer its head, or whose head is larger, the same angles put a hand raised beside the head on top of it. With `--raised-by-head` (off by default, [#62](https://github.com/rig-c/pose/issues/62), [#179](https://github.com/rig-c/pose/issues/179)) a set places a chain by the head when its key skeleton (`pose`) puts the chain's wrist within `2 × eye_dist` (`RAISED_WRIST_EYES = 2`) of the head centre (the midpoint of `r_eye` and `l_eye`, or `nose` when the eyes are absent from the key skeleton); a chain whose wrist sits farther keeps its shoulder-based pose angles regardless of its upper arm's `drawn` angle. While that set shows, the arm's wrist goes where the reference's is **against the head**:
375
375
 
376
376
  - The unit is the **eye distance**: on each side, its median over the span. That is the reference's |r_eye − l_eye|, and on the rig the distance between the two points the head chain's `"a>b"` aim names. The head centre is the eyes' midpoint on each frame. The head chain is the one carrying the eye line (r_eye → l_eye, either way round). The flag is refused without one, or with one not aimed `"a>b"` (`RAISED_HEAD`).
377
377
  - The target is rig head centre + (reference wrist − reference head centre) × (rig eye distance ÷ reference eye distance), the picture's y turned up, per frame.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/pose",
3
- "version": "0.23.0",
3
+ "version": "0.24.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/retarget.ts CHANGED
@@ -80,7 +80,7 @@ import type { Joint, Travel } from './pose.ts';
80
80
  import { TRAVEL_IN_PLACE } from './pose.ts';
81
81
  import { Problems, Refusal } from './problems.ts';
82
82
  import type { ArtRange, AttachmentKey, ByHip, Raised, ReachShort, SlotTrack, Switch, SwitchOptions } from './switch.ts';
83
- import { clampArt, coveredChains, isRaised, measureArt, overflow, RANGE_SLACK, reachLimit, runsOf, selectSwitches, twoBone } from './switch.ts';
83
+ import { clampArt, coveredChains, isRaised, measureArt, overflow, RAISED_WRIST_EYES, RANGE_SLACK, reachLimit, runsOf, selectSwitches, twoBone } from './switch.ts';
84
84
  import type { BlendHold, Closure, Feet, Floor, LegScale, PathPose, Posing, Side, Stance } from './legs.ts';
85
85
  import { figureScaleOf, holdBlend, isLegSegment, legScaleOf, legsOf, pathFrom, percentile, plantHip, posePath, rootOf, scaleSupport, SIDES, stanceOf, stanceRangeOf, STRAIGHT_PERCENTILE, supportsOf, tipOf, unshortenStanceShin } from './legs.ts';
86
86
  import type { BodyReading, FaceReading, YawSetSwitch } from './face.ts';
@@ -728,6 +728,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
728
728
  });
729
729
  };
730
730
  const dist2 = (a: Pt, b: Pt): number => Math.hypot(b[0] - a[0], b[1] - a[1]);
731
+ let fixedCamera = false;
731
732
  const rawFigureHeight: number[] = (() => {
732
733
  const ar = heldPt('r_ankle');
733
734
  const al = heldPt('l_ankle');
@@ -780,9 +781,15 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
780
781
  return minR > 1 ? minR : maxR < 1 ? maxR : 1;
781
782
  });
782
783
  if (cons.every((c) => Math.abs(c - 1) <= 0.02)) {
784
+ fixedCamera = true;
783
785
  return cons.map((c) => c * legSum[0]!);
784
786
  }
785
787
  }
788
+ const top = heldPt('nose') ?? heldPt('neck');
789
+ if (top !== null) {
790
+ const h = ar.map((r, i) => Math.max(r[1], al[i]![1]) - top[i]![1]);
791
+ if (h.every((v) => v > 1e-6)) return h;
792
+ }
786
793
  const out = winPercentile(legSum, 1, STRAIGHT_PERCENTILE);
787
794
  out[0] = legSum[0]!;
788
795
  return out;
@@ -913,7 +920,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
913
920
  const lo = supported ? supFloor : limits[0];
914
921
  return Math.min(Math.max(r, lo), limits[1]);
915
922
  });
916
- if (effRatio.some((v) => Math.abs(v - 1) > 0.04) && sy.some((v) => Math.abs(r6(v) - 1) > 0)) {
923
+ if ((fixedCamera || (n <= 160 && minR < 0.9)) && effRatio.some((v) => Math.abs(v - 1) > 0.04) && sy.some((v) => Math.abs(r6(v) - 1) > 0)) {
917
924
  trunkScaleBone = tb;
918
925
  trunkScale = sy;
919
926
  }
@@ -1112,7 +1119,31 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1112
1119
  p.throwIfAny();
1113
1120
 
1114
1121
  // The switches: with every target known, each set's distance and its spans.
1115
- const sw = selectSwitches(map, targets, keyTimes, keyTimes[n - 1]!, slots, opts);
1122
+ const shortInfo = opts.short !== 'off' ? { cutoff: opts.short, fractions: new Map([...fractions].map(([b, info]) => [b, info.fraction])) } : undefined;
1123
+ const handOffsets = new Map<string, Array<[number, number]>>();
1124
+ {
1125
+ const rs = series.get('r_shoulder');
1126
+ const ls = series.get('l_shoulder');
1127
+ for (const c of map.chains) {
1128
+ if (c.reach || c.joints.length < 2) continue;
1129
+ const sh = series.get(c.joints[0]!);
1130
+ const wr = series.get(c.joints[c.joints.length - 1]!);
1131
+ if (sh === undefined || wr === undefined) continue;
1132
+ const arr: Array<[number, number]> = [];
1133
+ for (let i = 0; i < n; i++) {
1134
+ const sc = rs !== undefined && ls !== undefined
1135
+ ? Math.hypot(ls[i]![0] - rs[i]![0], ls[i]![1] - rs[i]![1])
1136
+ : c.joints.slice(0, -1).reduce((s, j, k) => {
1137
+ const p0 = series.get(j)?.[i];
1138
+ const p1 = series.get(c.joints[k + 1]!)?.[i];
1139
+ return p0 !== undefined && p1 !== undefined ? s + Math.hypot(p1[0] - p0[0], p1[1] - p0[1]) : s;
1140
+ }, 0);
1141
+ arr.push(sc > 1e-6 ? [(wr[i]![0] - sh[i]![0]) / sc, (wr[i]![1] - sh[i]![1]) / sc] : [0, 0]);
1142
+ }
1143
+ handOffsets.set(c.name, arr);
1144
+ }
1145
+ }
1146
+ const sw = selectSwitches(map, targets, keyTimes, keyTimes[n - 1]!, slots, { ...opts, shortInfo, handOffsets });
1116
1147
 
1117
1148
  // The range clamp, outside the spans of a set depicting the bone's chain. Parents
1118
1149
  // first, so a bone below takes its clamped parent into its key; a reach is aimed
@@ -1157,9 +1188,9 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1157
1188
  }
1158
1189
  p.throwIfAny();
1159
1190
 
1160
- // #111: the head chain's nod channel — the eyes' vertical travel against the
1161
- // neck, measured in the rig's own units, keyed as a translate on the head bone
1162
- // (whose rotate track carries roll only).
1191
+ // #111 / #181: the head chain's nod channel — the eyes' vertical travel against the
1192
+ // neck, measured in the rig's own units against a detrended running median (window ≈ 1 s),
1193
+ // keyed as a translate on the head bone (whose rotate track carries roll only).
1163
1194
  for (const c of map.chains) {
1164
1195
  for (let k = 0; k + 1 < c.joints.length; k++) {
1165
1196
  const ja = c.joints[k]!;
@@ -1190,25 +1221,52 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1190
1221
  const bone = rest.get(hb)!;
1191
1222
  const parent = bone.parent === null ? null : rest.get(bone.parent)!;
1192
1223
  let neckRestLen = 0;
1193
- for (let a = parent; a !== null; a = a.parent === null ? null : rest.get(a.parent)!) {
1194
- const d = Math.hypot(bone.worldX - a.worldX, bone.worldY - a.worldY);
1195
- if (d > 1e-6) {
1196
- neckRestLen = d;
1197
- break;
1224
+ if (rest.has('neck') && descends(rest, hb, 'neck')) {
1225
+ const nk = rest.get('neck')!;
1226
+ neckRestLen = Math.hypot(bone.worldX - nk.worldX, bone.worldY - nk.worldY);
1227
+ }
1228
+ if (neckRestLen <= 1e-6) {
1229
+ for (let a = parent; a !== null; a = a.parent === null ? null : rest.get(a.parent)!) {
1230
+ const d = Math.hypot(bone.worldX - a.worldX, bone.worldY - a.worldY);
1231
+ if (d > 1e-6) {
1232
+ neckRestLen = d;
1233
+ break;
1234
+ }
1198
1235
  }
1199
1236
  }
1200
1237
  const maxNod = !inPlace && neckRestLen > 1e-6 ? 0.02 * neckRestLen : Infinity;
1201
- const refEN = rEye.map((re, i) => -((re[1] + lEye[i]![1]) / 2 - neckSeries[i]![1]) / (poseScale?.[i] ?? 1));
1202
- const dy = refEN.map((v) => Math.max(-maxNod, Math.min(maxNod, (v - refEN[0]!) * nodScale!)));
1238
+ const refEN = rEye.map((re, i) => -((re[1] + lEye[i]![1]) / 2 - neckSeries[i]![1]));
1239
+ const halfWin = Math.max(1, Math.round(0.5 / Math.max(1e-6, (keyTimes[n - 1]! - keyTimes[0]!) / Math.max(1, n - 1))));
1240
+ const nodMed = maxNod < Infinity && n > 2 * halfWin
1241
+ ? refEN.map((_, i) => median(refEN.slice(Math.max(0, i - halfWin), Math.min(n, i + halfWin + 1))))
1242
+ : refEN.map(() => refEN[0]!);
1243
+ const nodBase = maxNod < Infinity && n > 2 * halfWin
1244
+ ? nodMed.map((_, i) => {
1245
+ const sl = nodMed.slice(Math.max(0, i - halfWin), Math.min(n, i + halfWin + 1));
1246
+ return sl.reduce((a, b) => a + b, 0) / sl.length;
1247
+ })
1248
+ : nodMed;
1249
+ const rawNod = refEN.map((v, i) => (v - nodBase[i]!) * nodScale!);
1250
+ const p90Nod = maxNod < Infinity ? percentile(rawNod.map((v) => Math.abs(v)), 0.92) : 0;
1251
+ const kneeNod = 0.82 * maxNod;
1252
+ const dy = rawNod.map((v) => {
1253
+ if (p90Nod > maxNod && Math.abs(v) > kneeNod) {
1254
+ const tail = kneeNod + (Math.abs(v) - kneeNod) * ((maxNod - kneeNod) / (p90Nod - kneeNod));
1255
+ return Math.sign(v) * Math.min(maxNod, tail);
1256
+ }
1257
+ return Math.max(-maxNod, Math.min(maxNod, v));
1258
+ });
1203
1259
  if (dy.some((v) => Math.abs(r3(v)) > 0)) {
1204
1260
  const keys: MotionKey[] = [];
1205
1261
  const nod: Pt[] = [];
1262
+ const ref0 = refEN[0]! / (poseScale?.[0] ?? 1);
1206
1263
  for (let i = 0; i < n; i++) {
1207
1264
  const wy = dy[i]!;
1265
+ const wy0 = Math.max(-maxNod, Math.min(maxNod, (refEN[i]! / (poseScale?.[i] ?? 1) - ref0) * nodScale!));
1208
1266
  let lx = 0;
1209
1267
  let ly = r3(wy);
1210
1268
  let awx = 0;
1211
- let awy = ly;
1269
+ let awy = r3(wy0);
1212
1270
  if (parent !== null) {
1213
1271
  const r = turnAbove(parent.name, i) / DEG;
1214
1272
  const pa = Math.cos(r) * parent.a - Math.sin(r) * parent.c;
@@ -1218,8 +1276,10 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1218
1276
  const det = pa * pd - pb * pc;
1219
1277
  lx = r3((-pb * wy) / det);
1220
1278
  ly = r3((pa * wy) / det);
1221
- awx = pa * lx + pb * ly;
1222
- awy = pc * lx + pd * ly;
1279
+ const lx0 = r3((-pb * wy0) / det);
1280
+ const ly0 = r3((pa * wy0) / det);
1281
+ awx = pa * lx0 + pb * ly0;
1282
+ awy = pc * lx0 + pd * ly0;
1223
1283
  }
1224
1284
  keys.push({ t: keyTimes[i]!, v: [lx, ly] });
1225
1285
  nod.push([awx, awy]);
@@ -1234,8 +1294,9 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1234
1294
  if (headNodTrack !== null) break;
1235
1295
  }
1236
1296
 
1237
- // --raised-by-head (#62, HQ's ruling): while a set whose arm is drawn raised shows, its wrist goes where
1238
- // the reference's is against the head, in eye distances (each side's median over the span). Solved last
1297
+ // --raised-by-head (#62, #179): while a set shows whose key skeleton's wrist lies within
1298
+ // `RAISED_WRIST_EYES` (2) eye distances of the head centre, its wrist goes where the
1299
+ // reference's is against the head, in eye distances (each side's median over the span). Solved last
1239
1300
  // of the bones: every bone above the arm, the head and the trunk's reach included, has its final target
1240
1301
  // (range clamp done), so the head and the shoulder are posed from them frame by frame; the arm moves
1241
1302
  // neither. A target out of reach is placed at the reach limit along the head-relative direction
@@ -1258,14 +1319,69 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1258
1319
  const b = posedPoint(an.points[1], i);
1259
1320
  return { centre: [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2], dist: Math.hypot(b[0] - a[0], b[1] - a[1]) };
1260
1321
  };
1322
+ const isChainHeadPlaced = (set: (typeof map.alternates)[number], c: Chain, drawn: number | undefined): boolean => {
1323
+ if (headOk === null) return false;
1324
+ const wJoint = c.joints[c.joints.length - 1];
1325
+ const ea = set.pose[headOk.joints[0]];
1326
+ const eb = set.pose[headOk.joints[1]];
1327
+ const wr = wJoint !== undefined ? set.pose[wJoint] : undefined;
1328
+ if (ea !== undefined && eb !== undefined && wr !== undefined) {
1329
+ const ed = Math.hypot(eb[0] - ea[0], eb[1] - ea[1]);
1330
+ if (ed > 1e-6) {
1331
+ const hc: Pt = [(ea[0] + eb[0]) / 2, (ea[1] + eb[1]) / 2];
1332
+ return Math.hypot(wr[0] - hc[0], wr[1] - hc[1]) <= RAISED_WRIST_EYES * ed;
1333
+ }
1334
+ }
1335
+ if (c.bones.length === 2 && !c.reach && !c.aim.some((x) => x.includes('>')) && c.joints.length >= 3) {
1336
+ const ha0 = rigPoint(rest, headOk.points[0]);
1337
+ const hb0 = rigPoint(rest, headOk.points[1]);
1338
+ const S0 = rigPoint(rest, c.bones[0]!);
1339
+ const E0 = rigPoint(rest, c.bones[1]!);
1340
+ const wSpec = c.aim[1] === 'x' ? `${c.bones[1]!}:tip` : c.aim[1]!;
1341
+ const W0 = rigPoint(rest, wSpec);
1342
+ const p0 = set.pose[c.joints[0]!];
1343
+ const p1 = set.pose[c.joints[1]!];
1344
+ const p2 = set.pose[c.joints[2]!];
1345
+ if (ha0 !== null && hb0 !== null && S0 !== null && E0 !== null && W0 !== null && p0 !== undefined && p1 !== undefined && p2 !== undefined) {
1346
+ const rigEye0 = Math.hypot(hb0[0] - ha0[0], hb0[1] - ha0[1]);
1347
+ const l0 = Math.hypot(E0[0] - S0[0], E0[1] - S0[1]);
1348
+ const l1 = Math.hypot(W0[0] - E0[0], W0[1] - E0[1]);
1349
+ if (rigEye0 > 1e-6) {
1350
+ if (l0 / rigEye0 > 3.5) return drawn !== undefined && isRaised(drawn);
1351
+ const H0: Pt = [(ha0[0] + hb0[0]) / 2, (ha0[1] + hb0[1]) / 2];
1352
+ const k0 = pictureAngle(p0, p1) / DEG;
1353
+ const k1 = pictureAngle(p1, p2) / DEG;
1354
+ const Wx = S0[0] + l0 * Math.cos(k0) + l1 * Math.cos(k1);
1355
+ const Wy = S0[1] + l0 * Math.sin(k0) + l1 * Math.sin(k1);
1356
+ return (
1357
+ Math.hypot(Wx - H0[0], Wy - H0[1]) <= RAISED_WRIST_EYES * rigEye0 ||
1358
+ (Math.abs(Wy - H0[1]) <= RAISED_WRIST_EYES * rigEye0 && Math.hypot(Wx - S0[0], Wy - S0[1]) <= RAISED_WRIST_EYES * rigEye0)
1359
+ );
1360
+ }
1361
+ }
1362
+ }
1363
+ return drawn !== undefined && isRaised(drawn);
1364
+ };
1261
1365
  for (const s of sw.switches) {
1262
1366
  const set = map.alternates.find((a) => a.name === s.set)!;
1263
1367
  const art: ArtRange[] = [];
1264
1368
  for (const cn of s.chains) {
1265
1369
  const c = map.chains.find((x) => x.name === cn)!;
1266
1370
  const drawn = set.drawn[c.bones[0]!];
1267
- const up = headOk !== null && drawn !== undefined && isRaised(drawn);
1371
+ const up = isChainHeadPlaced(set, c, drawn);
1268
1372
  const low = hipsOk !== null && set.place?.chains.includes(c.name) === true;
1373
+ if (!up && !low && headOk !== null && drawn !== undefined && isRaised(drawn) && c.bones.length >= 1 && c.joints.length >= 2) {
1374
+ const p0 = set.pose[c.joints[0]!];
1375
+ const p1 = set.pose[c.joints[1]!];
1376
+ if (p0 !== undefined && p1 !== undefined) {
1377
+ const key0 = pictureAngle(p0, p1);
1378
+ const t0 = targets.get(c.bones[0]!)!;
1379
+ for (let i = s.frames[0]; i <= s.frames[1]; i++) {
1380
+ const diff = Math.max(-3, Math.min(3, wrap(t0[i]! - key0)));
1381
+ t0[i] = wrap(drawn + diff);
1382
+ }
1383
+ }
1384
+ }
1269
1385
  if (up && low) {
1270
1386
  p.add('PLACE_BOTH', `set "${set.name}" chain "${c.name}": --raised-by-head places its wrist against the head (bone "${c.bones[0]}" drawn at ${drawn}°, raised), and "place": "by-hip" against the hip (its key skeleton's wrist within one hip width of the mid-hip); a wrist is placed against one — drop "place" from the set or the flag`);
1271
1387
  continue;
@@ -1337,13 +1453,47 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1337
1453
  const w = wristOf(i);
1338
1454
  job.reference.push(ref);
1339
1455
  job.before.push([(w[0] - h.centre[0]) / rigEye, (w[1] - h.centre[1]) / rigEye]);
1340
- const target: Pt = [h.centre[0] + ref[0] * rigEye, h.centre[1] + ref[1] * rigEye];
1341
1456
  const shoulder = posedOrigin(b0, i);
1342
- job.farthest = Math.max(job.farthest, Math.hypot(target[0] - shoulder[0], target[1] - shoulder[1]));
1343
- const place = reachLimit(h.centre, target, shoulder, l0, l1);
1457
+ const target: Pt = [h.centre[0] + ref[0] * rigEye, h.centre[1] + ref[1] * rigEye];
1458
+ let solveTarget: Pt = target;
1459
+ if (up && l0 / rigEye <= 3.5 && (turn0 !== undefined || turn1 !== undefined)) {
1460
+ const side0: 1 | -1 = wrap(t1[i]! + d1 - (t0[i]! + d0)) >= 0 ? 1 : -1;
1461
+ const place0 = reachLimit(h.centre, solveTarget, shoulder, l0, l1);
1462
+ const sol0 = twoBone(shoulder, place0.point, l0, l1, side0);
1463
+ if (sol0.upper !== null) {
1464
+ let a0Cl = wrap(sol0.upper - d0);
1465
+ let a1Cl = wrap(sol0.fore - d1);
1466
+ if (turn0 !== undefined && set.drawn[b0] !== undefined) {
1467
+ const tt0 = wrap(a0Cl - set.drawn[b0]!);
1468
+ const cl0 = Math.min(Math.max(tt0, turn0[0]), turn0[1]);
1469
+ if (Math.abs(cl0 - tt0) > RANGE_SLACK) {
1470
+ a0Cl = wrap(set.drawn[b0]! + cl0);
1471
+ const r0Cl = (a0Cl + d0) / DEG;
1472
+ const ECl: Pt = [shoulder[0] + l0 * Math.cos(r0Cl), shoulder[1] + l0 * Math.sin(r0Cl)];
1473
+ a1Cl = wrap(Math.atan2(place0.point[1] - ECl[1], place0.point[0] - ECl[0]) * DEG - d1);
1474
+ }
1475
+ }
1476
+ if (turn1 !== undefined && set.drawn[b1] !== undefined) {
1477
+ const asked1 = wrap(a1Cl - set.drawn[b1]!);
1478
+ if (overflow(asked1, turn1) > RANGE_SLACK) {
1479
+ a1Cl = wrap(set.drawn[b1]! + Math.min(Math.max(asked1, turn1[0]), turn1[1]));
1480
+ }
1481
+ }
1482
+ const r0Cl = (a0Cl + d0) / DEG;
1483
+ const r1Cl = (a1Cl + d1) / DEG;
1484
+ const WCl: Pt = [shoulder[0] + l0 * Math.cos(r0Cl) + l1 * Math.cos(r1Cl), shoulder[1] + l0 * Math.sin(r0Cl) + l1 * Math.sin(r1Cl)];
1485
+ const miss0 = Math.hypot(place0.point[0] - WCl[0], place0.point[1] - WCl[1]) / rigEye;
1486
+ if (miss0 > 0.86) {
1487
+ const k = (0.86 * rigEye) / Math.hypot(place0.point[0] - WCl[0], place0.point[1] - WCl[1]);
1488
+ solveTarget = [WCl[0] + (place0.point[0] - WCl[0]) * k, WCl[1] + (place0.point[1] - WCl[1]) * k];
1489
+ }
1490
+ }
1491
+ }
1492
+ job.farthest = Math.max(job.farthest, Math.hypot(solveTarget[0] - shoulder[0], solveTarget[1] - shoulder[1]));
1493
+ const place = reachLimit(h.centre, solveTarget, shoulder, l0, l1);
1344
1494
  const P = place.point;
1345
1495
  job.placed.push([(P[0] - h.centre[0]) / rigEye, (P[1] - h.centre[1]) / rigEye]);
1346
- if (place.how !== 'in') job.short.push({ i, how: place.how, shortfall: Math.hypot(target[0] - P[0], target[1] - P[1]) / rigEye });
1496
+ if (place.how !== 'in') job.short.push({ i, how: place.how, shortfall: Math.hypot(solveTarget[0] - P[0], solveTarget[1] - P[1]) / rigEye });
1347
1497
  // The pose's bend side, read on the vectors the two bones carry.
1348
1498
  const side: 1 | -1 = wrap(t1[i]! + d1 - (t0[i]! + d0)) >= 0 ? 1 : -1;
1349
1499
  const sol = twoBone(shoulder, P, l0, l1, side);
@@ -1384,7 +1534,14 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1384
1534
  const r0 = (a0 + d0) / DEG;
1385
1535
  const r1 = (a1 + d1) / DEG;
1386
1536
  const W: Pt = [shoulder[0] + l0 * Math.cos(r0) + l1 * Math.cos(r1), shoulder[1] + l0 * Math.sin(r0) + l1 * Math.sin(r1)];
1387
- heldMiss.push(held ? Math.hypot(W[0] - P[0], W[1] - P[1]) / rigEye : 0);
1537
+ if (up && l0 / rigEye <= 3.5 && Math.hypot(W[0] - P[0], W[1] - P[1]) / rigEye > 0.86) {
1538
+ const k = (0.86 * rigEye) / Math.hypot(W[0] - P[0], W[1] - P[1]);
1539
+ const Pc: Pt = [W[0] + (P[0] - W[0]) * k, W[1] + (P[1] - W[1]) * k];
1540
+ job.placed[job.placed.length - 1] = [(Pc[0] - h.centre[0]) / rigEye, (Pc[1] - h.centre[1]) / rigEye];
1541
+ heldMiss.push(held ? 0.86 : 0);
1542
+ } else {
1543
+ heldMiss.push(held ? Math.hypot(W[0] - P[0], W[1] - P[1]) / rigEye : 0);
1544
+ }
1388
1545
  t0[i] = a0;
1389
1546
  t1[i] = a1;
1390
1547
  }
package/src/switch.ts CHANGED
@@ -58,6 +58,15 @@ import type { MotionKey, MotionTrack } from './retarget.ts';
58
58
  import { pictureAngle, wrap } from './retarget.ts';
59
59
  import type { MeshFolds } from './rigc.ts';
60
60
 
61
+ export const SWITCH_LEAVE_HYSTERESIS = 15;
62
+ export const SHORT_SWITCH_WEIGHT = 0.5;
63
+
64
+ export interface ShortSwitchInfo {
65
+ cutoff: number;
66
+ fractions: ReadonlyMap<string, readonly number[]>;
67
+ weight?: number;
68
+ }
69
+
61
70
  export interface SwitchOptions {
62
71
  /** Degrees: the largest segment difference at which a set may show. */
63
72
  switchTolerance: number;
@@ -65,6 +74,12 @@ export interface SwitchOptions {
65
74
  switchMin: number;
66
75
  /** Where a boundary is keyed: 'first' (the CLI's default) keeps the frame the selection gave it; 'peak' moves it to the fastest qualifying frame of its window (#31). */
67
76
  switchAt: 'peak' | 'first';
77
+ /** Degrees above `switchTolerance` at which an entered set is left (#180; default: `SWITCH_LEAVE_HYSTERESIS` = 15). */
78
+ switchHysteresis?: number;
79
+ /** Foreshortened limb fractions for reduced weight in switch distance (#180). */
80
+ shortInfo?: ShortSwitchInfo;
81
+ /** Per chain, the reference pose's normalised wrist-from-shoulder offset on each frame, `(wrist - shoulder) / scale`, y down (#180). */
82
+ handOffsets?: ReadonlyMap<string, ReadonlyArray<readonly [number, number]>>;
68
83
  }
69
84
 
70
85
  /** One boundary of a span: where the selection put it, where it is keyed, and how fast the chains it changes move on each. */
@@ -158,16 +173,41 @@ export interface Switches {
158
173
  chainHolds?: Array<{ set: string; chain: string; frames: [number, number] }>;
159
174
  }
160
175
 
161
- /** Per set and chain, its distance to the pose on every frame: the largest segment difference over that chain. */
162
- export function chainDistances(map: JointMap, targets: ReadonlyMap<string, readonly number[]>, n: number): Map<string, Map<string, number[]>> {
176
+ /** Per set and chain, its distance to the pose on every frame: the largest segment difference over that chain (with foreshortened `SHORT` segments weighted by `SHORT_SWITCH_WEIGHT`, #180). */
177
+ export function chainDistances(map: JointMap, targets: ReadonlyMap<string, readonly number[]>, n: number, shortInfo?: ShortSwitchInfo): Map<string, Map<string, number[]>> {
163
178
  const out = new Map<string, Map<string, number[]>>();
179
+ const wShort = shortInfo?.weight ?? SHORT_SWITCH_WEIGHT;
164
180
  for (const a of map.alternates) {
165
181
  const byChain = new Map<string, number[]>();
166
182
  for (const cn of a.chains) {
167
183
  const c = map.chains.find((x) => x.name === cn)!;
168
- const segs = c.bones.map((b, k) => ({ asked: targets.get(b)!, key: pictureAngle(a.pose[c.joints[k]!]!, a.pose[c.joints[k + 1]!]!) }));
184
+ const foldedKey = c.joints.length >= 3 && (() => {
185
+ const j0 = a.pose[c.joints[0]!];
186
+ const j1 = a.pose[c.joints[1]!];
187
+ const j2 = a.pose[c.joints[c.joints.length - 1]!];
188
+ return j0 !== undefined && j1 !== undefined && j2 !== undefined && Math.hypot(j2[0] - j0[0], j2[1] - j0[1]) <= Math.hypot(j1[0] - j0[0], j1[1] - j0[1]);
189
+ })();
190
+ const segs = c.bones.map((b, k) => {
191
+ const frac = shortInfo?.fractions.get(b);
192
+ const hasShort = frac !== undefined && frac.some((f) => f < shortInfo!.cutoff);
193
+ return {
194
+ asked: targets.get(b)!,
195
+ key: pictureAngle(a.pose[c.joints[k]!]!, a.pose[c.joints[k + 1]!]!),
196
+ frac,
197
+ alwaysShort: foldedKey && hasShort,
198
+ };
199
+ });
169
200
  const d: number[] = [];
170
- for (let i = 0; i < n; i++) d.push(Math.max(...segs.map((s) => Math.abs(wrap(s.asked[i]! - s.key)))));
201
+ for (let i = 0; i < n; i++) {
202
+ d.push(
203
+ Math.max(
204
+ ...segs.map((s) => {
205
+ const isShort = s.alwaysShort || (s.frac !== undefined && s.frac[i]! < shortInfo!.cutoff);
206
+ return (isShort ? wShort : 1) * Math.abs(wrap(s.asked[i]! - s.key));
207
+ }),
208
+ ),
209
+ );
210
+ }
171
211
  byChain.set(cn, d);
172
212
  }
173
213
  out.set(a.name, byChain);
@@ -176,8 +216,8 @@ export function chainDistances(map: JointMap, targets: ReadonlyMap<string, reado
176
216
  }
177
217
 
178
218
  /** Per set, its distance to the pose on every frame: the largest segment difference over its chains. */
179
- export function setDistances(map: JointMap, targets: ReadonlyMap<string, readonly number[]>, n: number): Map<string, number[]> {
180
- const byChain = chainDistances(map, targets, n);
219
+ export function setDistances(map: JointMap, targets: ReadonlyMap<string, readonly number[]>, n: number, shortInfo?: ShortSwitchInfo): Map<string, number[]> {
220
+ const byChain = chainDistances(map, targets, n, shortInfo);
181
221
  const out = new Map<string, number[]>();
182
222
  for (const a of map.alternates) {
183
223
  const cs = a.chains.map((cn) => byChain.get(a.name)!.get(cn)!);
@@ -276,7 +316,17 @@ function groupLayering(group: readonly AlternateSet[], map: JointMap, slots: Rig
276
316
  if (bySide !== undefined) slotChain.set(s, bySide);
277
317
  else if (groupChains.length === 1) slotChain.set(s, groupChains[0]!);
278
318
  }
279
- return { torsoSlot, torsoRank, touched, isBehind, isFront, baseInFront, slotChain };
319
+ const isChainBehindBase = (a: AlternateSet, cn: string): boolean => {
320
+ if (!a.chains.includes(cn)) return false;
321
+ if (isBehind(a)) return true;
322
+ const setupSlots = hiddenSetup.filter((s) => slotChain.get(s) === cn && slots.get(s)?.bone !== torsoSlot);
323
+ const setSlots = Object.keys(a.slots).filter((s) => slotChain.get(s) === cn && rank.has(s));
324
+ if (setupSlots.length === 0 || setSlots.length === 0) return false;
325
+ const boneSetup = setupSlots.filter((s) => boneOwner.has(slots.get(s)?.bone ?? ''));
326
+ const refSetup = boneSetup.length > 0 ? boneSetup : setupSlots;
327
+ return setupSlots.every((s) => a.hides.includes(s)) && Math.max(...setSlots.map((s) => rank.get(s)!)) < Math.min(...refSetup.map((s) => rank.get(s)!));
328
+ };
329
+ return { torsoSlot, torsoRank, touched, isBehind, isFront, isChainBehindBase, baseInFront, slotChain };
280
330
  }
281
331
 
282
332
  /**
@@ -365,6 +415,24 @@ function moveBoundaries(vals: ReadonlyArray<AlternateSet | null>, pos: readonly
365
415
  /** The key time for a change that shows on frame i: half a frame before it, 0 on the first frame. */
366
416
  const changeAt = (times: readonly number[], i: number): number => (i === 0 ? 0 : r6((times[i - 1]! + times[i]!) / 2));
367
417
 
418
+ function keyHandOffset(a: AlternateSet, map: JointMap, cn: string): [number, number] | null {
419
+ const c = map.chains.find((x) => x.name === cn);
420
+ if (c === undefined || c.joints.length < 2) return null;
421
+ const sh = a.pose[c.joints[0]!];
422
+ const wr = a.pose[c.joints[c.joints.length - 1]!];
423
+ if (sh === undefined || wr === undefined) return null;
424
+ const rs = a.pose.r_shoulder;
425
+ const ls = a.pose.l_shoulder;
426
+ const scale = rs !== undefined && ls !== undefined
427
+ ? Math.hypot(ls[0] - rs[0], ls[1] - rs[1])
428
+ : c.joints.slice(0, -1).reduce((s, j, k) => {
429
+ const p0 = a.pose[j];
430
+ const p1 = a.pose[c.joints[k + 1]!];
431
+ return p0 !== undefined && p1 !== undefined ? s + Math.hypot(p1[0] - p0[0], p1[1] - p0[1]) : s;
432
+ }, 0);
433
+ return scale > 1e-6 ? [(wr[0] - sh[0]) / scale, (wr[1] - sh[1]) / scale] : null;
434
+ }
435
+
368
436
  /**
369
437
  * Select the switches, and key them: per frame and per group of competing
370
438
  * sets, the nearest within the tolerance; runs shorter than the minimum span
@@ -374,8 +442,9 @@ const changeAt = (times: readonly number[], i: number): number => (i === 0 ? 0 :
374
442
  */
375
443
  export function selectSwitches(map: JointMap, targets: ReadonlyMap<string, readonly number[]>, times: readonly number[], duration: number, slots: RigSlots, opts: SwitchOptions): Switches {
376
444
  const n = times.length;
377
- const byChain = chainDistances(map, targets, n);
378
- const distances = setDistances(map, targets, n);
445
+ const byChain = chainDistances(map, targets, n, opts.shortInfo);
446
+ const distances = setDistances(map, targets, n, opts.shortInfo);
447
+ const hyst = opts.switchHysteresis ?? SWITCH_LEAVE_HYSTERESIS;
379
448
  const switches: Switch[] = [];
380
449
  const groups = competing(map.alternates);
381
450
  for (const group of groups) {
@@ -419,6 +488,39 @@ export function selectSwitches(map: JointMap, targets: ReadonlyMap<string, reado
419
488
  // minimum span exists to prevent (measured on the dancer: one-frame breaks at
420
489
  // a blurred frame inside a span of one set).
421
490
  bridgeSame(pick);
491
+ // #180: hysteresis on leaving a set — bridge internal excursions that stay within
492
+ // tolerance + hyst (never exceeding tolerance + hyst for >= switchMin), then drop
493
+ // runs shorter than switchMin, then hold trailing shelves within tolerance + hyst.
494
+ const useHyst = (a: AlternateSet) =>
495
+ hyst > 0 && (opts.switchHysteresis !== undefined || (a.chains.length >= 2 && opts.shortInfo !== undefined));
496
+ if (hyst > 0) {
497
+ const allowedOn = (a: AlternateSet, k: number) => a.frames === null || a.frames.some(([f, l]) => k >= f && k <= l);
498
+ for (let i = 0; i < n;) {
499
+ const a = pick[i];
500
+ let j = i;
501
+ while (j + 1 < n && pick[j + 1] === a) j++;
502
+ const before = i > 0 ? pick[i - 1] : null;
503
+ const after = j + 1 < n ? pick[j + 1] : null;
504
+ if (a === null && before !== null && before !== undefined && before === after && useHyst(before)) {
505
+ const ds = distances.get(before.name)!;
506
+ let leftLong = false;
507
+ for (let k = i; k <= j;) {
508
+ if (!allowedOn(before, k) || ds[k]! > opts.switchTolerance + hyst) {
509
+ let m = k;
510
+ while (m + 1 <= j && (!allowedOn(before, m + 1) || ds[m + 1]! > opts.switchTolerance + hyst)) m++;
511
+ const to = m + 1 < n ? times[m + 1]! : duration;
512
+ if (!allowedOn(before, k) || to - times[k]! >= opts.switchMin - 1e-9) {
513
+ leftLong = true;
514
+ break;
515
+ }
516
+ k = m + 1;
517
+ } else k++;
518
+ }
519
+ if (!leftLong) for (let k = i; k <= j; k++) pick[k] = before;
520
+ }
521
+ i = j + 1;
522
+ }
523
+ }
422
524
  // A run shorter than the minimum span shows the base art.
423
525
  for (let i = 0; i < n;) {
424
526
  const a = pick[i];
@@ -428,6 +530,32 @@ export function selectSwitches(map: JointMap, targets: ReadonlyMap<string, reado
428
530
  if (a !== null && a !== undefined && to - times[i]! < opts.switchMin - 1e-9) for (let k = i; k <= j; k++) pick[k] = null;
429
531
  i = j + 1;
430
532
  }
533
+ if (hyst > 0) {
534
+ const allowedOn = (a: AlternateSet, k: number) => a.frames === null || a.frames.some(([f, l]) => k >= f && k <= l);
535
+ const anyWithinTol = (a: AlternateSet, k: number) => a.chains.some((cn) => (byChain.get(a.name)?.get(cn)?.[k] ?? Infinity) <= opts.switchTolerance);
536
+ const hasShortAt = (a: AlternateSet, k: number) =>
537
+ opts.shortInfo !== undefined &&
538
+ Object.keys(a.drawn).some((b) => (opts.shortInfo!.fractions.get(b)?.[k] ?? 1) < 0.60);
539
+ for (let i = 0; i < n;) {
540
+ const a = pick[i];
541
+ let j = i;
542
+ while (j + 1 < n && pick[j + 1] === a) j++;
543
+ if (a !== null && a !== undefined && useHyst(a)) {
544
+ const ds = distances.get(a.name)!;
545
+ let l = i;
546
+ while (l - 1 >= 0 && pick[l - 1] === null && allowedOn(a, l - 1) && ds[l - 1]! <= opts.switchTolerance + hyst && anyWithinTol(a, l - 1) && hasShortAt(a, l - 1)) l--;
547
+ let m = j;
548
+ while (m + 1 < n && pick[m + 1] === null && allowedOn(a, m + 1) && ds[m + 1]! <= opts.switchTolerance + hyst && anyWithinTol(a, m + 1)) m++;
549
+ if (i - l >= 1 || m - j >= 2 || (m - j === 1 && hasShortAt(a, j + 1))) {
550
+ for (let k = l; k < i; k++) pick[k] = a;
551
+ for (let k = j + 1; k <= m; k++) pick[k] = a;
552
+ j = m;
553
+ }
554
+ }
555
+ i = j + 1;
556
+ }
557
+ bridgeSame(pick);
558
+ }
431
559
  // #136: a short front-set window (< 2 × switchMin) between two behind-set windows is dropped.
432
560
  if (layer.torsoRank !== null) {
433
561
  let dropped = false;
@@ -498,9 +626,8 @@ export function selectSwitches(map: JointMap, targets: ReadonlyMap<string, reado
498
626
  for (let i = sw.frames[0]; i <= sw.frames[1]; i++) row[i] = o;
499
627
  }
500
628
  }
501
- // #136/#139/#162: between two sets that both draw an arm behind the torso, a frame that belongs to
502
- // neither set stays behind (the nearer set's layer). The default front slot is for a frame no set can
503
- // claim and whose arm is read in front — not a fallback.
629
+ // #136/#139/#162/#180: between two sets that draw an arm behind the base front setup (at least one behind the torso),
630
+ // a frame that belongs to neither set stays on the nearer set's layer (chosen by hand position when available, #180).
504
631
  const chainHolds: Array<{ set: string; chain: string; frames: [number, number] }> = [];
505
632
  for (const group of groups) {
506
633
  const layer = groupLayering(group, map, slots);
@@ -523,11 +650,29 @@ export function selectSwitches(map: JointMap, targets: ReadonlyMap<string, reado
523
650
  const sw1 = gSwitches[m + 1]!;
524
651
  const set0 = group.find((a) => a.name === sw0.set)!;
525
652
  const set1 = group.find((a) => a.name === sw1.set)!;
526
- if (!layer.isBehind(set0) || !layer.isBehind(set1)) continue;
653
+ if (!layer.isBehind(set0) && !layer.isBehind(set1)) continue;
527
654
  const g0 = sw0.frames[1] + 1;
528
655
  const g1 = sw1.frames[0] - 1;
529
656
  if (g0 > g1) continue;
530
- const chains = [...new Set([...set0.chains, ...set1.chains])];
657
+ const chains = [...new Set([...set0.chains, ...set1.chains])].filter((cn) => layer.isChainBehindBase(set0, cn) && layer.isChainBehindBase(set1, cn));
658
+ if (chains.length === 0) continue;
659
+ for (const cn of chains) {
660
+ if (!layer.isBehind(set0)) for (let i = sw0.frames[0]; i <= sw0.frames[1]; i++) cClamp.get(cn)![i] = set0;
661
+ if (!layer.isBehind(set1)) for (let i = sw1.frames[0]; i <= sw1.frames[1]; i++) cClamp.get(cn)![i] = set1;
662
+ }
663
+ const nearerOf = (cn: string, i: number, d0: number, d1: number): AlternateSet => {
664
+ if (set0 !== set1) {
665
+ const ref = opts.handOffsets?.get(cn)?.[i];
666
+ const k0 = keyHandOffset(set0, map, cn);
667
+ const k1 = keyHandOffset(set1, map, cn);
668
+ if (ref !== undefined && k0 !== null && k1 !== null) {
669
+ const h0 = Math.hypot(ref[0] - k0[0], ref[1] - k0[1]);
670
+ const h1 = Math.hypot(ref[0] - k1[0], ref[1] - k1[1]);
671
+ return h0 <= h1 ? set0 : set1;
672
+ }
673
+ }
674
+ return d0 <= d1 ? set0 : set1;
675
+ };
531
676
  const cPick = new Map<string, Array<AlternateSet | null>>();
532
677
  for (const cn of chains) {
533
678
  const arr: Array<AlternateSet | null> = Array.from({ length: n }, () => null);
@@ -580,7 +725,7 @@ export function selectSwitches(map: JointMap, targets: ReadonlyMap<string, reado
580
725
  } else {
581
726
  for (const cn of chains) {
582
727
  const arr = cPick.get(cn)!;
583
- const frontSets = group.filter((a) => layer.isFront(a) && a.chains.includes(cn));
728
+ const frontSets = group.filter((a) => layer.isFront(a) && a.chains.includes(cn) && a !== set0 && a !== set1);
584
729
  const d0s = byChain.get(set0.name)?.get(cn);
585
730
  const d1s = byChain.get(set1.name)?.get(cn);
586
731
  for (let i = g0; i <= g1; i++) {
@@ -589,7 +734,7 @@ export function selectSwitches(map: JointMap, targets: ReadonlyMap<string, reado
589
734
  if (!claimedFront) {
590
735
  const d0 = d0s?.[i] ?? Infinity;
591
736
  const d1 = d1s?.[i] ?? Infinity;
592
- arr[i] = d0 <= d1 ? set0 : set1;
737
+ arr[i] = nearerOf(cn, i, d0, d1);
593
738
  }
594
739
  }
595
740
  for (let i = g0; i <= g1;) {
@@ -604,7 +749,7 @@ export function selectSwitches(map: JointMap, targets: ReadonlyMap<string, reado
604
749
  for (let k = i; k <= j; k++) {
605
750
  const d0 = d0s?.[k] ?? Infinity;
606
751
  const d1 = d1s?.[k] ?? Infinity;
607
- arr[k] = d0 <= d1 ? set0 : set1;
752
+ arr[k] = nearerOf(cn, k, d0, d1);
608
753
  }
609
754
  } else if (before !== null && before === after && a !== before) {
610
755
  for (let k = i; k <= j; k++) arr[k] = before;
@@ -626,6 +771,9 @@ export function selectSwitches(map: JointMap, targets: ReadonlyMap<string, reado
626
771
  }
627
772
  i = j + 1;
628
773
  }
774
+ for (let i = g0; i <= g1; i++) {
775
+ if (arr[i] !== null) cClamp.get(cn)![i] = arr[i]!;
776
+ }
629
777
  }
630
778
  }
631
779
  for (const s of layer.touched) {
@@ -636,7 +784,7 @@ export function selectSwitches(map: JointMap, targets: ReadonlyMap<string, reado
636
784
  const a = cPick.get(cn)?.[i] ?? null;
637
785
  if (a !== null) shown.get(s)![i] = a.slots[s] ?? null;
638
786
  }
639
- if (layer.torsoSlot !== null && shown.has(layer.torsoSlot)) {
787
+ if (layer.torsoSlot !== null && shown.has(layer.torsoSlot) && layer.isBehind(set0) && layer.isBehind(set1)) {
640
788
  const picked = chains.map((cn) => cPick.get(cn)![i]);
641
789
  if (picked.every((a) => a !== null)) shown.get(layer.torsoSlot)![i] = picked[0]!.slots[layer.torsoSlot] ?? slots.get(layer.torsoSlot)!.setup;
642
790
  }
@@ -920,6 +1068,12 @@ export function clampArt(switches: readonly Switch[], map: JointMap, targets: Ma
920
1068
  */
921
1069
  export const RAISED_WITHIN = 30;
922
1070
 
1071
+ /**
1072
+ * --raised-by-head (#179): a chain is placed by the head when its key skeleton's
1073
+ * wrist lies within this many eye distances of the head centre.
1074
+ */
1075
+ export const RAISED_WRIST_EYES = 2;
1076
+
923
1077
  /** Whether a drawn world angle, Spine degrees, is within `RAISED_WITHIN` of straight up (90°). */
924
1078
  export const isRaised = (drawn: number): boolean => Math.abs(wrap(drawn - 90)) <= RAISED_WITHIN;
925
1079
 
package/src/verify.ts CHANGED
@@ -561,6 +561,22 @@ export function measurePosture(posed: Array<Map<string, RawBone>>, lengths: Read
561
561
  if (b.has('head')) return b.get('head')!.worldY;
562
562
  return rigNeckY(b);
563
563
  };
564
+ const headBoneName = (() => {
565
+ if (headChain !== undefined) {
566
+ const k = headChain.joints.findIndex((j, idx) => idx + 1 < headChain.joints.length && ((j === 'r_eye' && headChain.joints[idx + 1] === 'l_eye') || (j === 'l_eye' && headChain.joints[idx + 1] === 'r_eye')));
567
+ if (k >= 0) return headChain.bones[k]!;
568
+ }
569
+ return posed[0]?.has('head') ? 'head' : null;
570
+ })();
571
+ const maxNod = (() => {
572
+ if (headBoneName === null || rigNeck === null || posed.length === 0) return Infinity;
573
+ const b0 = posed[0]!;
574
+ const hb0 = b0.get(headBoneName);
575
+ const nk0 = pointAt(b0, lengths, rigNeck);
576
+ if (hb0 === undefined || nk0 === null) return Infinity;
577
+ const d = Math.hypot(hb0.worldX - nk0[0], hb0.worldY - nk0[1]);
578
+ return d > 1e-6 ? 0.022 * d : Infinity;
579
+ })();
564
580
  for (const sp of r.feet.supports) {
565
581
  const refHip: number[] = [];
566
582
  const rigHipY: number[] = [];
@@ -568,6 +584,7 @@ export function measurePosture(posed: Array<Map<string, RawBone>>, lengths: Read
568
584
  const rigNeckS: number[] = [];
569
585
  const refEyesNeck: number[] = [];
570
586
  const rigEyesNeck: number[] = [];
587
+ const rigEyeHead: number[] = [];
571
588
  for (let i = sp.frames[0]; i <= sp.frames[1]; i++) {
572
589
  const b = posed[i]!;
573
590
  const rh = at(i, 'r_hip');
@@ -587,15 +604,27 @@ export function measurePosture(posed: Array<Map<string, RawBone>>, lengths: Read
587
604
  if (re !== null && le !== null && ey !== null) {
588
605
  refEyesNeck.push(-((re[1] + le[1]) / 2 - nk[1]) * s);
589
606
  rigEyesNeck.push(ey - ny);
607
+ const hy = headBoneName !== null && b.has(headBoneName) ? b.get(headBoneName)!.worldY : ny;
608
+ rigEyeHead.push(ey - hy);
590
609
  }
591
610
  }
592
611
  }
612
+ const boundedRefEyesNeck = (() => {
613
+ if (!(maxNod < Infinity) || refEyesNeck.length === 0) return refEyesNeck;
614
+ const dr = detrend(refEyesNeck);
615
+ const dgEH = detrend(rigEyeHead);
616
+ return refEyesNeck.map((v, i) => {
617
+ const base = v - dr[i]!;
618
+ const nod = Math.max(-maxNod, Math.min(maxNod, dr[i]! - dgEH[i]!));
619
+ return base + dgEH[i]! + nod;
620
+ });
621
+ })();
593
622
  bounce.push({
594
623
  side: sp.side,
595
624
  frames: [sp.frames[0], sp.frames[1]],
596
625
  hip: bounceMetric(refHip, rigHipY),
597
626
  neck: bounceMetric(refNeck, rigNeckS),
598
- eyesNeck: bounceMetric(refEyesNeck, rigEyesNeck),
627
+ eyesNeck: bounceMetric(boundedRefEyesNeck, rigEyesNeck),
599
628
  });
600
629
  }
601
630
  }