@rig-c/pose 0.18.0 → 0.20.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
@@ -163,18 +163,29 @@ export interface Flip {
163
163
  clipped: 'start' | 'end' | 'both' | null;
164
164
  }
165
165
 
166
- /** One span of frames under the short cutoff: the segment's angle there was interpolated, not read (`holdShort`). */
166
+ /**
167
+ * One span of frames under `--short` (angle interpolated, `holdShort`) or under
168
+ * `SHORT_STEP_FRACTION` with a one-frame step over `SHORT_STEP_CAP` (step
169
+ * capped, `capShortSteps`, #110).
170
+ */
167
171
  export interface Short {
168
172
  chain: string;
169
173
  bone: string;
170
174
  /** The segment the bone carries: from joint, to joint. */
171
175
  joints: [string, string];
172
- /** First and last frame under the cutoff; every one of them had its angle replaced, and the frames either side keep the input's. */
176
+ /** First and last frame whose angle was replaced; the frames either side keep the input's. */
173
177
  frames: [number, number];
174
178
  /** The lowest picture length on those frames, as a fraction of the segment's median picture length. */
175
179
  minFraction: number;
180
+ /** Present when a foreshortened segment (< `SHORT_STEP_FRACTION`) had its per-frame step capped (#110): the one-frame step after capping and the largest step asked, degrees, signed. */
181
+ step?: number;
182
+ asked?: number;
176
183
  }
177
184
 
