@rig-c/pose 0.39.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/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 { drawGapLines, 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
+ let 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), ...drawGapLines(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;
@@ -237,16 +256,26 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
237
256
  if (r.face !== null) say(` FACE ${faceLine(r.face)}`);
238
257
  if (r.body !== null) say(` BODY ${bodyLine(r.body)}`);
239
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
+ }
240
266
  // --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);
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!);
242
268
  if (reduced !== null) {
243
269
  for (const row of reduced.rows) {
244
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'];
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)`);
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`) : '';
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}`);
246
274
  }
275
+ for (const u of reduced.unsmoothed ?? []) say(` REDUCE ${unsmoothedLine(u)}`);
247
276
  const sum = (kind: ReduceRow['kind'], k: 'before' | 'after') => reduced.rows.filter((x) => x.kind === kind).reduce((t, x) => t + x[k], 0);
248
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` : '';
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)' : ''}`);
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)' : ''}`);
250
279
  }
251
280
 
252
281
  // The idle against the new animation (#43 item 2): what it drops, and with --carry-idle, looped back in.
@@ -295,7 +324,9 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
295
324
  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
325
  }
297
326
  const added = [...carried, ...fragment];
298
- const animation = added.length === 0 ? r.animation : { ...r.animation, tracks: [...own, ...added] };
327
+ const tracked = added.length === 0 ? r.animation : { ...r.animation, tracks: [...own, ...added] };
328
+ // #304: rig-c's one draw-order timeline sits on the animation beside its tracks; written only when keyed.
329
+ const animation = drawOrder === null || drawOrder.keys.length === 0 ? tracked : { ...tracked, drawOrder: drawOrderKeys(drawOrder) };
299
330
  const motion = { ...base, animations: { ...animations, [a.name]: animation } };
300
331
  const g1 = gate(input.rig, motion, join(scratch, 'with'), a.profile, a.images);
301
332
  if (!g1.green) {
@@ -314,11 +345,12 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
314
345
  const support = a.reduce !== undefined && a.reduceKeepPlanted === true && r.feet !== null ? supportFrames(r.feet.supports) : undefined;
315
346
  const landings = measureLanding(doc, a.name, map, r, posed, support).map((l) => {
316
347
  if (a.reduce === undefined) return l;
317
- if (l.kind === 'angle') return { ...l, tolerance: l.tolerance + a.reduce };
348
+ // #283: a chain's own tolerance is added as the run's is; the sum is rounded only under a per-chain --reduce.
349
+ if (l.kind === 'angle') return { ...l, tolerance: a.reduceChains === undefined ? l.tolerance + a.reduce : Number((l.tolerance + reduceOf!(l.bone)).toPrecision(12)) };
318
350
  if (l.kind === 'origin' && a.reduceHip !== undefined) return { ...l, tolerance: l.tolerance + a.reduceHip };
319
351
  if (l.kind !== 'foot') return l;
320
352
  const ankle = l.bone.replace(/:tip$/, '');
321
- const by = footReduce(rest, r, ankle, a.reduce, a.reduceHip);
353
+ const by = footReduce(rest, r, ankle, a.reduce, a.reduceHip, a.reduceChains === undefined ? undefined : reduceOf!);
322
354
  // #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
355
  const hip = a.reduceHip ?? 0;
324
356
  if (!drift.some((d) => d.bone === l.bone)) {
@@ -326,7 +358,9 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
326
358
  const side = lb?.r.ankle === ankle ? 'r' : lb?.l.ankle === ankle ? 'l' : null;
327
359
  const legs = (u: number) => (side === null || r.rigLeg === null ? null : u / r.rigLeg[side]);
328
360
  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 });
361
+ // #283: under a per-chain --reduce, the tolerance its leg was reduced within.
362
+ const leg = a.reduceChains === undefined ? {} : { legTolerance: legTolerance(rest, r, ankle, reduceOf!) };
363
+ drift.push({ bone: l.bone, side, plant: l.tolerance, reduce: by, units: l.tolerance + by, legLengths: legs(l.tolerance + by), ...held, ...leg });
330
364
  }
331
365
  return { ...l, tolerance: l.tolerance + (l.on === 'support' ? hip : by) };
332
366
  });
@@ -389,7 +423,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
389
423
 
390
424
  assertReportInputs([rigSnapshot,mapSnapshot,poseSnapshot]);
