@rig-c/pose 0.24.1 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/pose",
3
- "version": "0.24.1",
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
@@ -1380,6 +1380,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1380
1380
  const set = map.alternates.find((a) => a.name === s.set)!;
1381
1381
  const art: ArtRange[] = [];
1382
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;
1383
1384
  const c = map.chains.find((x) => x.name === cn)!;
1384
1385
  const drawn = set.drawn[c.bones[0]!];
1385
1386
  const up = isChainHeadPlaced(set, c, drawn);
@@ -1633,6 +1634,45 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1633
1634
  if (job.anchor.kind === 'head') raised.push({ set, chain, frames, seconds, eyes: unit, ...body });
1634
1635
  else byHip.push({ set, chain, frames, seconds, hips: unit, ...body });
1635
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
+ }
1636
1676
 
1637
1677
  // The legs on their final targets: a bone under a scaled bone is keyed through
1638
1678
  // its parent's posed matrix (legs.ts), and each ankle's world position is read.
@@ -2038,24 +2078,26 @@ export function capShortSteps(
2038
2078
  }
2039
2079
  let segEnd = segStart;
2040
2080
  while (segEnd + 1 < n && covered?.[segEnd + 1] !== true) segEnd++;
2041
- 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) {
2042
2084
  const u: number[] = new Array<number>(n).fill(0);
2043
- u[segStart] = angles[segStart]!;
2044
- 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]!);
2045
2087
  const fits = (lo: number, hi: number) => Math.abs(u[hi]! - u[lo]!) <= stepCap * (hi - lo);
2046
2088
  interface Win { lo: number; hi: number; asked: number }
2047
2089
  const wins: Win[] = [];
2048
- for (let k = segStart + 1; k <= segEnd; k++) {
2090
+ for (let k = loBound + 1; k <= hiBound; k++) {
2049
2091
  if (!(Math.min(fraction[k - 1]!, fraction[k]!) < fractionCutoff)) continue;
2050
2092
  const jump = u[k]! - u[k - 1]!;
2051
2093
  if (Math.abs(jump) <= stepCap) continue;
2052
2094
  let h = 1;
2053
- let lo = Math.max(segStart, k - h - 1);
2054
- let hi = Math.min(segEnd, k + h);
2055
- 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)) {
2056
2098
  h++;
2057
- lo = Math.max(segStart, k - h - 1);
2058
- hi = Math.min(segEnd, k + h);
2099
+ lo = Math.max(loBound, k - h - 1);
2100
+ hi = Math.min(hiBound, k + h);
2059
2101
  }
2060
2102
  wins.push({ lo, hi, asked: jump });
2061
2103
  }
@@ -2072,9 +2114,9 @@ export function capShortSteps(
2072
2114
  }
2073
2115
  if (changed) continue;
2074
2116
  for (const w of wins) {
2075
- if (fits(w.lo, w.hi) || (w.lo === segStart && w.hi === segEnd)) continue;
2076
- w.lo = Math.max(segStart, w.lo - 1);
2077
- 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);
2078
2120
  changed = true;
2079
2121
  }
2080
2122
  }
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++) {