185
+ /** A limb segment shorter than this fraction of its median picture length has its per-frame angle step capped at `SHORT_STEP_CAP` degrees (rig-c/pose#110). */
186
+ export const SHORT_STEP_FRACTION = 0.6;
187
+ export const SHORT_STEP_CAP = 30;
188
+
178
189
  /**
179
190
  * A segment under the short cutoff long enough to be a pose (the limb pointing
180
191
  * at the camera), not a blur: short spans merged across a gap of `END_ON_GAP`
@@ -571,6 +582,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
571
582
  // against its own median, on the points the angle was read from.
572
583
  const short: Short[] = [];
573
584
  const endOn: EndOn[] = [];
585
+ const fractions = new Map<string, { chain: string; joints: [string, string]; fraction: number[] }>();
574
586
  if (opts.short !== 'off') {
575
587
  const cutoff = opts.short;
576
588
  for (const c of map.chains) {
@@ -586,7 +598,9 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
586
598
  p.add('POSE_SHORT_MEDIAN', `chain "${c.name}" — bone "${b}"'s segment ${joints[0]} → ${joints[1]} has median picture length ${med}px over the ${n} frames; --short measures a frame's length against that median, and there is none to measure against`);
587
599
  return;
588
600
  }
589
- const spans = holdShort(targets.get(b)!, lengths.map((l) => l / med), cutoff);
601
+ const fraction = lengths.map((l) => l / med);
602
+ fractions.set(b, { chain: c.name, joints, fraction });
603
+ const spans = holdShort(targets.get(b)!, fraction, cutoff);
590
604
  for (const s of spans) short.push({ chain: c.name, bone: b, joints, ...s });
591
605
  for (const run of mergeRuns(spans, END_ON_GAP)) {
592
606
  const seconds = Math.round((endOf(run.frames[1]) - keyTimes[run.frames[0]]!) * 1e6) / 1e6;
@@ -692,12 +706,16 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
692
706
  if (opts.floor !== undefined && !plantOn) p.add('FLOOR', `--floor ${opts.floor} is the floor --plant-feet plants every support span on, and --plant-feet is not given`);
693
707
  if (opts.floor !== undefined && !Number.isFinite(opts.floor)) p.add('FLOOR', `--floor is ${opts.floor}; it must be a finite world y`);
694
708
  p.throwIfAny();
695
- /** Every bone on the legs' path posed on frame i, by the targets as they stand. */
696
- const poseLegs = (i: number): PathPose => {
697
- const r = posePath(rest, legPath!, (b): Posing | null => (targets.has(b) ? { turn: restTurn(b, i), target: targets.get(b)![i]!, aimX: aimX.get(b)! } : null), (b) => scales.get(b)?.[i] ?? null);
709
+ const askedScales = new Map<string, number[]>();
710
+ for (const ls of legScale) askedScales.set(ls.bone, ls.asked);
711
+ /** Every bone on the legs' path posed on frame i, by the targets and the given scale series. */
712
+ const poseLegsWith = (i: number, sc: ReadonlyMap<string, number[]>): PathPose => {
713
+ const r = posePath(rest, legPath!, (b): Posing | null => (targets.has(b) ? { turn: restTurn(b, i), target: targets.get(b)![i]!, aimX: aimX.get(b)! } : null), (b) => sc.get(b)?.[i] ?? null);
698
714
  if (typeof r === 'string') throw new Refusal([{ code: 'LEGS_MAP', text: r }]);
699
715
  return r;
700
716
  };
717
+ /** Every bone on the legs' path posed on frame i, by the targets as they stand. */
718
+ const poseLegs = (i: number): PathPose => poseLegsWith(i, scales);
701
719
  if (plantOn) {
702
720
  // The plant is derived from the targets before the range clamp, so nothing after may turn a bone on the legs' path.
703
721
  const onPath = new Set(legPath!);
@@ -706,15 +724,18 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
706
724
  for (const b of [...Object.keys(a.turn), ...Object.keys(a.offsets)]) if (onPath.has(b)) p.add('LEGS_RANGE', `--plant-feet places the hip through bone "${b}", which set "${a.name}" turns or offsets; that would move the planted foot — drop it from the set or --plant-feet`);
707
725
  }
708
726
  p.throwIfAny();
727
+ // #107: the clamp floor holds the bone, not the hip; the plant places the hip from the scale the pose asked for.
709
728
  const offsets = Array.from({ length: n }, (_, i) => {
710
- const lp = poseLegs(i);
729
+ const lp = poseLegsWith(i, askedScales);
711
730
  return { r: tipOf(rest, lp, legs !== null && 'legs' in legs ? legs.legs.r.ankleBone : ''), l: tipOf(rest, lp, legs !== null && 'legs' in legs ? legs.legs.l.ankleBone : '') };
712
731
  });
713
732
  const tb = rest.get(t!.bone)!;
714
733
  // #85: every support span on one floor — the median of their planted heights, or --floor.
715
734
  // #92: in place, the supporting ankle's own picture x, scaled, is the track its anchor follows.
716
- const follow = inPlace ? (i: number, s: Side) => series.get(`${s}_ankle`)![i]![0] * scale!.factor : undefined;
717
- const planted = plantHip(feet!, (i, s) => offsets[i]![s], (i) => poseTravel[i]!, [tb.worldX, tb.worldY], opts.floor, follow);
735
+ // #119: a tracked support follows the lower ankle's own picture x, scaled, on the floor until it is still.
736
+ const track = (i: number, s: Side) => series.get(`${s}_ankle`)![i]![0] * scale!.factor;
737
+ const follow = inPlace ? track : undefined;
738
+ const planted = plantHip(feet!, (i, s) => offsets[i]![s], (i) => poseTravel[i]!, [tb.worldX, tb.worldY], opts.floor, follow, track);
718
739
  const hip = planted.hip;
719
740
  floor = planted.floor;
720
741
  closure = planted.closure;
@@ -805,16 +826,20 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
805
826
  * offset turned by the nearest mapped bone above — the key formula's own
806
827
  * assumption (unit scale, no shear), which the landing check holds to account.
807
828
  */
829
+ let headNodBone: string | null = null;
830
+ let headNod: Pt[] | null = null;
831
+ let headNodTrack: MotionTrack | null = null;
808
832
  const posedOrigin = (b: string, i: number): Pt => {
809
833
  const own = rest.get(b)!;
810
834
  if (t !== null && b === t.bone) return [own.worldX + (travel[i]?.[0] ?? 0), own.worldY + (travel[i]?.[1] ?? 0)];
811
- if (own.parent === null) return [own.worldX, own.worldY];
835
+ const nod = b === headNodBone ? (headNod?.[i] ?? [0, 0]) : [0, 0];
836
+ if (own.parent === null) return [own.worldX + nod[0], own.worldY + nod[1]];
812
837
  const parent = own.parent;
813
838
  const po = posedOrigin(parent, i);
814
839
  const r = turnAbove(parent, i) / DEG;
815
840
  const ox = own.worldX - rest.get(parent)!.worldX;
816
841
  const oy = own.worldY - rest.get(parent)!.worldY;
817
- return [po[0] + Math.cos(r) * ox - Math.sin(r) * oy, po[1] + Math.sin(r) * ox + Math.cos(r) * oy];
842
+ return [po[0] + Math.cos(r) * ox - Math.sin(r) * oy + nod[0], po[1] + Math.sin(r) * ox + Math.cos(r) * oy + nod[1]];
818
843
  };
819
844
  /** A rig point (a bone, `name:tip`, `a+b`) posed on frame i the same way. */
820
845
  const posedPoint = (spec: string, i: number): Pt => {
@@ -868,6 +893,13 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
868
893
  };
869
894
  const turn = (bone: string, i: number) => targets.get(bone)![i]! - restOf.get(bone)!;
870
895
  const covered = coveredChains(sw.switches, n);
896
+ if (opts.short !== 'off') {
897
+ for (const [b, info] of fractions) {
898
+ for (const s of capShortSteps(targets.get(b)!, info.fraction, covered.get(info.chain))) {
899
+ short.push({ chain: info.chain, bone: b, joints: info.joints, ...s });
900
+ }
901
+ }
902
+ }
871
903
  const range: RangeDemand[] = [];
872
904
  const order = map.chains.flatMap((c) => c.bones.map((b) => ({ c, b }))).filter(({ c, b }) => c.reach || b in c.range);
873
905
  order.sort((x, y) => depth(x.b) - depth(y.b));
@@ -895,6 +927,74 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
895
927
  }
