@rig-c/pose 0.31.1 → 0.32.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
@@ -5,21 +5,21 @@
5
5
  * and write — only when every check passed.
6
6
  */
7
7
  import { createHash } from 'node:crypto';
8
- import { cpSync, existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
8
+ import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
9
9
  import { tmpdir } from 'node:os';
10
10
  import { dirname, join, relative, resolve } from 'node:path';
11
11
  import { knownSetsOf, readMap, resolveMap, type JointMap, type Rest } from './map.ts';
12
- import { cleanLine, inferredSource } from './clean.ts';
12
+ import { cleanLine, cleanSummaryLine, inferredSource } from './clean.ts';
13
13
  import { convertCoco17, personTrack, readPoseSequence, TRAVEL_IN_PLACE, type CameraFrame } from './pose.ts';
14
14
  import { isObject, lineOf, parseJson, Problems, Refusal, type Problem } from './problems.ts';
15
15
  import { boneIdentity, droppedLine, keptOutLine, mergeProblems, readFragment, readIdle, targetOf, trackLabel, type IdleReading, type RigcTrack, type Target } from './idle.ts';
16
- import { bendLine, endOnLine, flipLine, holdLine, rangeLine, retarget, shortLine, turnLine, type MotionTrack, type RetargetOptions } from './retarget.ts';
16
+ import { bendGroupLine, bendGroups, bendLine, endOnLine, flipLine, holdLine, nodLine, rangeLine, reachCostLine, retarget, shortLine, turnLine, type MotionTrack, type RetargetOptions, type ScaleBand, type ScaleFit } from './retarget.ts';
17
17
  import { attachmentBones, baseMotionOf, buildInto, rigcVersion, folds, gate, readBuild, resolveRig, restOf, slotsOf } from './rigc.ts';
18
18
  import { artRangeLine, chainsMoving, raisedLine, RAISED_WITHIN, reachLine, switchLine, uncoveredFolds } from './switch.ts';
19
- import { landed, measureFeet, measureLanding, measurePosture, measureShear, poseFrames, postureLines } from './verify.ts';
19
+ import { jitterLine, landed, type FeetReading, type JitterRow, type Landing, measureFeet, measureJitter, measureLanding, measurePosture, measureShear, poseFrames, postureLines } from './verify.ts';
20
20
  import { legsOf, PLANT_SWITCH, STANCE_BOUNDS } from './legs.ts';
21
21
  import { M2_IDENTITY, mulM2, reduceKeys, reduceScale, reduceUnderScale, SCALE_REDUCE, setupLocal, smoothTrunk, type M2, type ReduceBone, type Reduced, type ReduceRow, type UnderBone } from './reduce.ts';
22
- import type { BlendHold, Closure, Floor, LegScale, Lowered, Side, Stance, SupportScaleRun } from './legs.ts';
22
+ import type { BlendHold, Closure, Floor, FreeAnkleShift, LegScale, LegSegmentRatio, Lowered, Side, Stance, SupportScaleRun } from './legs.ts';
23
23
  import { reportRenderDomain, writeReport, type ReportInput } from './report.ts';
24
24
  import { bodyLine, faceLine } from './face.ts';
25
25
 
@@ -55,6 +55,14 @@ export interface RetargetArgs extends RetargetOptions {
55
55
  reduce?: number;
56
56
  /** With reduce: thin the hip's translate keys within this many rig units. */
57
57
  reduceHip?: number;
58
+ /**
59
+ * What stdout carries (rig-c/pose#238). `default`: the CLEAN line's hold record
60
+ * as a count and each hinge's wrong-way frames as one line; `verbose`: every
61
+ * line in full, as before #238; `summary`: the run line, one line per measure
62
+ * (GATE, FEET, LIFT, JITTER, LAND) and the `wrote` lines. Refusals are the
63
+ * same in all three and the gate is the same gate.
64
+ */
65
+ output?: 'default' | 'verbose' | 'summary';
58
66
  }
59
67
 
60
68
  export interface RunResult {
@@ -64,10 +72,16 @@ export interface RunResult {
64
72
 
65
73
  export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunResult {
66
74
  const lines: string[] = [];
67
- const say = (l: string) => {
75
+ const output = a.output ?? 'default';
76
+ /** A line every mode prints: the run line, the summary lines, the `wrote` lines. */
77
+ const tell = (l: string) => {
68
78
  lines.push(l);
69
79
  log(l);
70
80
  };
81
+ /** A line of the full output: default and --verbose print it; --summary does not. */
82
+ const say = (l: string) => {
83
+ if (output !== 'summary') tell(l);
84
+ };
71
85
  const refuse = (problems: readonly Problem[]): never => {
72
86
  throw new Refusal(problems);
73
87
  };
@@ -75,12 +89,18 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
75
89
  for (const [flag, path] of [['--map', a.map], ['--pose', a.pose], ['--merge', a.merge]] as const) {
76
90
  if (path !== undefined && !existsSync(path)) args.add('ARG_PATH', `${flag} ${path} does not exist`);
77
91
  }
78
- // A file this run writes goes into a directory that is there already; --build and --render make theirs.
79
- for (const [flag, path] of [['--out', a.out], ['--report', a.report], ['--camera', a.camera]] as const) {
92
+ // rig-c/pose#239: the report is written unless asked elsewhere — beside the motion, as <out>.report.json.
93
+ const report = a.report ?? defaultReportPath(a.out);
94
+ // A file this run writes goes into a directory the user named: one that is not there yet is made
95
+ // after the run is green (one line says so), as --build and --render make theirs; a file in the way is refused.
96
+ const makeDirs = new Map<string, string[]>();
97
+ for (const [flag, path] of [['--out', a.out], ['--report', report], ['--camera', a.camera]] as const) {
80
98
  if (path === undefined) continue;
81
99
  const dir = dirname(path);
82
- if (!existsSync(dir)) args.add('ARG_PATH', `${flag} ${path} — directory ${dir} does not exist`);
83
- else if (!statSync(dir).isDirectory()) args.add('ARG_PATH', `${flag} ${path} — ${dir} is not a directory`);
100
+ let up = dir;
101
+ while (!existsSync(up) && dirname(up) !== up) up = dirname(up);
102
+ if (!statSync(up).isDirectory()) args.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`);
103
+ else if (up !== dir) makeDirs.set(dir, [...(makeDirs.get(dir) ?? []), `${flag} ${path}`]);
84
104
  }
85
105
  args.throwIfAny();
86
106
  const input = resolveRig(a.rig, a.baseMotion);
@@ -106,7 +126,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
106
126
  // The rig as it stands, through the gate, so the rest pose is rig-c's reading of a green build.
107
127
  const g0 = gate(input.rig, base, join(scratch, 'base'), a.profile, a.images);
108
128
  if (!g0.green) {
109
- refuse([{ code: 'RIG_GATE_BASE', text: `${input.rig} with ${input.motion ?? 'no motion spec'} is red under rigc build --profile ${a.profile} before any animation is added (exit ${g0.run.status})` }, ...g0.fails.map((f) => ({ code: 'RIGC', text: f }))]);
129
+ refuse([{ code: 'RIG_GATE_BASE', text: `${input.rig} with ${input.motion ?? 'no motion spec'} is red under rigc build --profile ${a.profile} before any animation is added (exit ${g0.run.status})${unreadMotionHint(a.rig, input.motion)}` }, ...g0.fails.map((f) => ({ code: 'RIGC', text: f }))]);
110
130
  }
111
131
  const doc0 = readBuild(g0.build);
112
132
  const rigc = rigcVersion();
@@ -114,7 +134,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
114
134
  const slots = slotsOf(doc0);
115
135
  resolveMap(map, rest, a.map, slots, input.sets);
116
136
 
117
- const r = retarget(rest, map, frames, times, { ...a, ...(seq.travel !== undefined ? { travel: seq.travel } : {}) }, slots, input.sets);
137
+ const r = retarget(rest, map, frames, times, { ...a, ...(seq.travel !== undefined ? { travel: seq.travel } : {}), ...(seq.cleaned!.params.loop ? { loop: true } : {}) }, slots, input.sets);
118
138
  if (fragment.length > 0 && ((r.face?.sets?.length ?? 0) > 0 || (r.body?.sets?.length ?? 0) > 0)) {
119
139
  const known = knownSetsOf(map, input.sets);
120
140
  for (const s of [...(r.face?.sets ?? []), ...(r.body?.sets ?? [])]) {
@@ -131,20 +151,25 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
131
151
  }
132
152
  }
133
153
  }
134
- say(`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)` : ''}` : ''}`);
154
+ 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)` : ''}` : ''}`);
135
155
  say(` RIGC rig-c ${rigc.version} (${rigc.entry}) gates and poses this run`);
136
- say(` CLEAN ${cleanLine(seq.cleaned!)}`);
156
+ say(` CLEAN ${output === 'verbose' ? cleanLine(seq.cleaned!) : cleanSummaryLine(seq.cleaned!)}`);
137
157
  if (a.smoothAlias !== undefined) say(` SMOOTH --smooth is carried by clean; ignored (given ${a.smoothAlias}; this sequence was cleaned with legs ${seq.cleaned!.params.legs}, rest ${seq.cleaned!.params.rest})`);
138
158
  if (seq.camera !== undefined) say(` CAMERA ${cameraLine(seq.camera, times, seq.cameraSource)}`);
139
159
  else if (a.plantFeet === true) say(' CAMERA none in the sequence: --plant-feet reads the picture as a fixed camera, so a zoom or a pan in the video moves the floor with it and the floor may step with the picture (tools/scan.py reads the camera, rig-c/pose#63)');
140
- if (r.scale !== null) say(` SCALE ${r.scale.factor.toFixed(5)} rig units per picture px (rig ${r.scale.rig.toFixed(2)} / pose median ${r.scale.pose.toFixed(2)})`);
160
+ if (r.scale !== null) {
161
+ const fit = r.scaleFit === null ? [] : scaleFitLines(r.scale.factor, r.scaleFit);
162
+ say(` SCALE ${r.scale.factor.toFixed(5)} rig units per picture px (rig ${r.scale.rig.toFixed(2)} / pose median ${r.scale.pose.toFixed(2)})${r.scaleFit === null ? '' : `; ${segmentClause(r.scaleFit)}`}`);
163
+ for (const l of fit) say(` SCALE ${l}`);
164
+ }
141
165
  for (const h of r.holds) say(` HOLD ${holdLine(h, r.times)}`);
142
166
  for (const s of r.short) say(` ${s.across === true ? 'HOLD ' : 'SHORT '} ${shortLine(s, r.times, a.short)}`);
143
167
  if (a.short !== 'off') {
144
168
  for (const e of r.endOn) say(` END-ON ${endOnLine(e, r.times, a.short)}`);
145
169
  }
146
170
  if (a.flip !== 'off') for (const fl of r.flips) say(` FLIP ${flipLine(fl, r.times, a.flip)}`);
147
- for (const e of r.bends) say(` BEND ${bendLine(e)} — clamped straight`);
171
+ if (output === 'verbose') for (const e of r.bends) say(` BEND ${bendLine(e)} — clamped straight`);
172
+ else for (const g of bendGroups(r.bends)) say(` BEND ${bendGroupLine(g, 'clamp')}`);
148
173
  for (const e of r.turns) say(` TURN ${turnLine(e, r.times)}`);
149
174
  for (const u of r.turnsUnread) say(` TURN line "${u.line}" (${u.joints[0]} → ${u.joints[1]}) not measured: ${u.why}`);
150
175
  if (map.alternates.length > 0) {
@@ -159,14 +184,16 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
159
184
  if (map.alternates.some((x) => x.place !== undefined && x.place !== null) && r.byHip.length === 0) say(` BY-HIP none — no set with "place": "by-hip" showed; no key moved`);
160
185
  for (const e of r.byHip) say(` BY-HIP ${raisedLine(e, r.times)}`);
161
186
  for (const e of r.byHip) if (e.short.length > 0) say(` REACH ${reachLine(e, r.times)}`);
187
+ for (const e of r.reach) say(` REACH ${reachCostLine(e, r.times)}`);
162
188
  for (const e of r.artRange) say(` ART-RANGE ${artRangeLine(e, r.times)}`);
163
- for (const l of r.legScale) say(` LEG-SCALE ${legScaleLine(l, r.times)}`);
189
+ for (const l of r.legScale) say(` LEG-SCALE ${legScaleLine(l, r.times, r.feet?.legLength ?? null, r.rigLeg)}`);
164
190
  for (const l of r.legScale) for (const s of l.support ?? []) say(` LEG-SCALE ${supportScaleLine(l, s, r.times)}`);
165
191
  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`);
166
192
  if (r.feet !== null) {
167
193
  const f = r.feet;
168
194
  const span = (x: [number, number]) => (x[0] === x[1] ? `${x[0]}` : `${x[0]}–${x[1]}`);
169
195
  say(` FEET ${r.planted ? 'planted' : 'read, not planted (no --plant-feet)'}: leg length (median hip → ankle) r ${f.legLength.r.toFixed(1)}px, l ${f.legLength.l.toFixed(1)}px; ${f.supports.length} support span(s), ${f.changes.filter((c) => c.from !== null).length} change(s) of support, ${f.jumps.length} jump(s), ${f.steps.length} step(s)`);
196
+ for (const sg of r.segments) say(` FEET ${segmentLine(sg)}`);
170
197
  say(` FEET supports: ${f.supports.map((x) => `${x.side} ${span(x.frames)}`).join(', ') || 'none'}`);
171
198
  say(` FEET jumps (both ankles and mid_hip > 0.05 leg lengths above their rolling floor, ≥ 2 frames): ${f.jumps.map(span).join(', ') || 'none'}`);
172
199
  if (f.lowered !== undefined) say(` FEET ${loweredLine(f.lowered)}`);
@@ -179,7 +206,10 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
179
206
  }
180
207
  if (r.blendHold !== null) for (const l of blendLines(r.blendHold, r.times)) say(` FEET ${l}`);
181
208
  }
182
- if (r.stance !== null) for (const l of stanceLines(r.stance, r.times)) say(` STANCE ${l}`);
209
+ if (seq.travel === TRAVEL_IN_PLACE && !r.planted) say(` TRAVEL ${openTravelLine(r.openDrift)}`);
210
+ if (r.stance !== null) for (const l of stanceLines(r.stance, r.times, r.feet === null || r.legStraight === null ? undefined : { legLength: r.feet.legLength, straight: r.legStraight, rigLeg: r.rigLeg })) say(` STANCE ${l}`);
211
+ const nod = nodLine(r.headNod, r.nodSkipped, map.face?.pitch !== undefined);
212
+ if (nod !== null) say(` NOD ${nod}`);
183
213
  if (r.face !== null) say(` FACE ${faceLine(r.face)}`);
184
214
  if (r.body !== null) say(` BODY ${bodyLine(r.body)}`);
185
215
 
@@ -247,17 +277,27 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
247
277
  if (!g1.green) {
248
278
  refuse([{ code: 'RIG_GATE', text: `rigc build --profile ${a.profile} is red with "${a.name}" in (exit ${g1.run.status}); nothing written` }, ...g1.fails.map((f) => ({ code: 'RIGC', text: f }))]);
249
279
  }
250
- say(` GATE rigc build --profile ${a.profile}: green${g1.summary === null ? '' : ` — ${g1.summary}`}`);
280
+ const gateText = gateLine(a.profile, g1.summary);
281
+ say(` GATE ${gateText}`);
251
282
 
252
283
  const doc = readBuild(g1.build);
253
284
  const posed = poseFrames(doc, a.name, r.times);
254
285
  // A reduced track is held to the dense target plus its tolerance (#32); an ankle, to what the
255
286
  // reduced bones above it may move it by (#94, `footReduce`). A scale keeps its own: it is reduced within half of it.
256
- const landings = measureLanding(doc, a.name, map, r, posed).map((l) => (a.reduce === undefined ? l
257
- : l.kind === 'angle' ? { ...l, tolerance: l.tolerance + a.reduce }
258
- : l.kind === 'origin' && a.reduceHip !== undefined ? { ...l, tolerance: l.tolerance + a.reduceHip }
259
- : l.kind === 'foot' ? { ...l, tolerance: l.tolerance + footReduce(rest, r, l.bone.replace(/:tip$/, ''), a.reduce, a.reduceHip) }
260
- : l));
287
+ // #240: each ankle's allowance is computed once, here, and both the LAND tolerance and the drift line read it.
288
+ const drift: AnkleDrift[] = [];
289
+ const landings = measureLanding(doc, a.name, map, r, posed).map((l) => {
290
+ if (a.reduce === undefined) return l;
291
+ if (l.kind === 'angle') return { ...l, tolerance: l.tolerance + a.reduce };
292
+ if (l.kind === 'origin' && a.reduceHip !== undefined) return { ...l, tolerance: l.tolerance + a.reduceHip };
293
+ if (l.kind !== 'foot') return l;
294
+ const ankle = l.bone.replace(/:tip$/, '');
295
+ const by = footReduce(rest, r, ankle, a.reduce, a.reduceHip);
296
+ const lb = legBonesOf(map);
297
+ const side = lb?.r.ankle === ankle ? 'r' : lb?.l.ankle === ankle ? 'l' : null;
298
+ drift.push({ bone: l.bone, side, plant: l.tolerance, reduce: by, units: l.tolerance + by, legLengths: side === null || r.rigLeg === null ? null : (l.tolerance + by) / r.rigLeg[side] });
299
+ return { ...l, tolerance: l.tolerance + by };
300
+ });
261
301
  const p = new Problems();
262
302
  // A carried idle track on a bone above a mapped one moves it where the keys did not ask (the key formula holds unmapped bones at setup).
263
303
  const carriedAbove = (bone: string): string => {
@@ -278,6 +318,9 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
278
318
  else p.add('POSE_LANDS', `${line} — the rig does not turn the way the key formula assumes (a constraint, a scaled or sheared parent, a non-normal inherit mode)${carriedAbove(l.bone)}`);
279
319
  }
280
320
  p.throwIfAny();
321
+ // #236: each keyed bone's jitter as posed, against its map pair's — names the bone that moved a group's ratio.
322
+ const jitter = measureJitter(posed, map, r, frames, a.minScore);
323
+ for (const j of jitter) say(` JITTER ${jitterLine(j)}`);
281
324
 
282
325
  const lengths = new Map(doc.bones.map((b) => [b.name, b.length ?? 0]));
283
326
  const hips = seq.cleaned?.params.hipsInferred;
@@ -286,15 +329,10 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
286
329
  const legBones = legBonesOf(map);
287
330
  const feetReading = legBones === null ? null : measureFeet(posed, lengths, r, legBones);
288
331
  if (feetReading !== null) {
289
- const f = feetReading;
290
- say(` FEET the posed build, outside jumps: the lowest ankle's vertical range ${f.lowest.range.toFixed(2)} units = ${f.lowest.legs.toFixed(4)} leg lengths (rig leg r ${f.legLength.r.toFixed(1)}, l ${f.legLength.l.toFixed(1)} units) over ${f.lowest.frames} frames`);
291
- const worst = (k: 'verticalLegs' | 'horizontalLegs') => f.plants.reduce<(typeof f.plants)[number] | null>((m, x) => (m === null || x[k] > m[k] ? x : m), null);
292
- const wv = worst('verticalLegs');
293
- const wh = worst('horizontalLegs');
294
- if (wv !== null && wh !== null) say(` FEET the supporting ankle over each support span: worst vertical range ${wv.vertical.toFixed(2)} units = ${wv.verticalLegs.toFixed(4)} leg lengths (${wv.side} ${wv.frames[0]}–${wv.frames[1]}), worst horizontal drift ${wh.horizontal.toFixed(2)} units = ${wh.horizontalLegs.toFixed(4)} leg lengths (${wh.side} ${wh.frames[0]}–${wh.frames[1]})`);
332
+ for (const l of posedFeetLines(feetReading)) say(` FEET ${l}`);
295
333
  }
296
334
  const shear = r.scales.size === 0 ? [] : measureShear(doc, posed, r.scales.keys());
297
- for (const x of shear) say(` SHEAR bone "${x.bone}": art sheared up to ${x.shear.toFixed(2)}° (frame ${x.frame}); world x axis ${x.x[0].toFixed(3)} … ${x.x[1].toFixed(3)}, y axis ${x.y[0].toFixed(3)} … ${x.y[1].toFixed(3)} of setup — a scaled bone, or one under it; a reading`);
335
+ for (const x of shear) say(` SHEAR bone "${x.bone}": art sheared up to ${x.shear.toFixed(2)}° (frame ${x.frame}); world x axis ${x.x[0].toFixed(3)} … ${x.x[1].toFixed(3)}, y axis ${x.y[0].toFixed(3)} … ${x.y[1].toFixed(3)} of setup — a scaled bone, or one under it; a reading${shearRemedy(x, rest, shear)}`);
298
336
 
299
337
  const dts = r.times.slice(1).map((t, i) => t - r.times[i]!).sort((x, y) => x - y);
300
338
  const fps = dts.length === 0 ? 12 : Math.max(1, Math.min(60, Math.round(1 / dts[dts.length >> 1]!)));
@@ -309,8 +347,11 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
309
347
  for (const u of uncovered) say(` UNCOVERED mesh "${u.attachment}" (slot "${u.slot}"; chains ${u.chains.length === 0 ? 'none' : u.chains.join(', ')}) folds with no switch covering it on ${u.times.length} frame(s): t=${spans(u.times, 1 / fps)}`);
310
348
  }
311
349
 
312
- if (a.report !== undefined) assertReportInputs([rigSnapshot,mapSnapshot,poseSnapshot]);
313
- const reportText = a.report === undefined ? null : writeReport({
350
+ // #240: what --reduce allowed each ankle, at the end of the run, from the same figure the LAND check applied.
351
+ for (const d of drift) say(` REDUCE ${ankleDriftLine(d)}`);
352
+
353
+ assertReportInputs([rigSnapshot,mapSnapshot,poseSnapshot]);
354
+ const reportText = writeReport({
314
355
  spec: 'rig-pose-report/1',
315
356
  rigc,
316
357
  rig: input.name,
@@ -320,6 +361,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
320
361
  cleaned: seq.cleaned,
321
362
  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' },
322
363
  scale: r.scale,
364
+ scaleFit: r.scaleFit,
323
365
  holds: r.holds,
324
366
  short: r.short,
325
367
  endOn: r.endOn,
@@ -327,11 +369,13 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
327
369
  flips: r.flips,
328
370
  gate: g1.summary,
329
371
  landings,
372
+ jitter,
330
373
  posture,
331
374
  folds: f.meshes,
332
375
  switches: r.switches,
333
376
  uncovered,
334
377
  turns: r.turns,
378
+ reach: r.reach,
335
379
  range: r.range,
336
380
  artRange: r.artRange,
337
381
  raised: r.raised,
@@ -358,15 +402,15 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
358
402
  ...(r.body.yaw === undefined ? {} : { yaw: { bone: r.body.yaw.bone, limits: r.body.yaw.limits, knee: r.body.yaw.knee, read: r.body.yaw.read, mapped: r.body.yaw.mapped, scale: r.body.yaw.scale, corr: r.body.yaw.corr } }),
359
403
  ...(r.body.sets === undefined ? {} : { sets: r.body.sets }),
360
404
  },
361
- legScale: r.legScale.map(({ scale: _scale, asked: _asked, ...rest }) => rest),
405
+ legScale: r.legScale.map(({ scale: _scale, asked: _asked, picture: _picture, ...rest }) => rest),
362
406
  pivot: r.pivot,
363
407
  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 },
364
- feet: r.feet === null ? null : { planted: r.planted, legLength: r.feet.legLength, 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 },
408
+ 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 },
365
409
  shear,
366
410
  idleDropped: idle === null ? null : idle.dropped,
367
411
  idleKeptOut: idle === null ? null : idle.keptOut,
368
412
  merged: fragment.map((tr) => ({ track: trackLabel(tr, groups), keys: tr.keys.length })),
369
- reduce: reduced === null ? null : { tolerance: a.reduce, hipTolerance: a.reduceHip ?? null, rows: reduced.rows },
413
+ reduce: reduced === null ? null : { tolerance: a.reduce, hipTolerance: a.reduceHip ?? null, rows: reduced.rows, ankleDrift: drift },
370
414
  }, map, { frameTimes: r.times }, f.meshes !== null && doc.slots.length > 0 ? reportRenderDomain(JSON.parse(readFileSync(join(f.frames, '..', 'frames.json'), 'utf8')), a.name) : null, {rig:rigSnapshot.identity,map:mapSnapshot.identity,pose:poseSnapshot.identity});
371
415
  if (a.build !== undefined) {
372
416
  // Built again where it will live, not copied: rigc's page paths are relative to the build (#18).
@@ -375,7 +419,11 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
375
419
  if (!kept.green) refuse([{ code: 'RIG_GATE', text: `rigc build into --build ${a.build} is red where the same build was green in scratch (exit ${kept.run.status}); nothing written` }, ...kept.fails.map((x) => ({ code: 'RIGC', text: x }))]);
376
420
  }
377
421
  // The kept build reads the rig again; its bytes must still match the report.
378
- if (a.report !== undefined) assertReportInputs([rigSnapshot,mapSnapshot,poseSnapshot]);
422
+ assertReportInputs([rigSnapshot,mapSnapshot,poseSnapshot]);
423
+ for (const [dir, flags] of makeDirs) {
424
+ mkdirSync(dir, { recursive: true });
425
+ tell(madeLine(dir, flags));
426
+ }
379
427
  writeFileSync(a.out, `${JSON.stringify(motion, null, 2)}\n`);
380
428
  if (a.camera !== undefined) {
381
429
  const ss = r.figureScale.frames.map((x) => x.s);
@@ -410,16 +458,96 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
410
458
  rmSync(a.render, { recursive: true, force: true });
411
459
  cpSync(f.frames, a.render, { recursive: true });
412
460
  }
413
- if (a.report !== undefined && reportText !== null) writeFileSync(a.report, reportText);
461
+ const previous = existsSync(report) ? statSync(report).mtime : null;
462
+ writeFileSync(report, reportText);
414
463
 
415
- if (a.camera !== undefined) say(` wrote ${a.camera} — camera track (${r.figureScale.frames.length} frames)`);
416
- say(` wrote ${a.out} — animations: ${Object.keys(motion.animations).join(', ')}`);
464
+ if (output === 'summary') for (const l of summaryLines({ gate: gateText, feet: feetReading, stance: r.stance, jitter, landings, times: r.times })) tell(` ${l}`);
465
+ if (a.camera !== undefined) tell(` wrote ${a.camera} — camera track (${r.figureScale.frames.length} frames)`);
466
+ tell(reportLine(report, a.report === undefined, previous));
467
+ tell(` wrote ${a.out} — animations: ${Object.keys(motion.animations).join(', ')}`);
417
468
  return { lines, written: true };
418
469
  } finally {
419
470
  rmSync(scratch, { recursive: true, force: true });
420
471
  }
