@rig-c/pose 0.24.0 → 0.25.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
@@ -182,7 +182,7 @@ The threshold was fixed by measuring first ([#15](https://github.com/rig-c/pose/
182
182
 
183
183
  Re-measured with the guard on, against the guarded unsmoothed run, on #11's criteria (power above 6 Hz down ≥ 2× on every point, timing lag 0, p95 speed ≥ 0.85×): no window passes. `--smooth 7` comes closest — the left wrist's power drops 1.99× and the hip's p95 speed is 0.847× — so `--smooth` stays off by default, and so does the guard: the rule posted before the run made a default for either one depend on a window passing. **Use `--flip 90`**: with it, no bone of the gated build turns more than 90° in one frame (without it, 148.6°), and every bone still lands.
184
184
 
185
- **The short hold.** A segment pointing at the camera is short in the picture, and its picture direction is then detector noise: the rig turns the bone to follow it. So after holds, and before the flip guard, every limb segment the map aims is measured on every frame (not a `reach`, and not a line across the body from a left joint to its right twin, such as the hip line or the eye line: those shorten when the body or head turns, which is a different thing, and the cutoff below was measured on limbs only) — its picture length, between the same two points its angle is read from, over its own median length across the pose. On a frame under `--short <fraction>` its angle is not read from the pose: it is interpolated, the short way round, from the last frame at or above the cutoff to the next one, and held flat at the first or last frame of the pose. The flip guard, the hinge check and the keys all see the held angle. `--short off` (the default) reads every frame. The report lists each span under `short` (chain, bone, segment, first and last frame, the lowest fraction).
185
+ **The short hold.** A segment pointing at the camera is short in the picture, and its picture direction is then detector noise: the rig turns the bone to follow it. So after holds, and before the flip guard, every mapped segment is measured on every frame (not a `reach`) — its picture length, between the same two points its angle is read from, over its own median length across the pose. On a frame under `--short <fraction>` its angle is not read from the pose: it is interpolated, the short way round, from the last frame at or above the cutoff to the next one, and held flat at the first or last frame of the pose. A horizontal line across the body from a left joint to its right twin (`a>b`, such as the hip line or the eye line) also holds its last confident angle and interpolates across the span whenever its picture length falls under `--short` or its 2D reading reverses sign within one frame past `--flip` (when the left and right joints cross in 2D during a full side turn, [#184](https://github.com/rig-c/pose/issues/184)); its hold is printed as `HOLD chain "..."` and is not counted toward limb `END-ON` runs. The flip guard, the hinge check and the keys all see the held angle. `--short off` (the default) reads every frame unless an across-body line reverses past `--flip`. The report lists each span under `short` (chain, bone, segment, first and last frame, the lowest fraction, and `across` / `reversed` when across-body).
186
186
 
187
187
  The cutoff was fixed by measuring first ([#22](https://github.com/rig-c/pose/issues/22)), over both upper arms, forearms, thighs and shins. On the 13 s dance (24 fps, 2,504 segment-frames) the fraction's 0.5th percentile is 0.51, its 5th 0.82, and 7 segment-frames (0.28%) are under 0.35; on a 16 s second sequence (30 fps, 3,936) they are 0.27 and 0.75, and 34 (0.86%). Every frame under 0.35 was looked at, and was one of two kinds:
188
188
 
@@ -191,7 +191,7 @@ The cutoff was fixed by measuring first ([#22](https://github.com/rig-c/pose/iss
191
191
 
192
192
  Both have a meaningless direction; only the first is a pose the rig cannot draw. **0.35** is the smallest round value that takes in every end-on frame (the highest is 0.35, on the dance) without reaching normal motion (the 5th percentiles are 0.75 and 0.82). A run under the cutoff — spans merged across a gap of one frame — lasting **≥ 0.15 s** (3.6 frames at 24 fps, 4.5 at 30) is the end-on kind: it excludes every blur collapse above (at most 3 frames, 0.10 s at 30 fps) and keeps every end-on span (6–8 frames). The run prints it as `END-ON` and the report lists it under `endOn` (chain, bone, segment, frames, seconds, the lowest fraction): it is the list of moments that need an alternate drawing ([#6](https://github.com/rig-c/pose/issues/6)).
193
193
 
194
- Re-checked on the exam01 test rig with `--short 0.35 --flip 90 --bends clamp`. On the second sequence the three spreads the flip guard made out of end-on segments are SHORT holds now, not FLIPs: right upper arm 399–400, right thigh 368–369, right shin 368–371. Of the flip guard's other spreads only one is left, the left forearm at 322–323; every other one fell on frames under the cutoff and is a SHORT hold instead. No guarded bone of the gated build turns more than 81.4° in one frame (85.1° with `--flip 90` alone), and every bone lands. `endOn` there lists the right upper arm (395–401) and the right shin (365–370). It does not list the right thigh, which is under 0.35 on one frame only (369, 0.16). The plan before the run expected the thigh too, and that expectation was wrong. On the dance `endOn` lists exactly S2's left upper arm (30–37); the three FLIPs are #15's, the largest one-frame turn is 78.2°, and every bone lands. A first implementation also held the lines across the body. It listed the eye line (head in profile) and the hip line (body side-on) as end-on, and holding the hip line narrowed the rig's hip tilt range to 39.3° against the pose's 125.2°. The cutoff was never measured on those lines, so they are excluded; with them out the rig reads 125.2° again. The default stays off: a default needs its own criterion and a third sequence.
194
+ Re-checked on the exam01 test rig with `--short 0.35 --flip 90 --bends clamp`. On the second sequence the three spreads the flip guard made out of end-on segments are SHORT holds now, not FLIPs: right upper arm 399–400, right thigh 368–369, right shin 368–371. Of the flip guard's other spreads only one is left, the left forearm at 322–323; every other one fell on frames under the cutoff and is a SHORT hold instead. No guarded bone of the gated build turns more than 81.4° in one frame (85.1° with `--flip 90` alone), and every bone lands. `endOn` there lists the right upper arm (395–401) and the right shin (365–370). It does not list the right thigh, which is under 0.35 on one frame only (369, 0.16). The plan before the run expected the thigh too, and that expectation was wrong. On the dance `endOn` lists exactly S2's left upper arm (30–37); the three FLIPs are #15's, the largest one-frame turn is 78.2°, and every bone lands. When the second sequence's pelvis is mapped (`r_hip → l_hip`), its side-on turn (frames 463–491, body yaw +79.5°) foreshortens the hip line to 0.17 of standing and crosses `r_hip.x` past `l_hip.x` on frames 482–484 (+177.4° / −179.4°); `holdAcross` ([#184](https://github.com/rig-c/pose/issues/184)) holds `hip.rotate` across the unconfident span (`[−2.2°, +2.6°]` over 463–491, `0` `FLIP`s) without listing horizontal lines as limb `END-ON`. The default stays off: a default needs its own criterion and a third sequence.
195
195
 
196
196
  ### The legs: foreshortening and planted feet
197
197
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/pose",
3
- "version": "0.24.0",
3
+ "version": "0.25.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
@@ -182,6 +182,10 @@ export interface Short {
182
182
  /** 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. */
183
183
  step?: number;
184
184
  asked?: number;
185
+ /** Present when a horizontal chain across the body (`isAcross`) held its last confident angle (#184). */
186
+ across?: boolean;
187
+ /** Largest one-frame turn (degrees, signed) inside a held horizontal-chain span when it reversed beyond `--flip` (#184). */
188
+ reversed?: number;
185
189
  }
186
190
 
187
191
  /** 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). */
@@ -610,30 +614,40 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
610
614
  }
611
615
  p.throwIfAny();
612
616
 
613
- // The short hold, on every limb segment the pose aims directly (not a reach, and
614
- // not a line across the body from a left joint to its right twin — the hip line,
615
- // the eye line — whose shortening is the body or head turning, not a limb seen
616
- // end-on; #22's cutoff was measured on limb segments only): its picture length
617
- // against its own median, on the points the angle was read from.
617
+ // The short hold, on every segment the pose aims directly (not a reach): its
618
+ // picture length against its own median, on the points the angle was read from.
619
+ // A horizontal line across the body (`isAcross`: pelvis, shoulders, head line)
620
+ // holds and interpolates across spans under `--short` or reversed within a frame
621
+ // beyond `--flip` (#184), without listing the run as `endOn` (`TURN` reads turned
622
+ // body/head lines).
618
623
  const short: Short[] = [];
619
624
  const endOn: EndOn[] = [];
620
625
  const fractions = new Map<string, { chain: string; joints: [string, string]; fraction: number[] }>();
621
- if (opts.short !== 'off') {
622
- const cutoff = opts.short;
626
+ if (opts.short !== 'off' || opts.flip !== 'off') {
623
627
  for (const c of map.chains) {
624
628
  if (c.reach) continue;
625
629
  c.bones.forEach((b, k) => {
626
630
  const joints: [string, string] = [c.joints[k]!, c.joints[k + 1]!];
627
- if (isAcross(joints[0], joints[1])) return;
631
+ const across = isAcross(joints[0], joints[1]);
632
+ if (!across && opts.short === 'off') return;
628
633
  const from = series.get(joints[0])!;
629
634
  const to = series.get(joints[1])!;
630
635
  const lengths = from.map((f, i) => Math.hypot(to[i]![0] - f[0], to[i]![1] - f[1]));
631
636
  const med = median(lengths);
632
637
  if (med < 1e-6) {
633
- 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`);
638
+ if (opts.short !== 'off') {
639
+ 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`);
640
+ }
634
641
  return;
635
642
  }
636
643
  const fraction = lengths.map((l) => l / med);
644
+ if (across) {
645
+ const restDir = joints[0].startsWith('r_') ? 0 : 180;
646
+ const spans = holdAcross(targets.get(b)!, fraction, opts.short, opts.flip, restDir);
647
+ for (const s of spans) short.push({ chain: c.name, bone: b, joints, ...s });
648
+ return;
649
+ }
650
+ const cutoff = opts.short as number;
637
651
  fractions.set(b, { chain: c.name, joints, fraction });
638
652
  const spans = holdShort(targets.get(b)!, fraction, cutoff);
639
653
  for (const s of spans) short.push({ chain: c.name, bone: b, joints, ...s });
@@ -1366,6 +1380,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1366
1380
  const set = map.alternates.find((a) => a.name === s.set)!;
1367
1381
  const art: ArtRange[] = [];
1368
1382
  for (const cn of s.chains) {
1383
+ if (sw.chainHolds?.some((h) => h.chain === cn && h.set !== s.set && h.frames[0] <= s.frames[0] && h.frames[1] >= s.frames[1]) === true) continue;
1369
1384
  const c = map.chains.find((x) => x.name === cn)!;
1370
1385
  const drawn = set.drawn[c.bones[0]!];
1371
1386
  const up = isChainHeadPlaced(set, c, drawn);
@@ -1619,6 +1634,45 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1619
1634
  if (job.anchor.kind === 'head') raised.push({ set, chain, frames, seconds, eyes: unit, ...body });
1620
1635
  else byHip.push({ set, chain, frames, seconds, hips: unit, ...body });
1621
1636
  }
1637
+ if (opts.short !== 'off') {
1638
+ for (const s of short) {
1639
+ if (s.step === undefined || s.across === true) continue;
1640
+ const cov = covered.get(s.chain);
1641
+ const [lo, hi] = s.frames;
1642
+ if (lo <= 0 || hi + 1 >= n) continue;
1643
+ if (cov?.[lo - 1] !== true && cov?.[hi + 1] !== true) continue;
1644
+ const tg = targets.get(s.bone)!;
1645
+ const startA = tg[lo - 1]!;
1646
+ const total = wrap(tg[hi + 1]! - startA);
1647
+ const steps = hi - lo + 2;
1648
+ for (let k = lo; k <= hi; k++) {
1649
+ tg[k] = wrap(startA + ((k - lo + 1) / steps) * total);
1650
+ }
1651
+ s.step = r3(total / steps);
1652
+ }
1653
+ const holds = sw.chainHolds ?? [];
1654
+ for (const h of holds) {
1655
+ const swParent = sw.switches.find((s) => s.set === h.set && s.frames[0] === h.frames[0] && s.frames[1] > h.frames[1]);
1656
+ if (!swParent) continue;
1657
+ const nextH = holds.find((nh) => nh.chain === h.chain && nh.frames[0] > h.frames[1] && nh.frames[0] - h.frames[1] <= 10);
1658
+ if (!nextH) continue;
1659
+ const lo = h.frames[1] + 1;
1660
+ const hi = nextH.frames[0] - 1;
1661
+ if (lo > hi || lo <= 0 || hi + 1 >= n) continue;
1662
+ const ch = map.chains.find((c) => c.name === h.chain);
1663
+ if (!ch) continue;
1664
+ const steps = hi - lo + 2;
1665
+ for (const b of ch.bones) {
1666
+ const tg = targets.get(b);
1667
+ if (!tg) continue;
1668
+ const startA = tg[lo - 1]!;
1669
+ const total = wrap(tg[hi + 1]! - startA);
1670
+ for (let k = lo; k <= hi; k++) {
1671
+ tg[k] = wrap(startA + ((k - lo + 1) / steps) * total);
1672
+ }
1673
+ }
1674
+ }
1675
+ }
1622
1676
 
1623
1677
  // The legs on their final targets: a bone under a scaled bone is keyed through
1624
1678
  // its parent's posed matrix (legs.ts), and each ankle's world position is read.
@@ -1912,6 +1966,79 @@ export function holdShort(angles: number[], fraction: readonly number[], cutoff:
1912
1966
  return out;
1913
1967
  }
1914
1968
 
1969
+ /**
1970
+ * Hold and interpolate across unconfident spans on a horizontal chain (`isAcross`,
1971
+ * e.g. `r_hip → l_hip`, `r_eye → l_eye`, `r_shoulder → l_shoulder`, rig-c/pose#184),
1972
+ * in place. A frame is unconfident when its picture length over its median is under
1973
+ * `shortCutoff` (with `--short`) or when its angle turns more than `flipThreshold`
1974
+ * from the last confident angle or reverses more than 90° from the line's rest
1975
+ * direction `restDir` (with `--flip`). Each contiguous unconfident run has its
1976
+ * angles interpolated between the confident frames either side (flat at the ends),
1977
+ * the short way round, exactly as `holdShort` does on limb chains.
1978
+ */
1979
+ export function holdAcross(
1980
+ angles: number[],
1981
+ fraction: readonly number[],
1982
+ shortCutoff: number | 'off',
1983
+ flipThreshold: number | 'off',
1984
+ restDir = 0,
1985
+ ): ShortSpan[] {
1986
+ const n = angles.length;
1987
+ if (n === 0 || (shortCutoff === 'off' && flipThreshold === 'off')) return [];
1988
+ const raw = [...angles];
1989
+ const unconf = new Array<boolean>(n).fill(false);
1990
+ let lastConfident = raw[0]!;
1991
+ for (let i = 0; i < n; i++) {
1992
+ const s = shortCutoff !== 'off' && fraction[i]! < shortCutoff;
1993
+ const rev = Math.abs(wrap(raw[i]! - restDir)) > 90;
1994
+ if (!s && !rev) {
1995
+ lastConfident = raw[i]!;
1996
+ break;
1997
+ }
1998
+ }
1999
+ for (let i = 0; i < n; i++) {
2000
+ const isShort = shortCutoff !== 'off' && fraction[i]! < shortCutoff;
2001
+ const isFlipped =
2002
+ flipThreshold !== 'off' &&
2003
+ (Math.abs(wrap(raw[i]! - lastConfident)) > flipThreshold || Math.abs(wrap(raw[i]! - restDir)) > 90);
2004
+ if (isShort || isFlipped) {
2005
+ unconf[i] = true;
2006
+ } else {
2007
+ lastConfident = raw[i]!;
2008
+ }
2009
+ }
2010
+ const out: ShortSpan[] = [];
2011
+ for (let i = 0; i < n; i++) {
2012
+ if (!unconf[i]) continue;
2013
+ let j = i;
2014
+ while (j + 1 < n && unconf[j + 1]) j++;
2015
+ const lo = i - 1;
2016
+ const hi = j + 1;
2017
+ let minFraction = fraction[i]!;
2018
+ for (let k = i + 1; k <= j; k++) minFraction = Math.min(minFraction, fraction[k]!);
2019
+ let maxTurn = 0;
2020
+ for (let k = Math.max(1, i); k <= Math.min(n - 1, j + 1); k++) {
2021
+ const jmp = wrap(raw[k]! - raw[k - 1]!);
2022
+ if (Math.abs(jmp) > Math.abs(maxTurn)) maxTurn = jmp;
2023
+ }
2024
+ if (lo >= 0 || hi < n) {
2025
+ const a = lo >= 0 ? angles[lo]! : angles[hi]!;
2026
+ const b = hi < n ? angles[hi]! : a;
2027
+ const turn = wrap(b - a);
2028
+ for (let k = i; k <= j; k++) angles[k] = lo < 0 ? b : wrap(a + (turn * (k - lo)) / (hi - lo));
2029
+ }
2030
+ const revThreshold = flipThreshold !== 'off' ? flipThreshold : 90;
2031
+ out.push({
2032
+ frames: [i, j],
2033
+ minFraction,
2034
+ across: true,
2035
+ ...(Math.abs(maxTurn) > revThreshold ? { reversed: r3(maxTurn) } : {}),
2036
+ });
2037
+ i = j;
2038
+ }
2039
+ return out;
2040
+ }
2041
+
1915
2042
  /** Short spans `gap` frames apart or fewer, as one run each: its first and last frame, and the lowest fraction. */
1916
2043
  export function mergeRuns(spans: readonly ShortSpan[], gap: number): ShortSpan[] {
1917
2044
  const out: ShortSpan[] = [];
@@ -1951,24 +2078,26 @@ export function capShortSteps(
1951
2078
  }
1952
2079
  let segEnd = segStart;
1953
2080
  while (segEnd + 1 < n && covered?.[segEnd + 1] !== true) segEnd++;
1954
- if (segEnd - segStart >= 1) {
2081
+ const loBound = Math.max(0, segStart - 1);
2082
+ const hiBound = Math.min(n - 1, segEnd + 1);
2083
+ if (hiBound - loBound >= 2) {
1955
2084
  const u: number[] = new Array<number>(n).fill(0);
1956
- u[segStart] = angles[segStart]!;
1957
- for (let i = segStart + 1; i <= segEnd; i++) u[i] = u[i - 1]! + wrap(angles[i]! - angles[i - 1]!);
2085
+ u[loBound] = angles[loBound]!;
2086
+ for (let i = loBound + 1; i <= hiBound; i++) u[i] = u[i - 1]! + wrap(angles[i]! - angles[i - 1]!);
1958
2087
  const fits = (lo: number, hi: number) => Math.abs(u[hi]! - u[lo]!) <= stepCap * (hi - lo);
1959
2088
  interface Win { lo: number; hi: number; asked: number }
1960
2089
  const wins: Win[] = [];
1961
- for (let k = segStart + 1; k <= segEnd; k++) {
2090
+ for (let k = loBound + 1; k <= hiBound; k++) {
1962
2091
  if (!(Math.min(fraction[k - 1]!, fraction[k]!) < fractionCutoff)) continue;
1963
2092
  const jump = u[k]! - u[k - 1]!;
1964
2093
  if (Math.abs(jump) <= stepCap) continue;
1965
2094
  let h = 1;
1966
- let lo = Math.max(segStart, k - h - 1);
1967
- let hi = Math.min(segEnd, k + h);
1968
- while (!fits(lo, hi) && (lo > segStart || hi < segEnd)) {
2095
+ let lo = Math.max(loBound, k - h - 1);
2096
+ let hi = Math.min(hiBound, k + h);
2097
+ while (!fits(lo, hi) && (lo > loBound || hi < hiBound)) {
1969
2098
  h++;
1970
- lo = Math.max(segStart, k - h - 1);
1971
- hi = Math.min(segEnd, k + h);
2099
+ lo = Math.max(loBound, k - h - 1);
2100
+ hi = Math.min(hiBound, k + h);
1972
2101
  }
1973
2102
  wins.push({ lo, hi, asked: jump });
1974
2103
  }
@@ -1985,9 +2114,9 @@ export function capShortSteps(
1985
2114
  }
1986
2115
  if (changed) continue;
1987
2116
  for (const w of wins) {
1988
- if (fits(w.lo, w.hi) || (w.lo === segStart && w.hi === segEnd)) continue;
1989
- w.lo = Math.max(segStart, w.lo - 1);
1990
- w.hi = Math.min(segEnd, w.hi + 1);
2117
+ if (fits(w.lo, w.hi) || (w.lo === loBound && w.hi === hiBound)) continue;
2118
+ w.lo = Math.max(loBound, w.lo - 1);
2119
+ w.hi = Math.min(hiBound, w.hi + 1);
1991
2120
  changed = true;
1992
2121
  }
1993
2122
  }
@@ -2011,9 +2140,9 @@ export function capShortSteps(
2011
2140
  const spanOf = (frames: [number, number], times: readonly number[]): string =>
2012
2141
  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)`;
2013
2142
 
2014
- export function shortLine(s: Short, times: readonly number[], cutoff: number): string {
2143
+ export function shortLine(s: Short, times: readonly number[], cutoff: number | 'off'): string {
2144
+ const sg = (v: number) => `${v >= 0 ? '+' : '−'}${Math.abs(v).toFixed(1)}°`;
2015
2145
  if (s.step !== undefined && s.asked !== undefined) {
2016
- const sg = (v: number) => `${v >= 0 ? '+' : '−'}${Math.abs(v).toFixed(1)}°`;
2017
2146
  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`;
2018
2147
  }
2019
2148
  const lo = s.frames[0] - 1;
@@ -2023,6 +2152,12 @@ export function shortLine(s: Short, times: readonly number[], cutoff: number): s
2023
2152
  : lo < 0 ? `held flat at frame ${hi}'s angle (the pose starts short)`
2024
2153
  : hi >= times.length ? `held flat at frame ${lo}'s angle (the pose ends short)`
2025
2154
  : `interpolated from frame ${lo}'s angle to frame ${hi}'s, the short way round`;
2155
+ if (s.across === true) {
2156
+ const shortWhy = cutoff !== 'off' && s.minFraction < cutoff ? `picture length down to ${s.minFraction.toFixed(2)} of its median, under ${cutoff}` : null;
2157
+ const revWhy = s.reversed !== undefined ? `reversed ${sg(s.reversed)} in one frame` : null;
2158
+ const why = shortWhy !== null && revWhy !== null ? `${shortWhy}, ${revWhy}` : shortWhy ?? `${revWhy ?? 'reversed'} (picture length down to ${s.minFraction.toFixed(2)} of its median)`;
2159
+ return `chain "${s.chain}" bone "${s.bone}" (${s.joints[0]} → ${s.joints[1]}) ${spanOf(s.frames, times)}: ${why}; its direction is not read — ${held}`;
2160
+ }
2026
2161
  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 ${cutoff}; its direction is not read — ${held}`;
2027
2162
  }
2028
2163
 
package/src/run.ts CHANGED
@@ -135,8 +135,8 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
135
135
  else if (a.plantFeet === true) say(' CAMERA none in the sequence: --plant-feet reads the picture as a fixed camera, so a zoom or a pan in the video moves the floor with it and the floor may step with the picture (tools/scan.py reads the camera, rig-c/pose#63)');
136
136
  if (r.scale !== null) say(` SCALE ${r.scale.factor.toFixed(5)} rig units per picture px (rig ${r.scale.rig.toFixed(2)} / pose median ${r.scale.pose.toFixed(2)})`);
137
137
  for (const h of r.holds) say(` HOLD ${holdLine(h, r.times)}`);
138
+ for (const s of r.short) say(` ${s.across === true ? 'HOLD ' : 'SHORT '} ${shortLine(s, r.times, a.short)}`);
138
139
  if (a.short !== 'off') {
139
- for (const s of r.short) say(` SHORT ${shortLine(s, r.times, a.short)}`);
140
140
  for (const e of r.endOn) say(` END-ON ${endOnLine(e, r.times, a.short)}`);
141
141
  }
142
142
  if (a.flip !== 'off') for (const fl of r.flips) say(` FLIP ${flipLine(fl, r.times, a.flip)}`);
package/src/switch.ts CHANGED
@@ -322,6 +322,7 @@ function groupLayering(group: readonly AlternateSet[], map: JointMap, slots: Rig
322
322
  const setupSlots = hiddenSetup.filter((s) => slotChain.get(s) === cn && slots.get(s)?.bone !== torsoSlot);
323
323
  const setSlots = Object.keys(a.slots).filter((s) => slotChain.get(s) === cn && rank.has(s));
324
324
  if (setupSlots.length === 0 || setSlots.length === 0) return false;
325
+ if (setupSlots.every((s) => a.slots[s] !== undefined && a.slots[s] !== slots.get(s)?.setup)) return true;
325
326
  const boneSetup = setupSlots.filter((s) => boneOwner.has(slots.get(s)?.bone ?? ''));
326
327
  const refSetup = boneSetup.length > 0 ? boneSetup : setupSlots;
327
328
  return setupSlots.every((s) => a.hides.includes(s)) && Math.max(...setSlots.map((s) => rank.get(s)!)) < Math.min(...refSetup.map((s) => rank.get(s)!));
@@ -626,8 +627,9 @@ export function selectSwitches(map: JointMap, targets: ReadonlyMap<string, reado
626
627
  for (let i = sw.frames[0]; i <= sw.frames[1]; i++) row[i] = o;
627
628
  }
628
629
  }
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).
630
+ // #136/#139/#162/#180/#187: between two gesture sets, switch both arms on one frame t_switch
631
+ // where the measured path crosses halfway between their drawn poses without dropping to the base arm,
632
+ // and in a one-sided gesture set keep an arm whose wrist stays behind the torso on its previous behind set.
631
633
  const chainHolds: Array<{ set: string; chain: string; frames: [number, number] }> = [];
632
634
  for (const group of groups) {
633
635
  const layer = groupLayering(group, map, slots);
@@ -645,6 +647,56 @@ export function selectSwitches(map: JointMap, targets: ReadonlyMap<string, reado
645
647
  }
646
648
  cClamp.set(cn, arr);
647
649
  }
650
+ for (const s of layer.touched) {
651
+ if (!shown.has(s)) shown.set(s, Array.from({ length: n }, () => slots.get(s)!.setup));
652
+ }
653
+ // #187 Rule 3: in a one-sided gesture set (one chain raised with drawn > 0 while another chain has all
654
+ // bones drawn < 0 within switchTolerance of the preceding behind-the-torso set), an arm whose wrist the
655
+ // scan places behind the torso keeps its previous behind-the-torso set and angles.
656
+ const heldBehindOnSwitch = new Map<number, Set<string>>();
657
+ for (let idx = 1; idx < gSwitches.length; idx++) {
658
+ const sw = gSwitches[idx]!;
659
+ const prevSw = gSwitches[idx - 1]!;
660
+ const set = group.find((a) => a.name === sw.set)!;
661
+ const prevSet = group.find((a) => a.name === prevSw.set)!;
662
+ if (!layer.isBehind(prevSet) || set === prevSet || set.chains.length < 2) continue;
663
+ const hasUpChain = set.chains.some((cn) => {
664
+ const c = map.chains.find((x) => x.name === cn);
665
+ return c !== undefined && c.bones.some((b) => (set.drawn[b] ?? -180) > 0);
666
+ });
667
+ if (!hasUpChain) continue;
668
+ for (const cn of set.chains) {
669
+ const c = map.chains.find((x) => x.name === cn);
670
+ if (c === undefined) continue;
671
+ const allDown = c.bones.every((b) => (set.drawn[b] ?? 0) < 0);
672
+ if (!allDown) continue;
673
+ const keyNearPrev =
674
+ c.bones.every((b) => set.drawn[b] !== undefined && prevSet.drawn[b] !== undefined) &&
675
+ c.bones.reduce((s, b) => s + Math.abs(wrap(set.drawn[b]! - prevSet.drawn[b]!)), 0) / c.bones.length <= opts.switchTolerance;
676
+ if (!keyNearPrev) continue;
677
+ const ho = opts.handOffsets?.get(cn);
678
+ if (ho === undefined) continue;
679
+ const slice = ho.slice(sw.frames[0], sw.frames[1] + 1);
680
+ const medDy = median(slice.map((p) => p[1]));
681
+ const medDx = median(slice.map((p) => p[0]));
682
+ const isRight = /(?:^|_)r(?:_|$)/.test(cn);
683
+ const inward = isRight ? medDx >= -0.05 : medDx <= 0.05;
684
+ if (!(medDy > 0.5 && inward)) continue;
685
+ let setHeld = heldBehindOnSwitch.get(idx);
686
+ if (setHeld === undefined) {
687
+ setHeld = new Set();
688
+ heldBehindOnSwitch.set(idx, setHeld);
689
+ }
690
+ setHeld.add(cn);
691
+ const cArr = cClamp.get(cn)!;
692
+ for (let i = sw.frames[0]; i <= sw.frames[1]; i++) {
693
+ cArr[i] = prevSet;
694
+ for (const [s, sc] of layer.slotChain) {
695
+ if (sc === cn && shown.has(s)) shown.get(s)![i] = prevSet.slots[s] ?? null;
696
+ }
697
+ }
698
+ }
699
+ }
648
700
  for (let m = 0; m + 1 < gSwitches.length; m++) {
649
701
  const sw0 = gSwitches[m]!;
650
702
  const sw1 = gSwitches[m + 1]!;
@@ -656,6 +708,14 @@ export function selectSwitches(map: JointMap, targets: ReadonlyMap<string, reado
656
708
  if (g0 > g1) continue;
657
709
  const chains = [...new Set([...set0.chains, ...set1.chains])].filter((cn) => layer.isChainBehindBase(set0, cn) && layer.isChainBehindBase(set1, cn));
658
710
  if (chains.length === 0) continue;
711
+ const heldOn0 = heldBehindOnSwitch.get(m);
712
+ const heldOn1 = heldBehindOnSwitch.get(m + 1);
713
+ const heldSetFor = (cn: string): AlternateSet | null => {
714
+ if (heldOn1?.has(cn)) return set0;
715
+ if (heldOn0?.has(cn) && m > 0 && set1 === group.find((a) => a.name === gSwitches[m - 1]!.set)) return set1;
716
+ return null;
717
+ };
718
+ const switchingChains = chains.filter((cn) => heldSetFor(cn) === null);
659
719
  for (const cn of chains) {
660
720
  if (!layer.isBehind(set0)) for (let i = sw0.frames[0]; i <= sw0.frames[1]; i++) cClamp.get(cn)![i] = set0;
661
721
  if (!layer.isBehind(set1)) for (let i = sw1.frames[0]; i <= sw1.frames[1]; i++) cClamp.get(cn)![i] = set1;
@@ -673,11 +733,29 @@ export function selectSwitches(map: JointMap, targets: ReadonlyMap<string, reado
673
733
  }
674
734
  return d0 <= d1 ? set0 : set1;
675
735
  };
736
+ const rawChainMax = (a: AlternateSet, cn: string, i: number): number => {
737
+ const c = map.chains.find((x) => x.name === cn);
738
+ if (c === undefined) return Infinity;
739
+ return Math.max(...c.bones.map((b) => (a.drawn[b] !== undefined ? Math.abs(wrap(targets.get(b)![i]! - a.drawn[b]!)) : 0)));
740
+ };
741
+ const isTightSet = (a: AlternateSet, cn: string): boolean => {
742
+ const c = map.chains.find((x) => x.name === cn);
743
+ return c !== undefined && c.bones.some((b) => a.turn[b] !== undefined && a.turn[b]![1] - a.turn[b]![0] <= 10);
744
+ };
676
745
  const cPick = new Map<string, Array<AlternateSet | null>>();
677
746
  for (const cn of chains) {
747
+ const heldSet = heldSetFor(cn);
678
748
  const arr: Array<AlternateSet | null> = Array.from({ length: n }, () => null);
679
- for (let i = sw0.frames[0]; i <= sw0.frames[1]; i++) arr[i] = set0;
680
- for (let i = sw1.frames[0]; i <= sw1.frames[1]; i++) arr[i] = set1;
749
+ for (let i = sw0.frames[0]; i <= sw0.frames[1]; i++) arr[i] = heldSet ?? set0;
750
+ for (let i = sw1.frames[0]; i <= sw1.frames[1]; i++) arr[i] = heldSet ?? set1;
751
+ if (heldSet !== null) {
752
+ for (let i = g0; i <= g1; i++) {
753
+ arr[i] = heldSet;
754
+ cClamp.get(cn)![i] = heldSet;
755
+ }
756
+ cPick.set(cn, arr);
757
+ continue;
758
+ }
681
759
  const d0s = byChain.get(set0.name)?.get(cn);
682
760
  const d1s = byChain.get(set1.name)?.get(cn);
683
761
  for (let i = g0; i <= g1; i++) {
@@ -706,24 +784,142 @@ export function selectSwitches(map: JointMap, targets: ReadonlyMap<string, reado
706
784
  }
707
785
  i = j + 1;
708
786
  }
709
- for (let i = g0; i <= g1; i++) {
710
- if (arr[i] !== null) cClamp.get(cn)![i] = arr[i]!;
787
+ const tight0 = isTightSet(set0, cn);
788
+ const tight1 = isTightSet(set1, cn);
789
+ if (tight0 && set0 !== set1) {
790
+ let i = g0;
791
+ while (i <= g1 && rawChainMax(set0, cn, i) <= opts.switchTolerance + hyst) {
792
+ cClamp.get(cn)![i] = set0;
793
+ i++;
794
+ }
795
+ }
796
+ if (tight1 && set0 !== set1) {
797
+ let enter1 = g1 + 1;
798
+ for (let i = g0; i <= g1; i++) {
799
+ if (rawChainMax(set1, cn, i) <= opts.switchTolerance) {
800
+ enter1 = i;
801
+ break;
802
+ }
803
+ }
804
+ for (let i = enter1; i <= g1; i++) cClamp.get(cn)![i] = set1;
805
+ }
806
+ if (!tight0 && !tight1) {
807
+ for (let i = g0; i <= g1; i++) {
808
+ if (arr[i] !== null) cClamp.get(cn)![i] = arr[i]!;
809
+ }
810
+ }
811
+ if (!tight0 && set0 !== set1) {
812
+ const c = map.chains.find((x) => x.name === cn);
813
+ if (c !== undefined) {
814
+ let k = sw0.frames[1];
815
+ while (
816
+ k >= sw0.frames[0] &&
817
+ c.bones.some((b) => {
818
+ const lim = set0.turn[b];
819
+ const dr = set0.drawn[b];
820
+ const ndr = set1.drawn[b];
821
+ if (lim === undefined || dr === undefined || ndr === undefined) return false;
822
+ const t = wrap(targets.get(b)![k]! - dr);
823
+ return overflow(t, lim) > RANGE_SLACK && Math.abs(wrap(targets.get(b)![k + 1]! - ndr)) < Math.abs(wrap(targets.get(b)![k]! - ndr));
824
+ })
825
+ ) {
826
+ cClamp.get(cn)![k] = null;
827
+ k--;
828
+ }
829
+ }
711
830
  }
712
831
  cPick.set(cn, arr);
713
832
  }
714
- const anyHeldBehind = chains.some((cn) => {
833
+ // #187 Rule 1 & Rule 2: when two gesture sets are adjacent (gap <= 10 frames, or the arm is moving
834
+ // toward the next set's pose), unwrap any bone that turned the long way (> 180° through inverted angles)
835
+ // onto the short arc and pick one shared switch frame t_switch where the measured path crosses halfway
836
+ // between set0.drawn and set1.drawn.
837
+ const movingTowardNext =
838
+ set0 !== set1 &&
839
+ distances.get(set1.name)![g1]! < distances.get(set1.name)![g0]! &&
840
+ distances.get(set0.name)![g1]! > distances.get(set0.name)![g0]!;
841
+ const bridgeDirect = set0 !== set1 && (g1 - g0 + 1 <= 10 || movingTowardNext);
842
+ let tSwitch = g1 + 1;
843
+ if (bridgeDirect && switchingChains.length > 0) {
844
+ const swBones = switchingChains.flatMap((cn) => map.chains.find((c) => c.name === cn)?.bones ?? []);
845
+ for (const b of swBones) {
846
+ const tg = targets.get(b) as number[] | undefined;
847
+ const d0 = set0.drawn[b];
848
+ const d1 = set1.drawn[b];
849
+ if (tg === undefined || d0 === undefined || d1 === undefined) continue;
850
+ const i0 = sw0.frames[1];
851
+ const i1 = sw1.frames[0];
852
+ const startA = tg[i0]!;
853
+ const endA = tg[i1]!;
854
+ const unwrapped: number[] = [startA];
855
+ for (let i = i0 + 1; i <= i1; i++) {
856
+ unwrapped.push(unwrapped[unwrapped.length - 1]! + wrap(tg[i]! - tg[i - 1]!));
857
+ }
858
+ const totalTurn = unwrapped[unwrapped.length - 1]! - startA;
859
+ const shortTurn = wrap(endA - startA);
860
+ const invertsBetween = Array.from({ length: g1 - g0 + 1 }, (_, k) => {
861
+ const a = tg[g0 + k]!;
862
+ return Math.abs(wrap(a - d0)) > 90 && Math.abs(wrap(a - d1)) > 90;
863
+ }).some(Boolean);
864
+ if (Math.abs(totalTurn) > 180 && Math.abs(totalTurn - shortTurn) > 1e-6 && invertsBetween && Math.abs(shortTurn) <= 150) {
865
+ for (let i = g0; i <= g1; i++) {
866
+ const p = (unwrapped[i - i0]! - startA) / totalTurn;
867
+ tg[i] = wrap(startA + p * shortTurn);
868
+ }
869
+ }
870
+ }
871
+ let clampEnd0 = g0 - 1;
872
+ while (clampEnd0 + 1 <= g1 && switchingChains.every((cn) => cClamp.get(cn)![clampEnd0 + 1] === set0)) {
873
+ clampEnd0++;
874
+ }
875
+ const effAngle = (b: string, i: number): number => {
876
+ const raw = targets.get(b)![i]!;
877
+ if (clampEnd0 >= g0 && i > clampEnd0) {
878
+ const frac = opts.shortInfo?.fractions.get(b)?.[i] ?? 1;
879
+ const d0 = set0.drawn[b];
880
+ const d1 = set1.drawn[b];
881
+ if (frac < 0.6 && d0 !== undefined && d1 !== undefined) {
882
+ const steps = g1 + 1 - clampEnd0;
883
+ return wrap(d0 + ((i - clampEnd0) / steps) * wrap(d1 - d0));
884
+ }
885
+ }
886
+ return raw;
887
+ };
888
+ const pairs = swBones.filter((b) => set0.drawn[b] !== undefined && set1.drawn[b] !== undefined);
889
+ const span = pairs.length > 0
890
+ ? pairs.reduce((s, b) => s + Math.abs(wrap(set1.drawn[b]! - set0.drawn[b]!)), 0) / pairs.length
891
+ : 0;
892
+ for (let i = Math.max(g0, clampEnd0 + 1); i <= g1; i++) {
893
+ const d0 = pairs.length > 0
894
+ ? pairs.reduce((s, b) => s + Math.abs(wrap(effAngle(b, i) - set0.drawn[b]!)), 0) / pairs.length
895
+ : (distances.get(set0.name)?.[i] ?? Infinity);
896
+ const d1 = pairs.length > 0
897
+ ? pairs.reduce((s, b) => s + Math.abs(wrap(effAngle(b, i) - set1.drawn[b]!)), 0) / pairs.length
898
+ : (distances.get(set1.name)?.[i] ?? Infinity);
899
+ if (d1 <= d0 || (span > 1e-6 && d0 >= 0.5 * span)) {
900
+ tSwitch = i;
901
+ break;
902
+ }
903
+ }
904
+ }
905
+ const anyHeldBehind = switchingChains.some((cn) => {
715
906
  const arr = cPick.get(cn)!;
716
907
  for (let i = g0; i <= g1; i++) if (arr[i] === null) return false;
717
908
  return true;
718
909
  });
719
- if (!anyHeldBehind && set0 !== set1) {
720
- const frontNear = group.filter((a) => layer.isFront(a)).some((a) => {
910
+ if (!anyHeldBehind) {
911
+ const frontNear = group.filter((a) => layer.isFront(a) && a !== set0 && a !== set1).some((a) => {
721
912
  for (let i = g0; i <= g1; i++) if (distances.get(a.name)![i]! <= opts.switchTolerance) return true;
722
913
  return false;
723
914
  });
724
- if (!frontNear) for (const cn of chains) for (let i = g0; i <= g1; i++) cPick.get(cn)![i] = set0;
915
+ if (bridgeDirect) {
916
+ for (const cn of switchingChains) {
917
+ for (let i = g0; i <= g1; i++) cPick.get(cn)![i] = i < tSwitch ? set0 : set1;
918
+ }
919
+ } else if (!frontNear) for (const cn of chains) for (let i = g0; i <= g1; i++) cPick.get(cn)![i] = set0;
725
920
  } else {
726
921
  for (const cn of chains) {
922
+ if (heldSetFor(cn) !== null) continue;
727
923
  const arr = cPick.get(cn)!;
728
924
  const frontSets = group.filter((a) => layer.isFront(a) && a.chains.includes(cn) && a !== set0 && a !== set1);
729
925
  const d0s = byChain.get(set0.name)?.get(cn);
@@ -771,21 +967,20 @@ export function selectSwitches(map: JointMap, targets: ReadonlyMap<string, reado
771
967
  }
772
968
  i = j + 1;
773
969
  }
774
- for (let i = g0; i <= g1; i++) {
775
- if (arr[i] !== null) cClamp.get(cn)![i] = arr[i]!;
970
+ }
971
+ if (bridgeDirect) {
972
+ for (const cn of switchingChains) {
973
+ for (let i = g0; i <= g1; i++) cPick.get(cn)![i] = i < tSwitch ? set0 : set1;
776
974
  }
777
975
  }
778
976
  }
779
- for (const s of layer.touched) {
780
- if (!shown.has(s)) shown.set(s, Array.from({ length: n }, () => slots.get(s)!.setup));
781
- }
782
977
  for (let i = g0; i <= g1; i++) {
783
978
  for (const [s, cn] of layer.slotChain) {
784
979
  const a = cPick.get(cn)?.[i] ?? null;
785
980
  if (a !== null) shown.get(s)![i] = a.slots[s] ?? null;
786
981
  }
787
- if (layer.torsoSlot !== null && shown.has(layer.torsoSlot) && layer.isBehind(set0) && layer.isBehind(set1)) {
788
- const picked = chains.map((cn) => cPick.get(cn)![i]);
982
+ if (layer.torsoSlot !== null && shown.has(layer.torsoSlot)) {
983
+ const picked = (switchingChains.length > 0 ? switchingChains : chains).map((cn) => cPick.get(cn)![i]);
789
984
  if (picked.every((a) => a !== null)) shown.get(layer.torsoSlot)![i] = picked[0]!.slots[layer.torsoSlot] ?? slots.get(layer.torsoSlot)!.setup;
790
985
  }
791
986
  }
@@ -834,8 +1029,10 @@ export function selectSwitches(map: JointMap, targets: ReadonlyMap<string, reado
834
1029
  /** Per chain, the frames on which a switch of a set depicting it shows: its art was painted for that pose. */
835
1030
  export function coveredChains(switches: readonly Switch[], n: number, chainHolds?: ReadonlyArray<{ set: string; chain: string; frames: [number, number] }>): Map<string, boolean[]> {
836
1031
  const out = new Map<string, boolean[]>();
1032
+ const held = new Set((chainHolds ?? []).map((h) => `${h.set}:${h.chain}`));
837
1033
  for (const sw of switches) {
838
1034
  for (const c of sw.chains) {
1035
+ if (held.has(`${sw.set}:${c}`)) continue;
839
1036
  if (!out.has(c)) out.set(c, Array.from({ length: n }, () => false));
840
1037
  const row = out.get(c)!;
841
1038
  for (let i = sw.frames[0]; i <= sw.frames[1]; i++) row[i] = true;
@@ -1017,7 +1214,7 @@ export const ART_EASE_STEP = 3;
1017
1214
  export function clampArt(switches: readonly Switch[], map: JointMap, targets: Map<string, number[]>, chainHolds?: ReadonlyArray<{ set: string; chain: string; frames: [number, number] }>): void {
1018
1215
  const boneChain = new Map<string, string>();
1019
1216
  for (const c of map.chains) for (const b of c.bones) boneChain.set(b, c.name);
1020
- const clampSpan = (set: AlternateSet, bone: string, [a, b]: [number, number]) => {
1217
+ const clampSpan = (set: AlternateSet, bone: string, [a, b0]: [number, number]) => {
1021
1218
  const limits = set.turn[bone];
1022
1219
  if (limits === undefined) return;
1023
1220
  const drawn = set.drawn[bone]!;
@@ -1028,12 +1225,29 @@ export function clampArt(switches: readonly Switch[], map: JointMap, targets: Ma
1028
1225
  const c = Math.min(Math.max(t, limits[0]), limits[1]);
1029
1226
  return Math.abs(c - t) > RANGE_SLACK ? c - t : 0;
1030
1227
  };
1228
+ const tightHold = limits[1] - limits[0] <= 10;
1229
+ let b = b0;
1230
+ let trimmedExit = false;
1231
+ if (!tightHold && b + 1 < n && delta(b) !== 0) {
1232
+ const nextSw = switches.find((s) => s.frames[0] > b && s.frames[0] - b <= 10 && s.set !== set.name);
1233
+ const nextDrawn = nextSw !== undefined ? map.alternates.find((x) => x.name === nextSw.set)?.drawn[bone] : undefined;
1234
+ if (nextDrawn !== undefined) {
1235
+ const distNext = (i: number) => Math.abs(wrap(target[i]! - nextDrawn));
1236
+ if (distNext(b + 1) < distNext(b)) {
1237
+ while (b >= a && delta(b) !== 0 && (b === a || distNext(b) < distNext(b - 1))) {
1238
+ b--;
1239
+ trimmedExit = true;
1240
+ }
1241
+ }
1242
+ }
1243
+ }
1244
+ if (a > b) return;
1031
1245
  const da = delta(a);
1032
1246
  const db = delta(b);
1033
- const easeIn = a > 0 && da !== 0 && Math.sign(delta(a - 1)) === Math.sign(da)
1247
+ const easeIn = a > 0 && da !== 0 && Math.sign(delta(a - 1)) === Math.sign(da) && !tightHold
1034
1248
  ? Math.max(ART_EASE_FRAMES, Math.ceil(Math.abs(da) / ART_EASE_STEP) - 1)
1035
1249
  : 0;
1036
- const easeOut = b + 1 < n && db !== 0 && Math.sign(delta(b + 1)) === Math.sign(db)
1250
+ const easeOut = b + 1 < n && db !== 0 && Math.sign(delta(b + 1)) === Math.sign(db) && !tightHold && !trimmedExit
1037
1251
  ? Math.max(ART_EASE_FRAMES, Math.ceil(Math.abs(db) / ART_EASE_STEP) - 1)
1038
1252
  : 0;
1039
1253
  for (let i = a; i <= b; i++) {