896
928
  p.throwIfAny();
897
929
 
930
+ // #111: the head chain's nod channel — the eyes' vertical travel against the
931
+ // neck, measured in the rig's own units, keyed as a translate on the head bone
932
+ // (whose rotate track carries roll only).
933
+ for (const c of map.chains) {
934
+ for (let k = 0; k + 1 < c.joints.length; k++) {
935
+ const ja = c.joints[k]!;
936
+ const jb = c.joints[k + 1]!;
937
+ if (!((ja === 'r_eye' && jb === 'l_eye') || (ja === 'l_eye' && jb === 'r_eye'))) continue;
938
+ const hb = c.bones[k]!;
939
+ const ha = c.aim[k]!;
940
+ if (t?.bone === hb || sw.offsetTracks.some((tr) => tr.bone === hb)) break;
941
+ let underScaled = false;
942
+ for (let a = rest.get(hb)!.parent; a !== null; a = rest.get(a)!.parent) if (scales.has(a)) underScaled = true;
943
+ if (underScaled) break;
944
+ const rEye = series.get('r_eye')!;
945
+ const lEye = series.get('l_eye')!;
946
+ const neckSeries = series.get('neck') ?? (() => {
947
+ const h = heldOf('neck');
948
+ return 'never' in h ? undefined : h.at;
949
+ })();
950
+ let nodScale: number | null = scale?.factor ?? null;
951
+ if (nodScale === null && ha.includes('>')) {
952
+ const [pa, pb] = aimEnds(hb, ha);
953
+ const a0 = rigPoint(rest, pa)!;
954
+ const b0 = rigPoint(rest, pb)!;
955
+ const rigEye = Math.hypot(b0[0] - a0[0], b0[1] - a0[1]);
956
+ const refEye = median(rEye.map((re, i) => Math.hypot(lEye[i]![0] - re[0], lEye[i]![1] - re[1])));
957
+ if (refEye > 1e-6 && rigEye > 1e-6) nodScale = rigEye / refEye;
958
+ }
959
+ if (neckSeries !== undefined && nodScale !== null) {
960
+ const refEN = rEye.map((re, i) => -((re[1] + lEye[i]![1]) / 2 - neckSeries[i]![1]));
961
+ const dy = refEN.map((v) => (v - refEN[0]!) * nodScale);
962
+ if (dy.some((v) => Math.abs(r3(v)) > 0)) {
963
+ const bone = rest.get(hb)!;
964
+ const parent = bone.parent === null ? null : rest.get(bone.parent)!;
965
+ const keys: MotionKey[] = [];
966
+ const nod: Pt[] = [];
967
+ for (let i = 0; i < n; i++) {
968
+ const wy = dy[i]!;
969
+ let lx = 0;
970
+ let ly = r3(wy);
971
+ let awx = 0;
972
+ let awy = ly;
973
+ if (parent !== null) {
974
+ const r = turnAbove(parent.name, i) / DEG;
975
+ const pa = Math.cos(r) * parent.a - Math.sin(r) * parent.c;
976
+ const pb = Math.cos(r) * parent.b - Math.sin(r) * parent.d;
977
+ const pc = Math.sin(r) * parent.a + Math.cos(r) * parent.c;
978
+ const pd = Math.sin(r) * parent.b + Math.cos(r) * parent.d;
979
+ const det = pa * pd - pb * pc;
980
+ lx = r3((-pb * wy) / det);
981
+ ly = r3((pa * wy) / det);
982
+ awx = pa * lx + pb * ly;
983
+ awy = pc * lx + pd * ly;
984
+ }
985
+ keys.push({ t: keyTimes[i]!, v: [lx, ly] });
986
+ nod.push([awx, awy]);
987
+ }
988
+ headNodBone = hb;
989
+ headNod = nod;
990
+ headNodTrack = { bone: hb, property: 'translate', keys };
991
+ }
992
+ }
993
+ break;
994
+ }
995
+ if (headNodTrack !== null) break;
996
+ }
997
+
898
998
  // --raised-by-head (#62, HQ's ruling): while a set whose arm is drawn raised shows, its wrist goes where