391
425
  // 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 } : {}) };
426
+ 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
427
  const reportText = writeReport({
394
428
  spec: 'rig-pose-report/1',
395
429
  rigc,
@@ -414,9 +448,12 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
414
448
  seams: seamRead.seams,
415
449
  seamPairs: seamRead.seamPairs,
416
450
  switches: r.switches,
451
+ switchRuns: r.switchRuns,
417
452
  uncovered,
418
453
  turns: r.turns,
419
454
  reach: r.reach,
455
+ // Only when the map has a reach chain (#282): a map without one writes the report it always has.
456
+ ...(r.reachOrigin.length > 0 ? { reachOrigin: r.reachOrigin } : {}),
420
457
  range: r.range,
421
458
  artRange: r.artRange,
422
459
  raised: r.raised,
@@ -448,13 +485,15 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
448
485
  pelvis: r.pelvis,
449
486
  // Only when the sequence carries depth (#281): a sequence without it writes the report it always has.
450
487
  ...(layers !== null ? { layers: layers.map((l, frame) => ({ frame, order: l.order, depth: l.depth })) } : {}),
488
+ // Only with --draw-order depth (#304).
489
+ ...(drawOrder !== null ? { drawOrder: { source: 'depth', pairs: drawOrder.pairs, keys: drawOrder.keys, spans: drawOrder.spans, gaps: drawOrder.gaps ?? [] } } : {}),
451
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 },
452
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 },
453
492
  shear,
454
493
  idleDropped: idle === null ? null : idle.dropped,
455
494
  idleKeptOut: idle === null ? null : idle.keptOut,
