@rig-c/pose 0.22.2 → 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/src/retarget.ts CHANGED
@@ -74,17 +74,17 @@
74
74
  import { screenToSpineDegrees } from 'rig-c/transform';
75
75
  import type { Body18 } from './joints.ts';
76
76
  import { isBody18, VIRTUAL } from './joints.ts';
77
- import type { Chain, JointMap, Rest, RigSlots } from './map.ts';
78
- import { aimAngle, aimEnds, descends, partBone, pointParts, rigPoint } from './map.ts';
77
+ import type { Chain, JointMap, Rest, RigSets, RigSlots } from './map.ts';
78
+ import { aimAngle, aimEnds, bodyYawSets, descends, faceYawSets, knownSetsOf, partBone, pointParts, rigPoint } from './map.ts';
79
79
  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
- import type { ArtRange, 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';
82
+ import type { ArtRange, AttachmentKey, ByHip, Raised, ReachShort, SlotTrack, Switch, SwitchOptions } 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
- import type { FaceReading } from './face.ts';
87
- import { readFace } from './face.ts';
86
+ import type { BodyReading, FaceReading, YawSetSwitch } from './face.ts';
87
+ import { readBody, readFace, readFaceAngles, selectYawSets } from './face.ts';
88
88
 
89
89
  const DEG = 180 / Math.PI;
90
90
 
@@ -326,6 +326,8 @@ export interface Retarget {
326
326
  byHip: ByHip[];
327
327
  /** With "face" in the map (#138): per-frame yaw/pitch reading and range-compressed mapping. */
328
328
  face: FaceReading | null;
329
+ /** Per-frame body yaw read from camera-normalised shoulder and hip width (#173), and optional "body.yaw" dial mapping. */
330
+ body: BodyReading | null;
329
331
  /** Continuous per-frame figure scale relative to frame 0 (`#163`, `#161`). */
330
332
  figureScale: FigureScaleReading;
331
333
  }
@@ -430,7 +432,7 @@ function pivotOf(rest: Rest, bone: string | null, legs: ReturnType<typeof legsOf
430
432
  * good, when a hinge's side cannot be read off a straight rest, and — with
431
433
  * `bends: 'refuse'` — when a hinge bends the wrong way.
432
434
  */
433
- export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partial<Record<Body18, Joint>>>, times: readonly number[], opts: RetargetOptions, slots: RigSlots = new Map()): Retarget {
435
+ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partial<Record<Body18, Joint>>>, times: readonly number[], opts: RetargetOptions, slots: RigSlots = new Map(), rigSets: RigSets = new Map()): Retarget {
434
436
  const p = new Problems();
435
437
  const n = frames.length;
436
438
  const t0 = times[0]!;
@@ -462,6 +464,10 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
462
464
  needed.add('r_eye');
463
465
  needed.add('l_eye');
464
466
  }
467
+ if (map.body !== undefined) {
468
+ needed.add('r_shoulder');
469
+ needed.add('l_shoulder');
470
+ }
465
471
  const series = new Map<string, Pt[]>();
466
472
  const good = new Map<string, boolean[]>();
467
473
  const holds: Hold[] = [];
@@ -722,6 +728,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
722
728
  });
723
729
  };
724
730
  const dist2 = (a: Pt, b: Pt): number => Math.hypot(b[0] - a[0], b[1] - a[1]);
731
+ let fixedCamera = false;
725
732
  const rawFigureHeight: number[] = (() => {
726
733
  const ar = heldPt('r_ankle');
727
734
  const al = heldPt('l_ankle');
@@ -774,9 +781,15 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
774
781
  return minR > 1 ? minR : maxR < 1 ? maxR : 1;
775
782
  });
776
783
  if (cons.every((c) => Math.abs(c - 1) <= 0.02)) {
784
+ fixedCamera = true;
777
785
  return cons.map((c) => c * legSum[0]!);
778
786
  }
779
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
+ }
780
793
  const out = winPercentile(legSum, 1, STRAIGHT_PERCENTILE);
781
794
  out[0] = legSum[0]!;
782
795
  return out;
@@ -907,7 +920,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
907
920
  const lo = supported ? supFloor : limits[0];
908
921
  return Math.min(Math.max(r, lo), limits[1]);
909
922
  });
910
- 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)) {
911
924
  trunkScaleBone = tb;
912
925
  trunkScale = sy;
913
926
  }