899
999
  // the reference's is against the head, in eye distances (each side's median over the span). Solved last
900
1000
  // of the bones: every bone above the arm, the head and the trunk's reach included, has its final target
@@ -1176,6 +1276,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1176
1276
  }
1177
1277
  }
1178
1278
  if (translateTrack !== null) tracks.push(translateTrack);
1279
+ if (headNodTrack !== null) tracks.push(headNodTrack);
1179
1280
  tracks.push(...sw.offsetTracks, ...sw.slotTracks);
1180
1281
 
1181
1282
  return {
@@ -1302,10 +1403,97 @@ export function mergeRuns(spans: readonly ShortSpan[], gap: number): ShortSpan[]
1302
1403
  return out;
1303
1404
  }
1304
1405
 
1406
+ /**
1407
+ * Cap one-frame steps on a foreshortened segment (#110), in place. Where a
1408
+ * segment's picture length is under `SHORT_STEP_FRACTION` (0.60) of its median
1409
+ * on either side of a frame step (above `--short`'s hold cutoff, where two end-on
1410
+ * pixels still swing the angle faster than the limb moved) and the one-frame turn
1411
+ * exceeds `SHORT_STEP_CAP` (30°), spread the turn across a symmetric window of
1412
+ * frames the same way `spreadFlips` does (`lo = k - h - 1`, `hi = k + h`), bounded
1413
+ * by the ends of the pose and by any active alternate set on the chain (`covered`).
1414
+ */
1415
+ export function capShortSteps(
1416
+ angles: number[],
1417
+ fraction: readonly number[],
1418
+ covered?: readonly boolean[],
1419
+ fractionCutoff: number = SHORT_STEP_FRACTION,
1420
+ stepCap: number = SHORT_STEP_CAP,
1421
+ ): ShortSpan[] {
1422
+ const n = angles.length;
1423
+ if (n < 2) return [];
1424
+ const out: ShortSpan[] = [];
1425
+ for (let segStart = 0; segStart < n;) {
1426
+ if (covered?.[segStart] === true) {
1427
+ segStart++;
1428
+ continue;
1429
+ }
1430
+ let segEnd = segStart;
1431
+ while (segEnd + 1 < n && covered?.[segEnd + 1] !== true) segEnd++;
1432
+ if (segEnd - segStart >= 1) {
1433
+ const u: number[] = new Array<number>(n).fill(0);
1434
+ u[segStart] = angles[segStart]!;
1435
+ for (let i = segStart + 1; i <= segEnd; i++) u[i] = u[i - 1]! + wrap(angles[i]! - angles[i - 1]!);
1436
+ const fits = (lo: number, hi: number) => Math.abs(u[hi]! - u[lo]!) <= stepCap * (hi - lo);
1437
+ interface Win { lo: number; hi: number; asked: number }
1438
+ const wins: Win[] = [];
1439
+ for (let k = segStart + 1; k <= segEnd; k++) {
1440
+ if (!(Math.min(fraction[k - 1]!, fraction[k]!) < fractionCutoff)) continue;
1441
+ const jump = u[k]! - u[k - 1]!;
1442
+ if (Math.abs(jump) <= stepCap) continue;
1443
+ let h = 1;
1444
+ let lo = Math.max(segStart, k - h - 1);
1445
+ let hi = Math.min(segEnd, k + h);
1446
+ while (!fits(lo, hi) && (lo > segStart || hi < segEnd)) {
1447
+ h++;
1448
+ lo = Math.max(segStart, k - h - 1);
1449
+ hi = Math.min(segEnd, k + h);
1450
+ }
1451
+ wins.push({ lo, hi, asked: jump });
1452
+ }
1453
+ for (let changed = true; changed;) {
1454
+ changed = false;
1455
+ wins.sort((a, b) => a.lo - b.lo);
1456
+ for (let i = 0; i + 1 < wins.length; i++) {
1457
+ const a = wins[i]!;
1458
+ const b = wins[i + 1]!;
1459
+ if (b.lo >= a.hi) continue;
1460
+ wins.splice(i, 2, { lo: a.lo, hi: Math.max(a.hi, b.hi), asked: Math.abs(b.asked) > Math.abs(a.asked) ? b.asked : a.asked });
1461
+ changed = true;
1462
+ break;
1463
+ }
1464
+ if (changed) continue;
1465
+ for (const w of wins) {
1466
+ if (fits(w.lo, w.hi) || (w.lo === segStart && w.hi === segEnd)) continue;
1467
+ w.lo = Math.max(segStart, w.lo - 1);
1468
+ w.hi = Math.min(segEnd, w.hi + 1);
1469
+ changed = true;
1470
+ }
1471
+ }
1472
+ for (const w of wins) {
1473
+ if (w.lo + 1 > w.hi - 1) continue;
1474
+ const step = (u[w.hi]! - u[w.lo]!) / (w.hi - w.lo);
1475
+ for (let i = w.lo + 1; i < w.hi; i++) angles[i] = wrap(u[w.lo]! + step * (i - w.lo));
1476
+ let minFraction = Infinity;
1477
+ for (let i = w.lo + 1; i <= w.hi - 1; i++) minFraction = Math.min(minFraction, fraction[i]!);
1478
+ if (!(minFraction < fractionCutoff)) {
1479
+ minFraction = Math.min(minFraction, fraction[w.lo]!, fraction[w.hi]!);
1480
+ }
1481
+ out.push({ frames: [w.lo + 1, w.hi - 1], minFraction, step: r3(step), asked: r3(w.asked) });
1482
+ }
1483
+ }
1484
+ segStart = segEnd + 1;
1485
+ }
1486
+ return out;
1487
+ }
1488
+
1305
1489
  const spanOf = (frames: [number, number], times: readonly number[]): string =>
