@rig-c/pose 0.20.1 → 0.20.3

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.20.1",
3
+ "version": "0.20.3",
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/legs.ts CHANGED
@@ -683,6 +683,23 @@ function placeHip(feet: Feet, offset: (i: number, s: Side) => Pt, travel: (i: nu
683
683
  const tracked = new Array<Side | null>(n).fill(null);
684
684
  for (const t of feet.tracked ?? []) for (let i = t.frames[0]; i <= t.frames[1]; i++) tracked[i] = t.side;
685
685
  const trkX = trackFn ?? follow;
686
+ // #142: on a run (two or more flights), each support span's push-off ankle lift and side-to-side
687
+ // straight-length asymmetry would otherwise swing floorY - offset(i, s)[1] far past the pose's
688
+ // own bounce and ratchet the un-floored picture pass per cycle. Keep the support spans' mean floor
689
+ // shift across both support spans and flights, holding each grounded ankle on floorY via blends.
690
+ const isRun = feet.jumps.length >= 2 && feet.supports.length > 0;
691
+ let runShift = 0;
692
+ if (isRun && floorY !== null) {
693
+ const t0 = travel(0)[1];
694
+ const shifts: number[] = [];
695
+ for (let i = 0; i < n; i++) {
696
+ const s = feet.support[i] ?? null;
697
+ if (s === null) continue;
698
+ shifts.push(floorY - offset(i, s)[1] - (start[1] + travel(i)[1] - t0));
699
+ }
700
+ if (shifts.length > 0) runShift = shifts.reduce((a, b) => a + b, 0) / shifts.length;
701
+ }
702
+ const flightShift = runShift;
686
703
  /** How the hip is derived on a frame: held over a foot's anchor, tracking the lower foot on the floor (#119), or carried by the pose's travel from a frame's hip (and for a jump against `floorY`, carried so the landing frame meets the floor and frames inside the flight never go below it, #103). */
687
704
  type Mode =
688
705
  | { kind: 'plant'; side: Side; anchor: Pt; from: number }
@@ -693,6 +710,13 @@ function placeHip(feet: Feet, offset: (i: number, s: Side) => Pt, travel: (i: nu
693
710
  const o = offset(i, mode.side);
694
711
  // In place (#92) the anchor's x follows the supporting ankle's own track from the frame it planted.
695
712
  const dx = follow === undefined ? 0 : follow(i, mode.side) - follow(mode.from, mode.side);
713
+ if (isRun) {
714
+ const yPose = start[1] + travel(i)[1] - travel(0)[1] + (floorY === null ? 0 : runShift);
715
+ if (floorY === null) return [mode.anchor[0] + dx - o[0], yPose];
716
+ const d = -o[1];
717
+ const y = d > 0 ? Math.min(Math.max(yPose, mode.anchor[1] + STANCE_BOUNDS[0] * d), mode.anchor[1] + STANCE_BOUNDS[1] * d) : yPose;
718
+ return [mode.anchor[0] + dx - o[0], y];
719
+ }
696
720
  return [mode.anchor[0] + dx - o[0], mode.anchor[1] - o[1]];
697
721
  }
698
722
  if (mode.kind === 'track') {
@@ -703,6 +727,13 @@ function placeHip(feet: Feet, offset: (i: number, s: Side) => Pt, travel: (i: nu
703
727
  }
704
728
  const a = travel(i);
705
729
  const b = travel(mode.from);
730
+ if (isRun) {
731
+ let y = start[1] + a[1] - travel(0)[1] + (floorY === null ? 0 : flightShift);
732
+ if (mode.end !== undefined && floorY !== null && i <= mode.end) {
733
+ y = Math.max(y, floorY - Math.min(offset(i, 'r')[1], offset(i, 'l')[1]));
734
+ }
735
+ return [mode.at[0] + a[0] - b[0], y];
736
+ }
706
737
  let y = mode.at[1] + a[1] - b[1];
707
738
  if (mode.carry !== undefined && mode.land !== undefined && mode.end !== undefined && floorY !== null) {
708
739
  if (mode.land < n && i <= mode.land && mode.land > mode.from) {
@@ -756,7 +787,7 @@ function placeHip(feet: Feet, offset: (i: number, s: Side) => Pt, travel: (i: nu
756
787
  // The new support is held where it is on this frame, under the hip the old derivation gives here.
757
788
  const here = of(mode!, i);
758
789
  const o = offset(i, s);
759
- from = mode;
790
+ from = isRun ? null : mode;
760
791
  mode = plant(i, s, [here[0] + o[0], here[1] + o[1]]);
761
792
  blendAt = i;
762
793
  }
@@ -784,6 +815,10 @@ function placeHip(feet: Feet, offset: (i: number, s: Side) => Pt, travel: (i: nu
784
815
  } else {
785
816
  from = null;
786
817
  hip.push(to);
818
+ const m = mode!;
819
+ if (isRun && floorY !== null && m.kind === 'plant' && Math.abs(to[1] - (m.anchor[1] - offset(i, m.side)[1])) > BLEND_HOLD_WITHIN) {
820
+ blends.push({ frame: i, side: m.side, y: m.anchor[1] });
821
+ }
787
822
  }
788
823
  }
789
824
  return { hip, anchors, blends };
@@ -1014,7 +1049,61 @@ export interface StancePosed {
1014
1049
  * free side and with no frame between, takes their state, and is listed with
1015
1050
  * `absorbed`.
1016
1051
  */
1017
- export function stanceOf(feet: Feet, ankle: Record<Side, readonly Pt[]>, tolerance: number, posed: (i: number) => StancePosed, secondsOf: (run: [number, number]) => number, lower?: StanceLower): Stance {
1052
+ /**
1053
+ * Under `--stance` and `--leg-scale` (rig-c/pose#137), a free foot read `both`
1054
+ * (planted beside the supporting one within `tolerance`) whose posed ankle sits
1055
+ * above the floor (`ankle[free][1] > ankle[support][1]`) while neither the
1056
+ * whole leg (`lo >= STANCE_BOUNDS[0]`) nor its thigh (`s0 >= STANCE_BOUNDS[0]`)
1057
+ * is folded below `STANCE_BOUNDS[0]`, yet its shin was shortened below it
1058
+ * (`s1 < STANCE_BOUNDS[0]`), owes that shin shortening to a low knee / high
1059
+ * ankle split along a straight stance leg in the picture. Keeping the shortened
1060
+ * shin scale makes `--stance` multiply both thigh and shin by `needed`,
1061
+ * over-stretching the thigh (pushing the knee down), leaving the shin and foot
1062
+ * squashed, and exceeding `STANCE_BOUNDS[1]` so the free ankle floats off #85's
1063
+ * floor. Lifting the shin's scale to `min(1, s0)` before `stanceOf` reads
1064
+ * `needed` restores the straight leg's shin proportion first so the chain is
1065
+ * stretched only by the remaining gap.
1066
+ */
1067
+ export function unshortenStanceShin(
1068
+ scales: Map<string, number[]>,
1069
+ legs: Record<Side, { chain: Chain; ankleBone: string }>,
1070
+ posed: (i: number) => StancePosed,
1071
+ lower?: StanceLower,
1072
+ ): (i: number, free?: Side) => StancePosed {
1073
+ const copied = new Set<string>();
1074
+ const ensureCopy = (b: string): number[] => {
1075
+ const cur = scales.get(b)!;
1076
+ if (copied.has(b)) return cur;
1077
+ const copy = [...cur];
1078
+ scales.set(b, copy);
1079
+ copied.add(b);
1080
+ return copy;
1081
+ };
1082
+ return (i: number, free?: Side): StancePosed => {
1083
+ const p = posed(i);
1084
+ if (free === undefined) return p;
1085
+ if (lowerOf(lower, free, i) < STANCE_BOUNDS[0]) return p;
1086
+ const support: Side = free === 'r' ? 'l' : 'r';
1087
+ const floor = p.ankle[support][1];
1088
+ const root = p.root[free][1];
1089
+ const a = p.ankle[free][1];
1090
+ if (!(root - a > 1e-9 && a > floor + 1e-9)) return p;
1091
+ const bones = legs[free].chain.bones;
1092
+ if (bones.length < 2) return p;
1093
+ const b0 = bones[0]!;
1094
+ const b1 = bones[bones.length - 1]!;
1095
+ const arr0 = scales.get(b0);
1096
+ const arr1 = scales.get(b1);
1097
+ if (arr0 === undefined || arr1 === undefined) return p;
1098
+ const s0 = arr0[i]!;
1099
+ const s1 = arr1[i]!;
1100
+ if (!(s0 >= STANCE_BOUNDS[0] && s1 < STANCE_BOUNDS[0])) return p;
1101
+ ensureCopy(b1)[i] = Math.min(1, s0);
1102
+ return posed(i);
1103
+ };
1104
+ }
1105
+
1106
+ export function stanceOf(feet: Feet, ankle: Record<Side, readonly Pt[]>, tolerance: number, posed: (i: number, free?: Side) => StancePosed, secondsOf: (run: [number, number]) => number, lower?: StanceLower): Stance {
1018
1107
  const n = feet.support.length;
1019
1108
  const stretch: Stance['stretch'] = new Array(n).fill(null);
1020
1109
  const tracked = new Array<Side | null>(n).fill(null);
@@ -1085,7 +1174,7 @@ export function stanceOf(feet: Feet, ankle: Record<Side, readonly Pt[]>, toleran
1085
1174
  rows[i] = { state: r.state, side: f, height, k: null, needed: null, over: false, left: 0, lo };
1086
1175
  continue;
1087
1176
  }
1088
- const p = posed(i);
1177
+ const p = posed(i, f);
1089
1178
  const floor = p.ankle[s][1];
1090
1179
  const root = p.root[f][1];
1091
1180
  const a = p.ankle[f][1];
package/src/map.ts CHANGED
@@ -111,6 +111,8 @@ export interface JointMap {
111
111
  export interface RigSlot {
112
112
  setup: string | null;
113
113
  attachments: ReadonlySet<string>;
114
+ /** The bone the slot is attached to, when read from a compiled build. */
115
+ bone?: string;
114
116
  }
115
117
  export type RigSlots = ReadonlyMap<string, RigSlot>;
116
118
 
package/src/retarget.ts CHANGED
@@ -82,7 +82,7 @@ import { Problems, Refusal } from './problems.ts';
82
82
  import type { ArtRange, ByHip, Raised, ReachShort, SlotTrack, Switch, SwitchOptions } from './switch.ts';
83
83
  import { clampArt, coveredChains, isRaised, measureArt, overflow, 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
- import { holdBlend, isLegSegment, legScaleOf, legsOf, pathFrom, plantHip, posePath, rootOf, scaleSupport, SIDES, stanceOf, stanceRangeOf, supportsOf, tipOf } from './legs.ts';
85
+ import { holdBlend, isLegSegment, legScaleOf, legsOf, pathFrom, plantHip, posePath, rootOf, scaleSupport, SIDES, stanceOf, stanceRangeOf, supportsOf, tipOf, unshortenStanceShin } from './legs.ts';
86
86
 
87
87
  const DEG = 180 / Math.PI;
88
88
 
@@ -775,11 +775,20 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
775
775
  const ankleSeries = { r: series.get('r_ankle')!, l: series.get('l_ankle')! };
776
776
  const lower = stanceRangeOf(ankleSeries, { r: series.get('r_hip')!, l: series.get('l_hip')! });
777
777
  blendHold = holdBlend(planted.blends, n, posedFeet, lower);
778
+ const holdAnchorX = feet!.jumps.length >= 2 ? blendHold.stretch.map((b, i) => (b ? posedFeet(i).ankle[b.side][0] : 0)) : null;
778
779
  stretchChain(blendHold.stretch);
780
+ if (holdAnchorX !== null) {
781
+ for (let i = 0; i < n; i++) {
782
+ const b = blendHold.stretch[i];
783
+ if (!b) continue;
784
+ hip[i]![0] += holdAnchorX[i]! - posedFeet(i).ankle[b.side][0];
785
+ travel[i]![0] = hip[i]![0] - tb.worldX;
786
+ }
787
+ }
779
788
  // --stance (#60): with the hip placed, a free ankle within the tolerance of the floor has its leg
780
789
  // stretched (every bone of its chain's scalex, about the chain's root) until it reaches it.
781
790
  if (opts.stance !== undefined && opts.stance !== 'off') {
782
- stance = stanceOf(feet!, ankleSeries, opts.stance, posedFeet, secondsOf, lower);
791
+ stance = stanceOf(feet!, ankleSeries, opts.stance, legScaleOn ? unshortenStanceShin(scales, L, posedFeet, lower) : posedFeet, secondsOf, lower);
783
792
  // The supporting leg (the blend's) and the free leg (the stance's) are different chains on a frame.
784
793
  for (let i = 0; i < n; i++) {
785
794
  const b = blendHold.stretch[i];
@@ -897,7 +906,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
897
906
  return null;
898
907
  };
899
908
  const turn = (bone: string, i: number) => targets.get(bone)![i]! - restOf.get(bone)!;
900
- const covered = coveredChains(sw.switches, n);
909
+ const covered = coveredChains(sw.switches, n, sw.chainHolds);
901
910
  if (opts.short !== 'off') {
902
911
  for (const [b, info] of fractions) {
903
912
  for (const s of capShortSteps(targets.get(b)!, info.fraction, covered.get(info.chain))) {
@@ -1188,7 +1197,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1188
1197
  const own = measured.filter((e) => e.set === s.set && e.frames[0] >= s.frames[0] && e.frames[1] <= s.frames[1]);
1189
1198
  artRange.push(...own.filter((e) => !solvedBy(e, s)), ...(raisedArt.find((x) => x.sw === s)?.entries ?? []));
1190
1199
  }
1191
- clampArt(sw.switches, map, targets);
1200
+ clampArt(sw.switches, map, targets, sw.chainHolds);
1192
1201
  // Where each raised wrist lands as keyed: after the solve and the set's `turn`.
1193
1202
  for (const job of raisedJobs) {
1194
1203
  const after: Pt[] = [];
package/src/rigc.ts CHANGED
@@ -143,7 +143,7 @@ export function slotsOf(doc: CompiledDocument): RigSlots {
143
143
  for (const s of doc.slots) {
144
144
  const attachments = new Set<string>();
145
145
  for (const k of doc.skins) for (const name of Object.keys(k.attachments[s.name] ?? {})) attachments.add(name);
146
- out.set(s.name, { setup: s.setup, attachments });
146
+ out.set(s.name, { setup: s.setup, attachments, bone: s.bone });
147
147
  }
148
148
  return out;
149
149
  }
package/src/switch.ts CHANGED
@@ -154,19 +154,35 @@ export interface Switches {
154
154
  shown: Map<string, Array<string | null>>;
155
155
  /** Per set, its distance on each frame, degrees. */
156
156
  distances: Map<string, number[]>;
157
+ /** Per chain, the behind-the-torso set spans (including same-set gap holds, #136) for `clampArt`. */
158
+ chainHolds?: Array<{ set: string; chain: string; frames: [number, number] }>;
157
159
  }
158
160
 
159
- /** Per set, its distance to the pose on every frame: the largest segment difference over its chains. */
160
- export function setDistances(map: JointMap, targets: ReadonlyMap<string, readonly number[]>, n: number): Map<string, number[]> {
161
- const out = new Map<string, number[]>();
161
+ /** Per set and chain, its distance to the pose on every frame: the largest segment difference over that chain. */
162
+ export function chainDistances(map: JointMap, targets: ReadonlyMap<string, readonly number[]>, n: number): Map<string, Map<string, number[]>> {
163
+ const out = new Map<string, Map<string, number[]>>();
162
164
  for (const a of map.alternates) {
163
- const segs: Array<{ asked: readonly number[]; key: number }> = [];
165
+ const byChain = new Map<string, number[]>();
164
166
  for (const cn of a.chains) {
165
167
  const c = map.chains.find((x) => x.name === cn)!;
166
- c.bones.forEach((b, k) => segs.push({ asked: targets.get(b)!, key: pictureAngle(a.pose[c.joints[k]!]!, a.pose[c.joints[k + 1]!]!) }));
168
+ const segs = c.bones.map((b, k) => ({ asked: targets.get(b)!, key: pictureAngle(a.pose[c.joints[k]!]!, a.pose[c.joints[k + 1]!]!) }));
169
+ const d: number[] = [];
170
+ for (let i = 0; i < n; i++) d.push(Math.max(...segs.map((s) => Math.abs(wrap(s.asked[i]! - s.key)))));
171
+ byChain.set(cn, d);
167
172
  }
173
+ out.set(a.name, byChain);
174
+ }
175
+ return out;
176
+ }
177
+
178
+ /** Per set, its distance to the pose on every frame: the largest segment difference over its chains. */
179
+ export function setDistances(map: JointMap, targets: ReadonlyMap<string, readonly number[]>, n: number): Map<string, number[]> {
180
+ const byChain = chainDistances(map, targets, n);
181
+ const out = new Map<string, number[]>();
182
+ for (const a of map.alternates) {
183
+ const cs = a.chains.map((cn) => byChain.get(a.name)!.get(cn)!);
168
184
  const d: number[] = [];
169
- for (let i = 0; i < n; i++) d.push(Math.max(...segs.map((s) => Math.abs(wrap(s.asked[i]! - s.key)))));
185
+ for (let i = 0; i < n; i++) d.push(Math.max(...cs.map((c) => c[i]!)));
170
186
  out.set(a.name, d);
171
187
  }
172
188
  return out;
@@ -210,6 +226,59 @@ function competing(sets: readonly AlternateSet[]): AlternateSet[][] {
210
226
  return [...groups.values()];
211
227
  }
212
228
 
229
+ /**
230
+ * How a competing group's slots sit against the torso in the rig's draw order
231
+ * (`slots` is in draw order, back to front, #136): which slot is the never-hidden
232
+ * torso slot (when the group touches or sits beside one), which sets draw their
233
+ * limb slots behind it vs in front of it, whether the base setup slots sit in
234
+ * front of it, and which chain owns each non-torso slot.
235
+ */
236
+ function groupLayering(group: readonly AlternateSet[], map: JointMap, slots: RigSlots) {
237
+ const rank = new Map([...slots.keys()].map((s, i) => [s, i]));
238
+ const touched = [...new Set(group.flatMap((a) => [...Object.keys(a.slots), ...a.hides]))].filter((s) => rank.has(s));
239
+ const hiddenSetup = touched.filter((s) => slots.get(s)!.setup !== null && group.some((a) => a.hides.includes(s)));
240
+ const extra = touched.filter((s) => slots.get(s)!.setup === null);
241
+ const torsoSlot = touched.find((s) => slots.get(s)!.setup !== null && !group.some((a) => a.hides.includes(s))) ?? null;
242
+ let torsoRank: number | null = torsoSlot !== null ? rank.get(torsoSlot)! : null;
243
+ if (torsoRank === null && hiddenSetup.length > 0 && extra.length > 0) {
244
+ const minFront = Math.min(...hiddenSetup.map((s) => rank.get(s)!));
245
+ const maxBack = Math.max(...extra.filter((s) => rank.get(s)! < minFront).map((s) => rank.get(s)!));
246
+ if (Number.isFinite(maxBack)) {
247
+ const between = [...slots.keys()].find((s) => !touched.includes(s) && slots.get(s)!.setup !== null && rank.get(s)! > maxBack && rank.get(s)! < minFront);
248
+ torsoRank = between !== undefined ? rank.get(between)! : minFront - 0.5;
249
+ }
250
+ }
251
+ const limbSlotsOf = (a: AlternateSet) => Object.keys(a.slots).filter((s) => s !== torsoSlot && rank.has(s));
252
+ const isBehind = (a: AlternateSet): boolean => {
253
+ if (torsoRank === null) return false;
254
+ const ls = limbSlotsOf(a);
255
+ return ls.length > 0 && ls.every((s) => rank.get(s)! < torsoRank) && a.hides.some((h) => (rank.get(h) ?? -1) > torsoRank);
256
+ };
257
+ const isFront = (a: AlternateSet): boolean => {
258
+ if (torsoRank === null) return false;
259
+ const ls = limbSlotsOf(a);
260
+ return ls.length > 0 && ls.every((s) => rank.get(s)! > torsoRank);
261
+ };
262
+ const baseInFront = torsoRank !== null && hiddenSetup.length > 0 && hiddenSetup.every((s) => rank.get(s)! > torsoRank);
263
+ const groupChains = [...new Set(group.flatMap((a) => a.chains))];
264
+ const boneOwner = new Map<string, string>();
265
+ for (const cn of groupChains) for (const b of map.chains.find((c) => c.name === cn)!.bones) boneOwner.set(b, cn);
266
+ const slotChain = new Map<string, string>();
267
+ for (const s of touched) {
268
+ if (s === torsoSlot) continue;
269
+ const b = slots.get(s)?.bone;
270
+ if (b !== undefined && boneOwner.has(b)) {
271
+ slotChain.set(s, boneOwner.get(b)!);
272
+ continue;
273
+ }
274
+ const side = /(?:^|_)([rl])(?:_|$)/.exec(s)?.[1];
275
+ const bySide = side === undefined ? undefined : groupChains.find((c) => new RegExp(`(?:^|_)${side}(?:_|$)`).test(c));
276
+ if (bySide !== undefined) slotChain.set(s, bySide);
277
+ else if (groupChains.length === 1) slotChain.set(s, groupChains[0]!);
278
+ }
279
+ return { torsoSlot, torsoRank, touched, isBehind, isFront, baseInFront, slotChain };
280
+ }
281
+
213
282
  /**
214
283
  * Per frame, the largest |angular speed| over the given bones' target world
215
284
  * angles, degrees per frame: a central difference, one-sided on the first and
@@ -305,9 +374,12 @@ const changeAt = (times: readonly number[], i: number): number => (i === 0 ? 0 :
305
374
  */
306
375
  export function selectSwitches(map: JointMap, targets: ReadonlyMap<string, readonly number[]>, times: readonly number[], duration: number, slots: RigSlots, opts: SwitchOptions): Switches {
307
376
  const n = times.length;
377
+ const byChain = chainDistances(map, targets, n);
308
378
  const distances = setDistances(map, targets, n);
309
379
  const switches: Switch[] = [];
310
- for (const group of competing(map.alternates)) {
380
+ const groups = competing(map.alternates);
381
+ for (const group of groups) {
382
+ const layer = groupLayering(group, map, slots);
311
383
  const pick: Array<AlternateSet | null> = [];
312
384
  for (let i = 0; i < n; i++) {
313
385
  // A set with a `frames` window shows only inside it, and there it is chosen
@@ -328,22 +400,25 @@ export function selectSwitches(map: JointMap, targets: ReadonlyMap<string, reado
328
400
  }
329
401
  pick.push(best);
330
402
  }
403
+ const bridgeSame = (arr: Array<AlternateSet | null>) => {
404
+ for (let i = 0; i < n;) {
405
+ const a = arr[i];
406
+ let j = i;
407
+ while (j + 1 < n && arr[j + 1] === a) j++;
408
+ const before = i > 0 ? arr[i - 1] : null;
409
+ const after = j + 1 < n ? arr[j + 1] : null;
410
+ if (before !== null && before !== undefined && before === after && a !== before) {
411
+ const gap = times[j + 1]! - times[i]!;
412
+ if (gap < opts.switchMin - 1e-9) for (let k = i; k <= j; k++) arr[k] = before;
413
+ }
414
+ i = j + 1;
415
+ }
416
+ };
331
417
  // A gap shorter than the minimum span between two runs of one set is that set
332
418
  // too: the base art flashing for a frame between them is the flicker the
333
419
  // minimum span exists to prevent (measured on the dancer: one-frame breaks at
334
420
  // a blurred frame inside a span of one set).
335
- for (let i = 0; i < n;) {
336
- const a = pick[i];
337
- let j = i;
338
- while (j + 1 < n && pick[j + 1] === a) j++;
339
- const before = i > 0 ? pick[i - 1] : null;
340
- const after = j + 1 < n ? pick[j + 1] : null;
341
- if (before !== null && before !== undefined && before === after && a !== before) {
342
- const gap = times[j + 1]! - times[i]!;
343
- if (gap < opts.switchMin - 1e-9) for (let k = i; k <= j; k++) pick[k] = before;
344
- }
345
- i = j + 1;
346
- }
421
+ bridgeSame(pick);
347
422
  // A run shorter than the minimum span shows the base art.
348
423
  for (let i = 0; i < n;) {
349
424
  const a = pick[i];
@@ -353,6 +428,31 @@ export function selectSwitches(map: JointMap, targets: ReadonlyMap<string, reado
353
428
  if (a !== null && a !== undefined && to - times[i]! < opts.switchMin - 1e-9) for (let k = i; k <= j; k++) pick[k] = null;
354
429
  i = j + 1;
355
430
  }
431
+ // #136: a short front-set window (< 2 × switchMin) between two behind-set windows is dropped.
432
+ if (layer.torsoRank !== null) {
433
+ let dropped = false;
434
+ for (let i = 0; i < n;) {
435
+ const a = pick[i] ?? null;
436
+ let j = i;
437
+ while (j + 1 < n && (pick[j + 1] ?? null) === a) j++;
438
+ if (a !== null && layer.isFront(a)) {
439
+ let p = i - 1;
440
+ while (p >= 0 && (pick[p] ?? null) === null) p--;
441
+ let q = j + 1;
442
+ while (q < n && (pick[q] ?? null) === null) q++;
443
+ const prev = p >= 0 ? (pick[p] ?? null) : null;
444
+ const next = q < n ? (pick[q] ?? null) : null;
445
+ const to = j + 1 < n ? times[j + 1]! : duration;
446
+ if (prev !== null && next !== null && layer.isBehind(prev) && layer.isBehind(next) && to - times[i]! < 2 * opts.switchMin - 1e-9) {
447
+ const fill = prev === next && p === i - 1 && q === j + 1 && Array.from({ length: j - i + 1 }, (_, k) => distances.get(prev.name)![i + k]! <= opts.switchTolerance).every(Boolean) ? prev : null;
448
+ for (let k = i; k <= j; k++) pick[k] = fill;
449
+ dropped = true;
450
+ }
451
+ }
452
+ i = j + 1;
453
+ }
454
+ if (dropped) bridgeSame(pick);
455
+ }
356
456
  // The segments between boundaries, then each boundary moved (#31).
357
457
  const vals: Array<AlternateSet | null> = [pick[0] ?? null];
358
458
  const pos: number[] = [];
@@ -398,6 +498,112 @@ export function selectSwitches(map: JointMap, targets: ReadonlyMap<string, reado
398
498
  for (let i = sw.frames[0]; i <= sw.frames[1]; i++) row[i] = o;
399
499
  }
400
500
  }
501
+ // #136: when a group's base setup slots sit in front of the torso, an unswitched gap between two
502
+ // behind-the-torso switches keeps each chain on its behind-the-torso slots on the frames where that
503
+ // chain stays within tolerance of the surrounding behind sets (with the same minimum-span bridging),
504
+ // and across a joint behind-to-behind transition where all chains move from one behind set to another.
505
+ const chainHolds: Array<{ set: string; chain: string; frames: [number, number] }> = [];
506
+ for (const group of groups) {
507
+ const layer = groupLayering(group, map, slots);
508
+ if (!layer.baseInFront) continue;
509
+ const names = new Set(group.map((a) => a.name));
510
+ const gSwitches = switches.filter((s) => names.has(s.set));
511
+ const groupChains = [...new Set(group.flatMap((a) => a.chains))];
512
+ const cClamp = new Map<string, Array<AlternateSet | null>>();
513
+ for (const cn of groupChains) {
514
+ const arr: Array<AlternateSet | null> = Array.from({ length: n }, () => null);
515
+ for (const sw of gSwitches) {
516
+ if (!sw.chains.includes(cn)) continue;
517
+ const set = group.find((a) => a.name === sw.set)!;
518
+ if (layer.isBehind(set)) for (let i = sw.frames[0]; i <= sw.frames[1]; i++) arr[i] = set;
519
+ }
520
+ cClamp.set(cn, arr);
521
+ }
522
+ for (let m = 0; m + 1 < gSwitches.length; m++) {
523
+ const sw0 = gSwitches[m]!;
524
+ const sw1 = gSwitches[m + 1]!;
525
+ const set0 = group.find((a) => a.name === sw0.set)!;
526
+ const set1 = group.find((a) => a.name === sw1.set)!;
527
+ if (!layer.isBehind(set0) || !layer.isBehind(set1)) continue;
528
+ const g0 = sw0.frames[1] + 1;
529
+ const g1 = sw1.frames[0] - 1;
530
+ if (g0 > g1) continue;
531
+ const chains = [...new Set([...set0.chains, ...set1.chains])];
532
+ const cPick = new Map<string, Array<AlternateSet | null>>();
533
+ for (const cn of chains) {
534
+ const arr: Array<AlternateSet | null> = Array.from({ length: n }, () => null);
535
+ for (let i = sw0.frames[0]; i <= sw0.frames[1]; i++) arr[i] = set0;
536
+ for (let i = sw1.frames[0]; i <= sw1.frames[1]; i++) arr[i] = set1;
537
+ const d0s = byChain.get(set0.name)?.get(cn);
538
+ const d1s = byChain.get(set1.name)?.get(cn);
539
+ for (let i = g0; i <= g1; i++) {
540
+ const d0 = d0s?.[i] ?? Infinity;
541
+ const d1 = d1s?.[i] ?? Infinity;
542
+ if (Math.min(d0, d1) <= opts.switchTolerance) arr[i] = d0 <= d1 ? set0 : set1;
543
+ }
544
+ for (let i = g0; i <= g1;) {
545
+ const a = arr[i];
546
+ let j = i;
547
+ while (j + 1 <= g1 && arr[j + 1] === a) j++;
548
+ const before = arr[i - 1] ?? null;
549
+ const after = arr[j + 1] ?? null;
550
+ if (before !== null && before === after && a !== before) {
551
+ const gap = times[j + 1]! - times[i]!;
552
+ if (gap < opts.switchMin - 1e-9) for (let k = i; k <= j; k++) arr[k] = before;
553
+ }
554
+ i = j + 1;
555
+ }
556
+ for (let i = g0; i <= g1;) {
557
+ const a = arr[i];
558
+ let j = i;
559
+ while (j + 1 <= g1 && arr[j + 1] === a) j++;
560
+ if (a !== null && i > g0 && j < g1 && times[j + 1]! - times[i]! < opts.switchMin - 1e-9) {
561
+ for (let k = i; k <= j; k++) arr[k] = null;
562
+ }
563
+ i = j + 1;
564
+ }
565
+ for (let i = g0; i <= g1; i++) {
566
+ if (arr[i] !== null) cClamp.get(cn)![i] = arr[i]!;
567
+ }
568
+ cPick.set(cn, arr);
569
+ }
570
+ const anyHeldBehind = chains.some((cn) => {
571
+ const arr = cPick.get(cn)!;
572
+ for (let i = g0; i <= g1; i++) if (arr[i] === null) return false;
573
+ return true;
574
+ });
575
+ if (!anyHeldBehind && set0 !== set1) {
576
+ const frontNear = group.filter((a) => layer.isFront(a)).some((a) => {
577
+ for (let i = g0; i <= g1; i++) if (distances.get(a.name)![i]! <= opts.switchTolerance) return true;
578
+ return false;
579
+ });
580
+ if (!frontNear) for (const cn of chains) for (let i = g0; i <= g1; i++) cPick.get(cn)![i] = set0;
581
+ }
582
+ for (const s of layer.touched) {
583
+ if (!shown.has(s)) shown.set(s, Array.from({ length: n }, () => slots.get(s)!.setup));
584
+ }
585
+ for (let i = g0; i <= g1; i++) {
586
+ for (const [s, cn] of layer.slotChain) {
587
+ const a = cPick.get(cn)?.[i] ?? null;
588
+ if (a !== null) shown.get(s)![i] = a.slots[s] ?? null;
589
+ }
590
+ if (layer.torsoSlot !== null && shown.has(layer.torsoSlot)) {
591
+ const picked = chains.map((cn) => cPick.get(cn)![i]);
592
+ if (picked.every((a) => a !== null)) shown.get(layer.torsoSlot)![i] = picked[0]!.slots[layer.torsoSlot] ?? slots.get(layer.torsoSlot)!.setup;
593
+ }
594
+ }
595
+ }
596
+ for (const cn of groupChains) {
597
+ const arr = cClamp.get(cn)!;
598
+ for (let i = 0; i < n;) {
599
+ const a = arr[i];
600
+ let j = i;
601
+ while (j + 1 < n && arr[j + 1] === a) j++;
602
+ if (a !== null) chainHolds.push({ set: a.name, chain: cn, frames: [i, j] });
603
+ i = j + 1;
604
+ }
605
+ }
606
+ }
401
607
  const slotTracks: SlotTrack[] = [];
402
608
  for (const s of slotOrder) {
403
609
  const row = shown.get(s);
@@ -425,11 +631,11 @@ export function selectSwitches(map: JointMap, targets: ReadonlyMap<string, reado
425
631
  for (let k = 0; k + 1 < keys.length; k++) keys[k]!.ease = 'stepped';
426
632
  offsetTracks.push({ bone: b, property: 'translate', keys });
427
633
  }
428
- return { switches, slotTracks, offsetTracks, shown, distances };
634
+ return { switches, slotTracks, offsetTracks, shown, distances, chainHolds };
429
635
  }
430
636
 
431
637
  /** Per chain, the frames on which a switch of a set depicting it shows: its art was painted for that pose. */
432
- export function coveredChains(switches: readonly Switch[], n: number): Map<string, boolean[]> {
638
+ export function coveredChains(switches: readonly Switch[], n: number, chainHolds?: ReadonlyArray<{ set: string; chain: string; frames: [number, number] }>): Map<string, boolean[]> {
433
639
  const out = new Map<string, boolean[]>();
434
640
  for (const sw of switches) {
435
641
  for (const c of sw.chains) {
@@ -438,6 +644,11 @@ export function coveredChains(switches: readonly Switch[], n: number): Map<strin
438
644
  for (let i = sw.frames[0]; i <= sw.frames[1]; i++) row[i] = true;
439
645
  }
440
646
  }
647
+ for (const h of chainHolds ?? []) {
648
+ if (!out.has(h.chain)) out.set(h.chain, Array.from({ length: n }, () => false));
649
+ const row = out.get(h.chain)!;
650
+ for (let i = h.frames[0]; i <= h.frames[1]; i++) row[i] = true;
651
+ }
441
652
  return out;
442
653
  }
443
654
 
@@ -606,35 +817,50 @@ export const ART_EASE_STEP = 3;
606
817
  * own world angle, and keys are differences of world angles, so a forearm
607
818
  * under a clamped upper arm keeps its own target.
608
819
  */
609
- export function clampArt(switches: readonly Switch[], map: JointMap, targets: Map<string, number[]>): void {
820
+ export function clampArt(switches: readonly Switch[], map: JointMap, targets: Map<string, number[]>, chainHolds?: ReadonlyArray<{ set: string; chain: string; frames: [number, number] }>): void {
821
+ const boneChain = new Map<string, string>();
822
+ for (const c of map.chains) for (const b of c.bones) boneChain.set(b, c.name);
823
+ const clampSpan = (set: AlternateSet, bone: string, [a, b]: [number, number]) => {
824
+ const limits = set.turn[bone];
825
+ if (limits === undefined) return;
826
+ const drawn = set.drawn[bone]!;
827
+ const target = targets.get(bone)!;
828
+ const n = target.length;
829
+ const delta = (i: number): number => {
830
+ const t = wrap(target[i]! - drawn);
831
+ const c = Math.min(Math.max(t, limits[0]), limits[1]);
832
+ return Math.abs(c - t) > RANGE_SLACK ? c - t : 0;
833
+ };
834
+ const da = delta(a);
835
+ const db = delta(b);
836
+ const easeIn = a > 0 && da !== 0 && Math.sign(delta(a - 1)) === Math.sign(da)
837
+ ? Math.max(ART_EASE_FRAMES, Math.ceil(Math.abs(da) / ART_EASE_STEP) - 1)
838
+ : 0;
839
+ const easeOut = b + 1 < n && db !== 0 && Math.sign(delta(b + 1)) === Math.sign(db)
840
+ ? Math.max(ART_EASE_FRAMES, Math.ceil(Math.abs(db) / ART_EASE_STEP) - 1)
841
+ : 0;
842
+ for (let i = a; i <= b; i++) {
843
+ const d = delta(i);
844
+ if (d === 0) continue;
845
+ const wIn = i - a < easeIn ? (i - a + 1) / (easeIn + 1) : 1;
846
+ const wOut = b - i < easeOut ? (b - i + 1) / (easeOut + 1) : 1;
847
+ const w = Math.min(wIn, wOut);
848
+ target[i] = wrap(target[i]! + w * d);
849
+ }
850
+ };
851
+ const heldChains = new Set((chainHolds ?? []).map((h) => `${h.set}:${h.chain}`));
610
852
  for (const sw of switches) {
611
853
  const set = map.alternates.find((a) => a.name === sw.set)!;
612
- const [a, b] = sw.frames;
613
- for (const [bone, limits] of Object.entries(set.turn)) {
614
- const drawn = set.drawn[bone]!;
615
- const target = targets.get(bone)!;
616
- const n = target.length;
617
- const delta = (i: number): number => {
618
- const t = wrap(target[i]! - drawn);
619
- const c = Math.min(Math.max(t, limits[0]), limits[1]);
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);
637
- }
854
+ for (const bone of Object.keys(set.turn)) {
855
+ const cn = boneChain.get(bone);
856
+ if (cn !== undefined && heldChains.has(`${sw.set}:${cn}`)) continue;
857
+ clampSpan(set, bone, sw.frames);
858
+ }
859
+ }
860
+ for (const h of chainHolds ?? []) {
861
+ const set = map.alternates.find((a) => a.name === h.set)!;
862
+ for (const bone of Object.keys(set.turn)) {
863
+ if (boneChain.get(bone) === h.chain) clampSpan(set, bone, h.frames);
638
864
  }
639
865
  }
640
866
  }