@rig-c/pose 0.39.0 → 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/reduce.ts CHANGED
@@ -48,6 +48,8 @@ export interface ReduceBone {
48
48
  dense: number[];
49
49
  /** Frame indices that must keep a key regardless (the first and last always do). */
50
50
  keep: ReadonlySet<number>;
51
+ /** Its own world-aim tolerance in degrees, in place of the call's (rig-c/pose#283: its chain's). */
52
+ tolerance?: number;
51
53
  }
52
54
  export interface ReduceHip {
53
55
  bone: string;
@@ -328,6 +330,7 @@ export function reduceKeys(times: readonly number[], bones: readonly ReduceBone[
328
330
  const byName = new Map<string, ReduceBone>();
329
331
  for (const b of bones) {
330
332
  if (byName.has(b.bone)) throw new Error(`reduce: bone "${b.bone}" is given twice`);
333
+ if (b.tolerance !== undefined && !(Number.isFinite(b.tolerance) && b.tolerance > 0)) throw new Error(`reduce: bone "${b.bone}"'s tolerance is ${b.tolerance}°, not a positive number`);
331
334
  if (b.dense.length !== n) throw new Error(`reduce: bone "${b.bone}" has ${b.dense.length} dense key(s) for ${n} frame(s)`);
332
335
  byName.set(b.bone, b);
333
336
  }
@@ -349,7 +352,7 @@ export function reduceKeys(times: readonly number[], bones: readonly ReduceBone[
349
352
  const keys = fitTrack(times, samples, {
350
353
  targets,
351
354
  keep: keptFrames(b.keep, n, `bone "${b.bone}"`),
352
- tolerance,
355
+ tolerance: b.tolerance ?? tolerance,
353
356
  error: (i, v) => Math.abs(v[0]! - targets[i]![0]!),
354
357
  });
355
358
  const e = trackAt(keys, samples, 1).map((v, i) => v[0]! - targets[i]![0]!);
@@ -617,11 +620,12 @@ export function smoothTrunk(
617
620
  if (n < 4 || trunkBones.size === 0) return;
618
621
  const samples = samplesOf(times);
619
622
  const byName = new Map(bones.map((b) => [b.bone, b]));
623
+ /** rig-c/pose#283: a bone's own tolerance (its chain's under a per-chain --reduce), else the call's. */
624
+ const tolOf = (b: string): number => byName.get(b)?.tolerance ?? tolerance;
620
625
  const roots = [...trunkBones].filter((b) => {
621
626
  const info = byName.get(b);
622
627
  return info !== undefined && (info.above === null || !trunkBones.has(info.above));
623
628
  });
624
- const tolInner = 0.985 * tolerance;
625
629
 
626
630
  for (const root of roots) {
627
631
  const rootKeys = out.rotate.get(root);
@@ -646,8 +650,10 @@ export function smoothTrunk(
646
650
  const targetRoot = rootInfo.dense.map((d, i) => d - eAboveRoot[i]!);
647
651
  const yRootOld = yOldOf.get(root)!;
648
652
 
649
- const rootLb = targetRoot.map((t) => t - tolInner);
650
- const rootUb = targetRoot.map((t) => t + tolInner);
653
+ // #283: each bone's own tolerance (its chain's under a per-chain --reduce; the call's otherwise).
654
+ const rootInner = 0.985 * tolOf(root);
655
+ const rootLb = targetRoot.map((t) => t - rootInner);
656
+ const rootUb = targetRoot.map((t) => t + rootInner);
651
657
  let smRoot = optimizeBasis(basisOf.get(root)!, rootLb, rootUb);
652
658
  let eRoot = smRoot.y.map((v, i) => v - targetRoot[i]!);
653
659
 
@@ -667,14 +673,15 @@ export function smoothTrunk(
667
673
  for (const h of headBones) {
668
674
  const dH = byName.get(h)!.dense;
669
675
  const targetH = dH.map((d, i) => d - eRoot[i]!);
670
- const smH = optimizeBasis(basisOf.get(h)!, targetH.map((t) => t - tolInner), targetH.map((t) => t + tolInner));
676
+ const hInner = 0.985 * tolOf(h);
677
+ const smH = optimizeBasis(basisOf.get(h)!, targetH.map((t) => t - hInner), targetH.map((t) => t + hInner));
671
678
  smSub.set(h, smH);
672
679
  eSub.set(h, smH.y.map((v, i) => v - targetH[i]!));
673
680
  }
674
681
  for (const b0 of arm0Bones) {
675
682
  const yOld = yOldOf.get(b0)!;
676
683
  const wOld = Math.max(...out.errors.get(b0)!.map(Math.abs));
677
- const cap = Math.min(0.98 * tolerance, wOld + 0.035 * tolerance);
684
+ const cap = Math.min(0.98 * tolOf(b0), wOld + 0.035 * tolOf(b0));
678
685
  const targetB = byName.get(b0)!.dense.map((d, i) => d - eRoot[i]!);
679
686
  const smB = solveLimbPreservingJitter(basisOf.get(b0)!, yOld, targetB.map((t) => t - cap), targetB.map((t) => t + cap));
680
687
  smSub.set(b0, smB);
@@ -685,7 +692,7 @@ export function smoothTrunk(
685
692
  const eUp = eSub.get(up)!;
686
693
  const yOld = yOldOf.get(b1)!;
687
694
  const wOld = Math.max(...out.errors.get(b1)!.map(Math.abs));
688
- const cap = Math.min(0.98 * tolerance, wOld + 0.035 * tolerance);
695
+ const cap = Math.min(0.98 * tolOf(b1), wOld + 0.035 * tolOf(b1));
689
696
  const targetB = byName.get(b1)!.dense.map((d, i) => d - eUp[i]!);
690
697
  const smB = solveLimbPreservingJitter(basisOf.get(b1)!, yOld, targetB.map((t) => t - cap), targetB.map((t) => t + cap));
691
698
  smSub.set(b1, smB);
@@ -699,17 +706,17 @@ export function smoothTrunk(
699
706
  for (let i = 0; i < n; i++) {
700
707
  for (const h of headBones) {
701
708
  const eh = eSub.get(h)![i]!;
702
- if (Math.abs(eh) > 0.99 * tolerance) {
703
- tightenAround(i, Math.abs(eh) - 0.975 * tolerance);
709
+ if (Math.abs(eh) > 0.99 * tolOf(h)) {
710
+ tightenAround(i, Math.abs(eh) - 0.975 * tolOf(h));
704
711
  anyTightened = true;
705
712
  }
706
713
  }
707
714
  for (const b of [...arm0Bones, ...arm1Bones]) {
708
715
  const wOld = Math.max(...out.errors.get(b)!.map(Math.abs));
709
- const cap = Math.min(0.985 * tolerance, wOld + 0.04 * tolerance);
716
+ const cap = Math.min(0.985 * tolOf(b), wOld + 0.04 * tolOf(b));
710
717
  const eb = eSub.get(b)![i]!;
711
718
  if (Math.abs(eb) > cap) {
712
- tightenAround(i, Math.abs(eb) - (cap - 0.01 * tolerance));
719
+ tightenAround(i, Math.abs(eb) - (cap - 0.01 * tolOf(b)));
713
720
  anyTightened = true;
714
721
  }
715
722
  }
@@ -827,6 +834,8 @@ export interface UnderBone {
827
834
  /** The dense rotate keys (unwrapped, as written). */
828
835
  dense: readonly number[];
829
836
  keep: ReadonlySet<number>;
837
+ /** Its own world-aim tolerance in degrees, in place of the call's (rig-c/pose#283). */
838
+ tolerance?: number;
830
839
  }
831
840
 
832
841
  /**
@@ -879,7 +888,7 @@ export function reduceUnderScale(times: readonly number[], bones: readonly Under
879
888
  return Math.abs(wrap180(Math.atan2(w[1], w[0]) * DEG - b.target[i]!));
880
889
  };
881
890
  const keep = keptFrames(b.keep, n, `bone "${b.bone}"`);
882
- const rk = fitTrack(times, samples, { targets: solved.map((k) => [k]), keep, tolerance, error: (i, v) => aimError(i, v[0]!) });
891
+ const rk = fitTrack(times, samples, { targets: solved.map((k) => [k]), keep, tolerance: b.tolerance ?? tolerance, error: (i, v) => aimError(i, v[0]!) });
883
892
  const kAt = trackAt(rk, samples, 1).map((v) => v[0]!);
884
893
  const ae = worstOf(kAt.map((_, i) => i), (i) => aimError(i, kAt[i]!))!;
885
894
  rows.push({ bone: b.bone, kind: 'angle', before: n, after: rk.length, worst: ae.worst, frame: ae.frame });
package/src/retarget.ts CHANGED
@@ -79,7 +79,7 @@ import { aimAngle, aimEnds, bodyYawSets, descends, faceYawSets, knownSetsOf, par
79
79
  import type { Joint, Travel } from './pose.ts';
80
80
  import { TRAVEL_IN_PLACE } from './pose.ts';
81
81
  import { Problems, Refusal } from './problems.ts';
82
- import type { ArtRange, AttachmentKey, ByHip, Raised, ReachShort, SlotTrack, Switch, SwitchOptions } from './switch.ts';
82
+ import type { ArtRange, AttachmentKey, ByHip, Raised, ReachShort, SetRuns, SlotTrack, Switch, SwitchOptions } from './switch.ts';
83
83
  import { clampArt, coveredChains, isRaised, measureArt, overflow, phaseGroupProblems, RAISED_WRIST_EYES, RANGE_SLACK, reachLimit, runsOf, selectSwitches, twoBone } from './switch.ts';
84
84
  import type { BlendHold, Closure, Feet, Floor, LegScale, LegSegmentRatio, PathPose, Posing, Side, Stance } from './legs.ts';
85
85
  import { figureScaleOf, freeAnkleShiftOf, holdBlend, isLegSegment, legScaleOf, legsOf, pathFrom, percentile, plantHip, posePath, rootOf, scaleSupport, shiftOver, SIDES, stanceOf, stanceRangeOf, STRAIGHT_PERCENTILE, supportsOf, tipOf, unshortenStanceShin } from './legs.ts';
@@ -369,6 +369,34 @@ export interface ReachReading {
369
369
  ratio: number | null;
370
370
  }
371
371
 
372
+ /**
373
+ * rig-c/pose#282: where a `reach` chain's bone starts against the source joint
374
+ * it re-aims from. The pose's pair starts at the translated joint, which the
375
+ * rig puts at the translated bone's posed origin; the reach aims from its own
376
+ * bone's posed origin. The distance between the two, at setup and on the
377
+ * frames, is the lever #236 names — a reading, with a WARN past
378
+ * `REACH_ORIGIN_WARN` of the segment. One per reach chain, in map order.
379
+ */
380
+ export interface ReachOrigin {
381
+ chain: string;
382
+ bone: string;
383
+ /** The pair: the chain's first joint (the translated joint) and its second. */
384
+ joints: [string, string];
385
+ /** The translated bone, whose posed origin is where the rig puts the source joint. */
386
+ anchor: string;
387
+ /** The distance at setup, world units. */
388
+ setup: number;
389
+ /** The largest distance as posed on the frames, world units, and its frame. */
390
+ posed: { max: number; frame: number };
391
+ /** The reached length: the pair's median picture length, scaled (world units). */
392
+ segment: number;
393
+ /** max(setup, posed.max) ÷ segment. */
394
+ ofSegment: number;
395
+ /** ofSegment > threshold. */
396
+ warn: boolean;
397
+ threshold: number;
398
+ }
399
+
372
400
  /**
373
401
  * Mean |second difference| of a series of angles, degrees: |wrap(v[i+1] − v[i])
374
402
  * − wrap(v[i] − v[i−1])| over every i whose three frames `ok` admits (every
@@ -419,6 +447,8 @@ export interface Retarget {
419
447
  scaleFit: ScaleFit | null;
420
448
  /** The spans each alternate set shows, with their art turn. */
421
449
  switches: Switch[];
450
+ /** rig-c/pose#279: per set without a `phase`, the runs it was nearest on and the runs the minimum span suppressed. */
451
+ switchRuns: SetRuns[];
422
452
  /** Per switched or hidden slot, what it shows on each frame (null: nothing). */
423
453
  shown: Map<string, Array<string | null>>;
424
454
  /** Per set, its distance to the pose on each frame, degrees. */
@@ -475,6 +505,8 @@ export interface Retarget {
475
505
  nodSkipped: NodSkipped | null;
476
506
  /** Each `reach` chain hanging below a bone the animation turns or translates (#236): its aim's departure from the pose pair's angle. */
477
507
  reach: ReachReading[];
508
+ /** rig-c/pose#282: each `reach` chain's origin against the source joint it re-aims from; empty when the map has none. */
509
+ reachOrigin: ReachOrigin[];
478
510
  /** rig-c/pose#288: the pelvis bone's source; null when the map has no pelvis chain and the sequence no track. */
479
511
  pelvis: PelvisReading | null;
480
512
  }
@@ -1486,6 +1518,8 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1486
1518
  const reaches = map.chains.filter((c) => c.reach).sort((x, y) => depth(x.bones[0]!) - depth(y.bones[0]!));
1487
1519
  let aimReach: ((c: Chain) => void) | null = null;
1488
1520
  const reachAims = new Map<string, { aim: number[]; pair: number[]; length: number }>();
1521
+ /** #282: per reach chain, the distance from its bone's posed origin to the anchor's on each frame (the last aim's). */
1522
+ const reachGaps = new Map<string, number[]>();
1489
1523
  if (reaches.length > 0 && t !== null && scale !== null) {
1490
1524
  const s = scale.factor;
1491
1525
  const tb = rest.get(t.bone)!;
@@ -1494,8 +1528,10 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1494
1528
  const anchor = series.get(c.joints[0]!)!;
1495
1529
  const end = series.get(c.joints[1]!)!;
1496
1530
  const angles: number[] = [];
1531
+ const gaps: number[] = [];
1497
1532
  for (let i = 0; i < n; i++) {
1498
1533
  const o = posedOrigin(b, i);
1534
+ gaps.push(Math.hypot(o[0] - (tb.worldX + travel[i]![0]), o[1] - (tb.worldY + travel[i]![1])));
1499
1535
  const tx = tb.worldX + travel[i]![0] + (end[i]![0] - anchor[i]![0]) * s;
1500
1536
  const ty = tb.worldY + travel[i]![1] - (end[i]![1] - anchor[i]![1]) * s;
1501
1537
  const d = Math.hypot(tx - o[0], ty - o[1]);
@@ -1503,6 +1539,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
1503
1539
  }
1504
1540
  fillLeading(angles, () => p.add('POSE_SEGMENT_ZERO', `chain "${c.name}" — bone "${b}" reaches for ${c.joints[1]}, which the pose puts on the bone's posed origin on every frame; it has no direction`));
1505
1541
  targets.set(b, angles);
1542
+ reachGaps.set(c.name, gaps);
1506
1543
  // #236: the aim as reached, before its own range clamp, beside the pair's own angle (a reading).
1507
1544
  reachAims.set(c.name, { aim: [...angles], pair: Array.from({ length: n }, (_, i) => pictureAngle(anchor[i]!, end[i]!)), length: median(Array.from({ length: n }, (_, i) => Math.hypot(end[i]![0] - anchor[i]![0], end[i]![1] - anchor[i]![1]) * s)) });
1508
1545
  };
@@ -3087,6 +3124,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
3087
3124
  if (headNodTrack !== null) tracks.push(headNodTrack);
3088
3125
  tracks.push(...sw.offsetTracks, ...sw.slotTracks);
3089
3126
  const reach = reachReadings(rest, map, tracks, reachAims, t?.bone ?? null);
3127
+ const reachOrigin = reachOrigins(rest, map, reachAims, reachGaps, t?.bone ?? null);
3090
3128
 
3091
3129
  const pivot = pivotOf(rest, t?.bone ?? null, legs);
3092
3130
  const pelvis = pelvisReadingOf(pelvisChain, opts.pelvis, track, fromTrack, tracks);
@@ -3099,8 +3137,8 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
3099
3137
  return {
3100
3138
  animation: { duration: keyTimes[n - 1]!, tracks },
3101
3139
  times: keyTimes, targets, origin, holds, bends, short, endOn, flips, scale, scaleFit,
3102
- switches: sw.switches, shown: sw.shown, distances: sw.distances, turns, turnsUnread, range, artRange,
3103
- legScale, segments, legStraight, rigLeg, scales, feet, planted: plantOn, stance, floor, blendHold, closure, openDrift, ankles, pivot, raised, byHip, face, body, figureScale, headNod: headNodReading, nodSkipped, reach, pelvis,
3140
+ switches: sw.switches, switchRuns: sw.runs, shown: sw.shown, distances: sw.distances, turns, turnsUnread, range, artRange,
3141
+ legScale, segments, legStraight, rigLeg, scales, feet, planted: plantOn, stance, floor, blendHold, closure, openDrift, ankles, pivot, raised, byHip, face, body, figureScale, headNod: headNodReading, nodSkipped, reach, reachOrigin, pelvis,
3104
3142
  };
3105
3143
  }
3106
3144
 
@@ -3209,6 +3247,47 @@ function reachReadings(rest: Rest, map: JointMap, tracks: ReadonlyArray<MotionTr
3209
3247
  return out;
3210
3248
  }
3211
3249
 
3250
+ /**
3251
+ * #282: a reach bone whose origin sits more than this fraction of the segment
3252
+ * from the source joint is warned. The aim from an origin a fraction f up the
3253
+ * pair turns about 1 ÷ (1 − f) times the pair's turn (#236's lever: the example
3254
+ * wave's trunk, f = 0.5, measured 2.00×); 0.2 is 1.25×, a quarter more turn
3255
+ * than the pose has.
3256
+ */
3257
+ export const REACH_ORIGIN_WARN = 0.2;
3258
+
3259
+ /** #282: each reach chain's origin against the anchor's, at setup and as posed, in map order. */
3260
+ function reachOrigins(rest: Rest, map: JointMap, aims: ReadonlyMap<string, { length: number }>, gaps: ReadonlyMap<string, number[]>, anchor: string | null): ReachOrigin[] {
3261
+ const out: ReachOrigin[] = [];
3262
+ if (anchor === null) return out;
3263
+ const a0 = rest.get(anchor)!;
3264
+ for (const c of map.chains) {
3265
+ const got = aims.get(c.name);
3266
+ const g = gaps.get(c.name);
3267
+ if (!c.reach || got === undefined || g === undefined) continue;
3268
+ const b = c.bones[0]!;
3269
+ const bo = rest.get(b)!;
3270
+ const setup = r3(Math.hypot(bo.worldX - a0.worldX, bo.worldY - a0.worldY));
3271
+ let frame = 0;
3272
+ for (let i = 1; i < g.length; i++) if (g[i]! > g[frame]!) frame = i;
3273
+ const max = r3(g[frame] ?? 0);
3274
+ const segment = r3(got.length);
3275
+ const ofSegment = got.length > 0 ? r3(Math.max(setup, max) / got.length) : 0;
3276
+ out.push({ chain: c.name, bone: b, joints: [c.joints[0]!, c.joints[1]!], anchor, setup, posed: { max, frame }, segment, ofSegment, warn: ofSegment > REACH_ORIGIN_WARN, threshold: REACH_ORIGIN_WARN });
3277
+ }
3278
+ return out;
3279
+ }
3280
+
3281
+ /** #282: the `REACH-ORIGIN` line, one per reach chain. */
3282
+ export function reachOriginLine(e: ReachOrigin, times: readonly number[]): string {
3283
+ return `chain "${e.chain}" bone "${e.bone}" (${e.joints[0]} → ${e.joints[1]}): its origin ${e.setup.toFixed(2)} units from the source joint ${e.joints[0]} (bone "${e.anchor}"'s origin) at setup, up to ${e.posed.max.toFixed(2)} on the frames (frame ${e.posed.frame}, t=${times[e.posed.frame]!.toFixed(3)}s): ${e.ofSegment.toFixed(3)} of the ${e.segment.toFixed(2)}-unit segment`;
3284
+ }
3285
+
3286
+ /** #282: the WARN of a reach chain whose origin sits past REACH_ORIGIN_WARN of the segment. */
3287
+ export function reachOriginWarn(e: ReachOrigin): string {
3288
+ return `REACH_ORIGIN: chain "${e.chain}" bone "${e.bone}" starts ${e.ofSegment.toFixed(3)} of the segment from ${e.joints[0]}, past ${e.threshold.toFixed(2)}: re-aimed from there, it turns more than the pair (JITTER bone "${e.bone}"); "reach": false follows the measured angle`;
3289
+ }
3290
+
3212
3291
  /** The `REACH` line of a reach chain whose aim departs from its pair (#236). */
3213
3292
  export function reachCostLine(e: ReachReading, times: readonly number[]): string {
3214
3293
  const f = (v: number | null) => (v === null ? 'not measured' : `${v.toFixed(4)}°/f²`);
package/src/run.ts CHANGED
@@ -11,13 +11,14 @@ import { dirname, join, relative, resolve } from 'node:path';
11
11
  import { knownSetsOf, readMap, resolveMap, type JointMap, type Rest } from './map.ts';
12
12
  import { cleanLine, cleanSummaryLine, inferredSource } from './clean.ts';
13
13
  import { depthLine, layersOf, type FrameLayers } from './depth.ts';
14
+ import { drawOrderFromDepth, drawOrderKeys, drawOrderLines, type DrawOrderReading } from './draworder.ts';
14
15
  import { convertCoco17, personTrack, readPoseSequence, TRAVEL_IN_PLACE, type CameraFrame } from './pose.ts';
15
16
  import { isObject, lineOf, parseJson, Problems, Refusal, type Problem } from './problems.ts';
16
17
  import { boneIdentity, droppedLine, keptOutLine, mergeProblems, readFragment, readIdle, targetOf, trackLabel, type IdleReading, type RigcTrack, type Target } from './idle.ts';
17
- import { bendGroupLine, bendGroups, bendLine, endOnLine, flipLine, holdLine, nodLine, pelvisLine, rangeLine, reachCostLine, retarget, shortLine, turnLine, type MotionTrack, type RetargetOptions, type ScaleBand, type ScaleFit } from './retarget.ts';
18
+ import { bendGroupLine, bendGroups, bendLine, endOnLine, flipLine, holdLine, nodLine, pelvisLine, rangeLine, reachCostLine, reachOriginLine, reachOriginWarn, retarget, shortLine, turnLine, type MotionTrack, type RetargetOptions, type ScaleBand, type ScaleFit } from './retarget.ts';
18
19
  import { attachmentBones, baseMotionOf, buildInto, rigcVersion, folds, gate, readBuild, resolveRig, restOf, seamRender, slotsOf } from './rigc.ts';
19
20
  import { partsPath, projectSeams, readSeamDoc, readSeamPairs, seamLines, type SeamPair } from './seams.ts';
20
- import { artRangeLine, chainsMoving, phaseLines, raisedLine, RAISED_WITHIN, reachLine, switchLine, uncoveredFolds } from './switch.ts';
21
+ import { artRangeLine, chainsMoving, phaseLines, raisedLine, RAISED_WITHIN, reachLine, switchLine, switchMinLines, uncoveredFolds } from './switch.ts';
21
22
  import { jitterLine, landed, type FeetReading, type JitterRow, type Landing, measureFeet, measureJitter, measureLanding, measurePosture, measureShear, poseFrames, postureLines } from './verify.ts';
22
23
  import { FRONTAL_DISAGREE, legsOf, PLANT_SWITCH, STANCE_BOUNDS } from './legs.ts';
23
24
  import { M2_IDENTITY, mulM2, reduceKeys, reduceScale, reduceUnderScale, SCALE_REDUCE, setupLocal, smoothTrunk, type M2, type ReduceBone, type Reduced, type ReduceRow, type UnderBone } from './reduce.ts';
@@ -64,6 +65,8 @@ export interface RetargetArgs extends RetargetOptions {
64
65
  merge?: string;
65
66
  /** Thin the mapped bones' rotate keys within this many degrees (#32). */
66
67
  reduce?: number;
68
+ /** With reduce: a map chain's own tolerance in degrees, by chain name, in place of `reduce` on its bones (#283). */
69
+ reduceChains?: Record<string, number>;
67
70
  /** With reduce: thin the hip's translate keys within this many rig units. */
68
71
  reduceHip?: number;
69
72
  /** With reduce and plantFeet: keep each support span's frames on the supporting leg at its dense keys (#251). */
@@ -76,6 +79,8 @@ export interface RetargetArgs extends RetargetOptions {
76
79
  * same in all three and the gate is the same gate.
77
80
  */
78
81
  output?: 'default' | 'verbose' | 'summary';
82
+ /** `--draw-order depth` (rig-c/pose#304): key the animation's draw order from the sequence's depth. Absent: no draw-order key, the output as before. */
83
+ drawOrder?: 'depth';
79
84
  }
80
85
 
81
86
  export interface RunResult {
@@ -128,9 +133,13 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
128
133
  const fragment = a.merge === undefined ? [] : readFragment(parseJson(readFileSync(a.merge, 'utf8'), a.merge, 'MERGE_JSON'), a.merge);
129
134
  const mapSnapshot = readReportInput(a.map), poseSnapshot = readReportInput(a.pose);
130
135
  const map = readMap(mapSnapshot.bytes.toString('utf8'), a.map);
136
+ // #283: each chain --reduce names must be the map's; a bone takes its chain's tolerance (the tighter, if two chains name it).
137
+ const reduceOf = a.reduce === undefined ? null : reduceTolerances(map, a.reduce, a.reduceChains, a.map);
131
138
  const poseText = poseSnapshot.bytes.toString('utf8');
132
139
  const seq = a.coco ? convertCoco17(poseText, a.pose) : readPoseSequence(poseText, a.pose);
133
140
  if (seq.cleaned === undefined) refuse([{ code: 'POSE_NOT_CLEANED', text: notCleaned(a.pose, a.coco) }]);
141
+ // #304: the draw order is read off the sequence's depth; asked for on a sequence without it, refused by name.
142
+ if (a.drawOrder === 'depth' && !seq.frames.some((x) => x.depth !== undefined)) refuse([{ code: 'DRAWORDER_NO_DEPTH', text: `--draw-order depth keys the draw order from the sequence's depth, and ${a.pose} carries none (rig-pose-sequence/${seq.cleaned!.params.depth === undefined ? '1 or 1.1, no "depth" on any frame' : '1.2 with no frame naming a segment'}); clean it with --depth walk|run, or retarget without --draw-order` }]);
134
143
  const frames = personTrack(seq, a.person, a.pose);
135
144
  const times = seq.frames.map((f) => f.t);
136
145
 
@@ -164,7 +173,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
164
173
  }
165
174
  }
166
175
  }
167
- tell(`rig-pose retarget: ${seq.frames.length} frame(s), ${r.animation.duration.toFixed(3)}s, onto "${input.name}" as "${a.name}" — ${r.animation.tracks.length} track(s); min-score ${a.minScore}, flip ${a.flip === 'off' ? 'off' : `${a.flip}°`}, short ${a.short}, bends ${a.bends} (tolerance ${a.bendTolerance}°)${a.legScale !== undefined && a.legScale !== 'off' ? `, leg-scale ${a.legScale[0]}–${a.legScale[1]}` : ''}${a.plantFeet === true ? ', plant-feet' : ''}${a.stance !== undefined && a.stance !== 'off' ? `, stance ${a.stance}` : ''}${a.raisedByHead === true ? ', raised-by-head' : ''}${a.carryIdle === true ? ', carry-idle' : ''}${a.merge !== undefined ? `, merge ${a.merge}` : ''}${a.reduce !== undefined ? `, reduce ${a.reduce}°${a.reduceHip !== undefined ? ` (hip ${a.reduceHip} units)` : ''}${a.reduceKeepPlanted === true ? ', planted legs kept dense' : ''}` : ''}`);
176
+ tell(`rig-pose retarget: ${seq.frames.length} frame(s), ${r.animation.duration.toFixed(3)}s, onto "${input.name}" as "${a.name}" — ${r.animation.tracks.length} track(s); min-score ${a.minScore}, flip ${a.flip === 'off' ? 'off' : `${a.flip}°`}, short ${a.short}, bends ${a.bends} (tolerance ${a.bendTolerance}°)${a.legScale !== undefined && a.legScale !== 'off' ? `, leg-scale ${a.legScale[0]}–${a.legScale[1]}` : ''}${a.plantFeet === true ? ', plant-feet' : ''}${a.stance !== undefined && a.stance !== 'off' ? `, stance ${a.stance}` : ''}${a.raisedByHead === true ? ', raised-by-head' : ''}${a.carryIdle === true ? ', carry-idle' : ''}${a.merge !== undefined ? `, merge ${a.merge}` : ''}${a.reduce !== undefined ? `, reduce ${a.reduce}°${chainsText(a.reduceChains)}${a.reduceHip !== undefined ? ` (hip ${a.reduceHip} units)` : ''}${a.reduceKeepPlanted === true ? ', planted legs kept dense' : ''}` : ''}`);
168
177
  say(` RIGC rig-c ${rigc.version} (${rigc.entry}) gates and poses this run`);
169
178
  // #250: the gate was green without it, so nothing else would say the rig's own animations are not in --out.
170
179
  const unread = unreadMotionLine(a.rig, input.motion);
@@ -191,6 +200,8 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
191
200
  if (map.alternates.length > 0) {
192
201
  if (r.switches.length === 0) say(` SWITCH none — no set within ${a.switchTolerance}° of the pose for ${a.switchMin}s`);
193
202
  for (const s of r.switches) say(` SWITCH ${switchLine(s, r.times)}`);
203
+ // #279: only for a set the minimum span held back, so a run where nothing was suppressed prints what it always has.
204
+ for (const l of switchMinLines(r.switchRuns, a.switchMin)) say(` SWITCH-MIN ${l}`);
194
205
  for (const l of windowLines(map, r.switches, r.distances, r.times, a.switchTolerance)) say(` FRAMES ${l}`);
195
206
  for (const l of phaseLines(map, r.switches)) say(` PHASE ${l}`);
196
207
  }
@@ -202,6 +213,11 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
202
213
  for (const e of r.byHip) say(` BY-HIP ${raisedLine(e, r.times)}`);
203
214
  for (const e of r.byHip) if (e.short.length > 0) say(` REACH ${reachLine(e, r.times)}`);
204
215
  for (const e of r.reach) say(` REACH ${reachCostLine(e, r.times)}`);
216
+ // rig-c/pose#282: each reach chain's origin against its source joint; a map without one prints none.
217
+ for (const e of r.reachOrigin) {
218
+ say(` REACH-ORIGIN ${reachOriginLine(e, r.times)}`);
219
+ if (e.warn) say(` WARN ${reachOriginWarn(e)}`);
220
+ }
205
221
  for (const e of r.artRange) say(` ART-RANGE ${artRangeLine(e, r.times)}`);
206
222
  // #287: a range that clamps no frame says so once, on the last segment's line (the line count stays one per segment).
207
223
  const noClamp = legScaleNoClamp(r.legScale);
@@ -211,6 +227,9 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
211
227
  // rig-c/pose#281: the limbs' front-to-back order from the sequence's depth, only when it carries depth.
212
228
  const layers: FrameLayers[] | null = seq.frames.some((x) => x.depth !== undefined) ? frames.map((j, i) => layersOf(j, seq.frames[i]!.depth ?? {})) : null;
213
229
  if (layers !== null) say(` ${depthLine(layers, seq.frames, seq.cleaned!.params.depth?.gait ?? null)}`);
230
+ // rig-c/pose#304: with --draw-order depth, the legs' draw order keyed from those layers.
231
+ const drawOrder: DrawOrderReading | null = a.drawOrder === 'depth' && layers !== null ? drawOrderFromDepth(layers, frames, seq.frames.map((x) => x.depth), r.times, map, rest, doc0.slots, input.sets, seq.cleaned!.params.loop) : null;
232
+ if (drawOrder !== null) for (const l of drawOrderLines(drawOrder)) say(` ${l}`);
214
233
  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`);
215
234
  if (r.feet !== null) {
216
235
  const f = r.feet;
@@ -238,15 +257,16 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
238
257
  if (r.body !== null) say(` BODY ${bodyLine(r.body)}`);
239
258
 
240
259
  // --reduce (#32): the dense keys thinned top-down, before anything reads the tracks.
241
- const reduced = a.reduce === undefined ? null : reduceTracks(r, map, rest, a.reduce, a.reduceHip, a.reduceKeepPlanted === true);
260
+ const reduced = a.reduce === undefined ? null : a.reduceChains === undefined ? reduceTracks(r, map, rest, a.reduce, a.reduceHip, a.reduceKeepPlanted === true) : reduceTracks(r, map, rest, a.reduce, a.reduceHip, a.reduceKeepPlanted === true, reduceOf!);
242
261
  if (reduced !== null) {
243
262
  for (const row of reduced.rows) {
244
263
  const [who, track, what, unit] = row.kind === 'angle' ? ['bone', 'rotate', 'aim', '°'] : row.kind === 'origin' ? ['hip', 'translate', 'origin', ' units'] : ['bone', 'scalex', 'scale', ' of its setup length'];
245
- say(` REDUCE ${who} "${row.bone}" ${track}: ${row.before} → ${row.after} key(s); worst ${what} error ${row.worst.toPrecision(3)}${unit} at frame ${row.frame} (t=${r.times[row.frame]!.toFixed(3)}s)`);
264
+ const own = row.kind === 'angle' && reduceOf!(row.bone) !== a.reduce ? ` — within its chain's ${reduceOf!(row.bone)}°` : '';
265
+ say(` REDUCE ${who} "${row.bone}" ${track}: ${row.before} → ${row.after} key(s); worst ${what} error ${row.worst.toPrecision(3)}${unit} at frame ${row.frame} (t=${r.times[row.frame]!.toFixed(3)}s)${own}`);
246
266
  }
247
267
  const sum = (kind: ReduceRow['kind'], k: 'before' | 'after') => reduced.rows.filter((x) => x.kind === kind).reduce((t, x) => t + x[k], 0);
248
268
  const scales = reduced.rows.some((x) => x.kind === 'scale') ? `; scalex keys ${sum('scale', 'before')} → ${sum('scale', 'after')} within ${SCALE_REDUCE} of the setup length` : '';
249
- say(` REDUCE rotate keys ${sum('angle', 'before')} → ${sum('angle', 'after')} (${(sum('angle', 'before') / sum('angle', 'after')).toFixed(2)}×) within ${a.reduce}°${scales}${a.reduceHip === undefined ? '; the hip stays dense (no --reduce-hip)' : ''}`);
269
+ say(` REDUCE rotate keys ${sum('angle', 'before')} → ${sum('angle', 'after')} (${(sum('angle', 'before') / sum('angle', 'after')).toFixed(2)}×) within ${a.reduce}°${chainsText(a.reduceChains)}${scales}${a.reduceHip === undefined ? '; the hip stays dense (no --reduce-hip)' : ''}`);
250
270
  }
251
271
 
252
272
  // The idle against the new animation (#43 item 2): what it drops, and with --carry-idle, looped back in.
@@ -295,7 +315,9 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
295
315
  for (const tr of fragment) say(` MERGE ${a.merge}: ${trackLabel(tr, groups)}, ${tr.keys.length} key(s) at t=${tr.keys[0]!.t}${tr.keys.length > 1 ? `–${tr.keys[tr.keys.length - 1]!.t}` : ''}s`);
296
316
  }
297
317
  const added = [...carried, ...fragment];
298
- const animation = added.length === 0 ? r.animation : { ...r.animation, tracks: [...own, ...added] };
318
+ const tracked = added.length === 0 ? r.animation : { ...r.animation, tracks: [...own, ...added] };
319
+ // #304: rig-c's one draw-order timeline sits on the animation beside its tracks; written only when keyed.
320
+ const animation = drawOrder === null || drawOrder.keys.length === 0 ? tracked : { ...tracked, drawOrder: drawOrderKeys(drawOrder) };
299
321
  const motion = { ...base, animations: { ...animations, [a.name]: animation } };
300
322
  const g1 = gate(input.rig, motion, join(scratch, 'with'), a.profile, a.images);
301
323
  if (!g1.green) {
@@ -314,11 +336,12 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
314
336
  const support = a.reduce !== undefined && a.reduceKeepPlanted === true && r.feet !== null ? supportFrames(r.feet.supports) : undefined;
315
337
  const landings = measureLanding(doc, a.name, map, r, posed, support).map((l) => {
316
338
  if (a.reduce === undefined) return l;
317
- if (l.kind === 'angle') return { ...l, tolerance: l.tolerance + a.reduce };
339
+ // #283: a chain's own tolerance is added as the run's is; the sum is rounded only under a per-chain --reduce.
340
+ if (l.kind === 'angle') return { ...l, tolerance: a.reduceChains === undefined ? l.tolerance + a.reduce : Number((l.tolerance + reduceOf!(l.bone)).toPrecision(12)) };
318
341
  if (l.kind === 'origin' && a.reduceHip !== undefined) return { ...l, tolerance: l.tolerance + a.reduceHip };
319
342
  if (l.kind !== 'foot') return l;
320
343
  const ankle = l.bone.replace(/:tip$/, '');
321
- const by = footReduce(rest, r, ankle, a.reduce, a.reduceHip);
344
+ const by = footReduce(rest, r, ankle, a.reduce, a.reduceHip, a.reduceChains === undefined ? undefined : reduceOf!);
322
345
  // #251: on its support frames every mapped bone from the hip down was kept at its target, so only the hip's tolerance moves it.
323
346
  const hip = a.reduceHip ?? 0;
324
347
  if (!drift.some((d) => d.bone === l.bone)) {
@@ -326,7 +349,9 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
326
349
  const side = lb?.r.ankle === ankle ? 'r' : lb?.l.ankle === ankle ? 'l' : null;
327
350
  const legs = (u: number) => (side === null || r.rigLeg === null ? null : u / r.rigLeg[side]);
328
351
  const held = support === undefined ? {} : { support: side === null || support[side].size === 0 ? null : { reduce: hip, units: l.tolerance + hip, legLengths: legs(l.tolerance + hip) } };
329
- drift.push({ bone: l.bone, side, plant: l.tolerance, reduce: by, units: l.tolerance + by, legLengths: legs(l.tolerance + by), ...held });
352
+ // #283: under a per-chain --reduce, the tolerance its leg was reduced within.
353
+ const leg = a.reduceChains === undefined ? {} : { legTolerance: legTolerance(rest, r, ankle, reduceOf!) };
354
+ drift.push({ bone: l.bone, side, plant: l.tolerance, reduce: by, units: l.tolerance + by, legLengths: legs(l.tolerance + by), ...held, ...leg });
330
355
  }
331
356
  return { ...l, tolerance: l.tolerance + (l.on === 'support' ? hip : by) };
332
357
  });
@@ -389,7 +414,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
389
414
 
390
415
  assertReportInputs([rigSnapshot,mapSnapshot,poseSnapshot]);
391
416
  // Every retarget option as resolved: the report's `options`, and with the name the build record's `flags` (#270).
392
- const options = { minScore: a.minScore, flip: a.flip, short: a.short, bends: a.bends, bendTolerance: a.bendTolerance, switchTolerance: a.switchTolerance, switchMin: a.switchMin, switchAt: a.switchAt, profile: a.profile, legScale: a.legScale ?? 'off', plantFeet: a.plantFeet === true, stance: a.stance ?? 'off', floor: a.floor ?? null, raisedByHead: a.raisedByHead === true, idle: idleName, carryIdle: a.carryIdle === true, merge: a.merge ?? null, reduce: a.reduce ?? 'off', reduceHip: a.reduceHip ?? 'off', ...(a.reduceKeepPlanted === true ? { reduceKeepPlanted: true } : {}) };
417
+ const options = { minScore: a.minScore, flip: a.flip, short: a.short, bends: a.bends, bendTolerance: a.bendTolerance, switchTolerance: a.switchTolerance, switchMin: a.switchMin, switchAt: a.switchAt, profile: a.profile, legScale: a.legScale ?? 'off', plantFeet: a.plantFeet === true, stance: a.stance ?? 'off', floor: a.floor ?? null, raisedByHead: a.raisedByHead === true, idle: idleName, carryIdle: a.carryIdle === true, merge: a.merge ?? null, reduce: a.reduce ?? 'off', ...(a.reduceChains === undefined ? {} : { reduceChains: a.reduceChains }), reduceHip: a.reduceHip ?? 'off', ...(a.reduceKeepPlanted === true ? { reduceKeepPlanted: true } : {}), ...(a.drawOrder !== undefined ? { drawOrder: a.drawOrder } : {}) };
393
418
  const reportText = writeReport({
394
419
  spec: 'rig-pose-report/1',
395
420
  rigc,
@@ -414,9 +439,12 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
414
439
  seams: seamRead.seams,
415
440
  seamPairs: seamRead.seamPairs,
416
441
  switches: r.switches,
442
+ switchRuns: r.switchRuns,
417
443
  uncovered,
418
444
  turns: r.turns,
419
445
  reach: r.reach,
446
+ // Only when the map has a reach chain (#282): a map without one writes the report it always has.
447
+ ...(r.reachOrigin.length > 0 ? { reachOrigin: r.reachOrigin } : {}),
420
448
  range: r.range,
421
449
  artRange: r.artRange,
422
450
  raised: r.raised,
@@ -448,13 +476,15 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
448
476
  pelvis: r.pelvis,
449
477
  // Only when the sequence carries depth (#281): a sequence without it writes the report it always has.
450
478
  ...(layers !== null ? { layers: layers.map((l, frame) => ({ frame, order: l.order, depth: l.depth })) } : {}),
479
+ // Only with --draw-order depth (#304).
480
+ ...(drawOrder !== null ? { drawOrder: { source: 'depth', pairs: drawOrder.pairs, keys: drawOrder.keys, spans: drawOrder.spans } } : {}),
451
481
  stance: r.stance === null ? null : { tolerance: r.stance.tolerance, release: r.stance.release, minSeconds: r.stance.minSeconds, bounds: r.stance.bounds, ...(r.stance.range === undefined ? {} : { range: r.stance.range }), runs: r.stance.runs, over: r.stance.over },
452
482
  feet: r.feet === null ? null : { planted: r.planted, legLength: r.feet.legLength, straight: r.legStraight, rigLeg: r.rigLeg, segments: r.segments, supports: r.feet.supports, jumps: r.feet.jumps, steps: r.feet.steps, changes: r.feet.changes, ...(r.feet.lowered === undefined ? {} : { lowered: r.feet.lowered }), ...(r.feet.tracked === undefined ? {} : { tracked: r.feet.tracked }), floor: r.floor?.y ?? null, shifts: r.floor?.shifts ?? [], blend: r.blendHold === null ? null : { bounds: r.blendHold.bounds, ...(r.blendHold.range === undefined ? {} : { range: r.blendHold.range }), runs: r.blendHold.runs, over: r.blendHold.over }, reading: feetReading, closure: r.closure },
453
483
  shear,
454
484
  idleDropped: idle === null ? null : idle.dropped,
455
485
  idleKeptOut: idle === null ? null : idle.keptOut,
456
486
  merged: fragment.map((tr) => ({ track: trackLabel(tr, groups), keys: tr.keys.length })),
457
- reduce: reduced === null ? null : { tolerance: a.reduce, hipTolerance: a.reduceHip ?? null, rows: reduced.rows, ankleDrift: drift },
487
+ reduce: reduced === null ? null : { tolerance: a.reduce, ...(a.reduceChains === undefined ? {} : { chains: a.reduceChains }), hipTolerance: a.reduceHip ?? null, rows: reduced.rows, ankleDrift: drift },
458
488
  }, map, { frameTimes: r.times }, f.meshes !== null && doc.slots.length > 0 ? reportRenderDomain(JSON.parse(readFileSync(join(f.frames, '..', 'frames.json'), 'utf8')), a.name) : seamRead.frames === null ? null : reportRenderDomain(seamRead.frames, a.name), {rig:rigSnapshot.identity,map:mapSnapshot.identity,pose:poseSnapshot.identity});
459
489
  if (a.build !== undefined) {
460
490
  // Built again where it will live, not copied: rigc's page paths are relative to the build (#18).
@@ -626,7 +656,7 @@ const spanText = (x: [number, number]) => (x[0] === x[1] ? `${x[0]}` : `${x[0]}
626
656
  * aim and length it lands on (`reduceUnderScale`). With none, this is #32's
627
657
  * reduction, unchanged.
628
658
  */
629
- function reduceTracks(r: ReturnType<typeof retarget>, map: JointMap, rest: Rest, tolerance: number, hipTolerance: number | undefined, keepPlanted: boolean): Reduced {
659
+ function reduceTracks(r: ReturnType<typeof retarget>, map: JointMap, rest: Rest, tolerance: number, hipTolerance: number | undefined, keepPlanted: boolean, tolOf: (bone: string) => number = () => tolerance): Reduced {
630
660
  const n = r.times.length;
631
661
  const keep = new Map<string, Set<number>>();
632
662
  const add = (bone: string, ...frames: number[]) => {
@@ -665,7 +695,9 @@ function reduceTracks(r: ReturnType<typeof retarget>, map: JointMap, rest: Rest,
665
695
  return false;
666
696
  };
667
697
  const dense = new Map([...rotate].map(([b, tr]) => [b, tr.keys.map((k) => k.v[0]!)]));
668
- const bones: ReduceBone[] = [...rotate.values()].filter((tr) => !under(tr.bone)).map((tr) => ({ bone: tr.bone, above: above(tr.bone), dense: dense.get(tr.bone)!, keep: keep.get(tr.bone) ?? new Set<number>() }));
698
+ // #283: a bone whose chain --reduce names carries its own tolerance; every other takes the run's.
699
+ const own = (b: string): { tolerance?: number } => (tolOf(b) === tolerance ? {} : { tolerance: tolOf(b) });
700
+ const bones: ReduceBone[] = [...rotate.values()].filter((tr) => !under(tr.bone)).map((tr) => ({ bone: tr.bone, above: above(tr.bone), dense: dense.get(tr.bone)!, keep: keep.get(tr.bone) ?? new Set<number>(), ...own(tr.bone) }));
669
701
  const hipTrack = r.origin === null || hipTolerance === undefined ? undefined : r.animation.tracks.find((tr): tr is MotionTrack => 'bone' in tr && tr.property === 'translate' && tr.bone === r.origin!.bone && tr.keys.every((k) => k.ease === undefined));
670
702
  const parent = hipTrack === undefined ? null : rest.get(hipTrack.bone)!.parent;
671
703
  const pm = parent === null ? null : rest.get(parent)!;
@@ -758,6 +790,7 @@ function reduceTracks(r: ReturnType<typeof retarget>, map: JointMap, rest: Rest,
758
790
  asked: r.scales.get(b) ?? new Array<number>(n).fill(1),
759
791
  dense: dense.get(b)!,
760
792
  keep: keepOf(b),
793
+ ...own(b),
761
794
  };
762
795
  });
763
796
  const red = reduceUnderScale(r.times, given, tolerance, SCALE_REDUCE);
@@ -782,7 +815,7 @@ function reduceTracks(r: ReturnType<typeof retarget>, map: JointMap, rest: Rest,
782
815
  * world aim is within the angle tolerance of its dense one, top-down, so the
783
816
  * segments' errors are each bounded and the sum bounds the ankle's.
784
817
  */
785
- export function footReduce(rest: Rest, r: ReturnType<typeof retarget>, ankle: string, tolerance: number, hipTolerance: number | undefined): number {
818
+ export function footReduce(rest: Rest, r: ReturnType<typeof retarget>, ankle: string, tolerance: number, hipTolerance: number | undefined, tolOf?: (bone: string) => number): number {
786
819
  const top = r.origin?.bone ?? null;
787
820
  let sMax = 1;
788
821
  for (const s of r.scales.values()) for (const v of s) sMax = Math.max(sMax, v);
@@ -794,7 +827,8 @@ export function footReduce(rest: Rest, r: ReturnType<typeof retarget>, ankle: st
794
827
  if (p === null) break;
795
828
  lever += Math.hypot(x.worldX - p.worldX, x.worldY - p.worldY);
796
829
  }
797
- return (hipTolerance ?? 0) + lever * sMax * ((tolerance * Math.PI) / 180 + SCALE_REDUCE);
830
+ const deg = tolOf === undefined ? tolerance : legTolerance(rest, r, ankle, tolOf);
831
+ return (hipTolerance ?? 0) + lever * sMax * ((deg * Math.PI) / 180 + SCALE_REDUCE);
798
832
  }
799
833
 
800
834
  function spans(ts: number[], step: number): string {
@@ -1290,6 +1324,8 @@ export interface AnkleDrift {
1290
1324
  * Absent without the flag.
1291
1325
  */
1292
1326
  support?: { reduce: number; units: number; legLengths: number | null } | null;
1327
+ /** rig-c/pose#283: under a per-chain --reduce, the largest tolerance a bone from the hip down to it was reduced within (degrees). */
1328
+ legTolerance?: number;
1293
1329
  }
1294
1330
 
1295
1331
  /** #251: each side's support frames, from the spans --plant-feet read. */
@@ -1301,11 +1337,46 @@ function supportFrames(supports: ReadonlyArray<{ side: 'r' | 'l'; frames: [numbe
1301
1337
 
1302
1338
  /** The drift line: the allowance on one frame, and what it lets a planted ankle slide between two. */
1303
1339
  export function ankleDriftLine(d: AnkleDrift): string {
1340
+ const chainDeg = d.legTolerance;
1304
1341
  const legs = (v: number) => (d.legLengths === null ? '' : ` = ${(d.legLengths * v / d.units).toPrecision(3)} leg lengths`);
1305
1342
  const line = `ankle drift allowed: bone "${d.bone}" ${d.units.toPrecision(3)} units${legs(d.units)} on any one frame (plant ${d.plant.toPrecision(3)} + reduce ${d.reduce.toPrecision(3)}); between two frames up to ${(2 * d.units).toPrecision(3)} units${legs(2 * d.units)}`;
1306
- if (d.support === undefined) return `${line} — a tighter slide bar needs a smaller --reduce`;
1343
+ if (d.support === undefined) return `${line} — ${chainDeg === undefined ? 'a tighter slide bar needs a smaller --reduce' : `its leg reduced within ${chainDeg}° (--reduce <deg>,<chain>=<deg>); a tighter slide bar needs a smaller one on its chain`}`;
1307
1344
  if (d.support === null) return `${line}; its side never supports, so --reduce-keep-planted keeps no frame of it`;
1308
1345
  const s = d.support;
1309
1346
  const sLegs = (v: number) => (s.legLengths === null ? '' : ` = ${(s.legLengths * v / s.units).toPrecision(3)} leg lengths`);
1310
1347
  return `${line} off its support frames; on them, kept dense (--reduce-keep-planted), ${s.units.toPrecision(3)} units${sLegs(s.units)} (plant ${d.plant.toPrecision(3)} + hip ${s.reduce.toPrecision(3)}), between two frames of a support span up to ${(2 * s.units).toPrecision(3)} units${sLegs(2 * s.units)}`;
1311
1348
  }
1349
+
1350
+ /** rig-c/pose#283: the largest tolerance any bone from `ankle` up to the legs' top was reduced within. */
1351
+ export function legTolerance(rest: Rest, r: ReturnType<typeof retarget>, ankle: string, tolOf: (bone: string) => number): number {
1352
+ const top = r.origin?.bone ?? null;
1353
+ let deg = tolOf(ankle);
1354
+ for (let b: string | null = rest.get(ankle)!.parent; b !== null && b !== top; b = rest.get(b)!.parent) deg = Math.max(deg, tolOf(b));
1355
+ return deg;
1356
+ }
1357
+
1358
+ /**
1359
+ * rig-c/pose#283: the reduction's tolerance per bone. Every bone takes `tolerance`
1360
+ * but a bone of a chain `chains` names, which takes that chain's (the smallest,
1361
+ * when two named chains list it). A name the map has no chain for is refused by
1362
+ * name, beside the chains it has.
1363
+ */
1364
+ export function reduceTolerances(map: JointMap, tolerance: number, chains: Record<string, number> | undefined, mapPath: string): (bone: string) => number {
1365
+ if (chains === undefined) return () => tolerance;
1366
+ const names = map.chains.map((c) => c.name);
1367
+ const missing = Object.keys(chains).filter((c) => !names.includes(c));
1368
+ if (missing.length > 0) throw new Refusal(missing.map((c) => ({ code: 'POSE_REDUCE', text: `--reduce names chain "${c}", which ${mapPath} does not have; its chains are ${names.map((x) => `"${x}"`).join(', ')}` })));
1369
+ const by = new Map<string, number>();
1370
+ for (const c of map.chains) {
1371
+ const t = Object.hasOwn(chains, c.name) ? chains[c.name] : undefined;
1372
+ if (t === undefined) continue;
1373
+ for (const b of c.bones) by.set(b, Math.min(by.get(b) ?? t, t));
1374
+ }
1375
+ return (bone) => by.get(bone) ?? tolerance;
1376
+ }
1377
+
1378
+ /** rig-c/pose#283: the per-chain tolerances as the run and REDUCE lines print them; empty without. */
1379
+ export function chainsText(chains: Record<string, number> | undefined): string {
1380
+ if (chains === undefined) return '';
1381
+ return ` (${Object.entries(chains).map(([c, t]) => `chain "${c}" ${t}°`).join(', ')})`;
1382
+ }