456
495
  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 },
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 },
458
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});
459
498
  if (a.build !== undefined) {
460
499
  // Built again where it will live, not copied: rigc's page paths are relative to the build (#18).
@@ -626,7 +665,7 @@ const spanText = (x: [number, number]) => (x[0] === x[1] ? `${x[0]}` : `${x[0]}
626
665
  * aim and length it lands on (`reduceUnderScale`). With none, this is #32's
627
666
  * reduction, unchanged.
628
667
  */
629
- function reduceTracks(r: ReturnType<typeof retarget>, map: JointMap, rest: Rest, tolerance: number, hipTolerance: number | undefined, keepPlanted: boolean): Reduced {
668
+ function reduceTracks(r: ReturnType<typeof retarget>, map: JointMap, rest: Rest, tolerance: number, hipTolerance: number | undefined, keepPlanted: boolean, tolOf: (bone: string) => number = () => tolerance): Reduced {
630
669
  const n = r.times.length;
631
670
  const keep = new Map<string, Set<number>>();
632
671
  const add = (bone: string, ...frames: number[]) => {
@@ -665,7 +704,9 @@ function reduceTracks(r: ReturnType<typeof retarget>, map: JointMap, rest: Rest,
665
704
  return false;
666
705
  };
667
706
  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>() }));
707
+ // #283: a bone whose chain --reduce names carries its own tolerance; every other takes the run's.
708
+ const own = (b: string): { tolerance?: number } => (tolOf(b) === tolerance ? {} : { tolerance: tolOf(b) });
709
+ 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
710
  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
711
  const parent = hipTrack === undefined ? null : rest.get(hipTrack.bone)!.parent;
671
712
  const pm = parent === null ? null : rest.get(parent)!;
@@ -758,6 +799,7 @@ function reduceTracks(r: ReturnType<typeof retarget>, map: JointMap, rest: Rest,
758
799
  asked: r.scales.get(b) ?? new Array<number>(n).fill(1),
759
800
  dense: dense.get(b)!,
760
801
  keep: keepOf(b),
802
+ ...own(b),
761
803
  };
762
804
  });
763
805
  const red = reduceUnderScale(r.times, given, tolerance, SCALE_REDUCE);
@@ -782,7 +824,7 @@ function reduceTracks(r: ReturnType<typeof retarget>, map: JointMap, rest: Rest,
782
824
  * world aim is within the angle tolerance of its dense one, top-down, so the
783
825
  * segments' errors are each bounded and the sum bounds the ankle's.
784
826
  */
785
- export function footReduce(rest: Rest, r: ReturnType<typeof retarget>, ankle: string, tolerance: number, hipTolerance: number | undefined): number {
827
+ export function footReduce(rest: Rest, r: ReturnType<typeof retarget>, ankle: string, tolerance: number, hipTolerance: number | undefined, tolOf?: (bone: string) => number): number {
786
828
  const top = r.origin?.bone ?? null;
787
829
  let sMax = 1;
788
830
  for (const s of r.scales.values()) for (const v of s) sMax = Math.max(sMax, v);
@@ -794,7 +836,8 @@ export function footReduce(rest: Rest, r: ReturnType<typeof retarget>, ankle: st
794
836
  if (p === null) break;
795
837
  lever += Math.hypot(x.worldX - p.worldX, x.worldY - p.worldY);
796
838
  }
797
- return (hipTolerance ?? 0) + lever * sMax * ((tolerance * Math.PI) / 180 + SCALE_REDUCE);
839
+ const deg = tolOf === undefined ? tolerance : legTolerance(rest, r, ankle, tolOf);
840
+ return (hipTolerance ?? 0) + lever * sMax * ((deg * Math.PI) / 180 + SCALE_REDUCE);
798
841
  }
799
842
 
800
843
  function spans(ts: number[], step: number): string {
@@ -1290,6 +1333,8 @@ export interface AnkleDrift {
1290
1333
  * Absent without the flag.
1291
1334
  */
1292
1335
  support?: { reduce: number; units: number; legLengths: number | null } | null;
1336
+ /** rig-c/pose#283: under a per-chain --reduce, the largest tolerance a bone from the hip down to it was reduced within (degrees). */
1337
+ legTolerance?: number;
1293
1338
  }
1294
1339
 
1295
1340
  /** #251: each side's support frames, from the spans --plant-feet read. */
@@ -1301,11 +1346,120 @@ function supportFrames(supports: ReadonlyArray<{ side: 'r' | 'l'; frames: [numbe
1301
1346
 
1302
1347
  /** The drift line: the allowance on one frame, and what it lets a planted ankle slide between two. */
1303
1348
  export function ankleDriftLine(d: AnkleDrift): string {
1349
+ const chainDeg = d.legTolerance;
1304
1350
  const legs = (v: number) => (d.legLengths === null ? '' : ` = ${(d.legLengths * v / d.units).toPrecision(3)} leg lengths`);
1305
1351
  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`;
1352
+ 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
1353
  if (d.support === null) return `${line}; its side never supports, so --reduce-keep-planted keeps no frame of it`;
1308
1354
  const s = d.support;
1309
1355
  const sLegs = (v: number) => (s.legLengths === null ? '' : ` = ${(s.legLengths * v / s.units).toPrecision(3)} leg lengths`);
1310
1356
  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
1357
  }
1358
+
1359
+ /** rig-c/pose#283: the largest tolerance any bone from `ankle` up to the legs' top was reduced within. */
1360
+ export function legTolerance(rest: Rest, r: ReturnType<typeof retarget>, ankle: string, tolOf: (bone: string) => number): number {
1361
+ const top = r.origin?.bone ?? null;
1362
+ let deg = tolOf(ankle);
1363
+ for (let b: string | null = rest.get(ankle)!.parent; b !== null && b !== top; b = rest.get(b)!.parent) deg = Math.max(deg, tolOf(b));
1364
+ return deg;
1365
+ }
1366
+
1367
+ /**
1368
+ * rig-c/pose#283: the reduction's tolerance per bone. Every bone takes `tolerance`
1369
+ * but a bone of a chain `chains` names, which takes that chain's (the smallest,
1370
+ * when two named chains list it). A name the map has no chain for is refused by
1371
+ * name, beside the chains it has.
1372
+ */
1373
+ export function reduceTolerances(map: JointMap, tolerance: number, chains: Record<string, number> | undefined, mapPath: string): (bone: string) => number {
1374
+ if (chains === undefined) return () => tolerance;
1375
+ const names = map.chains.map((c) => c.name);
1376
+ const missing = Object.keys(chains).filter((c) => !names.includes(c));
1377
+ 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(', ')}` })));
1378
+ const by = new Map<string, number>();
1379
+ for (const c of map.chains) {
1380
+ const t = Object.hasOwn(chains, c.name) ? chains[c.name] : undefined;
1381
+ if (t === undefined) continue;
1382
+ for (const b of c.bones) by.set(b, Math.min(by.get(b) ?? t, t));
1383
+ }
1384
+ return (bone) => by.get(bone) ?? tolerance;
1385
+ }
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
+
1461
+ /** rig-c/pose#283: the per-chain tolerances as the run and REDUCE lines print them; empty without. */
1462
+ export function chainsText(chains: Record<string, number> | undefined): string {
1463
+ if (chains === undefined) return '';
1464
+ return ` (${Object.entries(chains).map(([c, t]) => `chain "${c}" ${t}°`).join(', ')})`;
1465
+ }
package/src/switch.ts CHANGED
@@ -180,8 +180,28 @@ export interface Uncovered {
180
180
  times: number[];
181
181
  }
182
182
 
183
+ /** One run of frames (rig-c/pose#279): first and last frame, how many, and the seconds the minimum span measured it by (first frame's time to the next frame's, the animation's end for the last). */
184
+ export interface FrameRun {
185
+ frames: [number, number];
186
+ count: number;
187
+ seconds: number;
188
+ }
189
+
190
+ /**
191
+ * rig-c/pose#279: per set selected by angle (not `phase`), the runs of frames on which it was the nearest set within
192
+ * the tolerance (inside its `frames` window, before bridging, hysteresis and the minimum span), and the runs the
193
+ * minimum span dropped — shorter than `switchMin` where they stood — on frames the set did not show in the end.
194
+ */
195
+ export interface SetRuns {
196
+ set: string;
197
+ nearest: FrameRun[];
198
+ suppressed: FrameRun[];
199
+ }
200
+
183
201
  export interface Switches {
184
202
  switches: Switch[];
203
+ /** rig-c/pose#279: one per set without a `phase`, in the map's order. */
204
+ runs: SetRuns[];
185
205
  /** The `attachment` tracks, one per slot whose picture changes. */
186
206
  slotTracks: SlotTrack[];
187
207
  /** The offsets' `translate` tracks, one per bone a shown set offsets. */
@@ -490,6 +510,11 @@ export function selectSwitches(map: JointMap, targets: ReadonlyMap<string, reado
490
510
  const distances = setDistances(map, targets, n, opts.shortInfo);
491
511
  const hyst = opts.switchHysteresis ?? SWITCH_LEAVE_HYSTERESIS;
492
512
  const switches: Switch[] = [];
513
+ // rig-c/pose#279: per angle set, the frames it was nearest on and the frames the minimum span dropped it from.
514
+ const nearestOn = new Map<string, boolean[]>();
515
+ const droppedOn = new Map<string, boolean[]>();
516
+ const runOf = (i: number, j: number): FrameRun => ({ frames: [i, j], count: j - i + 1, seconds: r3((j + 1 < n ? times[j + 1]! : duration) - times[i]!) });
517
+ const runsOfFlags = (flag: readonly boolean[]): FrameRun[] => runsOf(flag, 0).map(([i, j]) => runOf(i, j));
493
518
  // rig-c/pose#286: the supporting side on each frame, from the report's support spans (null: no foot, or no set declares a phase).
494
519
  const supportAt: Array<Side | null> = Array.from({ length: n }, () => null);
495
520
  for (const sp of opts.phaseSupports ?? []) for (let i = sp.frames[0]; i <= Math.min(sp.frames[1], n - 1); i++) supportAt[i] = sp.side;
@@ -546,6 +571,10 @@ export function selectSwitches(map: JointMap, targets: ReadonlyMap<string, reado
546
571
  }
547
572
  pick.push(best);
548
573
  }
574
+ for (const a of group) {
575
+ nearestOn.set(a.name, pick.map((p) => p === a));
576
+ droppedOn.set(a.name, Array.from({ length: n }, () => false));
577
+ }
549
578
  const bridgeSame = (arr: Array<AlternateSet | null>) => {
550
579
  for (let i = 0; i < n;) {
551
580
  const a = arr[i];
@@ -604,7 +633,13 @@ export function selectSwitches(map: JointMap, targets: ReadonlyMap<string, reado
604
633
  let j = i;
605
634
  while (j + 1 < n && pick[j + 1] === a) j++;
606
635
  const to = j + 1 < n ? times[j + 1]! : duration;
607
- if (a !== null && a !== undefined && to - times[i]! < opts.switchMin - 1e-9) for (let k = i; k <= j; k++) pick[k] = null;
636
+ if (a !== null && a !== undefined && to - times[i]! < opts.switchMin - 1e-9) {
637
+ const dropped = droppedOn.get(a.name)!;
638
+ for (let k = i; k <= j; k++) {
639
+ pick[k] = null;
640
+ dropped[k] = true;
641
+ }
642
+ }
608
643
  i = j + 1;
609
644
  }
610
645
  if (hyst > 0) {
@@ -658,8 +693,14 @@ export function selectSwitches(map: JointMap, targets: ReadonlyMap<string, reado
658
693
  }
659
694
  if (dropped) bridgeSame(pick);
660
695
  }
696
+ // rig-c/pose#279: a dropped frame the set came back to (hysteresis, bridging) was not suppressed.
697
+ for (const a of group) {
698
+ const dropped = droppedOn.get(a.name)!;
699
+ for (let k = 0; k < n; k++) if (pick[k] === a) dropped[k] = false;
700
+ }
661
701
  spans(pick, opts);
662
702
  }
703
+ const runs: SetRuns[] = map.alternates.filter((a) => nearestOn.has(a.name)).map((a) => ({ set: a.name, nearest: runsOfFlags(nearestOn.get(a.name)!), suppressed: runsOfFlags(droppedOn.get(a.name)!) }));
663
704
  const order = (name: string) => map.alternates.findIndex((a) => a.name === name);
664
705
  switches.sort((x, y) => x.frames[0] - y.frames[0] || order(x.set) - order(y.set));
665
706
 
@@ -1094,7 +1135,7 @@ export function selectSwitches(map: JointMap, targets: ReadonlyMap<string, reado
1094
1135
  for (let k = 0; k + 1 < keys.length; k++) keys[k]!.ease = 'stepped';
1095
1136
  offsetTracks.push({ bone: b, property: 'translate', keys });
1096
1137
  }
1097
- return { switches, slotTracks, offsetTracks, shown, distances, chainHolds };
1138
+ return { switches, runs, slotTracks, offsetTracks, shown, distances, chainHolds };
1098
1139
  }
1099
1140
 
1100
1141
  /** Per chain, the frames on which a switch of a set depicting it shows: its art was painted for that pose. */
@@ -1281,6 +1322,27 @@ export function phaseLines(map: JointMap, switches: readonly Switch[]): string[]
1281
1322
  return out;
1282
1323
  }
1283
1324
 
1325
+ /** How many runs a `SWITCH-MIN` line lists before it points at the report for the rest. */
1326
+ export const SWITCH_MIN_LISTED = 4;
1327
+
1328
+ /**
1329
+ * rig-c/pose#279: one `SWITCH-MIN` line per set the minimum span held back — the runs it dropped (frames, count,
1330
+ * seconds), and the set's nearest runs (how many, and the longest) — so a set that matched but was held back reads
1331
+ * apart from one that never matched. A set with nothing suppressed prints no line.
1332
+ */
1333
+ export function switchMinLines(runs: readonly SetRuns[], switchMin: number): string[] {
1334
+ const runText = (r: FrameRun) => `${r.count === 1 ? `frame ${r.frames[0]}` : `frames ${r.frames[0]}–${r.frames[1]}`} (${r.count} frame${r.count === 1 ? '' : 's'}, ${r.seconds.toFixed(3)}s)`;
1335
+ const out: string[] = [];
1336
+ for (const s of runs) {
1337
+ if (s.suppressed.length === 0) continue;
1338
+ const listed = s.suppressed.slice(0, SWITCH_MIN_LISTED).map(runText).join(', ');
1339
+ const more = s.suppressed.length > SWITCH_MIN_LISTED ? `, and ${s.suppressed.length - SWITCH_MIN_LISTED} more (the report's switchRuns lists every one)` : '';
1340
+ const longest = s.nearest.reduce((m, r) => (r.count > m.count ? r : m), s.nearest[0]!);
1341
+ out.push(`set "${s.set}": ${s.suppressed.length} run(s) shorter than --switch-min ${switchMin}s not shown: ${listed}${more}; nearest on ${s.nearest.length} run(s), the longest ${runText(longest)}`);
1342
+ }
1343
+ return out;
1344
+ }
1345
+
1284
1346
  const spd = (v: number) => `${v.toFixed(1)}°/frame`;
1285
1347
  const win = (w: [number, number]) => (w[0] === w[1] ? `window frame ${w[0]}` : `window frames ${w[0]}–${w[1]}`);
1286
1348
  function moveText(m: Move): string {