@rig-c/pose 0.20.1 → 0.20.2

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.2",
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
@@ -1014,7 +1014,61 @@ export interface StancePosed {
1014
1014
  * free side and with no frame between, takes their state, and is listed with
1015
1015
  * `absorbed`.
1016
1016
  */
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 {
1017
+ /**
1018
+ * Under `--stance` and `--leg-scale` (rig-c/pose#137), a free foot read `both`
1019
+ * (planted beside the supporting one within `tolerance`) whose posed ankle sits
1020
+ * above the floor (`ankle[free][1] > ankle[support][1]`) while neither the
1021
+ * whole leg (`lo >= STANCE_BOUNDS[0]`) nor its thigh (`s0 >= STANCE_BOUNDS[0]`)
1022
+ * is folded below `STANCE_BOUNDS[0]`, yet its shin was shortened below it
1023
+ * (`s1 < STANCE_BOUNDS[0]`), owes that shin shortening to a low knee / high
1024
+ * ankle split along a straight stance leg in the picture. Keeping the shortened
1025
+ * shin scale makes `--stance` multiply both thigh and shin by `needed`,
1026
+ * over-stretching the thigh (pushing the knee down), leaving the shin and foot
1027
+ * squashed, and exceeding `STANCE_BOUNDS[1]` so the free ankle floats off #85's
1028
+ * floor. Lifting the shin's scale to `min(1, s0)` before `stanceOf` reads
1029
+ * `needed` restores the straight leg's shin proportion first so the chain is
1030
+ * stretched only by the remaining gap.
1031
+ */
1032
+ export function unshortenStanceShin(
1033
+ scales: Map<string, number[]>,
1034
+ legs: Record<Side, { chain: Chain; ankleBone: string }>,
1035
+ posed: (i: number) => StancePosed,
1036
+ lower?: StanceLower,
1037
+ ): (i: number, free?: Side) => StancePosed {
1038
+ const copied = new Set<string>();
1039
+ const ensureCopy = (b: string): number[] => {
1040
+ const cur = scales.get(b)!;
1041
+ if (copied.has(b)) return cur;
1042
+ const copy = [...cur];
1043
+ scales.set(b, copy);
1044
+ copied.add(b);
1045
+ return copy;
1046
+ };
1047
+ return (i: number, free?: Side): StancePosed => {
1048
+ const p = posed(i);
1049
+ if (free === undefined) return p;
1050
+ if (lowerOf(lower, free, i) < STANCE_BOUNDS[0]) return p;
1051
+ const support: Side = free === 'r' ? 'l' : 'r';
1052
+ const floor = p.ankle[support][1];
1053
+ const root = p.root[free][1];
1054
+ const a = p.ankle[free][1];
1055
+ if (!(root - a > 1e-9 && a > floor + 1e-9)) return p;
1056
+ const bones = legs[free].chain.bones;
1057
+ if (bones.length < 2) return p;
1058
+ const b0 = bones[0]!;
1059
+ const b1 = bones[bones.length - 1]!;
1060
+ const arr0 = scales.get(b0);
1061
+ const arr1 = scales.get(b1);
1062
+ if (arr0 === undefined || arr1 === undefined) return p;
1063
+ const s0 = arr0[i]!;
1064
+ const s1 = arr1[i]!;
1065
+ if (!(s0 >= STANCE_BOUNDS[0] && s1 < STANCE_BOUNDS[0])) return p;
1066
+ ensureCopy(b1)[i] = Math.min(1, s0);
1067
+ return posed(i);
1068
+ };
1069
+ }
1070
+
1071
+ 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
1072
  const n = feet.support.length;
1019
1073
  const stretch: Stance['stretch'] = new Array(n).fill(null);
1020
1074
  const tracked = new Array<Side | null>(n).fill(null);
@@ -1085,7 +1139,7 @@ export function stanceOf(feet: Feet, ankle: Record<Side, readonly Pt[]>, toleran
1085
1139
  rows[i] = { state: r.state, side: f, height, k: null, needed: null, over: false, left: 0, lo };
1086
1140
  continue;
1087
1141
  }
1088
- const p = posed(i);
1142
+ const p = posed(i, f);
1089
1143
  const floor = p.ankle[s][1];
1090
1144
  const root = p.root[f][1];
1091
1145
  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
 
@@ -779,7 +779,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
779
779
  // --stance (#60): with the hip placed, a free ankle within the tolerance of the floor has its leg
780
780
  // stretched (every bone of its chain's scalex, about the chain's root) until it reaches it.
781
781
  if (opts.stance !== undefined && opts.stance !== 'off') {
782
- stance = stanceOf(feet!, ankleSeries, opts.stance, posedFeet, secondsOf, lower);
782
+ stance = stanceOf(feet!, ankleSeries, opts.stance, legScaleOn ? unshortenStanceShin(scales, L, posedFeet, lower) : posedFeet, secondsOf, lower);
783
783
  // The supporting leg (the blend's) and the free leg (the stance's) are different chains on a frame.
784
784
  for (let i = 0; i < n; i++) {
785
785
  const b = blendHold.stretch[i];
@@ -897,7 +897,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
897
897
  return null;
898
898
  };
899
899
  const turn = (bone: string, i: number) => targets.get(bone)![i]! - restOf.get(bone)!;
900
- const covered = coveredChains(sw.switches, n);
900
+ const covered = coveredChains(sw.switches, n, sw.chainHolds);
901
901
  if (opts.short !== 'off') {
902
902
  for (const [b, info] of fractions) {
903
903
  for (const s of capShortSteps(targets.get(b)!, info.fraction, covered.get(info.chain))) {
@@ -1188,7 +1188,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1188
1188
  const own = measured.filter((e) => e.set === s.set && e.frames[0] >= s.frames[0] && e.frames[1] <= s.frames[1]);
1189
1189
  artRange.push(...own.filter((e) => !solvedBy(e, s)), ...(raisedArt.find((x) => x.sw === s)?.entries ?? []));
1190
1190
  }
1191
- clampArt(sw.switches, map, targets);
1191
+ clampArt(sw.switches, map, targets, sw.chainHolds);
1192
1192
  // Where each raised wrist lands as keyed: after the solve and the set's `turn`.
1193
1193
  for (const job of raisedJobs) {
1194
1194
  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
  }