421
472
  }
422
473
 
474
+ /** The GATE line's text: the profile and rig-c's own summary of the green build. */
475
+ export function gateLine(profile: string, summary: string | null): string {
476
+ return `rigc build --profile ${profile}: green${summary === null ? '' : ` — ${summary}`}`;
477
+ }
478
+
479
+ /** The FEET lines of the posed build: the lowest ankle's range, and the supporting ankle's worst over the support spans. */
480
+ export function posedFeetLines(f: FeetReading): string[] {
481
+ const out = [`the posed build, outside jumps: the lowest ankle's vertical range ${lowestText(f)} (rig leg r ${f.legLength.r.toFixed(1)}, l ${f.legLength.l.toFixed(1)} units) over ${f.lowest.frames} frames`];
482
+ const plant = plantText(f);
483
+ if (plant !== null) out.push(`the supporting ankle over each support span: ${plant}`);
484
+ return out;
485
+ }
486
+
487
+ const lowestText = (f: FeetReading): string => `${f.lowest.range.toFixed(2)} units = ${f.lowest.legs.toFixed(4)} leg lengths`;
488
+
489
+ function plantText(f: FeetReading): string | null {
490
+ const worst = (k: 'verticalLegs' | 'horizontalLegs') => f.plants.reduce<(typeof f.plants)[number] | null>((m, x) => (m === null || x[k] > m[k] ? x : m), null);
491
+ const wv = worst('verticalLegs');
492
+ const wh = worst('horizontalLegs');
493
+ if (wv === null || wh === null) return null;
494
+ return `worst vertical range ${wv.vertical.toFixed(2)} units = ${wv.verticalLegs.toFixed(4)} leg lengths (${wv.side} ${wv.frames[0]}–${wv.frames[1]}), worst horizontal drift ${wh.horizontal.toFixed(2)} units = ${wh.horizontalLegs.toFixed(4)} leg lengths (${wh.side} ${wh.frames[0]}–${wh.frames[1]})`;
495
+ }
496
+
497
+ /** What `--summary` reads (rig-c/pose#238): the same records the full lines print. */
498
+ export interface SummaryInput {
499
+ gate: string;
500
+ feet: FeetReading | null;
501
+ stance: Stance | null;
502
+ jitter: readonly JitterRow[];
503
+ landings: readonly Landing[];
504
+ times: readonly number[];
505
+ }
506
+
507
+ /**
508
+ * `--summary`'s lines (rig-c/pose#238), one per measure, in the order a second
509
+ * run is compared on: GATE (the GATE line), FEET (the posed build's two FEET
510
+ * lines, joined), LIFT (the STANCE runs counted), JITTER (the JITTER rows' worst
511
+ * ratio) and LAND (each landing kind's worst against its tolerance). Each figure
512
+ * is formatted as the full line formats it, from the same record.
513
+ */
514
+ export function summaryLines(x: SummaryInput): string[] {
515
+ const out = [`GATE ${x.gate}`];
516
+ if (x.feet === null) out.push('FEET not measured: the map maps no legs');
517
+ else {
518
+ const plant = plantText(x.feet);
519
+ out.push(`FEET lowest ankle's vertical range ${lowestText(x.feet)} over ${x.feet.lowest.frames} frames; supporting ankle ${plant ?? 'over no support span'}`);
520
+ }
521
+ if (x.stance === null) out.push('LIFT not read (no --stance)');
522
+ else {
523
+ const own = x.stance.runs.filter((r) => r.absorbed !== true);
524
+ const count = (state: Stance['runs'][number]['state']) => {
525
+ const rs = own.filter((r) => r.state === state);
526
+ return { runs: rs.length, frames: rs.reduce((t, r) => t + r.frames[1] - r.frames[0] + 1, 0), rs };
527
+ };
528
+ const lifted = count('lifted'), both = count('both'), below = count('below');
529
+ const top = lifted.rs.reduce<Stance['runs'][number] | null>((m, r) => (m === null || r.maxHeight > m.maxHeight ? r : m), null);
530
+ out.push(`LIFT lifted ${lifted.runs} run(s), ${lifted.frames} frame(s)${top === null ? '' : `, highest ${top.maxHeight.toFixed(4)} leg lengths (${top.side} ${spanText(top.frames)})`}; planted both ${both.runs} run(s), ${both.frames} frame(s); below ${below.runs} run(s), ${below.frames} frame(s); ${x.stance.over.length} past the bounds`);
531
+ }
532
+ const read = x.jitter.filter((j) => j.ratio !== null);
533
+ const worst = read.reduce<JitterRow | null>((m, j) => (m === null || j.ratio! > m.ratio! ? j : m), null);
534
+ const still = x.jitter.filter((j) => j.triples > 0 && j.ratio === null).length;
535
+ const unread = x.jitter.filter((j) => j.triples === 0).length;
536
+ out.push(`JITTER ${x.jitter.length} bone(s): ${worst === null ? 'no ratio read' : `worst ${worst.ratio!.toFixed(2)}× on bone "${worst.bone}" (${worst.rig!.toFixed(4)}°/f² against its pair's ${worst.pair!.toFixed(4)}°/f²)`}; ${still} still pair(s), ${unread} not measured`);
537
+ const kinds: Array<[Landing['kind'], string, string]> = [['angle', 'aim', '°'], ['origin', 'origin', ' units'], ['scale', 'scale', ' of its setup length'], ['foot', 'ankle', ' units']];
538
+ const parts = kinds.flatMap(([kind, what, unit]) => {
539
+ const ls = x.landings.filter((l) => l.kind === kind);
540
+ if (ls.length === 0) return [];
541
+ const w = ls.reduce((m, l) => (l.worst > m.worst ? l : m));
542
+ const tol = kind === 'foot' ? Number(w.tolerance.toPrecision(3)) : w.tolerance;
543
+ return [`${what} ${ls.length}, worst ${w.worst.toPrecision(3)}${unit} on bone "${w.bone}" at frame ${w.frame} (t=${x.times[w.frame]!.toFixed(3)}s), tolerance ${tol}${unit}`];
544
+ });
545
+ out.push(`LAND ${x.landings.length} landed: ${parts.join('; ') || 'none'}`);
546
+ return out;
547
+ }
548
+
549
+ const spanText = (x: [number, number]) => (x[0] === x[1] ? `${x[0]}` : `${x[0]}–${x[1]}`);
550
+
423
551
  /**
424
552
  * The retarget's rotate tracks (and with a hip tolerance, its translate) thinned
425
553
  * in place (#32), on the final keys: the plant, the one floor, the stretches and
@@ -720,6 +848,16 @@ function loweredLine(lowered: readonly Lowered[]): string {
720
848
  return `${head}: ${frames} frame(s) read differently from before — ${lowered.map((x) => `frames ${span(x.frames)} ${side(x.was)} → ${side(x.now)}${x.cause === undefined ? '' : ` (${CAUSE[x.cause]})`}`).join(', ')}`;
721
849
  }
722
850
 
851
+ /**
852
+ * The in-place line without --plant-feet (rig-c/pose#242): what the declaration still changes, and the one thing it
853
+ * cannot without planted feet — the loop closure, which spreads back the planted hip's drift. `drift` is the
854
+ * translated bone's x on the last frame less the first (null: the map translates no bone).
855
+ */
856
+ export function openTravelLine(drift: number | null): string {
857
+ const hip = drift === null ? 'and the map translates no bone' : `so the hip follows the pose's own travel: x ${drift >= 0 ? '+' : ''}${drift.toFixed(2)} units from the first frame to the last, not closed`;
858
+ return `sequence declares travel: ${TRAVEL_IN_PLACE} but --plant-feet is off — its supports and the head nod are read in place as with it; the loop closure is --plant-feet's (it closes the planted hip), ${hip}`;
859
+ }
860
+
723
861
  /** The in-place line (rig-c/pose#92): what --plant-feet did instead of holding the foot, and the loop's closure. */
724
862
  export function travelLine(c: Closure): string {
725
863
  const per = c.frames > 1 ? Math.abs(c.drift) / (c.frames - 1) : 0;
@@ -741,9 +879,16 @@ export function blendLines(h: BlendHold, times: readonly number[]): string[] {
741
879
  * frames, with the tolerance and the stretch keyed; one per run a stretch was
742
880
  * capped.
743
881
  */
744
- export function stanceLines(st: Stance, times: readonly number[]): string[] {
882
+ export function stanceLines(st: Stance, times: readonly number[], unit?: StanceUnit): string[] {
745
883
  const span = (x: [number, number]) => (x[0] === x[1] ? `frame ${x[0]} (t=${times[x[0]]!.toFixed(3)}s)` : `frames ${x[0]}–${x[1]} (t=${times[x[0]]!.toFixed(3)}–${times[x[1]]!.toFixed(3)}s)`);
746
- const tol = `the tolerance ${st.tolerance} leg lengths`;
884
+ const tol = unit === undefined ? `the tolerance ${st.tolerance} leg lengths` : `the tolerance ${st.tolerance} leg lengths (${pxPair(st.tolerance, unit.legLength)})`;
885
+ // #237: what the tolerance is in px and in LEG-SCALE's straight length, and that "kept" is true only where no clamp moved the ankle.
886
+ const kept = (r: Stance['runs'][number]): string => {
887
+ if (r.moved === undefined) return "the pose's height kept";
888
+ const all = r.frames[1] - r.frames[0] + 1;
889
+ const head = r.moved.count === all ? `the pose's height not kept on any of its ${all} frame(s)` : `the pose's height kept on ${all - r.moved.count} of its ${all} frame(s)`;
890
+ return `${head}; on ${r.moved.count} (frames ${r.moved.frames.map(spanOf).join(', ')}) --leg-scale's clamps moved the free ankle off it, ${shiftText(r.moved, unit, "the pose's")}`;
891
+ };
747
892
  const release = `planted again under ${st.release} of it, ${(st.release * st.tolerance).toFixed(4)}`;
748
893
  const NAME: Record<Stance['runs'][number]['state'], string> = { both: 'planted-both', lifted: 'lifted', below: 'below' };
749
894
  // An absorbed run lies inside a run of the state it took; the line names that state.
@@ -755,13 +900,52 @@ export function stanceLines(st: Stance, times: readonly number[]): string[] {
755
900
  : r.state === 'both'
756
901
  ? `planted both: free foot ${r.side} ${span(r.frames)} — its ankle up to ${r.maxHeight.toFixed(4)} leg lengths above the supporting one in the picture, within ${tol}; its leg stretched by k ${r.k!.min === r.k!.max ? r.k!.max.toFixed(4) : `${r.k!.min.toFixed(4)} … ${r.k!.max.toFixed(4)}`} (largest ${r.k!.max.toFixed(4)})`
757
902
  : r.state === 'below'
758
- ? `past the tolerance below: free foot ${r.side} ${span(r.frames)} — ${where(r)}, past ${tol} (${release}); a free foot that far below the supporting one is a support error, not a stance: never planted both, the pose's height kept`
759
- : `lifted: free foot ${r.side} ${span(r.frames)} — ${where(r)}, over ${tol} (${release}); the pose's height kept`));
903
+ ? `past the tolerance below: free foot ${r.side} ${span(r.frames)} — ${where(r)}, past ${tol} (${release}); a free foot that far below the supporting one is a support error, not a stance: never planted both, ${kept(r)}`
904
+ : `lifted: free foot ${r.side} ${span(r.frames)} — ${where(r)}, over ${tol} (${release}); ${kept(r)}`));
905
+ if (unit !== undefined) out.unshift(stanceUnitLine(st.tolerance, unit));
760
906
  if (st.range !== undefined) out.push(legRangeLine('free', st.range, st.bounds));
761
907
  for (const o of st.over) out.push(`past the bounds: free foot ${o.side} ${span(o.frames)} — ${o.needed === null ? 'its ankle is not below its leg\'s root, so no stretch reaches the floor' : `the floor needed k ${o.needed.toFixed(4)}, past ${boundText(o.bound ?? st.bounds)}; held at the bound`}, the foot left up to ${o.left.toFixed(2)} units from the floor`);
762
908
  return out;
763
909
  }
764
910
 
911
+ /** The units the STANCE lines print against (rig-c/pose#237): FEET's leg length, the straight leg, and the rig's leg at setup. */
912
+ export interface StanceUnit {
913
+ /** FEET's: each side's median picture length hip → ankle, px. */
914
+ legLength: Record<Side, number>;
915
+ /** Each side's 95th-percentile picture length hip → ankle, px. */
916
+ straight: Record<Side, number>;
917
+ /** Each side's rig leg at setup, thigh origin → ankle, world units. */
918
+ rigLeg: Record<Side, number> | null;
919
+ }
920
+
921
+ const spanOf = (x: [number, number]) => (x[0] === x[1] ? `${x[0]}` : `${x[0]}–${x[1]}`);
922
+
923
+ /** `v` leg lengths in each side's px: `r 10.5 / l 10.6 px`. */
924
+ function pxPair(v: number, legLength: Record<Side, number>): string {
925
+ return `r ${(v * legLength.r).toFixed(1)} / l ${(v * legLength.l).toFixed(1)} px`;
926
+ }
927
+
928
+ /** The STANCE unit line (rig-c/pose#237): --stance in FEET's leg length, in px and as a share of the straight leg LEG-SCALE's percentile reads. */
929
+ export function stanceUnitLine(tolerance: number, u: StanceUnit): string {
930
+ const share = (s: Side) => (tolerance * u.legLength[s]) / u.straight[s];
931
+ return `unit: --stance ${tolerance} is in FEET's leg length (each side's median picture length hip → ankle, r ${u.legLength.r.toFixed(1)} / l ${u.legLength.l.toFixed(1)} px), not a straight length (a 95th percentile, as LEG-SCALE reads a segment's: the straight leg hip → ankle r ${u.straight.r.toFixed(1)} / l ${u.straight.l.toFixed(1)} px): ${tolerance} leg lengths = ${pxPair(tolerance, u.legLength)} = r ${share('r').toFixed(4)} / l ${share('l').toFixed(4)} of the straight leg`;
932
+ }
933
+
934
+ /** A free ankle's shift by the clamps, in its rig leg and in FEET's px: `up to 0.1500 leg lengths (22.6 px) above, up to … below`. */
935
+ function shiftText(m: FreeAnkleShift, unit: { legLength: Record<Side, number> | null; rigLeg: Record<Side, number> | null } | undefined, against: string): string {
936
+ const one = (x: { legLengths: number }, way: string) => `up to ${x.legLengths.toFixed(4)} leg lengths${unit?.legLength == null ? '' : ` (${(x.legLengths * unit.legLength[m.side]).toFixed(1)} px of FEET's)`} ${way}`;
937
+ const parts = [...(m.above === null ? [] : [one(m.above, `above ${against}`)]), ...(m.below === null ? [] : [one(m.below, `below ${against}`)])];
938
+ const leg = unit?.rigLeg == null ? '' : ` (leg lengths: the rig's ${m.side} leg at setup, thigh origin → ankle, ${unit.rigLeg[m.side].toFixed(1)} units)`;
939
+ return `${parts.length === 0 ? `by 0 leg lengths, at ${against}` : parts.join(' and ')}${leg}`;
940
+ }
941
+
942
+ /** The FEET segment line (rig-c/pose#237): a mapped leg segment's picture length ÷ straight, read with or without --leg-scale. */
943
+ export function segmentLine(sg: LegSegmentRatio): string {
944
+ const head = `segment chain "${sg.chain}" bone "${sg.bone}" (${sg.joints[0]} → ${sg.joints[1]})`;
945
+ if (sg.straight === null || sg.ratio === null) return `${head}: picture length ÷ straight not measured — ${sg.why ?? 'no straight length'}`;
946
+ return `${head}: picture length ÷ straight length (${sg.straight.toFixed(1)}px, the one --leg-scale scales against) min ${sg.ratio.min.toFixed(4)} · median ${sg.ratio.median.toFixed(4)} · max ${sg.ratio.max.toFixed(4)} — the scale --leg-scale asks before its clamp, read with or without it`;
947
+ }
948
+
765
949
  /** A bound as the lines print it: the default's figures as written, a derived lower bound to 4 places (#95). */
766
950
  function boundText(b: readonly [number, number]): string {
767
951
  return `[${b[0] === STANCE_BOUNDS[0] ? `${b[0]}` : b[0].toFixed(4)}, ${b[1]}]`;
@@ -774,7 +958,7 @@ function legRangeLine(which: 'free' | 'supporting', range: Partial<Record<'r' |
774
958
  }
775
959
 
776
960
  /** A LEG-SCALE line: the segment, its straight length, its ratio's spread, and the frames clamped (with the asked value, #225). */
777
- function legScaleLine(l: LegScale, times: readonly number[]): string {
961
+ export function legScaleLine(l: LegScale, times: readonly number[], legLength: Record<Side, number> | null = null, rigLeg: Record<Side, number> | null = null): string {
778
962
  const span = (x: [number, number]) => (x[0] === x[1] ? `${x[0]}` : `${x[0]}–${x[1]}`);
779
963
  const count = (rs: Array<[number, number]>) => rs.reduce((k, x) => k + x[1] - x[0] + 1, 0);
780
964
  const val = (r: { min: number; max: number }) => (r.min.toFixed(3) === r.max.toFixed(3) ? r.min.toFixed(3) : `${r.min.toFixed(3)}–${r.max.toFixed(3)}`);
@@ -789,7 +973,15 @@ function legScaleLine(l: LegScale, times: readonly number[]): string {
789
973
  const detail = rs.length === 1 ? ` (${askedOf(rs[0]!, kind)})` : ` (${rs.map((x) => `${span(x)}: ${askedOf(x, kind)}`).join(', ')})`;
790
974
  return `${count(rs)} frame(s) ${how} ${at}, clamped (frames ${rs.map(span).join(', ')})${detail}`;
791
975
  };
792
- return `chain "${l.chain}" bone "${l.bone}" (${l.joints[0]} → ${l.joints[1]}): straight length ${l.straight.toFixed(1)}px (larger 95th percentile of ${times.length} frames, both legs); picture length ÷ straight ${l.ratio.min.toFixed(3)} … ${l.ratio.max.toFixed(3)} (median ${l.ratio.median.toFixed(3)}); scalex keyed in [${l.limits[0]}, ${l.limits[1]}]: ${clamp(l.low, l.limits[0], 'under', 'low')}; ${clamp(l.high, l.limits[1], 'over', 'high')}`;
976
+ // #237: the straight length against FEET's leg length, the limits in px, and what the clamps did to a free ankle.
977
+ const side: Side = l.joints[0].startsWith('r_') ? 'r' : 'l';
978
+ const share = legLength === null ? '' : ` = ${(l.straight / legLength[side]).toFixed(3)} of FEET's leg length (median hip → ankle, ${side} ${legLength[side].toFixed(1)}px)`;
979
+ const limitsPx = `${(l.limits[0] * l.straight).toFixed(1)}–${(l.limits[1] * l.straight).toFixed(1)}px`;
980
+ const clampedFrames = count(l.low) + count(l.high);
981
+ const free = l.free !== undefined
982
+ ? `; its leg free (the other foot supporting) on ${l.free.count} of its ${clampedFrames} clamped frame(s) (frames ${l.free.frames.map(span).join(', ')}): the clamps on that leg left the free ankle ${shiftText(l.free, { legLength, rigLeg }, "the picture's (the leg at picture length ÷ straight)")}`
983
+ : clampedFrames > 0 && legLength !== null ? `; its leg free on none of its ${clampedFrames} clamped frame(s)` : '';
984
+ return `chain "${l.chain}" bone "${l.bone}" (${l.joints[0]} → ${l.joints[1]}): straight length ${l.straight.toFixed(1)}px (larger 95th percentile of ${times.length} frames, both legs)${share}; picture length ÷ straight ${l.ratio.min.toFixed(3)} … ${l.ratio.max.toFixed(3)} (median ${l.ratio.median.toFixed(3)}); scalex keyed in [${l.limits[0]}, ${l.limits[1]}] of the straight length (${limitsPx}): ${clamp(l.low, l.limits[0], 'under', 'low')}; ${clamp(l.high, l.limits[1], 'over', 'high')}${free}`;
793
985
  }
794
986
 
795
987
  /** A LEG-SCALE past the floor (support) line (rig-c/pose#133): the supporting leg segment scaled below `--leg-scale`'s floor down to #95's bound. */
@@ -823,3 +1015,97 @@ export function readReportInput(file:string):ReportInputSnapshot {
823
1015
  export function assertReportInputs(inputs:readonly ReportInputSnapshot[]):void {
824
1016
  for(const expected of inputs){const current=readReportInput(expected.file);if(current.identity.path!==expected.identity.path||current.identity.sha256!==expected.identity.sha256)throw new Refusal([{code:'REPORT_INPUTS_CHANGED',text:`${expected.identity.path}: consumed input changed before output writes; expected SHA-256 ${expected.identity.sha256}, found ${current.identity.sha256}`}]);}
825
1017
  }
1018
+
1019
+ /** rig-c/pose#239: the report's path when --report is not given — `<out>.report.json`, beside the motion (a trailing `.json` of --out is replaced). */
1020
+ export function defaultReportPath(out: string): string {
1021
+ return `${out.endsWith('.json') ? out.slice(0, -'.json'.length) : out}.report.json`;
1022
+ }
1023
+
1024
+ /** rig-c/pose#239: the line a directory this run made prints — the directory, and the paths that named it. */
1025
+ export function madeLine(dir: string, flags: readonly string[]): string {
1026
+ return ` made directory ${dir} — it did not exist; ${flags.join(' and ')} ${flags.length === 1 ? 'is' : 'are'} written into it`;
1027
+ }
1028
+
1029
+ /** rig-c/pose#239: the report's `wrote` line — whether it was the default, and the mtime of the report it replaced. */
1030
+ export function reportLine(report: string, byDefault: boolean, previous: Date | null): string {
1031
+ return ` wrote ${report} — the run's report (rig-pose-report/1)${byDefault ? ', beside --out by default; name another path with --report' : ''}${previous === null ? '' : `; it replaced the report there last written ${previous.toISOString()}`}`;
1032
+ }
1033
+
1034
+ /**
1035
+ * rig-c/pose#239: `--rig` naming a rig.json reads no motion spec, even with one beside it. When the base
1036
+ * gate is red that way, the refusal says the motion beside it was not read and names the two forms that read it.
1037
+ */
1038
+ export function unreadMotionHint(rigArg: string, motion: string | null): string {
1039
+ if (motion !== null) return '';
1040
+ const beside = join(dirname(rigArg), 'motion.json');
1041
+ if (!existsSync(beside) || statSync(rigArg).isDirectory()) return '';
1042
+ return `; ${beside} is beside it and was not read — --rig naming the rig.json reads no motion spec: pass --rig ${dirname(rigArg)} (the directory, which reads the motion.json beside its rig.json) or --base-motion ${beside}`;
1043
+ }
1044
+
1045
+ /** The SCALE line's own clause (rig-c/pose#240): the median it divides by, against the segment at its longest. */
1046
+ export function segmentClause(f: ScaleFit): string {
1047
+ const sg = f.segment;
1048
+ return `the median is ${sg.ratio.toFixed(3)} of its 95th percentile ${sg.straight.toFixed(2)} — under 1, the median frame foreshortens ${sg.joints[0]} → ${sg.joints[1]} and the factor is ${(1 / sg.ratio).toFixed(3)}× the one at its longest`;
1049
+ }
1050
+
1051
+ /**
1052
+ * The SCALE reading's remedy lines (rig-c/pose#240): the scale segment's median
1053
+ * against its 95th percentile, the legs' and hips' bands beside the factor (and
1054
+ * the line naming a factor outside both), and the hip's travel, pose beside rig.
1055
+ */
1056
+ export function scaleFitLines(factor: number, f: ScaleFit): string[] {
1057
+ const out: string[] = [];
1058
+ const sg = f.segment;
1059
+ const band = (b: ScaleBand) => `${b.lo.toFixed(5)}–${b.hi.toFixed(5)}`;
1060
+ const within = (w: boolean) => (w ? 'within' : 'outside');
1061
+ const leg = f.leg === null ? 'leg not read (needs both legs)' : `leg ${band(f.leg.band)} (${within(f.leg.within)})`;
1062
+ const hip = f.hip === null ? 'hip not read (needs both legs and both hips)' : `hip ${band(f.hip.band)} (${within(f.hip.within)})`;
1063
+ out.push(`bands, rig ÷ picture at its 95th percentile – median: ${leg}, ${hip}; scale.factor ${factor.toFixed(5)} (within: rig ÷ factor inside the picture's lengths ± 1 px)`);
1064
+ if (f.outside === true) {
1065
+ out.push(`outside both bands: scale.factor ${factor.toFixed(5)} sizes the figure as neither its legs nor its hips do — "translate" "scale" ${sg.joints[0]} → ${sg.joints[1]} is proportioned unlike the rig; a pair on the legs or hips reads inside a band`);
1066
+ }
1067
+ const t = f.travel;
1068
+ const per = (v: { legs: [number, number] | null; hips: [number, number] | null }, k: 0 | 1) => `${v.legs === null ? '' : ` = ${v.legs[k].toFixed(3)} legs`}${v.hips === null ? '' : ` = ${v.hips[k].toFixed(3)} hips`}`;
1069
+ out.push(`hip travel (${t.joint} → bone "${t.bone}"), range: pose x ${t.pose.x.toFixed(2)}px${per(t.pose, 0)}, y ${t.pose.y.toFixed(2)}px${per(t.pose, 1)}; rig x ${t.rig.x.toFixed(2)}${per(t.rig, 0)}, y ${t.rig.y.toFixed(2)}${per(t.rig, 1)}`);
1070
+ return out;
1071
+ }
1072
+
1073
+ /**
1074
+ * The SHEAR line's remedy clause (rig-c/pose#240): where the shear comes from
1075
+ * and the rig spec's `inherit` setting that removes it. Nothing on a shear that
1076
+ * prints as 0.00°, the line's own precision.
1077
+ */
1078
+ export function shearRemedy(x: { bone: string; shear: number }, rest: Rest, measured: ReadonlyArray<{ bone: string }>): string {
1079
+ if (x.shear.toFixed(2) === '0.00') return '';
1080
+ const b = rest.get(x.bone);
1081
+ if (b === undefined) return '';
1082
+ const inherit = b.inherit ?? 'normal';
1083
+ const parent = b.parent;
1084
+ const carried = parent !== null && measured.some((m) => m.bone === parent);
1085
+ if (inherit === 'normal' && carried) {
1086
+ return `; it inherits "normal" from scaled "${parent}" — "inherit": "noScale" on bone "${x.bone}" in the rig spec unshears its art and still turns it with "${parent}" ("onlyTranslation" also stops the turn)`;
1087
+ }
1088
+ if (inherit !== 'normal') return `; bone "${x.bone}" inherits "${inherit}", so the shear is not carried from "${parent ?? 'its parent'}"`;
1089
+ return `; its parent ${parent === null ? 'is none' : `"${parent}" is not scaled`}, so the shear is not carried from above`;
1090
+ }
1091
+
1092
+ /** rig-c/pose#240: how far --reduce may leave one ankle off the dense run's, as the landing check applies it. */
1093
+ export interface AnkleDrift {
1094
+ /** The ankle point, as its LAND line names it (`<bone>:tip`). */
1095
+ bone: string;
1096
+ side: 'r' | 'l' | null;
1097
+ /** The plant's own tolerance (without --reduce). */
1098
+ plant: number;
1099
+ /** `footReduce`: the most the reduced bones above it may move it. */
1100
+ reduce: number;
1101
+ /** plant + reduce, world units — the LAND line's tolerance. */
1102
+ units: number;
1103
+ /** `units` in that side's rig leg at setup (thigh origin → ankle); null without both legs. */
1104
+ legLengths: number | null;
1105
+ }
1106
+
1107
+ /** The drift line: the allowance on one frame, and what it lets a planted ankle slide between two. */
1108
+ export function ankleDriftLine(d: AnkleDrift): string {
1109
+ const legs = (v: number) => (d.legLengths === null ? '' : ` = ${(d.legLengths * v / d.units).toPrecision(3)} leg lengths`);
1110
+ return `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)} — a tighter slide bar needs a smaller --reduce`;
1111
+ }