@rig-c/pose 0.20.0 → 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/README.md CHANGED
@@ -136,7 +136,7 @@ and what the run cannot decide for you it lists:
136
136
  - `RAISED` — with `--raised-by-head` ([below](#alternate-drawings-alternates)): one line per span of a set that draws an arm raised, per chain. It gives the frames and the median eye distances (the rig's in units, the reference's in px). It gives the wrist's offset from the head centre in eye distances: the median of the reference's, of where the wrist was placed, of the rig's on the pose's angles, and of the rig's as keyed. It gives the worst |rig − reference| before and after, the worst |after − placed|, and the frames whose upper arm the set's `turn` held. `RAISED none` when the flag is given and no such set showed.
137
137
  - `BY-HIP` — with a set's `"place": "by-hip"` ([below](#alternate-drawings-alternates)): the `RAISED` line against the hip, one per span of such a set per chain it places. The unit is the hip width, the offset is from the mid-hip. `BY-HIP none` when the map has such a set and none of its spans showed.
138
138
  - `REACH` — with `--raised-by-head`, or a `"by-hip"` set: a placed span whose target was out of the arm's reach, too far or too near. It names the frames, which way, and the shortfall in eye distances (hip widths for `"by-hip"`). The wrist was placed at the reach limit instead. This is not `ART-RANGE`: the rig's proportions were the limit, not the art's range. A too-near frame is placed at the limit like a too-far one, and graded at the placed distance.
139
- - `LEG-SCALE` — with `--leg-scale` ([below](#the-legs-foreshortening-and-planted-feet)): per leg segment, its straight length, the spread of its picture length over it, and the frames clamped at each limit.
139
+ - `LEG-SCALE` — with `--leg-scale` ([below](#the-legs-foreshortening-and-planted-feet)): per leg segment, its straight length, the spread of its picture length over it, the frames clamped at each limit, and (with `--plant-feet`) one `past the floor (support)` line per supporting run that asked under the floor ([#133](https://github.com/rig-c/pose/issues/133), `legScale[].support` in the report).
140
140
  - `PIVOT` — when the map translates a bone and has both legs: the translated bone's origin against its thigh roots' midpoint at setup, in units and hip widths — the pivot every hip turn swings the legs about.
141
141
  - `FEET` — when the map has both legs: the supports and jumps read off the reference ankles (planted or not), and on the posed build, outside the jumps, the lowest ankle's vertical range and each support span's worst vertical range and horizontal drift of the supporting ankle, in leg lengths.
142
142
  - `STANCE` — with `--stance` ([below](#the-legs-foreshortening-and-planted-feet)): one line per run of frames planted both (with the stretch keyed, its largest k), lifted, or past the tolerance below (the pose's height kept, [#114](https://github.com/rig-c/pose/issues/114)), with the free side, the frames and the tolerance; and one per run whose stretch fell past its bounds (`past the bounds`), with the k the floor needed, the bound it had and how far the foot was left from it; and, where the leg's own range set a lower bound below 0.92, one `bound from the leg's own range` line with each side's lowest.
@@ -199,7 +199,7 @@ A knee bending toward the camera is seen from the front only as a shorter leg, a
199
199
 
200
200
  rig-c's `scalex` multiplies the bone's x axis, and a child with the `normal` inherit mode takes its parent's whole world matrix. So under a thigh scaled by s, a shin at angle φ in the thigh's axes points at atan2(sin φ, s·cos φ), and is itself scaled by |(s·cos φ, sin φ)|. The rotate key `turn − turn above` assumes unit scale. A mapped bone under a scaled bone is therefore keyed through its parent's posed matrix: a `rotate` key k and a `scalex` key s' make its local matrix R(k) · L · diag(s', 1), L its setup local matrix, and k and s' are solved so its world x axis lands on its target at the scale asked. On HQ's dancer the unit-scale formula missed the shins by up to 2.4°, their length by 0.16 of setup and the ankles by 38 units; solved, every bone lands. The child's art is still sheared by its parent's scale, and a bone under it (a foot) is squashed along the shin: `SHEAR` reads both.
201
201
 
202
- **`--plant-feet`** places the hip so the supporting foot stays where it took support, through the posed legs at the scale the pose asked for (when a segment asks for a scale below `--leg-scale`'s clamp floor, the clamp holds the bone and does not lift the hip by what the clamp withheld, [#107](https://github.com/rig-c/pose/issues/107)), instead of moving it by the pose's mid_hip travel:
202
+ **`--plant-feet`** places the hip so the supporting foot stays where it took support, through the posed legs at the scale the pose asked for (when a segment asks for a scale below `--leg-scale`'s clamp floor, the clamp does not lift the hip by what it withheld, [#107](https://github.com/rig-c/pose/issues/107); on a supporting leg, the segment's `scalex` follows the asked scale down to the bound #95 derives from the leg's own range so the supporting ankle stays on #85's floor while the free leg keeps the configured clamp, and any frame clamped at #95's bound lifts the hip by the remainder so the ankle never sinks, listed as `LEG-SCALE past the floor (support)`, [#133](https://github.com/rig-c/pose/issues/133)), instead of moving it by the pose's mid_hip travel:
203
203
 
204
204
  - The supporting foot is the one whose reference ankle is lower in the picture. It changes only when the other ankle has been at least **0.02 leg lengths** lower for at least **0.1 s** (timed as `END-ON` runs are), and once confirmed takes support from the frame it crossed that margin ([#102](https://github.com/rig-c/pose/issues/102)); a leg length is each side's median picture length hip → ankle, and the switch compares against their mean.
205
205
  - The new support is held where it is on the frame it takes over, and the hip blends in y from the old derivation to the new over **3 frames** while taking the new derivation's x immediately ([#102](https://github.com/rig-c/pose/issues/102)), so the planted foot does not slide across the blend.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/pose",
3
- "version": "0.20.0",
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
@@ -253,6 +253,17 @@ export function rootOf(rest: Rest, b: string): string {
253
253
 
254
254
  // --- leg scale -------------------------------------------------------------
255
255
 
256
+ export interface SupportScaleRun {
257
+ side: Side;
258
+ frames: [number, number];
259
+ /** The lowest and highest scale asked on the run's frames. */
260
+ asked: { min: number; max: number };
261
+ /** The lowest and highest scale keyed on the run's frames (clamped at `bound` when `asked` went under #95's bound). */
262
+ used: { min: number; max: number };
263
+ /** Present when `asked` went under #95's bound on any frame of the run: the lowest bound on the run, and the largest hip rise (world units, ≥ 0). */
264
+ clamped?: { bound: number; hipRise: number };
265
+ }
266
+
256
267
  export interface LegScale {
257
268
  chain: string;
258
269
  bone: string;
@@ -270,6 +281,13 @@ export interface LegScale {
270
281
  scale: number[];
271
282
  /** The scale the pose asked for on each frame before the clamp floor (`min(ratio, limits[1])`, rig-c/pose#107). */
272
283
  asked: number[];
284
+ /**
285
+ * rig-c/pose#133: under `--plant-feet`, runs of supporting frames on this
286
+ * segment where the asked scale went under `limits[0]` and followed `asked`
287
+ * down to the bound #95 derives from the leg's own range (`min(limits[0], r(i))`).
288
+ * Absent when no supporting frame on this segment asked under `limits[0]`.
289
+ */
290
+ support?: SupportScaleRun[];
273
291
  }
274
292
 
275
293
  /** One segment's scale per frame: picture length ÷ its 95th percentile, clamped; its asked scale without the clamp floor (#107); and the runs clamped. */
@@ -800,6 +818,96 @@ export function stanceRangeOf(ankle: Record<Side, readonly Pt[]>, hip: Record<Si
800
818
  return out;
801
819
  }
802
820
 
821
+ /**
822
+ * Under `--plant-feet` and `--leg-scale`, a supporting leg segment that asks a
823
+ * scale under `--leg-scale`'s floor (`limits[0]`) follows its asked scale down
824
+ * to the bound #95 derives from the leg's own range (`min(limits[0], r(i))`) so
825
+ * the supporting ankle stays on #85's floor after #107 (rig-c/pose#133). The
826
+ * free leg keeps the configured clamp. When an asked scale goes under #95's
827
+ * bound (`lower`, defaulting to the segment's own range `asked[i]`), it is
828
+ * clamped there and the hip rises by the vertical remainder (`askedOffset −
829
+ * usedOffset`), never sinks.
830
+ */
831
+ export function scaleSupport(
832
+ legScale: LegScale[],
833
+ scales: Map<string, number[]>,
834
+ feet: Feet,
835
+ legs: Record<Side, { chain: Chain; ankleBone: string }>,
836
+ hip: Pt[],
837
+ askedOffset: (i: number, s: Side) => Pt,
838
+ usedOffset: (i: number, s: Side) => Pt,
839
+ lower?: StanceLower,
840
+ ): void {
841
+ const n = feet.support.length;
842
+ const tracked = new Array<Side | null>(n).fill(null);
843
+ for (const t of feet.tracked ?? []) for (let i = t.frames[0]; i <= t.frames[1]; i++) tracked[i] = t.side;
844
+ const byBone = new Map(legScale.map((ls) => [ls.bone, ls]));
845
+ const copied = new Set<string>();
846
+ const ensureCopy = (b: string): number[] => {
847
+ const cur = scales.get(b)!;
848
+ if (copied.has(b)) return cur;
849
+ const copy = [...cur];
850
+ scales.set(b, copy);
851
+ copied.add(b);
852
+ return copy;
853
+ };
854
+ const perBone = new Map<string, Array<{ frame: number; side: Side; asked: number; used: number; bound: number; clamped: boolean; hipRise: number }>>();
855
+ for (let i = 0; i < n; i++) {
856
+ const s = feet.support[i] ?? tracked[i] ?? null;
857
+ if (s === null) continue;
858
+ const touched: Array<{ bone: string; asked: number; used: number; bound: number; clamped: boolean }> = [];
859
+ for (const b of legs[s].chain.bones) {
860
+ const ls = byBone.get(b);
861
+ if (ls === undefined) continue;
862
+ const asked = ls.asked[i]!;
863
+ const floor = ls.limits[0];
864
+ if (!(asked < floor)) continue;
865
+ const r = lower !== undefined ? lower[s][i]! : asked;
866
+ const bound = Math.min(floor, r);
867
+ const used = Math.max(asked, bound);
868
+ const clamped = used > asked + 1e-12;
869
+ ensureCopy(b)[i] = used;
870
+ touched.push({ bone: b, asked, used, bound, clamped });
871
+ }
872
+ if (touched.length === 0) continue;
873
+ let hipRise = 0;
874
+ if (touched.some((t) => t.clamped)) {
875
+ hipRise = Math.max(0, askedOffset(i, s)[1] - usedOffset(i, s)[1]);
876
+ hip[i]![1] += hipRise;
877
+ }
878
+ for (const t of touched) {
879
+ const list = perBone.get(t.bone) ?? [];
880
+ list.push({ frame: i, side: s, ...t, hipRise });
881
+ perBone.set(t.bone, list);
882
+ }
883
+ }
884
+ for (const ls of legScale) {
885
+ const entries = perBone.get(ls.bone);
886
+ if (entries === undefined || entries.length === 0) continue;
887
+ const out: SupportScaleRun[] = [];
888
+ for (const e of entries) {
889
+ const last = out[out.length - 1];
890
+ if (last !== undefined && last.side === e.side && last.frames[1] === e.frame - 1 && (last.clamped !== undefined) === e.clamped) {
891
+ last.frames[1] = e.frame;
892
+ last.asked = { min: Math.min(last.asked.min, e.asked), max: Math.max(last.asked.max, e.asked) };
893
+ last.used = { min: Math.min(last.used.min, e.used), max: Math.max(last.used.max, e.used) };
894
+ if (last.clamped !== undefined) {
895
+ last.clamped = { bound: Math.min(last.clamped.bound, e.bound), hipRise: Math.max(last.clamped.hipRise, e.hipRise) };
896
+ }
897
+ } else {
898
+ out.push({
899
+ side: e.side,
900
+ frames: [e.frame, e.frame],
901
+ asked: { min: e.asked, max: e.asked },
902
+ used: { min: e.used, max: e.used },
903
+ ...(e.clamped ? { clamped: { bound: e.bound, hipRise: e.hipRise } } : {}),
904
+ });
905
+ }
906
+ }
907
+ ls.support = out;
908
+ }
909
+ }
910
+
803
911
  /** The lower bound on frame i for side s: the leg's own (#95), or the default. */
804
912
  function lowerOf(lower: StanceLower | undefined, s: Side, i: number): number {
805
913
  return lower?.[s][i] ?? STANCE_BOUNDS[0];
@@ -906,7 +1014,61 @@ export interface StancePosed {
906
1014
  * free side and with no frame between, takes their state, and is listed with
907
1015
  * `absorbed`.
908
1016
  */
909
- 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 {
910
1072
  const n = feet.support.length;
911
1073
  const stretch: Stance['stretch'] = new Array(n).fill(null);
912
1074
  const tracked = new Array<Side | null>(n).fill(null);
@@ -977,7 +1139,7 @@ export function stanceOf(feet: Feet, ankle: Record<Side, readonly Pt[]>, toleran
977
1139
  rows[i] = { state: r.state, side: f, height, k: null, needed: null, over: false, left: 0, lo };
978
1140
  continue;
979
1141
  }
980
- const p = posed(i);
1142
+ const p = posed(i, f);
981
1143
  const floor = p.ankle[s][1];
982
1144
  const root = p.root[f][1];
983
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, 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
 
@@ -739,8 +739,13 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
739
739
  const hip = planted.hip;
740
740
  floor = planted.floor;
741
741
  closure = planted.closure;
742
- for (let i = 0; i < n; i++) travel[i] = [hip[i]![0] - tb.worldX, hip[i]![1] - tb.worldY];
743
742
  const L = (legs as { legs: Record<Side, { chain: Chain; ankleBone: string }> }).legs;
743
+ // #133: on a frame where the supporting leg asks under --leg-scale's floor, its scalex follows the asked
744
+ // value down to #95's bound so the ankle stays on #85's floor; the free leg keeps the configured clamp.
745
+ if (legScaleOn && feet !== null) {
746
+ scaleSupport(legScale, scales, feet, L, hip, (i, s) => offsets[i]![s], (i, s) => tipOf(rest, poseLegs(i), L[s].ankleBone));
747
+ }
748
+ for (let i = 0; i < n; i++) travel[i] = [hip[i]![0] - tb.worldX, hip[i]![1] - tb.worldY];
744
749
  const world = (i: number, v: Pt): Pt => [hip[i]![0] + v[0], hip[i]![1] + v[1]];
745
750
  /** Both legs posed on frame i by the scales as they stand, world (y up): each chain's root and ankle. */
746
751
  const posedFeet = (i: number) => {
@@ -774,7 +779,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
774
779
  // --stance (#60): with the hip placed, a free ankle within the tolerance of the floor has its leg
775
780
  // stretched (every bone of its chain's scalex, about the chain's root) until it reaches it.
776
781
  if (opts.stance !== undefined && opts.stance !== 'off') {
777
- 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);
778
783
  // The supporting leg (the blend's) and the free leg (the stance's) are different chains on a frame.
779
784
  for (let i = 0; i < n; i++) {
780
785
  const b = blendHold.stretch[i];
@@ -892,7 +897,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
892
897
  return null;
893
898
  };
894
899
  const turn = (bone: string, i: number) => targets.get(bone)![i]! - restOf.get(bone)!;
895
- const covered = coveredChains(sw.switches, n);
900
+ const covered = coveredChains(sw.switches, n, sw.chainHolds);
896
901
  if (opts.short !== 'off') {
897
902
  for (const [b, info] of fractions) {
898
903
  for (const s of capShortSteps(targets.get(b)!, info.fraction, covered.get(info.chain))) {
@@ -1183,7 +1188,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1183
1188
  const own = measured.filter((e) => e.set === s.set && e.frames[0] >= s.frames[0] && e.frames[1] <= s.frames[1]);
1184
1189
  artRange.push(...own.filter((e) => !solvedBy(e, s)), ...(raisedArt.find((x) => x.sw === s)?.entries ?? []));
1185
1190
  }
1186
- clampArt(sw.switches, map, targets);
1191
+ clampArt(sw.switches, map, targets, sw.chainHolds);
1187
1192
  // Where each raised wrist lands as keyed: after the solve and the set's `turn`.
1188
1193
  for (const job of raisedJobs) {
1189
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/run.ts CHANGED
@@ -18,7 +18,7 @@ import { artRangeLine, chainsMoving, raisedLine, RAISED_WITHIN, reachLine, switc
18
18
  import { landed, measureFeet, measureLanding, measurePosture, measureShear, poseFrames, postureLines } from './verify.ts';
19
19
  import { legsOf, PLANT_SWITCH, STANCE_BOUNDS } from './legs.ts';
20
20
  import { M2_IDENTITY, mulM2, reduceKeys, reduceScale, reduceUnderScale, SCALE_REDUCE, setupLocal, type M2, type ReduceBone, type Reduced, type ReduceRow, type UnderBone } from './reduce.ts';
21
- import type { BlendHold, Closure, Floor, LegScale, Lowered, Side, Stance } from './legs.ts';
21
+ import type { BlendHold, Closure, Floor, LegScale, Lowered, Side, Stance, SupportScaleRun } from './legs.ts';
22
22
 
23
23
  export interface RetargetArgs extends RetargetOptions {
24
24
  rig: string;
@@ -138,6 +138,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
138
138
  for (const e of r.byHip) if (e.short.length > 0) say(` REACH ${reachLine(e, r.times)}`);
139
139
  for (const e of r.artRange) say(` ART-RANGE ${artRangeLine(e, r.times)}`);
140
140
  for (const l of r.legScale) say(` LEG-SCALE ${legScaleLine(l, r.times)}`);
141
+ for (const l of r.legScale) for (const s of l.support ?? []) say(` LEG-SCALE ${supportScaleLine(l, s, r.times)}`);
141
142
  if (r.pivot !== null) say(` PIVOT bone "${r.pivot.bone}"'s origin is ${r.pivot.distance.toFixed(2)} units (${r.pivot.hipWidths.toFixed(3)} hip widths) from its thigh roots' midpoint (${r.pivot.roots.join(' + ')}): dx ${r.pivot.offset[0].toFixed(2)}, dy ${r.pivot.offset[1].toFixed(2)}, at setup — the pivot every hip turn swings the legs about`);
142
143
  if (r.feet !== null) {
143
144
  const f = r.feet;
@@ -673,6 +674,16 @@ function legScaleLine(l: LegScale, times: readonly number[]): string {
673
674
  return `chain "${l.chain}" bone "${l.bone}" (${l.joints[0]} → ${l.joints[1]}): straight length ${l.straight.toFixed(1)}px (95th percentile of ${times.length} frames); picture length ÷ straight ${l.ratio.min.toFixed(3)} … ${l.ratio.max.toFixed(3)} (median ${l.ratio.median.toFixed(3)}); scalex keyed in [${l.limits[0]}, ${l.limits[1]}]: ${clamp(l.low, l.limits[0], 'under')}; ${clamp(l.high, l.limits[1], 'over')}`;
674
675
  }
675
676
 
677
+ /** A LEG-SCALE past the floor (support) line (rig-c/pose#133): the supporting leg segment scaled below `--leg-scale`'s floor down to #95's bound. */
678
+ export function supportScaleLine(l: LegScale, s: SupportScaleRun, times: readonly number[]): string {
679
+ const span = (x: [number, number]) => (x[0] === x[1] ? `frame ${x[0]} (t=${times[x[0]]!.toFixed(3)}s)` : `frames ${x[0]}–${x[1]} (t=${times[x[0]]!.toFixed(3)}–${times[x[1]]!.toFixed(3)}s)`);
680
+ const val = (r: { min: number; max: number }) => (r.min === r.max ? r.min.toFixed(4) : `${r.min.toFixed(4)}–${r.max.toFixed(4)}`);
681
+ if (s.clamped !== undefined) {
682
+ return `past the floor (support): chain "${l.chain}" bone "${l.bone}" (supporting foot ${s.side}) ${span(s.frames)} — asked ${val(s.asked)} under ${l.limits[0]}, clamped at the leg's own range ${s.clamped.bound.toFixed(4)} (#95), used ${val(s.used)}; hip rose by up to +${s.clamped.hipRise.toFixed(2)} units (never sinks)`;
683
+ }
684
+ return `past the floor (support): chain "${l.chain}" bone "${l.bone}" (supporting foot ${s.side}) ${span(s.frames)} — asked ${val(s.asked)} under ${l.limits[0]}, used ${val(s.used)} (within the leg's own range, #95; ankle stays on the floor)`;
685
+ }
686
+
676
687
  /**
677
688
  * POSE_NOT_CLEANED (#67): retarget reads only a sequence `rig-pose clean` wrote.
678
689
  * The message is the command to run on this file; the output name is a
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
  }