@@ -1106,7 +1119,31 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1106
1119
  p.throwIfAny();
1107
1120
 
1108
1121
  // The switches: with every target known, each set's distance and its spans.
1109
- 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 });
1110
1147
 
1111
1148
  // The range clamp, outside the spans of a set depicting the bone's chain. Parents
1112
1149
  // first, so a bone below takes its clamped parent into its key; a reach is aimed
@@ -1151,9 +1188,9 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1151
1188
  }
1152
1189
  p.throwIfAny();
1153
1190
 
1154
- // #111: the head chain's nod channel — the eyes' vertical travel against the
1155
- // neck, measured in the rig's own units, keyed as a translate on the head bone
1156
- // (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).
1157
1194
  for (const c of map.chains) {
1158
1195
  for (let k = 0; k + 1 < c.joints.length; k++) {
1159
1196
  const ja = c.joints[k]!;
@@ -1184,25 +1221,52 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1184
1221
  const bone = rest.get(hb)!;
1185
1222
  const parent = bone.parent === null ? null : rest.get(bone.parent)!;
1186
1223
  let neckRestLen = 0;
1187
- for (let a = parent; a !== null; a = a.parent === null ? null : rest.get(a.parent)!) {
1188
- const d = Math.hypot(bone.worldX - a.worldX, bone.worldY - a.worldY);
1189
- if (d > 1e-6) {
1190
- neckRestLen = d;
1191
- 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
+ }
1192
1235
  }
1193
1236
  }
1194
1237
  const maxNod = !inPlace && neckRestLen > 1e-6 ? 0.02 * neckRestLen : Infinity;
1195
- const refEN = rEye.map((re, i) => -((re[1] + lEye[i]![1]) / 2 - neckSeries[i]![1]) / (poseScale?.[i] ?? 1));
1196
- 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
+ });
1197
1259
  if (dy.some((v) => Math.abs(r3(v)) > 0)) {
1198
1260
  const keys: MotionKey[] = [];
1199
1261
  const nod: Pt[] = [];
1262
+ const ref0 = refEN[0]! / (poseScale?.[0] ?? 1);
1200
1263
  for (let i = 0; i < n; i++) {
1201
1264
  const wy = dy[i]!;
1265
+ const wy0 = Math.max(-maxNod, Math.min(maxNod, (refEN[i]! / (poseScale?.[i] ?? 1) - ref0) * nodScale!));
1202
1266
  let lx = 0;
1203
1267
  let ly = r3(wy);
1204
1268
  let awx = 0;
1205
- let awy = ly;
1269
+ let awy = r3(wy0);
1206
1270
  if (parent !== null) {
1207
1271
  const r = turnAbove(parent.name, i) / DEG;
1208
1272
  const pa = Math.cos(r) * parent.a - Math.sin(r) * parent.c;
@@ -1212,8 +1276,10 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1212
1276
  const det = pa * pd - pb * pc;
1213
1277
  lx = r3((-pb * wy) / det);
1214
1278
  ly = r3((pa * wy) / det);
1215
- awx = pa * lx + pb * ly;
1216
- 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;
1217
1283
  }
1218
1284
  keys.push({ t: keyTimes[i]!, v: [lx, ly] });
1219
1285
  nod.push([awx, awy]);
@@ -1228,8 +1294,9 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1228
1294
  if (headNodTrack !== null) break;
1229
1295
  }
1230
1296
 
1231
- // --raised-by-head (#62, HQ's ruling): while a set whose arm is drawn raised shows, its wrist goes where
1232
- // 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
1233
1300
  // of the bones: every bone above the arm, the head and the trunk's reach included, has its final target
1234
1301
  // (range clamp done), so the head and the shoulder are posed from them frame by frame; the arm moves
1235
1302
  // neither. A target out of reach is placed at the reach limit along the head-relative direction
@@ -1252,14 +1319,69 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1252
1319
  const b = posedPoint(an.points[1], i);
1253
1320
  return { centre: [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2], dist: Math.hypot(b[0] - a[0], b[1] - a[1]) };
1254
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
+ };
1255
1365
  for (const s of sw.switches) {
1256
1366
  const set = map.alternates.find((a) => a.name === s.set)!;
1257
1367
  const art: ArtRange[] = [];
1258
1368
  for (const cn of s.chains) {
1259
1369
  const c = map.chains.find((x) => x.name === cn)!;
1260
1370
  const drawn = set.drawn[c.bones[0]!];
1261
- const up = headOk !== null && drawn !== undefined && isRaised(drawn);
1371
+ const up = isChainHeadPlaced(set, c, drawn);
1262
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
+ }
1263
1385
  if (up && low) {
1264
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`);
1265
1387
  continue;
@@ -1331,13 +1453,47 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1331
1453
  const w = wristOf(i);
1332
1454
  job.reference.push(ref);
1333
1455
  job.before.push([(w[0] - h.centre[0]) / rigEye, (w[1] - h.centre[1]) / rigEye]);
1334
- const target: Pt = [h.centre[0] + ref[0] * rigEye, h.centre[1] + ref[1] * rigEye];
1335
1456
  const shoulder = posedOrigin(b0, i);
1336
- job.farthest = Math.max(job.farthest, Math.hypot(target[0] - shoulder[0], target[1] - shoulder[1]));
1337
- 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);
1338
1494
  const P = place.point;
1339
1495
  job.placed.push([(P[0] - h.centre[0]) / rigEye, (P[1] - h.centre[1]) / rigEye]);
1340
- 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 });
1341
1497
  // The pose's bend side, read on the vectors the two bones carry.
1342
1498
  const side: 1 | -1 = wrap(t1[i]! + d1 - (t0[i]! + d0)) >= 0 ? 1 : -1;
1343
1499
  const sol = twoBone(shoulder, P, l0, l1, side);
@@ -1378,7 +1534,14 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1378
1534
  const r0 = (a0 + d0) / DEG;
1379
1535
  const r1 = (a1 + d1) / DEG;
1380
1536
  const W: Pt = [shoulder[0] + l0 * Math.cos(r0) + l1 * Math.cos(r1), shoulder[1] + l0 * Math.sin(r0) + l1 * Math.sin(r1)];
1381
- 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
+ }
1382
1545
  t0[i] = a0;
1383
1546
  t1[i] = a1;
1384
1547
  }
@@ -1520,6 +1683,109 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1520
1683
  targets.set(ax.bone, ax.keys.map((v, i) => wrap(rest0 + (above === null ? 0 : turn(above, i)) + v)));
1521
1684
  tracks.push({ bone: ax.bone, property: 'rotate', keys: ax.keys.map((v, i) => ({ t: keyTimes[i]!, v: [v] })) });
1522
1685
  }
1686
+ const fSets = faceYawSets(map.face);
1687
+ if (fSets !== undefined) face.sets = selectYawSets(face.read.rawYaw, fSets, keyTimes, opts.switchMin);
1688
+ }
1689
+ let body: BodyReading | null = null;
1690
+ {
1691
+ const rSh = heldPt('r_shoulder');
1692
+ const lSh = heldPt('l_shoulder');
1693
+ if (rSh !== null && lSh !== null) {
1694
+ const rHip = heldPt('r_hip');
1695
+ const lHip = heldPt('l_hip');
1696
+ const neck = heldPt('neck');
1697
+ const faceYaw = face !== null ? face.read.rawYaw : (() => {
1698
+ const nose = heldPt('nose');
1699
+ const rEye = heldPt('r_eye');
1700
+ const lEye = heldPt('l_eye');
1701
+ if (nose === null || rEye === null || lEye === null) return null;
1702
+ return readFaceAngles(nose, rEye, lEye, heldPt('r_ear'), heldPt('l_ear')).yaw;
1703
+ })();
1704
+ body = readBody(map.body, rSh, lSh, rHip, lHip, neck, !inPlace && n > 25 ? continuousScale : null, faceYaw);
1705
+ const bodyAx = body.yaw;
1706
+ if (bodyAx !== undefined) {
1707
+ const rest0 = restAngle(rest, bodyAx.bone, 'x');
1708
+ const above = mappedAbove(bodyAx.bone);
1709
+ targets.set(bodyAx.bone, bodyAx.keys.map((v, i) => wrap(rest0 + (above === null ? 0 : turn(above, i)) + v)));
1710
+ tracks.push({ bone: bodyAx.bone, property: 'rotate', keys: bodyAx.keys.map((v, i) => ({ t: keyTimes[i]!, v: [v] })) });
1711
+ }
1712
+ const bSets = bodyYawSets(map.body);
1713
+ if (bSets !== undefined) body.sets = selectYawSets(body.read.rawYaw, bSets, keyTimes, opts.switchMin);
1714
+ }
1715
+ }
1716
+ const activeYawSets: YawSetSwitch[] = [...(face?.sets ?? []), ...(body?.sets ?? [])];
1717
+ if (activeYawSets.some((s) => s.spans.length > 0)) {
1718
+ const known = knownSetsOf(map, rigSets);
1719
+ const slotOrder: string[] = [];
1720
+ for (const tr of sw.slotTracks) if (!slotOrder.includes(tr.slot)) slotOrder.push(tr.slot);
1721
+ for (const s of activeYawSets) {
1722
+ const def = known.get(s.set);
1723
+ if (def === undefined) continue;
1724
+ for (const sl of [...Object.keys(def.slots), ...def.hides]) {
1725
+ if (!slotOrder.includes(sl)) slotOrder.push(sl);
1726
+ }
1727
+ }
1728
+ const yawExactAt = new Map<string, Set<number>>();
1729
+ for (const s of activeYawSets) {
1730
+ const def = known.get(s.set);
1731
+ if (def === undefined) continue;
1732
+ const values: Array<[string, string | null]> = [...Object.entries(def.slots), ...def.hides.map((h): [string, null] => [h, null])];
1733
+ for (const [sl, v] of values) {
1734
+ if (!sw.shown.has(sl)) sw.shown.set(sl, Array.from({ length: n }, () => slots.get(sl)?.setup ?? null));
1735
+ const row = sw.shown.get(sl)!;
1736
+ for (const [lo, hi] of s.spans) {
1737
+ for (let i = lo; i <= hi; i++) row[i] = v;
1738
+ const exact = yawExactAt.get(sl) ?? new Set<number>();
1739
+ exact.add(lo);
1740
+ if (hi + 1 < n) exact.add(hi + 1);
1741
+ yawExactAt.set(sl, exact);
1742
+ }
1743
+ }
1744
+ }
1745
+ const rebuilt: SlotTrack[] = [];
1746
+ for (const sl of slotOrder) {
1747
+ const row = sw.shown.get(sl);
1748
+ if (row === undefined) continue;
1749
+ let prev = slots.get(sl)?.setup ?? null;
1750
+ const keys: AttachmentKey[] = [];
1751
+ const exact = yawExactAt.get(sl);
1752
+ row.forEach((v, i) => {
1753
+ if (v === prev) return;
1754
+ const t = i === 0 ? 0 : exact?.has(i) === true ? keyTimes[i]! : r6((keyTimes[i - 1]! + keyTimes[i]!) / 2);
1755
+ keys.push({ t, v });
1756
+ prev = v;
1757
+ });
1758
+ if (keys.length > 0) rebuilt.push({ slot: sl, property: 'attachment', keys });
1759
+ }
1760
+ sw.slotTracks.splice(0, sw.slotTracks.length, ...rebuilt);
1761
+ }
1762
+ if (activeYawSets.length > 0) {
1763
+ const known = knownSetsOf(map, rigSets);
1764
+ const otherSlotKeyers = new Map<string, string[]>();
1765
+ const addKeyer = (sl: string, who: string) => {
1766
+ const list = otherSlotKeyers.get(sl) ?? [];
1767
+ if (!list.includes(who)) list.push(who);
1768
+ otherSlotKeyers.set(sl, list);
1769
+ };
1770
+ for (const aSw of sw.switches) {
1771
+ for (const sl of [...Object.keys(aSw.slots), ...aSw.hides]) addKeyer(sl, `set "${aSw.set}"`);
1772
+ }
1773
+ for (const s of activeYawSets) {
1774
+ if (s.spans.length === 0) continue;
1775
+ const def = known.get(s.set);
1776
+ if (def === undefined) continue;
1777
+ for (const sl of [...Object.keys(def.slots), ...def.hides]) addKeyer(sl, `set "${s.set}"`);
1778
+ }
1779
+ for (const s of activeYawSets) {
1780
+ const def = known.get(s.set);
1781
+ if (def === undefined) continue;
1782
+ for (const h of def.hides) {
1783
+ const keyers = (otherSlotKeyers.get(h) ?? []).filter((who) => who !== `set "${s.set}"`);
1784
+ if (keyers.length > 0) {
1785
+ s.hidesKeyed.push({ slot: h, by: `slot "${h}" attachment (${keyers.join(', ')})` });
1786
+ }
1787
+ }
1788
+ }
1523
1789
  }
1524
1790
  // Scale keys: a scaled leg segment's own scale, or the scale that lands a bone under one at its asked length.
1525
1791
  for (const c of map.chains) {
@@ -1539,7 +1805,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1539
1805
  animation: { duration: keyTimes[n - 1]!, tracks },
1540
1806
  times: keyTimes, targets, origin, holds, bends, short, endOn, flips, scale,
1541
1807
  switches: sw.switches, shown: sw.shown, distances: sw.distances, turns, turnsUnread, range, artRange,
1542
- legScale, scales, feet, planted: plantOn, stance, floor, blendHold, closure, ankles, pivot: pivotOf(rest, t?.bone ?? null, legs), raised, byHip, face, figureScale,
1808
+ legScale, scales, feet, planted: plantOn, stance, floor, blendHold, closure, ankles, pivot: pivotOf(rest, t?.bone ?? null, legs), raised, byHip, face, body, figureScale,
1543
1809
  };
1544
1810
  }
1545
1811
 
package/src/rigc.ts CHANGED
@@ -13,7 +13,7 @@ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'no
13
13
  import { dirname, join, resolve } from 'node:path';
14
14
  import { fileURLToPath } from 'node:url';
15
15
  import { poseRawSetup, readModel, type CompiledDocument } from 'rig-c/core';
16
- import type { InheritMode, Rest, RestBone, RigSlot, RigSlots } from './map.ts';
16
+ import type { InheritMode, Rest, RestBone, RigSet, RigSets, RigSlot, RigSlots } from './map.ts';
17
17
  import { isObject, parseJson, Problems, Refusal, typeName } from './problems.ts';
18
18
 
19
19
  export interface RigInput {
@@ -23,6 +23,32 @@ export interface RigInput {
23
23
  name: string;
24
24
  /** The motion spec the rig is built with, or null when it has none. */
25
25
  motion: string | null;
26
+ /** Any alternate sets declared in `sets.json` (`rig-parts-sets/1`) beside `rig.json`. */
27
+ sets: RigSets;
28
+ }
29
+
30
+ function readRigSets(path: string): RigSets {
31
+ const out = new Map<string, RigSet>();
32
+ if (!existsSync(path)) return out;
33
+ const doc = parseJson(readFileSync(path, 'utf8'), path, 'SETS_JSON');
34
+ if (!isObject(doc) || (doc.spec !== 'rig-parts-sets/1' && doc.spec !== 'spine-parts-sets/1') || (!isObject(doc.sets) && !Array.isArray(doc.sets))) {
35
+ throw new Refusal([{ code: 'SETS_SPEC', text: `${path} is not a rig-parts-sets/1 spec with a "sets" object` }]);
36
+ }
37
+ const addSet = (name: string, s: unknown) => {
38
+ if (!isObject(s) || !isObject(s.slots)) return;
39
+ const slots: Record<string, string> = {};
40
+ for (const [k, v] of Object.entries(s.slots)) if (typeof v === 'string') slots[k] = v;
41
+ const hides = Array.isArray(s.hides) ? s.hides.filter((h): h is string => typeof h === 'string') : [];
42
+ out.set(name, { slots, hides });
43
+ };
44
+ if (Array.isArray(doc.sets)) {
45
+ for (const s of doc.sets) {
46
+ if (isObject(s) && typeof s.name === 'string') addSet(s.name, s);
47
+ }
48
+ } else {
49
+ for (const [name, s] of Object.entries(doc.sets)) addSet(name, s);
50
+ }
51
+ return out;
26
52
  }
27
53
 
28
54
  /**
@@ -49,7 +75,8 @@ export function resolveRig(path: string, baseMotion: string | undefined): RigInp
49
75
  if (!isObject(doc) || doc.spec !== 'rigc-rig/1' || typeof doc.name !== 'string') {
50
76
  throw new Refusal([{ code: 'RIG_SPEC', text: `${rig} is not a rig spec: "spec" is ${isObject(doc) ? typeName(doc.spec) : typeName(doc)} and "name" ${isObject(doc) ? typeName(doc.name) : '-'}; it must be "rigc-rig/1" with a name` }]);
51
77
  }
52
- return { rig, name: doc.name, motion };
78
+ const sets = readRigSets(join(dirname(rig), 'sets.json'));
79
+ return { rig, name: doc.name, motion, sets };
53
80
  }
54
81
 
55
82
  /** The motion spec the rig is built with, or an empty one under its name. */
package/src/run.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
8
8
  import { tmpdir } from 'node:os';
9
9
  import { dirname, join, resolve } from 'node:path';
10
- import { readMap, resolveMap, type JointMap, type Rest } from './map.ts';
10
+ import { knownSetsOf, readMap, resolveMap, type JointMap, type Rest } from './map.ts';
11
11
  import { cleanLine, inferredSource } from './clean.ts';
12
12
  import { convertCoco17, personTrack, readPoseSequence, TRAVEL_IN_PLACE, type CameraFrame } from './pose.ts';
13
13
  import { isObject, lineOf, parseJson, Problems, Refusal, type Problem } from './problems.ts';
@@ -19,7 +19,7 @@ import { landed, measureFeet, measureLanding, measurePosture, measureShear, pose
19
19
  import { legsOf, PLANT_SWITCH, STANCE_BOUNDS } from './legs.ts';
20
20
  import { M2_IDENTITY, mulM2, reduceKeys, reduceScale, reduceUnderScale, SCALE_REDUCE, setupLocal, smoothTrunk, type M2, type ReduceBone, type Reduced, type ReduceRow, type UnderBone } from './reduce.ts';
21
21
  import type { BlendHold, Closure, Floor, LegScale, Lowered, Side, Stance, SupportScaleRun } from './legs.ts';
22
- import { faceLine } from './face.ts';
22
+ import { bodyLine, faceLine } from './face.ts';
23
23
 
24
24
  export interface RetargetArgs extends RetargetOptions {
25
25
  rig: string;
@@ -108,9 +108,25 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
108
108
  const rigc = rigcVersion();
109
109
  const rest = restOf(doc0);
110
110
  const slots = slotsOf(doc0);
111
- resolveMap(map, rest, a.map, slots);
111
+ resolveMap(map, rest, a.map, slots, input.sets);
112
112
 
113
- const r = retarget(rest, map, frames, times, { ...a, ...(seq.travel !== undefined ? { travel: seq.travel } : {}) }, slots);
113
+ const r = retarget(rest, map, frames, times, { ...a, ...(seq.travel !== undefined ? { travel: seq.travel } : {}) }, slots, input.sets);
114
+ if (fragment.length > 0 && ((r.face?.sets?.length ?? 0) > 0 || (r.body?.sets?.length ?? 0) > 0)) {
115
+ const known = knownSetsOf(map, input.sets);
116
+ for (const s of [...(r.face?.sets ?? []), ...(r.body?.sets ?? [])]) {
117
+ const def = known.get(s.set);
118
+ if (def === undefined) continue;
119
+ for (const h of def.hides) {
120
+ for (const tr of fragment) {
121
+ const tgt = targetOf(tr);
122
+ if (tgt?.kind === 'slot' && tgt.name === h) {
123
+ const by = `slot "${h}" ${tr.property} (--merge ${a.merge})`;
124
+ if (!s.hidesKeyed.some((x) => x.slot === h && x.by === by)) s.hidesKeyed.push({ slot: h, by });
125
+ }
126
+ }
127
+ }
128
+ }
129
+ }
114
130
  say(`rig-pose retarget: ${seq.frames.length} frame(s), ${r.animation.duration.toFixed(3)}s, onto "${input.name}" as "${a.name}" — ${r.animation.tracks.length} track(s); min-score ${a.minScore}, flip ${a.flip === 'off' ? 'off' : `${a.flip}°`}, short ${a.short}, bends ${a.bends} (tolerance ${a.bendTolerance}°)${a.legScale !== undefined && a.legScale !== 'off' ? `, leg-scale ${a.legScale[0]}–${a.legScale[1]}` : ''}${a.plantFeet === true ? ', plant-feet' : ''}${a.stance !== undefined && a.stance !== 'off' ? `, stance ${a.stance}` : ''}${a.raisedByHead === true ? ', raised-by-head' : ''}${a.carryIdle === true ? ', carry-idle' : ''}${a.merge !== undefined ? `, merge ${a.merge}` : ''}${a.reduce !== undefined ? `, reduce ${a.reduce}°${a.reduceHip !== undefined ? ` (hip ${a.reduceHip} units)` : ''}` : ''}`);
115
131
  say(` RIGC rig-c ${rigc.version} (${rigc.entry}) gates and poses this run`);
116
132
  say(` CLEAN ${cleanLine(seq.cleaned!)}`);
@@ -161,6 +177,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
161
177
  }
162
178
  if (r.stance !== null) for (const l of stanceLines(r.stance, r.times)) say(` STANCE ${l}`);
163
179
  if (r.face !== null) say(` FACE ${faceLine(r.face)}`);
180
+ if (r.body !== null) say(` BODY ${bodyLine(r.body)}`);
164
181
 
165
182
  // --reduce (#32): the dense keys thinned top-down, before anything reads the tracks.
166
183
  const reduced = a.reduce === undefined ? null : reduceTracks(r, map, rest, a.reduce, a.reduceHip);
@@ -192,6 +209,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
192
209
  ...(map.translate === null ? [] : [map.translate.bone]),
193
210
  ...(map.face?.yaw === undefined ? [] : [map.face.yaw.bone]),
194
211
  ...(map.face?.pitch === undefined ? [] : [map.face.pitch.bone]),
212
+ ...(map.body?.yaw === undefined ? [] : [map.body.yaw.bone]),
195
213
  ];
196
214
  const below = new Map<string, string[]>();
197
215
  for (const m of new Set(mapped)) {
@@ -283,7 +301,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
283
301
  else if (f.meshes.length === 0) say(` FOLD rigc render --folds at ${fps} fps: no mesh folds`);
284
302
  else for (const m of f.meshes) say(` FOLD mesh "${m.attachment}" (slot "${m.slot}") folds on ${m.times.length} frame(s) at ${fps} fps: t=${spans(m.times, 1 / fps)}`);
285
303
  const uncovered = f.meshes === null ? null : uncoveredFolds(f.meshes, r.shown, r.times, slots, (slot, att) => chainsMoving(attachmentBones(doc, slot, att), rest, map));
286
- if (uncovered !== null && map.alternates.length > 0) {
304
+ if (uncovered !== null && (map.alternates.length > 0 || (r.face?.sets?.length ?? 0) > 0 || (r.body?.sets?.length ?? 0) > 0)) {
287
305
  for (const u of uncovered) say(` UNCOVERED mesh "${u.attachment}" (slot "${u.slot}"; chains ${u.chains.length === 0 ? 'none' : u.chains.join(', ')}) folds with no switch covering it on ${u.times.length} frame(s): t=${spans(u.times, 1 / fps)}`);
288
306
  }
289
307
 
@@ -363,8 +381,19 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
363
381
  read: { yaw: r.face.read.yaw, pitch: r.face.read.pitch },
364
382
  ...(r.face.yaw === undefined ? {} : { yaw: { bone: r.face.yaw.bone, limits: r.face.yaw.limits, knee: r.face.yaw.knee, read: r.face.yaw.read, mapped: r.face.yaw.mapped, scale: r.face.yaw.scale, corr: r.face.yaw.corr } }),
365
383
  ...(r.face.pitch === undefined ? {} : { pitch: { bone: r.face.pitch.bone, limits: r.face.pitch.limits, knee: r.face.pitch.knee, read: r.face.pitch.read, mapped: r.face.pitch.mapped, scale: r.face.pitch.scale, corr: r.face.pitch.corr } }),
384
+ ...(r.face.sets === undefined ? {} : { sets: r.face.sets }),
366
385
  },
367
386
  }),
387
+ body: r.body === null
388
+ ? null
389
+ : {
390
+ standing: r.body.standing,
391
+ read: { yaw: r.body.read.yaw, opening: r.body.read.opening },
392
+ over45: r.body.over45,
393
+ framesOver45: r.body.framesOver45,
394
+ ...(r.body.yaw === undefined ? {} : { yaw: { bone: r.body.yaw.bone, limits: r.body.yaw.limits, knee: r.body.yaw.knee, read: r.body.yaw.read, mapped: r.body.yaw.mapped, scale: r.body.yaw.scale, corr: r.body.yaw.corr } }),
395
+ ...(r.body.sets === undefined ? {} : { sets: r.body.sets }),
396
+ },
368
397
  legScale: r.legScale.map(({ scale: _scale, asked: _asked, ...rest }) => rest),
369
398
  pivot: r.pivot,
370
399
  stance: r.stance === null ? null : { tolerance: r.stance.tolerance, release: r.stance.release, minSeconds: r.stance.minSeconds, bounds: r.stance.bounds, ...(r.stance.range === undefined ? {} : { range: r.stance.range }), runs: r.stance.runs, over: r.stance.over },