@rig-c/pose 0.40.0 → 0.41.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/README.md CHANGED
@@ -392,6 +392,7 @@ The retarget writes one key per pose frame on every mapped bone. That is faithfu
392
392
  - A mapped bone under a scaled bone (keyed through its parent's posed matrix by `--leg-scale`) is solved again through its parent as reduced. Its rotate is then thinned with the error read as the world aim it lands on, and its `scalex` with the error read as the world length it lands on. Its world error is its own fit error, as down any other chain.
393
393
  - Each ankle is held to the plant's tolerance plus the most the reduction may move it: the hip's tolerance (with `--reduce-hip`), plus the leg from the hip to the ankle (each bone's setup offset from its parent and the last bone's length) times the largest scale asked, times the angle tolerance in radians plus 5e-5. The ankle's `LAND` line prints that tolerance, and at the end of the run `REDUCE ankle drift allowed: bone "…:tip" X units = Y leg lengths on any one frame` prints the same figure, split `(plant … + reduce …)` (`reduce.ankleDrift` in the report, `{ bone, side, plant, reduce, units, legLengths }`, the row's `units` the landing's `tolerance` itself), with the slide between two frames it lets through (2X) — so a slide bar is read against the reduction before it is run, not after ([#240](https://github.com/rig-c/pose/issues/240)). On the wave with `--leg-scale 0.8,1.05 --plant-feet --reduce 0.5 --reduce-hip 0.5`, that is 1.97 units = 0.0132 leg lengths a frame, 0.0263 between two.
394
394
  - **Per chain: `--reduce <deg>,<chain>=<deg>,…`** ([#283](https://github.com/rig-c/pose/issues/283)). The first item is every chain's tolerance; each `<chain>=<deg>` after it gives a chain the map names its own, on each of its bones (the smaller, when two named chains list one bone). One tolerance on every bone trades the ankle's slide against the trunk's jitter; this holds the legs tight while the trunk is smoothed. A chain the map does not have is refused by name (`POSE_REDUCE`, beside the chains it has), and a malformed item, a tolerance outside (0, 180) or a chain named twice by the command line. Each bone's `LAND` line holds it to 0.01° plus its chain's tolerance, #116's trunk smoothing keeps each bone in its own, and each ankle's allowance above is taken at the largest tolerance of the bones from the hip down to it. The run line and the `REDUCE` total name each chain's tolerance, each bone reduced within its chain's says so (`— within its chain's 0.2°`), and each `ankle drift allowed` line names the tolerance its leg was reduced within. The report carries them as `reduce.chains` and `options.reduceChains` (`{ chain: deg }`) and `reduce.ankleDrift[].legTolerance`, written only when given; without them a run's bytes are unchanged. Measured on the tests' synthetic travelling walk (`examples/walk`'s cycle three times, 72 frames, the swinging ankle 12 px fore and aft, on the wave's rig with the walk's map and its trunk given `reach`, `--plant-feet --leg-scale 0.8,1.05`), against slide (the FEET worst horizontal drift) ≤ 0.005 leg lengths and jitter (the worst `JITTER` ratio) ≤ 1.1: `--reduce 0.2` gives 0.0042 and 1.62, `--reduce 3` 0.0547 and 1.01; with `--reduce-keep-planted`, 0.0000 and 1.62 at 0.2, 0.0000 and 1.42 at 3 (the supporting leg's seam where its kept frames meet its reduced ones); `--reduce 3,leg_r=0.2,leg_l=0.2` gives 0.0042 and 0.99.
395
+ - **A parent held to its tightest child** ([#320](https://github.com/rig-c/pose/issues/320)). Under a per-chain `--reduce`, every mapped bone above a planted or aimed chain the command names is reduced within that chain's tolerance when its own is looser (the tightest such chain, when several hang below it). A chain is planted when it carries an ankle the landing check holds, aimed when the map gives it an `aim` other than `"x"`; a bent chain (an arm, a leg with no ankle check) holds nothing above it. The lever: a turn error θ on a bone moves a chain below it by θ·L (L the distance from the bone's origin to the chain's first bone) and turns it by θ. The reduction refits the chain against the turn, so its aim stays its own, but the move it cannot undo, and the ankle allowance counts L at the leg's own degrees — θ·L ≤ θ_leg·L is θ ≤ θ_leg whatever L is. An aimed chain is refitted, and on the trunk smoothed, against its parent's error, which held to the chain's degrees never asks it to absorb more than its own tolerance. Each held bone prints `REDUCE held: bone "hip" (chain "pelvis") reduced within 0.2°, not 3° — for chain "leg_r" (a planted ankle; L = 15 units to bone "leg_r0"), …`, its `REDUCE` row ends `— held to 0.2° for chain "leg_r", chain "leg_l" below it`, its `LAND` line takes 0.01° plus the held tolerance, and the report carries `reduce.held` (`[{ bone, chain, from, to, for: [{ chain, by, bone, lever }] }]`, `by` is `ankle` or `aim`), written only when a bone was held. A plain `--reduce` names no chain, so nothing is held and its bytes are unchanged. #116's trunk smoothing is kept only where it leaves every bone it manages within that bone's own tolerance; past it, the fitted keys stand and the run prints `REDUCE smoothing (rig-c/pose#116) from bone "chest" down not kept: …` (`reduce.unsmoothed`, `[{ root, bone, worst, tolerance }]`, written only then). Measured on the tests' synthetic walk with a drawn pelvis tilt (`examples/walk/make-pelvis.ts`, drawn, read by the silhouette, cleaned with `--loop`, on the wave's rig with the walk's map and a pelvis chain on `hip`, `--plant-feet`): `--reduce 3,leg_r=0.2,leg_l=0.2` without the hold missed both ankles (0.697 and 0.677 units against an allowance of 0.628); with it, bone `hip` is held to 0.2° and the ankles land at 0.248 and 0.232, the head at 2.63° within 3.01°, the same motion bytes as a hand `pelvis=0.2` entry.
395
396
  - **`--reduce-keep-planted`** (only with `--reduce` and `--plant-feet`, refused by name otherwise; [#251](https://github.com/rig-c/pose/issues/251)) adds every frame of each support span to the frames kept regardless, on every mapped bone from the hip down to that side's ankle (rotate, and `scalex` where scaled). A kept frame is keyed at its target, so on its support frames the planted ankle lands as dense but for the hip: its `LAND` line there is `ankle on its support frames (kept dense)`, held to the plant's tolerance plus `--reduce-hip`'s (0 without it), and off them `ankle off its support frames`, held to the allowance above. `reduce.ankleDrift[].support` carries the tighter figure (`{ reduce, units, legLengths }`, `reduce` being the hip's share; `null` for a side that never supports). The tolerance has no separate rounding term: on the tests' synthetic walk (72 frames, six support spans) the supporting ankle on its support frames sits 1.22e-3 units from the dense run's, against the plant's 0.0253. Measured on that walk with `--reduce 1`, the FEET slide (the worst horizontal drift of a supporting ankle over a support span) goes from 0.0226 leg lengths to 0.00002, the dense run's; with `--reduce-hip 0.5` beside it, 0.0048, since the hip is not kept. The price is the keys: the supporting leg keeps one per support frame (the walk's four leg rotate tracks, 47 → 135 keys).
396
397
  - When `--leg-scale` is active, each trunk bone (`chest` and `head`, not an ancestor of any scaled bone) has its kept knots and Bézier handles smoothed within `deg` of its dense world aim to minimise the mean absolute second difference of its evaluated local angle and its child's compensated local angle, holding any limb children within 0.03° of their fitted world aim (rig-c/pose#116).
397
398
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/pose",
3
- "version": "0.40.0",
3
+ "version": "0.41.0",
4
4
  "description": "Puts motion from a body-18 pose sequence onto a rig built with rig-c: bone keys in rig-c's motion format, checked through rig-c's gate before anything is written. A CLI for agents that cannot see the rig.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/draworder.ts CHANGED
@@ -19,7 +19,13 @@
19
19
  * together they fill one unbroken run of the setup order. A slot of anything
20
20
  * else drawn between them would be re-layered by the swap — that is changing
21
21
  * the setup order, not keying the pair — so the pair is not keyed and the line
22
- * names the slot.
22
+ * counts the slots between them first, then names them as far as the line's
23
+ * cap allows (#324; the report's `why` and `between` carry every one).
24
+ * - **The gap (#324).** Per pair, keyed or not, the leg depth gap |depth a −
25
+ * depth b| over the frames both limbs read — its median and max, in px and as
26
+ * a fraction of the leg (upper + lower rest length of the limb the tie is read
27
+ * from) — against the tie, and the frames that passed it: the `DRAWGAP` line
28
+ * and the report's `drawOrder.gaps`.
23
29
  * - **The rule.** On each frame the limb whose depth is the lower (farther from
24
30
  * the camera) is drawn behind: its slots first in the pair's run, in their
25
31
  * setup order, then the other's — when the two depths differ by at least
@@ -67,6 +73,25 @@ export interface DrawOrderPair {
67
73
  tie: number | null;
68
74
  /** Why the pair is not keyed. */
69
75
  why?: string;
76
+ /** The slots of anything else drawn between the pair's slots, in setup order (#324: the line counts them first and cuts this list). */
77
+ between?: string[];
78
+ }
79
+ /** One pair's leg depth gap against its tie (#324): the figures that say why a pair keyed little or nothing. */
80
+ export interface DrawOrderGap {
81
+ a: string;
82
+ b: string;
83
+ /** |depth a − depth b| over the frames both limbs read, px: the median and the largest. */
84
+ median: number | null;
85
+ max: number | null;
86
+ /** The same as a fraction of the leg (upper + lower rest length of the limb the tie is read from); null where a rest length is unread. */
87
+ medianLegs: number | null;
88
+ maxLegs: number | null;
89
+ /** The pair's tie, px and as a fraction of the leg. */
90
+ tie: number | null;
91
+ tieLegs: number | null;
92
+ /** Frames whose gap passed the tie (decided an order), of the frames both limbs read. */
93
+ passed: number;
94
+ frames: number;
70
95
  }
71
96
  export interface DrawOrderSpan {
72
97
  /** The chain drawn behind, and the one in front. */
@@ -82,6 +107,8 @@ export interface DrawOrderReading {
82
107
  keys: Array<DrawOrderKey & { frame: number }>;
83
108
  /** Every span of one decided order, setup or keyed, in frame order per pair. */
84
109
  spans: DrawOrderSpan[];
110
+ /** Per pair, the leg depth gap against the tie (#324); keyed or not. */
111
+ gaps?: DrawOrderGap[];
85
112
  }
86
113
 
87
114
  interface Resolved {
@@ -164,10 +191,19 @@ function resolvePair(pairLimbs: readonly [Limb, Limb], map: JointMap, rest: Rest
164
191
  const lo = idx[0]!;
165
192
  const hi = idx[idx.length - 1]!;
166
193
  const between = names.slice(lo, hi + 1).filter((n) => !slotsA.has(n) && !slotsB.has(n));
167
- if (between.length > 0) return none(`their slots do not overlap in the draw order: ${between.map((n) => `"${n}"`).join(', ')} ${between.length === 1 ? 'is' : 'are'} drawn between them, and a swap would re-layer ${between.length === 1 ? 'it' : 'them'} (the setup order is not changed here)`);
194
+ if (between.length > 0) return { ...none(betweenWhy(between, between.length)), pair: { ...pair, why: betweenWhy(between, between.length), between } };
168
195
  return { pair: { ...pair, keyed: true }, lo, hi, aBehind: arrangement(names, lo, a, b), bBehind: arrangement(names, lo, b, a) };
169
196
  }
170
197
 
198
+ /** The not-keyed reason for slots drawn between the pair: the count first, then the first `shown` of the list (#324). */
199
+ function betweenWhy(between: readonly string[], shown: number): string {
200
+ const tail = 'the setup order is not changed here';
201
+ if (between.length === 1) return shown > 0 ? `1 slot: "${between[0]}" is drawn between them, and a swap would re-layer it (${tail})` : `1 slot is drawn between them (… +1 more), and a swap would re-layer it (${tail})`;
202
+ const more = between.length - shown;
203
+ const list = `${between.slice(0, shown).map((n) => `"${n}"`).join(', ')}${more > 0 ? `${shown > 0 ? ', ' : ''}… +${more} more` : ''}`;
204
+ return `${between.length} slots are drawn between them (${list}), and a swap would re-layer them (${tail})`;
205
+ }
206
+
171
207
  type Joints = Partial<Record<Body18, Joint>>;
172
208
 
173
209
  /** A segment's rest length as the depth states it (drawn ÷ ratio), the median over the frames that name it; null where none does. */
@@ -187,13 +223,40 @@ function restOf(track: readonly Joints[], depth: ReadonlyArray<FrameDepth | unde
187
223
  return vals.length % 2 === 1 ? vals[m]! : (vals[m - 1]! + vals[m]!) / 2;
188
224
  }
189
225
 
190
- /** #281's tie for two limbs, px: √(1 − 0.95²) × (upper + lower ÷ 2), the smaller limb's; null where a rest length is unread. */
191
- function tieOf(track: readonly Joints[], depth: ReadonlyArray<FrameDepth | undefined>, limbs: readonly [Limb, Limb]): number | null {
192
- const hidden = limbs.map((l) => {
226
+ /**
227
+ * #281's tie for two limbs, px: √(1 − 0.95²) × (upper + lower ÷ 2), the smaller limb's; null where a rest length is unread.
228
+ * `leg` is that limb's upper + lower rest length (#324's unit for the gap).
229
+ */
230
+ function tieOf(track: readonly Joints[], depth: ReadonlyArray<FrameDepth | undefined>, limbs: readonly [Limb, Limb]): { tie: number | null; leg: number | null } {
231
+ const rests = limbs.map((l) => {
193
232
  const [u, w] = LIMBS[l].map((s) => restOf(track, depth, s));
194
- return u === null || u === undefined || w === null || w === undefined ? null : u + w / 2;
233
+ return u === null || u === undefined || w === null || w === undefined ? null : { hidden: u + w / 2, leg: u + w };
195
234
  });
196
- return hidden.some((h) => h === null) ? null : Math.round(TIE_PER_LENGTH * Math.min(hidden[0]!, hidden[1]!) * 100) / 100;
235
+ if (rests[0] == null || rests[1] == null) return { tie: null, leg: null };
236
+ const r = rests[1].hidden < rests[0].hidden ? rests[1] : rests[0];
237
+ return { tie: Math.round(TIE_PER_LENGTH * r.hidden * 100) / 100, leg: r.leg };
238
+ }
239
+
240
+ const round = (x: number, k: number): number => Math.round(x * 10 ** k) / 10 ** k;
241
+
242
+ /** One pair's leg depth gap against its tie over the frames (the rule's own test: unequal and at least the tie). */
243
+ function gapOf(pair: DrawOrderPair, layers: readonly FrameLayers[], leg: number | null): DrawOrderGap {
244
+ const tie = pair.tie ?? 0;
245
+ const gaps: number[] = [];
246
+ let passed = 0;
247
+ for (const l of layers) {
248
+ const za = l.depth[pair.limbs[0]];
249
+ const zb = l.depth[pair.limbs[1]];
250
+ if (za === undefined || zb === undefined) continue;
251
+ gaps.push(Math.abs(za - zb));
252
+ if (za !== zb && Math.abs(za - zb) >= tie) passed++;
253
+ }
254
+ gaps.sort((x, y) => x - y);
255
+ const m = gaps.length >> 1;
256
+ const median = gaps.length === 0 ? null : gaps.length % 2 === 1 ? gaps[m]! : (gaps[m - 1]! + gaps[m]!) / 2;
257
+ const max = gaps.length === 0 ? null : gaps[gaps.length - 1]!;
258
+ const legs = (x: number | null): number | null => (x === null || leg === null || !(leg > 0) ? null : round(x / leg, 3));
259
+ return { a: pair.a, b: pair.b, median: median === null ? null : round(median, 2), max: max === null ? null : round(max, 2), medianLegs: legs(median), maxLegs: legs(max), tie: pair.tie, tieLegs: legs(pair.tie), passed, frames: gaps.length };
197
260
  }
198
261
 
199
262
  /**
@@ -203,7 +266,11 @@ function tieOf(track: readonly Joints[], depth: ReadonlyArray<FrameDepth | undef
203
266
  */
204
267
  export function drawOrderFromDepth(layers: readonly FrameLayers[], track: readonly Joints[], depth: ReadonlyArray<FrameDepth | undefined>, times: readonly number[], map: JointMap, rest: Rest, order: ReadonlyArray<{ name: string; bone: string }>, sets?: RigSets, loop = false): DrawOrderReading {
205
268
  const resolved = DRAW_ORDER_PAIRS.map((p) => resolvePair(p, map, rest, order, sets));
206
- for (const r of resolved) r.pair.tie = tieOf(track, depth, r.pair.limbs);
269
+ const legs = resolved.map((r) => {
270
+ const t = tieOf(track, depth, r.pair.limbs);
271
+ r.pair.tie = t.tie;
272
+ return t.leg;
273
+ });
207
274
  const names = order.map((s) => s.name);
208
275
  // Per pair, per frame: who is behind ('a' | 'b'), or null for the setup order before any decision.
209
276
  const states = resolved.map((r) => {
@@ -247,7 +314,7 @@ export function drawOrderFromDepth(layers: readonly FrameLayers[], track: readon
247
314
  else spans.push({ behind: s === 'a' ? r.pair.a : r.pair.b, front: s === 'a' ? r.pair.b : r.pair.a, frames: [f, f], keyed: (s === 'a' ? r.aBehind : r.bBehind).length > 0 });
248
315
  });
249
316
  });
250
- return { pairs: resolved.map((r) => r.pair), keys, spans };
317
+ return { pairs: resolved.map((r) => r.pair), keys, spans, gaps: resolved.map((r, k) => gapOf(r.pair, layers, legs[k]!)) };
251
318
  }
252
319
 
253
320
  /** The animation's `drawOrder` value: the keys without their frames. */
@@ -285,6 +352,26 @@ export function drawOrderLines(d: DrawOrderReading, prefix = 'DRAWORDER '): stri
285
352
  while (n > 1 && text(n).length > DRAW_ORDER_LINE_MAX) n--;
286
353
  const why = d.pairs.every((p) => !p.keyed) ? 'no pair keyed' : 'the setup order holds on every frame';
287
354
  const lines = [keyed.length === 0 ? `${head} (${why})` : text(n)];
288
- for (const p of d.pairs) if (!p.keyed) lines.push(`${prefix}${p.a} / ${p.b} not keyed: ${p.why}`.slice(0, DRAW_ORDER_LINE_MAX));
355
+ for (const p of d.pairs) {
356
+ if (p.keyed) continue;
357
+ const line = (why: string | undefined): string => `${prefix}${p.a} / ${p.b} not keyed: ${why}`;
358
+ // Slots between the pair: the count first, the list cut until the line fits (#324).
359
+ let shown = p.between?.length ?? 0;
360
+ while (p.between !== undefined && shown > 0 && line(betweenWhy(p.between, shown)).length > DRAW_ORDER_LINE_MAX) shown--;
361
+ lines.push(line(p.between === undefined ? p.why : betweenWhy(p.between, shown)).slice(0, DRAW_ORDER_LINE_MAX));
362
+ }
289
363
  return lines;
290
364
  }
365
+
366
+ /** px with its fraction of the leg: "12.3 px (0.084 leg)". */
367
+ const pxLeg = (px: number | null, legs: number | null): string => (px === null ? 'unread' : `${px} px${legs === null ? '' : ` (${legs} leg)`}`);
368
+
369
+ /**
370
+ * The DRAWGAP lines (rig-c/pose#324): one per pair, keyed or not — the leg depth gap's median and
371
+ * max against the tie, and how many frames passed it. Cut to `DRAW_ORDER_LINE_MAX` characters.
372
+ */
373
+ export function drawGapLines(d: DrawOrderReading, prefix = 'DRAWGAP '): string[] {
374
+ return (d.gaps ?? []).map((g) =>
375
+ `${prefix}${g.a} / ${g.b}: leg depth gap median ${pxLeg(g.median, g.medianLegs)}, max ${pxLeg(g.max, g.maxLegs)}, against the tie of ${g.tie === null ? 'none (a rest length is unread; equal depths only)' : pxLeg(g.tie, g.tieLegs)}; ${g.passed} of ${g.frames} frame(s) passed it`.slice(0, DRAW_ORDER_LINE_MAX),
376
+ );
377
+ }
package/src/outdir.ts CHANGED
@@ -14,7 +14,7 @@ export type OutDirs = Map<string, string[]>;
14
14
 
15
15
  const writable = (dir: string): boolean => {
16
16
  try {
17
- accessSync(dir, constants.W_OK);
17
+ accessSync(dir, constants.W_OK | constants.X_OK);
18
18
  return true;
19
19
  } catch {
20
20
  return false;
@@ -22,9 +22,9 @@ const writable = (dir: string): boolean => {
22
22
  };
23
23
 
24
24
  /**
25
- * Check where each named output lands: its directory is there, or the nearest existing ancestor
26
- * is a directory this process can write into. A miss is added to `p`; a directory still to make
27
- * is added to `dirs`. A directory a flag names itself (--build, --render) is checked by its parent.
25
+ * Check where each named output lands: its directory is there and this process can write into it
26
+ * (rig-c/pose#323), or the nearest existing ancestor is a directory this process can write into.
27
+ * A miss is added to `p`; a directory still to make is added to `dirs`. A directory a flag names itself (--build, --render) is checked by its parent.
28
28
  */
29
29
  export function planOutDirs(outputs: ReadonlyArray<readonly [string, string | undefined]>, p: Problems, dirs: OutDirs = new Map()): OutDirs {
30
30
  for (const [flag, path] of outputs) {
@@ -33,7 +33,7 @@ export function planOutDirs(outputs: ReadonlyArray<readonly [string, string | un
33
33
  let up = dir;
34
34
  while (!existsSync(up) && dirname(up) !== up) up = dirname(up);
35
35
  if (!statSync(up).isDirectory()) p.add('ARG_PATH', up === dir ? `${flag} ${path} — ${dir} is not a directory` : `${flag} ${path} — ${up} is a file, so directory ${dir} cannot be made under it`);
36
- else if (up !== dir && !writable(up)) p.add('ARG_PATH', `${flag} ${path} — ${up} is not writable, so directory ${dir} cannot be made under it`);
36
+ else if (!writable(up)) p.add('ARG_PATH', up === dir ? `${flag} ${path} — ${dir} is not writable` : `${flag} ${path} — ${up} is not writable, so directory ${dir} cannot be made under it`);
37
37
  else if (up !== dir) dirs.set(dir, [...(dirs.get(dir) ?? []), `${flag} ${path}`]);
38
38
  }
39
39
  return dirs;
package/src/pelvis.ts CHANGED
@@ -31,6 +31,11 @@
31
31
  * scan runs down to where the colour was found. The first patch that holds
32
32
  * and is not the background is the garment; with none, the midpoint's median
33
33
  * is read as it is.
34
+ * The line is fitted through the band's top edge (rig-c/pose#318): where a
35
+ * strip of the garment is too thin for the colour test (a waistband drawn as
36
+ * two ink lines), the tops step between the strip's lines, and each column
37
+ * is followed up to its topmost garment pixel; an edge that cuts across the
38
+ * band under it (a fold) is declined.
34
39
  *
35
40
  * The forearms are not read: pixels near the segments elbow → wrist (and the
36
41
  * hands) are masked, since a hanging arm passes the hips in a walk. A frame
@@ -76,13 +81,17 @@ export const MAX_RMS = 2.5;
76
81
  export const ARM_RADIUS = 0.15;
77
82
  /** Hips closer than this in the picture, px, have no span to read across (a figure seen side-on). */
78
83
  export const MIN_SPAN = 6;
84
+ /** The band's top is taken above the scan's tops only when their climbs to it differ by more than this many rows (rig-c/pose#318): each end of a clean edge's climb is found to a row. */
85
+ export const CLIMB_SPREAD = 2;
86
+ /** The band's top is climbed against at most this many garment colours (rig-c/pose#318): a paletted garment's own, or a painted one's fullest cells. */
87
+ export const MAX_CLOTH = 16;
79
88
 
80
89
  /**
81
90
  * One frame's reading: the tilt (Spine degrees, r_hip → l_hip), or why it was declined. A waistband
82
91
  * read through the window mean says so (`mean`), and one whose colour was found below the hips says
83
- * how far (`below`, px).
92
+ * how far (`below`, px); one whose band top lay above the colour's edge in some columns says how many (`raised`, #318).
84
93
  */
85
- export type Reading = { tilt: number; cover: number; rms: number; mean?: true; below?: number } | { kind: string; why: string };
94
+ export type Reading = { tilt: number; cover: number; rms: number; mean?: true; below?: number; raised?: number } | { kind: string; why: string };
86
95
 
87
96
  /** The frame as RGB over white, its background colour, and which pixels are masked (forearms, hands). */
88
97
  interface Frame {
@@ -321,6 +330,7 @@ function waistband(f: Frame, c: Pt, span: number, x0: number, x1: number, cols:
321
330
  }
322
331
  }
323
332
  }
333
+ const raised = toBandTop(f, tops, g, top, colourAt);
324
334
  const share = tops.length / cols;
325
335
  if (share < MIN_COVER) {
326
336
  return above > cols / 2 ? { kind: 'garment above the band', why: `the garment runs above the band read (${above} of ${cols} columns carry its colour ${(1.5 * span).toFixed(0)} px above the hips); no waistband edge` } : { kind: 'waistband edge too short', why: `the garment's top edge is found in ${tops.length} of the ${cols} columns between the hips (${(100 * share).toFixed(0)}% < ${100 * MIN_COVER}%)` };
@@ -329,7 +339,197 @@ function waistband(f: Frame, c: Pt, span: number, x0: number, x1: number, cols:
329
339
  if (fit === null) return { kind: 'no line', why: 'the garment\'s top edge does not fit a line (fewer than three columns)' };
330
340
  if (fit.rms > MAX_RMS) return { kind: 'not a straight waistband', why: `the garment's top edge lies ${fit.rms.toFixed(2)} px from its line (root mean square > ${MAX_RMS}); not a straight waistband` };
331
341
  if (Math.abs(Math.atan(fit.m)) * (180 / Math.PI) > 45) return { kind: 'waistband too steep', why: `the garment's top edge runs ${(Math.atan(fit.m) * (180 / Math.PI)).toFixed(1)}° from level; a waistband is within 45°` };
332
- return { tilt: tiltOf(fit.m, rHip, lHip), cover: share, rms: fit.rms, ...(isMean ? { mean: true as const } : {}), ...(below > 0 ? { below } : {}) };
342
+ const off = notTheBand(f, fit, tops, top, colourAt);
343
+ if (off !== null) return off;
344
+ return { tilt: tiltOf(fit.m, rHip, lHip), cover: share, rms: fit.rms, ...(isMean ? { mean: true as const } : {}), ...(below > 0 ? { below } : {}), ...(raised > 0 ? { raised } : {}) };
345
+ }
346
+
347
+ /**
348
+ * rig-c/pose#318: the band's top edge, the topmost garment boundary in each column. The three-row test misses garment
349
+ * above the row it finds when a strip of it is too thin to carry the colour three rows deep (through the window mean,
350
+ * a strip between two ink lines never does), so the tops step between a strip's top line and the cloth under its lower
351
+ * line. The garment's colours are the picture's own: the pixels of three rows in each column `DITHER_RADIUS + 1` below
352
+ * the row the scan found, clear of the edge's blend (an outline there would lend the colour above to the garment). The
353
+ * thing above has one colour, `a`: the median, over the columns read, of the rows from the scan's top down to
354
+ * `3 · (DITHER_RADIUS + 1)` above the topmost top — clear of the edge's blend, an outline and a window, and by area, so
355
+ * a line crossing it or a tilted band's outline in a few of its rows does not move it. From each top
356
+ * the column is followed up, pixel by pixel, while a pixel is no nearer to `a` than to the nearest garment pixel (cloth
357
+ * of any shade, and the ink lines on it, are); a climb that reaches the scan's top or a forearm does not see the top.
358
+ * The band's top is the topmost cloth pixel on the path (within `GARMENT_DIFF` of a garment pixel), from where the climb
359
+ * stopped down to the scan's three rows: an outline above it, or a line leaving the band upward, is ink, not garment;
360
+ * a thin strip's cloth between its two lines is garment. A clean edge climbs
361
+ * about the same rows in every column — the distance between two boundaries each found to a row — so only when the
362
+ * climbs differ by more than `CLIMB_SPREAD` rows (90th less 10th percentile) do the columns take their topmost garment
363
+ * row — and a column whose top was not seen is then not read, since its scan top is the lower level; otherwise the
364
+ * tops stay exactly as read. Nothing is done when the colour above is the garment's (its median
365
+ * within `GARMENT_DIFF`). Rewrites `tops` in place; returns how many moved.
366
+ */
367
+ function toBandTop(f: Frame, tops: Pt[], g: RGB, top: number, colourAt: (x: number, y: number) => RGB | null): number {
368
+ if (tops.length === 0) return 0;
369
+ const lift = DITHER_RADIUS + 1;
370
+ const ya = Math.min(...tops.map((p) => p[1])) - 3 * lift;
371
+ if (ya < top) return 0;
372
+ // The garment's colours, at most `MAX_CLOTH`: a paletted garment's few, as they are; a painted one's many, as the mean
373
+ // colours of the `MAX_CLOTH` fullest cells of a `GARMENT_DIFF / 2` grid (first filled first among equals). The climb
374
+ // compares each pixel against them all, so their number is bounded whatever the span.
375
+ const exact = new Map<string, RGB>();
376
+ const cells = new Map<string, { s: RGB; n: number }>();
377
+ const step = GARMENT_DIFF / 2;
378
+ for (const [x, y0] of tops) {
379
+ for (let k = 0; k < 3; k++) {
380
+ const y = y0 + lift + k;
381
+ if (y < 0 || y >= f.h || f.masked(x, y)) continue;
382
+ const p = f.rgb(x, y);
383
+ if (exact.size <= MAX_CLOTH) exact.set(p.join(','), p);
384
+ const key = p.map((v) => Math.floor(v / step)).join(',');
385
+ const c = cells.get(key);
386
+ if (c === undefined) cells.set(key, { s: [p[0], p[1], p[2]], n: 1 });
387
+ else {
388
+ c.s[0] += p[0];
389
+ c.s[1] += p[1];
390
+ c.s[2] += p[2];
391
+ c.n++;
392
+ }
393
+ }
394
+ }
395
+ const cloth: RGB[] = exact.size <= MAX_CLOTH ? [...exact.values()] : [...cells.values()].sort((u, v) => v.n - u.n).slice(0, MAX_CLOTH).map((c) => [c.s[0] / c.n, c.s[1] / c.n, c.s[2] / c.n]);
396
+ if (cloth.length === 0) return 0;
397
+ // The colour above: the median of the region, channel by channel, read twice over it rather than held.
398
+ const a = regionMedian((visit) => {
399
+ for (const [x] of tops) {
400
+ for (let y = top; y <= ya; y++) {
401
+ const p = f.masked(x, y) ? null : colourAt(x, y);
402
+ if (p !== null) visit(p);
403
+ }
404
+ }
405
+ });
406
+ if (a === null) return 0;
407
+ if (diff(a, g) <= GARMENT_DIFF) return 0;
408
+ const nearest = (p: RGB, cs: RGB[]) => Math.min(...cs.map((q) => diff(p, q)));
409
+ const climbs = tops.map(([x, y0]) => {
410
+ let y = y0;
411
+ while (true) {
412
+ if (y - 1 < top || f.masked(x, y - 1)) return null;
413
+ const p = f.rgb(x, y - 1);
414
+ if (diff(p, a) < nearest(p, cloth)) break;
415
+ y--;
416
+ }
417
+ // The band's top: the topmost cloth pixel on the path, from where the climb stopped down to the scan's three rows.
418
+ for (let v = y; v <= y0 + 2; v++) if (v < f.h && nearest(f.rgb(x, v), cloth) <= GARMENT_DIFF) return y0 - v;
419
+ return null;
420
+ });
421
+ const ks = climbs.filter((k): k is number => k !== null).sort((p, q) => p - q);
422
+ if (ks.length === 0) return 0;
423
+ const rank = (q: number) => ks[Math.min(ks.length - 1, Math.max(0, Math.ceil(q * ks.length) - 1))]!;
424
+ if (rank(0.9) - rank(0.1) <= CLIMB_SPREAD) return 0;
425
+ let moved = 0;
426
+ const read = tops.flatMap((t, i): Pt[] => {
427
+ const k = climbs[i];
428
+ if (k === null || k === undefined) return [];
429
+ if (k !== 0) moved++;
430
+ return [[t[0], t[1] - k]];
431
+ });
432
+ tops.splice(0, tops.length, ...read);
433
+ return moved;
434
+ }
435
+
436
+ /**
437
+ * The median, channel by channel, of the colours a region yields (`median`'s value: the middle one, or the mean of the
438
+ * two middle ones), without holding them: a first pass counts each channel's values by whole step (0–255), which names
439
+ * the step the middle rank falls in; a second keeps only the values in that step, as distinct values and counts. Memory
440
+ * is the 256 counts and the distinct values of one step. Null for an empty region.
441
+ */
442
+ function regionMedian(each: (visit: (p: RGB) => void) => void): RGB | null {
443
+ const bin = (v: number) => Math.max(0, Math.min(255, Math.floor(v)));
444
+ const hist = [new Float64Array(256), new Float64Array(256), new Float64Array(256)];
445
+ let n = 0;
446
+ each((p) => {
447
+ n++;
448
+ for (let k = 0; k < 3; k++) hist[k]![bin(p[k]!)]!++;
449
+ });
450
+ if (n === 0) return null;
451
+ const m = n >> 1;
452
+ const ranks = n % 2 === 1 ? [m] : [m - 1, m];
453
+ // For each channel and rank: the step it falls in and its rank within the step.
454
+ const want = [0, 1, 2].map((k) => ranks.map((r) => {
455
+ let below = 0;
456
+ for (let b = 0; b < 256; b++) {
457
+ const c = hist[k]![b]!;
458
+ if (r < below + c) return { b, r: r - below };
459
+ below += c;
460
+ }
461
+ throw new Error('unreachable: a rank beyond the count');
462
+ }));
463
+ const kept = [0, 1, 2].map(() => new Map<number, Map<number, number>>());
464
+ for (let k = 0; k < 3; k++) for (const w of want[k]!) kept[k]!.set(w.b, new Map());
465
+ each((p) => {
466
+ for (let k = 0; k < 3; k++) {
467
+ const vs = kept[k]!.get(bin(p[k]!));
468
+ if (vs !== undefined) vs.set(p[k]!, (vs.get(p[k]!) ?? 0) + 1);
469
+ }
470
+ });
471
+ const at = (k: number, w: { b: number; r: number }) => {
472
+ let below = 0;
473
+ for (const [v, c] of [...kept[k]!.get(w.b)!.entries()].sort((u, z) => u[0] - z[0])) {
474
+ if (w.r < below + c) return v;
475
+ below += c;
476
+ }
477
+ throw new Error('unreachable: a rank beyond the step');
478
+ };
479
+ return [0, 1, 2].map((k) => {
480
+ const vs = want[k]!.map((w) => at(k, w));
481
+ return vs.length === 1 ? vs[0]! : (vs[0]! + vs[1]!) / 2;
482
+ }) as RGB;
483
+ }
484
+
485
+ /**
486
+ * rig-c/pose#318: the edge the waistband reader fitted is declined when it cuts across the band instead of running
487
+ * along it — a fold's edge under a band the colour test passed over. Above the edge, in each column read, the colour
488
+ * `DITHER_RADIUS + 1` rows up (past the outline and the window's blend) is followed up while it holds within
489
+ * `GARMENT_DIFF`; where it stops is the band's top. When at least `MIN_COVER` of the columns read show such a band (not
490
+ * the ground, not open up to the scan's top), its top is fitted as a line. The two lines bound a strip of thickness
491
+ * `ta` and `tb` at the read columns' ends. A line inside a band of thickness t across a width W departs from the band's
492
+ * direction by at most atan(t / W); an edge whose strip more than doubles across the hips (|ta − tb| > min(ta, tb)), or
493
+ * crosses the band's top, cuts across the garment instead of running along the band.
494
+ *
495
+ * Null when the edge is the band's (or nothing above it is a band to hold it to).
496
+ */
497
+ function notTheBand(f: Frame, fit: { m: number; q: number }, tops: Pt[], top: number, colourAt: (x: number, y: number) => RGB | null): Reading | null {
498
+ const edgeAt = (x: number) => fit.m * x + fit.q;
499
+ const bandTops: Pt[] = [];
500
+ for (const [x, y] of tops) {
501
+ const s = y - (DITHER_RADIUS + 1);
502
+ if (s < top || f.masked(x, s)) continue;
503
+ const c0 = colourAt(x, s);
504
+ if (c0 === null || diff(c0, f.bg) <= FIGURE_DIFF) continue;
505
+ let v = s;
506
+ let closed = false;
507
+ while (v - 1 >= top) {
508
+ if (f.masked(x, v - 1)) break;
509
+ const p = colourAt(x, v - 1);
510
+ if (p === null) break;
511
+ if (diff(p, c0) > GARMENT_DIFF) {
512
+ closed = true;
513
+ break;
514
+ }
515
+ v--;
516
+ }
517
+ if (closed) bandTops.push([x, v]);
518
+ }
519
+ if (bandTops.length < MIN_COVER * tops.length) return null;
520
+ const band = fitLine(bandTops);
521
+ if (band === null || band.rms > MAX_RMS) return null;
522
+ const xa = Math.min(...tops.map((p) => p[0]));
523
+ const xb = Math.max(...tops.map((p) => p[0]));
524
+ const ta = edgeAt(xa) - (band.m * xa + band.q);
525
+ const tb = edgeAt(xb) - (band.m * xb + band.q);
526
+ if (Math.abs(ta - tb) <= Math.min(ta, tb)) return null;
527
+ const deg = (m: number) => Math.atan(m) * (180 / Math.PI);
528
+ const allow = Math.max(0, Math.min(ta, tb));
529
+ return {
530
+ kind: 'edge leaves the band',
531
+ why: `the garment's edge fitted across the hips runs ${Math.abs(deg(fit.m) - deg(band.m)).toFixed(1)}° from the top of the band above it (${bandTops.length} of ${tops.length} columns; the band ${ta.toFixed(1)} px thick at x ${xa} and ${tb.toFixed(1)} px at x ${xb}); an edge inside a band ${allow.toFixed(1)} px thick at its thinnest departs from it by at most ${deg(allow / (xb - xa)).toFixed(1)}° across the ${xb - xa} px read, so this edge cuts across the garment (a fold), not along the band`,
532
+ };
333
533
  }
334
534
 
335
535
  /**
@@ -420,6 +620,8 @@ function viaNote(readings: Reading[]): string {
420
620
  const parts: string[] = [];
421
621
  if (mean > 0) parts.push(`${mean} through the ${2 * DITHER_RADIUS + 1}×${2 * DITHER_RADIUS + 1} window mean (the garment's colour does not hold pixel by pixel: a dithered or paletted picture)`);
422
622
  if (below > 0) parts.push(`${below} with the colour found below the hips (up to ${Math.max(...ok.map((r) => r.below ?? 0)).toFixed(1)} px; the hips on the band's edge)`);
623
+ const raised = ok.filter((r) => r.raised !== undefined);
624
+ if (raised.length > 0) parts.push(`${raised.length} with the band's top read above the colour's edge in some columns (up to ${Math.max(...raised.map((r) => r.raised ?? 0))} columns: a strip too thin to carry the colour, read as its top line)`);
423
625
  return parts.length === 0 ? '' : `; of the frames read, ${parts.join(', ')}`;
424
626
  }
425
627
 
package/src/reduce.ts CHANGED
@@ -80,6 +80,12 @@ export interface Reduced {
80
80
  rows: ReduceRow[];
81
81
  /** Each bone's world-aim error on every frame (reduced less dense, degrees), as the poser evaluates the reduced keys. */
82
82
  errors: Map<string, number[]>;
83
+ /**
84
+ * rig-c/pose#320: each trunk root whose smoothing (`smoothTrunk`, #116) was not kept because it left a bone it
85
+ * manages past that bone's own tolerance — the bone, its smoothed worst error and its tolerance (degrees); the
86
+ * fitted keys stand. Absent when every smoothing was kept.
87
+ */
88
+ unsmoothed?: Array<{ root: string; bone: string; worst: number; tolerance: number }>;
83
89
  }
84
90
 
85
91
  // ---------------------------------------------------------------------------
@@ -607,7 +613,9 @@ function solveLimbPreservingJitter(
607
613
  * above the sequence's own. Optimizing knot values and handle y-coordinates
608
614
  * jointly within the ±tolerance world-aim tube removes those C0 breaks while
609
615
  * keeping any limb chains hanging from the trunk within 0.04 of their landing
610
- * and jitter.
616
+ * and jitter. The tube's bounds are a penalty, not a constraint: a root whose
617
+ * smoothing leaves any bone it manages past that bone's own tolerance keeps its
618
+ * fitted keys instead, recorded in `out.unsmoothed` (rig-c/pose#320).
611
619
  */
612
620
  export function smoothTrunk(
613
621
  times: readonly number[],
@@ -737,6 +745,16 @@ export function smoothTrunk(
737
745
  }
738
746
  };
739
747
 
748
+ // rig-c/pose#320: the smoothing is kept only within every managed bone's own tolerance; past it, the fitted keys
749
+ // (within it by construction) stand, and the run says so.
750
+ const past = allManaged.map((b) => {
751
+ const e = b === root ? eRoot : eSub.get(b)!;
752
+ return { b, worst: Math.max(...e.map(Math.abs)) };
753
+ }).find((x) => x.worst > tolOf(x.b));
754
+ if (past !== undefined) {
755
+ (out.unsmoothed ??= []).push({ root, bone: past.b, worst: past.worst, tolerance: tolOf(past.b) });
756
+ continue;
757
+ }
740
758
  updateBone(root, smRoot.keys, eRoot);
741
759
  for (const b of [...headBones, ...arm0Bones, ...arm1Bones]) {
742
760
  updateBone(b, smSub.get(b)!.keys, eSub.get(b)!);
package/src/run.ts CHANGED
@@ -11,7 +11,7 @@ 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
+ import { drawGapLines, drawOrderFromDepth, drawOrderKeys, drawOrderLines, type DrawOrderReading } from './draworder.ts';
15
15
  import { convertCoco17, personTrack, readPoseSequence, TRAVEL_IN_PLACE, type CameraFrame } from './pose.ts';
16
16
  import { isObject, lineOf, parseJson, Problems, Refusal, type Problem } from './problems.ts';
17
17
  import { boneIdentity, droppedLine, keptOutLine, mergeProblems, readFragment, readIdle, targetOf, trackLabel, type IdleReading, type RigcTrack, type Target } from './idle.ts';
@@ -134,7 +134,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
134
134
  const mapSnapshot = readReportInput(a.map), poseSnapshot = readReportInput(a.pose);
135
135
  const map = readMap(mapSnapshot.bytes.toString('utf8'), a.map);
136
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);
137
+ let reduceOf = a.reduce === undefined ? null : reduceTolerances(map, a.reduce, a.reduceChains, a.map);
138
138
  const poseText = poseSnapshot.bytes.toString('utf8');
139
139
  const seq = a.coco ? convertCoco17(poseText, a.pose) : readPoseSequence(poseText, a.pose);
140
140
  if (seq.cleaned === undefined) refuse([{ code: 'POSE_NOT_CLEANED', text: notCleaned(a.pose, a.coco) }]);
@@ -229,7 +229,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
229
229
  if (layers !== null) say(` ${depthLine(layers, seq.frames, seq.cleaned!.params.depth?.gait ?? null)}`);
230
230
  // rig-c/pose#304: with --draw-order depth, the legs' draw order keyed from those layers.
231
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}`);
232
+ if (drawOrder !== null) for (const l of [...drawOrderLines(drawOrder), ...drawGapLines(drawOrder)]) say(` ${l}`);
233
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`);
234
234
  if (r.feet !== null) {
235
235
  const f = r.feet;
@@ -256,14 +256,23 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
256
256
  if (r.face !== null) say(` FACE ${faceLine(r.face)}`);
257
257
  if (r.body !== null) say(` BODY ${bodyLine(r.body)}`);
258
258
 
259
+ // #320: a mapped bone above a planted or aimed chain --reduce names is held to that chain's tolerance when its own is looser.
260
+ const held = reduceOf === null || a.reduceChains === undefined ? [] : holdParents(map, rest, new Set(r.targets.keys()), a.reduceChains, reduceOf, r.ankles === null ? [] : [r.ankles.bones.r, r.ankles.bones.l]);
261
+ if (held.length > 0) {
262
+ const first = reduceOf!;
263
+ reduceOf = (bone) => held.find((h) => h.bone === bone)?.to ?? first(bone);
264
+ for (const h of held) say(` REDUCE ${heldLine(h)}`);
265
+ }
259
266
  // --reduce (#32): the dense keys thinned top-down, before anything reads the tracks.
260
267
  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!);
261
268
  if (reduced !== null) {
262
269
  for (const row of reduced.rows) {
263
270
  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'];
264
- const own = row.kind === 'angle' && reduceOf!(row.bone) !== a.reduce ? ` — within its chain's ${reduceOf!(row.bone)}°` : '';
271
+ const hold = held.find((h) => h.bone === row.bone && row.kind === 'angle');
272
+ const own = row.kind === 'angle' && reduceOf!(row.bone) !== a.reduce ? (hold === undefined ? ` — within its chain's ${reduceOf!(row.bone)}°` : ` — held to ${hold.to}° for ${hold.for.map((c) => `chain "${c.chain}"`).join(', ')} below it`) : '';
265
273
  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}`);
266
274
  }
275
+ for (const u of reduced.unsmoothed ?? []) say(` REDUCE ${unsmoothedLine(u)}`);
267
276
  const sum = (kind: ReduceRow['kind'], k: 'before' | 'after') => reduced.rows.filter((x) => x.kind === kind).reduce((t, x) => t + x[k], 0);
268
277
  const scales = reduced.rows.some((x) => x.kind === 'scale') ? `; scalex keys ${sum('scale', 'before')} → ${sum('scale', 'after')} within ${SCALE_REDUCE} of the setup length` : '';
269
278
  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)' : ''}`);
@@ -477,14 +486,14 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
477
486
  // Only when the sequence carries depth (#281): a sequence without it writes the report it always has.
478
487
  ...(layers !== null ? { layers: layers.map((l, frame) => ({ frame, order: l.order, depth: l.depth })) } : {}),
479
488
  // Only with --draw-order depth (#304).
480
- ...(drawOrder !== null ? { drawOrder: { source: 'depth', pairs: drawOrder.pairs, keys: drawOrder.keys, spans: drawOrder.spans } } : {}),
489
+ ...(drawOrder !== null ? { drawOrder: { source: 'depth', pairs: drawOrder.pairs, keys: drawOrder.keys, spans: drawOrder.spans, gaps: drawOrder.gaps ?? [] } } : {}),
481
490
  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 },
482
491
  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 },
483
492
  shear,
484
493
  idleDropped: idle === null ? null : idle.dropped,
485
494
  idleKeptOut: idle === null ? null : idle.keptOut,
486
495
  merged: fragment.map((tr) => ({ track: trackLabel(tr, groups), keys: tr.keys.length })),
487
- reduce: reduced === null ? null : { tolerance: a.reduce, ...(a.reduceChains === undefined ? {} : { chains: a.reduceChains }), hipTolerance: a.reduceHip ?? null, rows: reduced.rows, ankleDrift: drift },
496
+ reduce: reduced === null ? null : { tolerance: a.reduce, ...(a.reduceChains === undefined ? {} : { chains: a.reduceChains }), ...(held.length === 0 ? {} : { held }), ...(reduced.unsmoothed === undefined ? {} : { unsmoothed: reduced.unsmoothed }), hipTolerance: a.reduceHip ?? null, rows: reduced.rows, ankleDrift: drift },
488
497
  }, 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});
489
498
  if (a.build !== undefined) {
490
499
  // Built again where it will live, not copied: rigc's page paths are relative to the build (#18).
@@ -1375,6 +1384,80 @@ export function reduceTolerances(map: JointMap, tolerance: number, chains: Recor
1375
1384
  return (bone) => by.get(bone) ?? tolerance;
1376
1385
  }
1377
1386
 
1387
+ /** rig-c/pose#320: a mapped bone held to the tolerance of a planted or aimed chain below it. */
1388
+ export interface ReduceHeld {
1389
+ /** The held bone, and the map chain it is a bone of (null for a mapped bone no chain lists). */
1390
+ bone: string;
1391
+ chain: string | null;
1392
+ /** Its tolerance as --reduce gave it (the run's, or its own chain's), degrees. */
1393
+ from: number;
1394
+ /** The tolerance it is held to: the tightest planted or aimed chain --reduce names below it, degrees. */
1395
+ to: number;
1396
+ /**
1397
+ * Each such chain below it reduced within `to`, in the map's order: its name, what holds it (`ankle`: it carries
1398
+ * a planted ankle's landing; `aim`: the map aims it), its first bone, and the lever — that bone's origin's setup
1399
+ * distance from the held bone's origin, rig units.
1400
+ */
1401
+ for: Array<{ chain: string; by: 'ankle' | 'aim'; bone: string; lever: number }>;
1402
+ }
1403
+
1404
+ /**
1405
+ * rig-c/pose#320: every mapped bone above a planted or aimed chain `chains`
1406
+ * names, whose own tolerance (`tolOf`) is looser than that chain's, held to it
1407
+ * — to the tightest such chain when several hang below it. A chain is planted
1408
+ * when it carries an ankle the landing check holds (`ankles`), aimed when the
1409
+ * map gives it an `aim` other than "x".
1410
+ *
1411
+ * The lever. A turn error θ on a bone turns everything under it rigidly: a
1412
+ * chain below has its first bone moved by θ·L (L = `lever`) and its aim turned
1413
+ * by θ. The reduction refits the chain against the turn (`target = dense −
1414
+ * e_above`), so the aim is the chain's own error, but not the move: the ankle
1415
+ * allowance (`footReduce`) sums every segment from the legs' top down — the
1416
+ * held bone's lever among them — times the leg's own degrees. θ·L ≤ θ_leg·L is
1417
+ * θ ≤ θ_leg whatever L is. An aimed chain is refitted, and on the trunk
1418
+ * smoothed (#116), against its parent's error as it stands: held to the
1419
+ * chain's degrees, that error never asks the chain to absorb more than its own
1420
+ * tolerance. A bent chain (an arm, a leg with no ankle check) has only its aim
1421
+ * checked, which the refit holds, so it holds nothing above it. Only a
1422
+ * per-chain entry can be tighter than a bone above it: with none, nothing is held.
1423
+ */
1424
+ export function holdParents(map: JointMap, rest: Rest, mapped: ReadonlySet<string>, chains: Record<string, number>, tolOf: (bone: string) => number, ankles: readonly string[]): ReduceHeld[] {
1425
+ const held: ReduceHeld[] = [];
1426
+ for (const c of map.chains) {
1427
+ const t = Object.hasOwn(chains, c.name) ? chains[c.name] : undefined;
1428
+ const top = c.bones[0];
1429
+ const by = c.bones.some((b) => ankles.includes(b)) ? ('ankle' as const) : c.aim.some((x) => x !== 'x') ? ('aim' as const) : null;
1430
+ if (t === undefined || top === undefined || by === null || !rest.has(top)) continue;
1431
+ const at = rest.get(top)!;
1432
+ for (let p = at.parent; p !== null; p = rest.get(p)?.parent ?? null) {
1433
+ if (!mapped.has(p) || c.bones.includes(p) || !(tolOf(p) > t)) continue;
1434
+ const was = held.findIndex((h) => h.bone === p);
1435
+ if (was >= 0 && held[was]!.to < t) continue;
1436
+ const x = rest.get(p)!;
1437
+ const child = { chain: c.name, by, bone: top, lever: Number(Math.hypot(at.worldX - x.worldX, at.worldY - x.worldY).toPrecision(6)) };
1438
+ if (was >= 0 && held[was]!.to === t) held[was]!.for.push(child);
1439
+ else if (was >= 0) held[was] = { ...held[was]!, to: t, for: [child] };
1440
+ else held.push({ bone: p, chain: map.chains.find((m) => m.bones.includes(p))?.name ?? null, from: tolOf(p), to: t, for: [child] });
1441
+ }
1442
+ }
1443
+ return held;
1444
+ }
1445
+
1446
+ /** rig-c/pose#320: the REDUCE line naming a held bone, the chains it was held for, and why. */
1447
+ export function heldLine(h: ReduceHeld): string {
1448
+ const below = h.for.map((c) => `chain "${c.chain}" (${c.by === 'ankle' ? 'a planted ankle' : 'aimed'}; L = ${c.lever} units to bone "${c.bone}")`).join(', ');
1449
+ const why = [
1450
+ ...(h.for.some((c) => c.by === 'ankle') ? ['the ankle allowance counts L at the leg\'s own degrees'] : []),
1451
+ ...(h.for.some((c) => c.by === 'aim') ? ['an aimed chain refits against its parent\'s error within its own'] : []),
1452
+ ].join(' and ');
1453
+ return `held: bone "${h.bone}"${h.chain === null ? '' : ` (chain "${h.chain}")`} reduced within ${h.to}°, not ${h.from}° — for ${below} below it, reduced within ${h.to}°: a turn error θ on bone "${h.bone}" moves a chain below it by θ·L and turns it by θ, and ${why}, so θ ≤ ${h.to}°`;
1454
+ }
1455
+
1456
+ /** rig-c/pose#320: the REDUCE line for a trunk smoothing (#116) not kept. */
1457
+ export function unsmoothedLine(u: { root: string; bone: string; worst: number; tolerance: number }): string {
1458
+ return `smoothing (rig-c/pose#116) from bone "${u.root}" down not kept: it left bone "${u.bone}" at ${u.worst.toPrecision(3)}°, past its ${u.tolerance}°; the fitted keys stand, each within its tolerance`;
1459
+ }
1460
+
1378
1461
  /** rig-c/pose#283: the per-chain tolerances as the run and REDUCE lines print them; empty without. */
1379
1462
  export function chainsText(chains: Record<string, number> | undefined): string {
1380
1463
  if (chains === undefined) return '';