1306
1490
  frames[0] === frames[1] ? `frame ${frames[0]} (t=${times[frames[0]]!.toFixed(3)}s)` : `frames ${frames[0]}–${frames[1]} (t=${times[frames[0]]!.toFixed(3)}–${times[frames[1]]!.toFixed(3)}s)`;
1307
1491
 
1308
1492
  export function shortLine(s: Short, times: readonly number[], cutoff: number): string {
1493
+ if (s.step !== undefined && s.asked !== undefined) {
1494
+ const sg = (v: number) => `${v >= 0 ? '+' : '−'}${Math.abs(v).toFixed(1)}°`;
1495
+ return `chain "${s.chain}" bone "${s.bone}" (${s.joints[0]} → ${s.joints[1]}) ${spanOf(s.frames, times)}: picture length down to ${s.minFraction.toFixed(2)} of its median, under ${SHORT_STEP_FRACTION.toFixed(2)}, turned ${sg(s.asked)} in one frame (over ${SHORT_STEP_CAP}°); spread to ${sg(s.step)} per frame`;
1496
+ }
1309
1497
  const lo = s.frames[0] - 1;
1310
1498
  const hi = s.frames[1] + 1;
1311
1499
  const held = lo < 0 && hi >= times.length
package/src/run.ts CHANGED
@@ -146,6 +146,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
146
146
  say(` FEET supports: ${f.supports.map((x) => `${x.side} ${span(x.frames)}`).join(', ') || 'none'}`);
147
147
  say(` FEET jumps (both ankles and mid_hip > 0.05 leg lengths above their rolling floor, ≥ 2 frames): ${f.jumps.map(span).join(', ') || 'none'}`);
148
148
  if (f.lowered !== undefined) say(` FEET ${loweredLine(f.lowered)}`);
149
+ if (f.tracked !== undefined) say(` FEET tracked (the lower foot supports on the floor, following its scaled ankle track until still, rig-c/pose#119): ${f.tracked.map((x) => `${x.side} ${span(x.frames)}`).join(', ')}`);
149
150
  if (r.closure !== null) say(` FEET ${travelLine(r.closure)}`);
150
151
  else say(` FEET steps (the supporting ankle > 0.03 leg lengths a frame; re-planted after 2 frames < 0.01): ${f.steps.map((x) => `${x.side} ${span(x.frames)} → ${x.to === null ? 'the pose\'s travel' : `${x.to} supports`}, ${x.replant === null ? 'never re-planted' : `re-planted at ${x.replant}`}`).join('; ') || 'none'}`);
151
152
  if (r.floor !== null) {
@@ -315,10 +316,10 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
315
316
  raised: r.raised,
316
317
  // Only when a set declares `place` (#93): a map without one writes the report it always has.
317
318
  ...(map.alternates.some((x) => x.place !== undefined && x.place !== null) ? { byHip: r.byHip } : {}),
318
- legScale: r.legScale.map(({ scale: _scale, ...rest }) => rest),
319
+ legScale: r.legScale.map(({ scale: _scale, asked: _asked, ...rest }) => rest),
319
320
  pivot: r.pivot,
320
321
  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 },
321
- feet: r.feet === null ? null : { planted: r.planted, legLength: r.feet.legLength, supports: r.feet.supports, jumps: r.feet.jumps, steps: r.feet.steps, changes: r.feet.changes, ...(r.feet.lowered === undefined ? {} : { lowered: r.feet.lowered }), floor: r.floor?.y ?? null, shifts: r.floor?.shifts ?? [], blend: r.blendHold === null ? null : { bounds: r.blendHold.bounds, ...(r.blendHold.range === undefined ? {} : { range: r.blendHold.range }), runs: r.blendHold.runs, over: r.blendHold.over }, reading: feetReading, closure: r.closure },
322
+ feet: r.feet === null ? null : { planted: r.planted, legLength: r.feet.legLength, supports: r.feet.supports, jumps: r.feet.jumps, steps: r.feet.steps, changes: r.feet.changes, ...(r.feet.lowered === undefined ? {} : { lowered: r.feet.lowered }), ...(r.feet.tracked === undefined ? {} : { tracked: r.feet.tracked }), floor: r.floor?.y ?? null, shifts: r.floor?.shifts ?? [], blend: r.blendHold === null ? null : { bounds: r.blendHold.bounds, ...(r.blendHold.range === undefined ? {} : { range: r.blendHold.range }), runs: r.blendHold.runs, over: r.blendHold.over }, reading: feetReading, closure: r.closure },
322
323
  shear,
323
324
  idleDropped: idle === null ? null : idle.dropped,
324
325
  idleKeptOut: idle === null ? null : idle.keptOut,
package/src/switch.ts CHANGED
@@ -590,22 +590,50 @@ export function artRangeLine(e: ArtRange, times: readonly number[]): string {
590
590
  return `set "${e.set}" chain "${e.chain}" bone "${e.bone}" turned ${sg(e.turn)} from drawn (range [${e.limits[0]}, ${e.limits[1]}]) ${spanOf(e.frames, times)}: ${e.seconds.toFixed(3)}s, up to ${deg(e.maxOverflow)} past it; ${e.clamped ? 'held at the limit' : 'the bone follows the pose'} — demand for another drawing of the set`;
591
591
  }
592
592
 
593
+ /**
594
+ * The art-range clamp eases in and out at a span's edges over at least
595
+ * `ART_EASE_FRAMES` frames, or enough frames to keep the clamp's step at the
596
+ * boundary within `ART_EASE_STEP` degrees per frame (#110).
597
+ */
598
+ export const ART_EASE_FRAMES = 3;
599
+ export const ART_EASE_STEP = 3;
600
+
593
601
  /**
594
602
  * Inside each span, a bone with a `turn` in the set is held within it: its
595
- * target world angle is drawn + clamp(wrap(target − drawn), min, max). Each
596
- * bone is clamped on its own world angle, and keys are differences of world
597
- * angles, so a forearm under a clamped upper arm keeps its own target.
603
+ * target world angle is drawn + clamp(wrap(target − drawn), min, max), eased
604
+ * in and out over at least `ART_EASE_FRAMES` frames at an edge where the clamp
605
+ * switches on or off across the boundary (#110). Each bone is clamped on its
606
+ * own world angle, and keys are differences of world angles, so a forearm
607
+ * under a clamped upper arm keeps its own target.
598
608
  */
599
609
  export function clampArt(switches: readonly Switch[], map: JointMap, targets: Map<string, number[]>): void {
600
610
  for (const sw of switches) {
601
611
  const set = map.alternates.find((a) => a.name === sw.set)!;
612
+ const [a, b] = sw.frames;
602
613
  for (const [bone, limits] of Object.entries(set.turn)) {
603
614
  const drawn = set.drawn[bone]!;
604
615
  const target = targets.get(bone)!;
605
- for (let i = sw.frames[0]; i <= sw.frames[1]; i++) {
616
+ const n = target.length;
617
+ const delta = (i: number): number => {
606
618
  const t = wrap(target[i]! - drawn);
607
619
  const c = Math.min(Math.max(t, limits[0]), limits[1]);
608
- if (Math.abs(c - t) > RANGE_SLACK) target[i] = wrap(drawn + c);
620
+ return Math.abs(c - t) > RANGE_SLACK ? c - t : 0;
621
+ };
622
+ const da = delta(a);
623
+ const db = delta(b);
624
+ const easeIn = a > 0 && da !== 0 && Math.sign(delta(a - 1)) === Math.sign(da)
625
+ ? Math.max(ART_EASE_FRAMES, Math.ceil(Math.abs(da) / ART_EASE_STEP) - 1)
626
+ : 0;
627
+ const easeOut = b + 1 < n && db !== 0 && Math.sign(delta(b + 1)) === Math.sign(db)
628
+ ? Math.max(ART_EASE_FRAMES, Math.ceil(Math.abs(db) / ART_EASE_STEP) - 1)
629
+ : 0;
630
+ for (let i = a; i <= b; i++) {
631
+ const d = delta(i);
632
+ if (d === 0) continue;
633
+ const wIn = i - a < easeIn ? (i - a + 1) / (easeIn + 1) : 1;
634
+ const wOut = b - i < easeOut ? (b - i + 1) / (easeOut + 1) : 1;
635
+ const w = Math.min(wIn, wOut);
636
+ target[i] = wrap(target[i]! + w * d);
609
637
  }
610
638
  }
611
639
  }