@rig-c/pose 0.31.1 → 0.33.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,22 @@
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';
17
- import { attachmentBones, baseMotionOf, buildInto, rigcVersion, folds, gate, readBuild, resolveRig, restOf, slotsOf } from './rigc.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
+ import { attachmentBones, baseMotionOf, buildInto, rigcVersion, folds, gate, readBuild, resolveRig, restOf, seamRender, slotsOf } from './rigc.ts';
18
+ import { partsPath, projectSeams, readSeamDoc, readSeamPairs, seamLines, type SeamPair } from './seams.ts';
18
19
  import { artRangeLine, chainsMoving, raisedLine, RAISED_WITHIN, reachLine, switchLine, uncoveredFolds } from './switch.ts';
19
- import { landed, measureFeet, measureLanding, measurePosture, measureShear, poseFrames, postureLines } from './verify.ts';
20
+ import { jitterLine, landed, type FeetReading, type JitterRow, type Landing, measureFeet, measureJitter, measureLanding, measurePosture, measureShear, poseFrames, postureLines } from './verify.ts';
20
21
  import { legsOf, PLANT_SWITCH, STANCE_BOUNDS } from './legs.ts';
21
22
  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';
23
+ import type { BlendHold, Closure, Floor, FreeAnkleShift, LegScale, LegSegmentRatio, Lowered, Side, Stance, SupportScaleRun } from './legs.ts';
23
24
  import { reportRenderDomain, writeReport, type ReportInput } from './report.ts';
24
25
  import { bodyLine, faceLine } from './face.ts';
25
26
 
@@ -41,6 +42,8 @@ export interface RetargetArgs extends RetargetOptions {
41
42
  build?: string;
42
43
  /** Keep rigc's render of the animation (frames, contact sheet, folds.json) here. */
43
44
  render?: string;
45
+ /** @rig-c/parts' `parts.json` (or the directory holding it): the neighbour pairs the seam reading reads (#216). */
46
+ parts?: string;
44
47
  /** Write the run's measurements as JSON here. */
45
48
  report?: string;
46
49
  /** Write the sequence's per-frame relative figure scale and pan as a sidecar JSON here (#161). */
@@ -55,6 +58,14 @@ export interface RetargetArgs extends RetargetOptions {
55
58
  reduce?: number;
56
59
  /** With reduce: thin the hip's translate keys within this many rig units. */
57
60
  reduceHip?: number;
61
+ /**
62
+ * What stdout carries (rig-c/pose#238). `default`: the CLEAN line's hold record
63
+ * as a count and each hinge's wrong-way frames as one line; `verbose`: every
64
+ * line in full, as before #238; `summary`: the run line, one line per measure
65
+ * (GATE, FEET, LIFT, JITTER, LAND) and the `wrote` lines. Refusals are the
66
+ * same in all three and the gate is the same gate.
67
+ */
68
+ output?: 'default' | 'verbose' | 'summary';
58
69
  }
59
70
 
60
71
  export interface RunResult {
@@ -64,10 +75,16 @@ export interface RunResult {
64
75
 
65
76
  export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunResult {
66
77
  const lines: string[] = [];
67
- const say = (l: string) => {
78
+ const output = a.output ?? 'default';
79
+ /** A line every mode prints: the run line, the summary lines, the `wrote` lines. */
80
+ const tell = (l: string) => {
68
81
  lines.push(l);
69
82
  log(l);
70
83
  };
84
+ /** A line of the full output: default and --verbose print it; --summary does not. */
85
+ const say = (l: string) => {
86
+ if (output !== 'summary') tell(l);
87
+ };
71
88
  const refuse = (problems: readonly Problem[]): never => {
72
89
  throw new Refusal(problems);
73
90
  };
@@ -75,12 +92,18 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
75
92
  for (const [flag, path] of [['--map', a.map], ['--pose', a.pose], ['--merge', a.merge]] as const) {
76
93
  if (path !== undefined && !existsSync(path)) args.add('ARG_PATH', `${flag} ${path} does not exist`);
77
94
  }
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) {
95
+ // rig-c/pose#239: the report is written unless asked elsewhere — beside the motion, as <out>.report.json.
96
+ const report = a.report ?? defaultReportPath(a.out);
97
+ // A file this run writes goes into a directory the user named: one that is not there yet is made
98
+ // after the run is green (one line says so), as --build and --render make theirs; a file in the way is refused.
99
+ const makeDirs = new Map<string, string[]>();
100
+ for (const [flag, path] of [['--out', a.out], ['--report', report], ['--camera', a.camera]] as const) {
80
101
  if (path === undefined) continue;
81
102
  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`);
103
+ let up = dir;
104
+ while (!existsSync(up) && dirname(up) !== up) up = dirname(up);
105
+ 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`);
106
+ else if (up !== dir) makeDirs.set(dir, [...(makeDirs.get(dir) ?? []), `${flag} ${path}`]);
84
107
  }
85
108
  args.throwIfAny();
86
109
  const input = resolveRig(a.rig, a.baseMotion);
@@ -92,6 +115,11 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
92
115
  if (!(idleName in animations) && (a.idle !== undefined || a.carryIdle === true)) {
93
116
  refuse([{ code: 'IDLE_NAME', text: `${a.idle !== undefined ? `--idle "${idleName}"` : '--carry-idle reads the animation "idle", and'} the rig's motion spec ${input.motion === null ? '— there is none, so it has no animations' : `${input.motion} has no animation "${idleName}" (it has ${Object.keys(animations).join(', ') || 'none'})`}; name the rig's idle with --idle` }]);
94
117
  }
118
+ // #216: the neighbour pairs, read before any work so a bad --parts is refused first.
119
+ const seamDecl = a.parts === undefined ? null : (() => {
120
+ const path = partsPath(a.parts);
121
+ return { path, pairs: readSeamPairs(readFileSync(path, 'utf8'), path) };
122
+ })();
95
123
  const fragment = a.merge === undefined ? [] : readFragment(parseJson(readFileSync(a.merge, 'utf8'), a.merge, 'MERGE_JSON'), a.merge);
96
124
  const mapSnapshot = readReportInput(a.map), poseSnapshot = readReportInput(a.pose);
97
125
  const map = readMap(mapSnapshot.bytes.toString('utf8'), a.map);
@@ -106,7 +134,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
106
134
  // The rig as it stands, through the gate, so the rest pose is rig-c's reading of a green build.
107
135
  const g0 = gate(input.rig, base, join(scratch, 'base'), a.profile, a.images);
108
136
  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 }))]);
137
+ 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
138
  }
111
139
  const doc0 = readBuild(g0.build);
112
140
  const rigc = rigcVersion();
@@ -114,7 +142,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
114
142
  const slots = slotsOf(doc0);
115
143
  resolveMap(map, rest, a.map, slots, input.sets);
116
144
 
117
- const r = retarget(rest, map, frames, times, { ...a, ...(seq.travel !== undefined ? { travel: seq.travel } : {}) }, slots, input.sets);
145
+ const r = retarget(rest, map, frames, times, { ...a, ...(seq.travel !== undefined ? { travel: seq.travel } : {}), ...(seq.cleaned!.params.loop ? { loop: true } : {}) }, slots, input.sets);
118
146
  if (fragment.length > 0 && ((r.face?.sets?.length ?? 0) > 0 || (r.body?.sets?.length ?? 0) > 0)) {
119
147
  const known = knownSetsOf(map, input.sets);
120
148
  for (const s of [...(r.face?.sets ?? []), ...(r.body?.sets ?? [])]) {
@@ -131,20 +159,25 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
131
159
  }
132
160
  }
133
161
  }
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)` : ''}` : ''}`);
162
+ 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
163
  say(` RIGC rig-c ${rigc.version} (${rigc.entry}) gates and poses this run`);
136
- say(` CLEAN ${cleanLine(seq.cleaned!)}`);
164
+ say(` CLEAN ${output === 'verbose' ? cleanLine(seq.cleaned!) : cleanSummaryLine(seq.cleaned!)}`);
137
165
  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
166
  if (seq.camera !== undefined) say(` CAMERA ${cameraLine(seq.camera, times, seq.cameraSource)}`);
139
167
  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)})`);
168
+ if (r.scale !== null) {
169
+ const fit = r.scaleFit === null ? [] : scaleFitLines(r.scale.factor, r.scaleFit);
170
+ 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)}`}`);
171
+ for (const l of fit) say(` SCALE ${l}`);
172
+ }
141
173
  for (const h of r.holds) say(` HOLD ${holdLine(h, r.times)}`);
142
174
  for (const s of r.short) say(` ${s.across === true ? 'HOLD ' : 'SHORT '} ${shortLine(s, r.times, a.short)}`);
143
175
  if (a.short !== 'off') {
144
176
  for (const e of r.endOn) say(` END-ON ${endOnLine(e, r.times, a.short)}`);
145
177
  }
146
178
  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`);
179
+ if (output === 'verbose') for (const e of r.bends) say(` BEND ${bendLine(e)} — clamped straight`);
180
+ else for (const g of bendGroups(r.bends)) say(` BEND ${bendGroupLine(g, 'clamp')}`);
148
181
  for (const e of r.turns) say(` TURN ${turnLine(e, r.times)}`);
149
182
  for (const u of r.turnsUnread) say(` TURN line "${u.line}" (${u.joints[0]} → ${u.joints[1]}) not measured: ${u.why}`);
150
183
  if (map.alternates.length > 0) {
@@ -159,14 +192,16 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
159
192
  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
193
  for (const e of r.byHip) say(` BY-HIP ${raisedLine(e, r.times)}`);
161
194
  for (const e of r.byHip) if (e.short.length > 0) say(` REACH ${reachLine(e, r.times)}`);
195
+ for (const e of r.reach) say(` REACH ${reachCostLine(e, r.times)}`);
162
196
  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)}`);
197
+ for (const l of r.legScale) say(` LEG-SCALE ${legScaleLine(l, r.times, r.feet?.legLength ?? null, r.rigLeg)}`);
164
198
  for (const l of r.legScale) for (const s of l.support ?? []) say(` LEG-SCALE ${supportScaleLine(l, s, r.times)}`);
165
199
  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
200
  if (r.feet !== null) {
167
201
  const f = r.feet;
168
202
  const span = (x: [number, number]) => (x[0] === x[1] ? `${x[0]}` : `${x[0]}–${x[1]}`);
169
203
  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)`);
204
+ for (const sg of r.segments) say(` FEET ${segmentLine(sg)}`);
170
205
  say(` FEET supports: ${f.supports.map((x) => `${x.side} ${span(x.frames)}`).join(', ') || 'none'}`);
171
206
  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
207
  if (f.lowered !== undefined) say(` FEET ${loweredLine(f.lowered)}`);
@@ -179,7 +214,10 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
179
214
  }
180
215
  if (r.blendHold !== null) for (const l of blendLines(r.blendHold, r.times)) say(` FEET ${l}`);
181
216
  }
182
- if (r.stance !== null) for (const l of stanceLines(r.stance, r.times)) say(` STANCE ${l}`);
217
+ if (seq.travel === TRAVEL_IN_PLACE && !r.planted) say(` TRAVEL ${openTravelLine(r.openDrift)}`);
218
+ 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}`);
219
+ const nod = nodLine(r.headNod, r.nodSkipped, map.face?.pitch !== undefined);
220
+ if (nod !== null) say(` NOD ${nod}`);
183
221
  if (r.face !== null) say(` FACE ${faceLine(r.face)}`);
184
222
  if (r.body !== null) say(` BODY ${bodyLine(r.body)}`);
185
223
 
@@ -247,17 +285,27 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
247
285
  if (!g1.green) {
248
286
  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
287
  }
250
- say(` GATE rigc build --profile ${a.profile}: green${g1.summary === null ? '' : ` — ${g1.summary}`}`);
288
+ const gateText = gateLine(a.profile, g1.summary);
289
+ say(` GATE ${gateText}`);
251
290
 
252
291
  const doc = readBuild(g1.build);
253
292
  const posed = poseFrames(doc, a.name, r.times);
254
293
  // A reduced track is held to the dense target plus its tolerance (#32); an ankle, to what the
255
294
  // 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));
295
+ // #240: each ankle's allowance is computed once, here, and both the LAND tolerance and the drift line read it.
296
+ const drift: AnkleDrift[] = [];
297
+ const landings = measureLanding(doc, a.name, map, r, posed).map((l) => {
298
+ if (a.reduce === undefined) return l;
299
+ if (l.kind === 'angle') return { ...l, tolerance: l.tolerance + a.reduce };
300
+ if (l.kind === 'origin' && a.reduceHip !== undefined) return { ...l, tolerance: l.tolerance + a.reduceHip };
301
+ if (l.kind !== 'foot') return l;
302
+ const ankle = l.bone.replace(/:tip$/, '');
303
+ const by = footReduce(rest, r, ankle, a.reduce, a.reduceHip);
304
+ const lb = legBonesOf(map);
305
+ const side = lb?.r.ankle === ankle ? 'r' : lb?.l.ankle === ankle ? 'l' : null;
306
+ 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] });
307
+ return { ...l, tolerance: l.tolerance + by };
308
+ });
261
309
  const p = new Problems();
262
310
  // 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
311
  const carriedAbove = (bone: string): string => {
@@ -278,6 +326,9 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
278
326
  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
327
  }
280
328
  p.throwIfAny();
329
+ // #236: each keyed bone's jitter as posed, against its map pair's — names the bone that moved a group's ratio.
330
+ const jitter = measureJitter(posed, map, r, frames, a.minScore);
331
+ for (const j of jitter) say(` JITTER ${jitterLine(j)}`);
281
332
 
282
333
  const lengths = new Map(doc.bones.map((b) => [b.name, b.length ?? 0]));
283
334
  const hips = seq.cleaned?.params.hipsInferred;
@@ -286,15 +337,10 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
286
337
  const legBones = legBonesOf(map);
287
338
  const feetReading = legBones === null ? null : measureFeet(posed, lengths, r, legBones);
288
339
  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]})`);
340
+ for (const l of posedFeetLines(feetReading)) say(` FEET ${l}`);
295
341
  }
296
342
  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`);
343
+ 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
344
 
299
345
  const dts = r.times.slice(1).map((t, i) => t - r.times[i]!).sort((x, y) => x - y);
300
346
  const fps = dts.length === 0 ? 12 : Math.max(1, Math.min(60, Math.round(1 / dts[dts.length >> 1]!)));
@@ -309,8 +355,15 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
309
355
  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
356
  }
311
357
 
312
- if (a.report !== undefined) assertReportInputs([rigSnapshot,mapSnapshot,poseSnapshot]);
313
- const reportText = a.report === undefined ? null : writeReport({
358
+ // #216: the seam reading over the declared neighbour pairs — a reading, never a gate.
359
+ const seamRead = readSeams(seamDecl, input.rig, doc.slots.map((s) => s.name), g1.build, a.name, join(scratch, 'seams'), fps);
360
+ for (const l of seamRead.lines) say(` SEAM ${l}`);
361
+
362
+ // #240: what --reduce allowed each ankle, at the end of the run, from the same figure the LAND check applied.
363
+ for (const d of drift) say(` REDUCE ${ankleDriftLine(d)}`);
364
+
365
+ assertReportInputs([rigSnapshot,mapSnapshot,poseSnapshot]);
366
+ const reportText = writeReport({
314
367
  spec: 'rig-pose-report/1',
315
368
  rigc,
316
369
  rig: input.name,
@@ -320,6 +373,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
320
373
  cleaned: seq.cleaned,
321
374
  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
375
  scale: r.scale,
376
+ scaleFit: r.scaleFit,
323
377
  holds: r.holds,
324
378
  short: r.short,
325
379
  endOn: r.endOn,
@@ -327,11 +381,15 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
327
381
  flips: r.flips,
328
382
  gate: g1.summary,
329
383
  landings,
384
+ jitter,
330
385
  posture,
331
386
  folds: f.meshes,
387
+ seams: seamRead.seams,
388
+ seamPairs: seamRead.seamPairs,
332
389
  switches: r.switches,
333
390
  uncovered,
334
391
  turns: r.turns,
392
+ reach: r.reach,
335
393
  range: r.range,
336
394
  artRange: r.artRange,
337
395
  raised: r.raised,
@@ -358,16 +416,16 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
358
416
  ...(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
417
  ...(r.body.sets === undefined ? {} : { sets: r.body.sets }),
360
418
  },
361
- legScale: r.legScale.map(({ scale: _scale, asked: _asked, ...rest }) => rest),
419
+ legScale: r.legScale.map(({ scale: _scale, asked: _asked, picture: _picture, ...rest }) => rest),
362
420
  pivot: r.pivot,
363
421
  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 },
422
+ 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
423
  shear,
366
424
  idleDropped: idle === null ? null : idle.dropped,
367
425
  idleKeptOut: idle === null ? null : idle.keptOut,
368
426
  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 },
370
- }, 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});
427
+ reduce: reduced === null ? null : { tolerance: a.reduce, hipTolerance: a.reduceHip ?? null, rows: reduced.rows, ankleDrift: drift },
428
+ }, 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});
371
429
  if (a.build !== undefined) {
372
430
  // Built again where it will live, not copied: rigc's page paths are relative to the build (#18).
373
431
  rmSync(a.build, { recursive: true, force: true });
@@ -375,7 +433,11 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
375
433
  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
434
  }
377
435
  // The kept build reads the rig again; its bytes must still match the report.
378
- if (a.report !== undefined) assertReportInputs([rigSnapshot,mapSnapshot,poseSnapshot]);
436
+ assertReportInputs([rigSnapshot,mapSnapshot,poseSnapshot]);
437
+ for (const [dir, flags] of makeDirs) {
438
+ mkdirSync(dir, { recursive: true });
439
+ tell(madeLine(dir, flags));
440
+ }
379
441
  writeFileSync(a.out, `${JSON.stringify(motion, null, 2)}\n`);
380
442
  if (a.camera !== undefined) {
381
443
  const ss = r.figureScale.frames.map((x) => x.s);
@@ -410,16 +472,96 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
410
472
  rmSync(a.render, { recursive: true, force: true });
411
473
  cpSync(f.frames, a.render, { recursive: true });
412
474
  }
413
- if (a.report !== undefined && reportText !== null) writeFileSync(a.report, reportText);
475
+ const previous = existsSync(report) ? statSync(report).mtime : null;
476
+ writeFileSync(report, reportText);
414
477
 
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(', ')}`);
478
+ if (output === 'summary') for (const l of summaryLines({ gate: gateText, feet: feetReading, stance: r.stance, jitter, landings, times: r.times })) tell(` ${l}`);
479
+ if (a.camera !== undefined) tell(` wrote ${a.camera} — camera track (${r.figureScale.frames.length} frames)`);
480
+ tell(reportLine(report, a.report === undefined, previous));
481
+ tell(` wrote ${a.out} — animations: ${Object.keys(motion.animations).join(', ')}`);
417
482
  return { lines, written: true };
418
483
  } finally {
419
484
  rmSync(scratch, { recursive: true, force: true });
420
485
  }
421
486
  }
422
487
 
488
+ /** The GATE line's text: the profile and rig-c's own summary of the green build. */
489
+ export function gateLine(profile: string, summary: string | null): string {
490
+ return `rigc build --profile ${profile}: green${summary === null ? '' : ` — ${summary}`}`;
491
+ }
492
+
493
+ /** The FEET lines of the posed build: the lowest ankle's range, and the supporting ankle's worst over the support spans. */
494
+ export function posedFeetLines(f: FeetReading): string[] {
495
+ 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`];
496
+ const plant = plantText(f);
497
+ if (plant !== null) out.push(`the supporting ankle over each support span: ${plant}`);
498
+ return out;
499
+ }
500
+
501
+ const lowestText = (f: FeetReading): string => `${f.lowest.range.toFixed(2)} units = ${f.lowest.legs.toFixed(4)} leg lengths`;
502
+
503
+ function plantText(f: FeetReading): string | null {
504
+ const worst = (k: 'verticalLegs' | 'horizontalLegs') => f.plants.reduce<(typeof f.plants)[number] | null>((m, x) => (m === null || x[k] > m[k] ? x : m), null);
505
+ const wv = worst('verticalLegs');
506
+ const wh = worst('horizontalLegs');
507
+ if (wv === null || wh === null) return null;
508
+ 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]})`;
509
+ }
510
+
511
+ /** What `--summary` reads (rig-c/pose#238): the same records the full lines print. */
512
+ export interface SummaryInput {
513
+ gate: string;
514
+ feet: FeetReading | null;
515
+ stance: Stance | null;
516
+ jitter: readonly JitterRow[];
517
+ landings: readonly Landing[];
518
+ times: readonly number[];
519
+ }
520
+
521
+ /**
522
+ * `--summary`'s lines (rig-c/pose#238), one per measure, in the order a second
523
+ * run is compared on: GATE (the GATE line), FEET (the posed build's two FEET
524
+ * lines, joined), LIFT (the STANCE runs counted), JITTER (the JITTER rows' worst
525
+ * ratio) and LAND (each landing kind's worst against its tolerance). Each figure
526
+ * is formatted as the full line formats it, from the same record.
527
+ */
528
+ export function summaryLines(x: SummaryInput): string[] {
529
+ const out = [`GATE ${x.gate}`];
530
+ if (x.feet === null) out.push('FEET not measured: the map maps no legs');
531
+ else {
532
+ const plant = plantText(x.feet);
533
+ out.push(`FEET lowest ankle's vertical range ${lowestText(x.feet)} over ${x.feet.lowest.frames} frames; supporting ankle ${plant ?? 'over no support span'}`);
534
+ }
535
+ if (x.stance === null) out.push('LIFT not read (no --stance)');
536
+ else {
537
+ const own = x.stance.runs.filter((r) => r.absorbed !== true);
538
+ const count = (state: Stance['runs'][number]['state']) => {
539
+ const rs = own.filter((r) => r.state === state);
540
+ return { runs: rs.length, frames: rs.reduce((t, r) => t + r.frames[1] - r.frames[0] + 1, 0), rs };
541
+ };
542
+ const lifted = count('lifted'), both = count('both'), below = count('below');
543
+ const top = lifted.rs.reduce<Stance['runs'][number] | null>((m, r) => (m === null || r.maxHeight > m.maxHeight ? r : m), null);
544
+ 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`);
545
+ }
546
+ const read = x.jitter.filter((j) => j.ratio !== null);
547
+ const worst = read.reduce<JitterRow | null>((m, j) => (m === null || j.ratio! > m.ratio! ? j : m), null);
548
+ const still = x.jitter.filter((j) => j.triples > 0 && j.ratio === null).length;
549
+ const unread = x.jitter.filter((j) => j.triples === 0).length;
550
+ 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`);
551
+ const kinds: Array<[Landing['kind'], string, string]> = [['angle', 'aim', '°'], ['origin', 'origin', ' units'], ['scale', 'scale', ' of its setup length'], ['foot', 'ankle', ' units']];
552
+ const parts = kinds.flatMap(([kind, what, unit]) => {
553
+ const ls = x.landings.filter((l) => l.kind === kind);
554
+ if (ls.length === 0) return [];
555
+ const w = ls.reduce((m, l) => (l.worst > m.worst ? l : m));
556
+ const tol = kind === 'foot' ? Number(w.tolerance.toPrecision(3)) : w.tolerance;
557
+ 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}`];
558
+ });
559
+ out.push(`LAND ${x.landings.length} landed: ${parts.join('; ') || 'none'}`);
560
+ return out;
561
+ }
562
+
563
+ const spanText = (x: [number, number]) => (x[0] === x[1] ? `${x[0]}` : `${x[0]}–${x[1]}`);
564
+
423
565
  /**
424
566
  * The retarget's rotate tracks (and with a hip tolerance, its translate) thinned
425
567
  * in place (#32), on the final keys: the plant, the one floor, the stretches and
@@ -720,6 +862,16 @@ function loweredLine(lowered: readonly Lowered[]): string {
720
862
  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
863
  }
722
864
 
865
+ /**
866
+ * The in-place line without --plant-feet (rig-c/pose#242): what the declaration still changes, and the one thing it
867
+ * cannot without planted feet — the loop closure, which spreads back the planted hip's drift. `drift` is the
868
+ * translated bone's x on the last frame less the first (null: the map translates no bone).
869
+ */
870
+ export function openTravelLine(drift: number | null): string {
871
+ 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`;
872
+ 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}`;
873
+ }
874
+
723
875
  /** The in-place line (rig-c/pose#92): what --plant-feet did instead of holding the foot, and the loop's closure. */
724
876
  export function travelLine(c: Closure): string {
725
877
  const per = c.frames > 1 ? Math.abs(c.drift) / (c.frames - 1) : 0;
@@ -741,9 +893,16 @@ export function blendLines(h: BlendHold, times: readonly number[]): string[] {
741
893
  * frames, with the tolerance and the stretch keyed; one per run a stretch was
742
894
  * capped.
743
895
  */
744
- export function stanceLines(st: Stance, times: readonly number[]): string[] {
896
+ export function stanceLines(st: Stance, times: readonly number[], unit?: StanceUnit): string[] {
745
897
  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`;
898
+ const tol = unit === undefined ? `the tolerance ${st.tolerance} leg lengths` : `the tolerance ${st.tolerance} leg lengths (${pxPair(st.tolerance, unit.legLength)})`;
899
+ // #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.
900
+ const kept = (r: Stance['runs'][number]): string => {
901
+ if (r.moved === undefined) return "the pose's height kept";
902
+ const all = r.frames[1] - r.frames[0] + 1;
903
+ 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)`;
904
+ 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")}`;
905
+ };
747
906
  const release = `planted again under ${st.release} of it, ${(st.release * st.tolerance).toFixed(4)}`;
748
907
  const NAME: Record<Stance['runs'][number]['state'], string> = { both: 'planted-both', lifted: 'lifted', below: 'below' };
749
908
  // An absorbed run lies inside a run of the state it took; the line names that state.
@@ -755,13 +914,52 @@ export function stanceLines(st: Stance, times: readonly number[]): string[] {
755
914
  : r.state === 'both'
756
915
  ? `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
916
  : 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`));
917
+ ? `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)}`
918
+ : `lifted: free foot ${r.side} ${span(r.frames)} — ${where(r)}, over ${tol} (${release}); ${kept(r)}`));
919
+ if (unit !== undefined) out.unshift(stanceUnitLine(st.tolerance, unit));
760
920
  if (st.range !== undefined) out.push(legRangeLine('free', st.range, st.bounds));
761
921
  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
922
  return out;
763
923
  }
764
924
 
925
+ /** 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. */
926
+ export interface StanceUnit {
927
+ /** FEET's: each side's median picture length hip → ankle, px. */
928
+ legLength: Record<Side, number>;
929
+ /** Each side's 95th-percentile picture length hip → ankle, px. */
930
+ straight: Record<Side, number>;
931
+ /** Each side's rig leg at setup, thigh origin → ankle, world units. */
932
+ rigLeg: Record<Side, number> | null;
933
+ }
934
+
935
+ const spanOf = (x: [number, number]) => (x[0] === x[1] ? `${x[0]}` : `${x[0]}–${x[1]}`);
936
+
937
+ /** `v` leg lengths in each side's px: `r 10.5 / l 10.6 px`. */
938
+ function pxPair(v: number, legLength: Record<Side, number>): string {
939
+ return `r ${(v * legLength.r).toFixed(1)} / l ${(v * legLength.l).toFixed(1)} px`;
940
+ }
941
+
942
+ /** 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. */
943
+ export function stanceUnitLine(tolerance: number, u: StanceUnit): string {
944
+ const share = (s: Side) => (tolerance * u.legLength[s]) / u.straight[s];
945
+ 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`;
946
+ }
947
+
948
+ /** 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`. */
949
+ function shiftText(m: FreeAnkleShift, unit: { legLength: Record<Side, number> | null; rigLeg: Record<Side, number> | null } | undefined, against: string): string {
950
+ 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}`;
951
+ const parts = [...(m.above === null ? [] : [one(m.above, `above ${against}`)]), ...(m.below === null ? [] : [one(m.below, `below ${against}`)])];
952
+ 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)`;
953
+ return `${parts.length === 0 ? `by 0 leg lengths, at ${against}` : parts.join(' and ')}${leg}`;
954
+ }
955
+
956
+ /** The FEET segment line (rig-c/pose#237): a mapped leg segment's picture length ÷ straight, read with or without --leg-scale. */
957
+ export function segmentLine(sg: LegSegmentRatio): string {
958
+ const head = `segment chain "${sg.chain}" bone "${sg.bone}" (${sg.joints[0]} → ${sg.joints[1]})`;
959
+ if (sg.straight === null || sg.ratio === null) return `${head}: picture length ÷ straight not measured — ${sg.why ?? 'no straight length'}`;
960
+ 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`;
961
+ }
962
+
765
963
  /** A bound as the lines print it: the default's figures as written, a derived lower bound to 4 places (#95). */
766
964
  function boundText(b: readonly [number, number]): string {
767
965
  return `[${b[0] === STANCE_BOUNDS[0] ? `${b[0]}` : b[0].toFixed(4)}, ${b[1]}]`;
@@ -774,7 +972,7 @@ function legRangeLine(which: 'free' | 'supporting', range: Partial<Record<'r' |
774
972
  }
775
973
 
776
974
  /** 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 {
975
+ export function legScaleLine(l: LegScale, times: readonly number[], legLength: Record<Side, number> | null = null, rigLeg: Record<Side, number> | null = null): string {
778
976
  const span = (x: [number, number]) => (x[0] === x[1] ? `${x[0]}` : `${x[0]}–${x[1]}`);
779
977
  const count = (rs: Array<[number, number]>) => rs.reduce((k, x) => k + x[1] - x[0] + 1, 0);
780
978
  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 +987,15 @@ function legScaleLine(l: LegScale, times: readonly number[]): string {
789
987
  const detail = rs.length === 1 ? ` (${askedOf(rs[0]!, kind)})` : ` (${rs.map((x) => `${span(x)}: ${askedOf(x, kind)}`).join(', ')})`;
790
988
  return `${count(rs)} frame(s) ${how} ${at}, clamped (frames ${rs.map(span).join(', ')})${detail}`;
791
989
  };
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')}`;
990
+ // #237: the straight length against FEET's leg length, the limits in px, and what the clamps did to a free ankle.
991
+ const side: Side = l.joints[0].startsWith('r_') ? 'r' : 'l';
992
+ const share = legLength === null ? '' : ` = ${(l.straight / legLength[side]).toFixed(3)} of FEET's leg length (median hip → ankle, ${side} ${legLength[side].toFixed(1)}px)`;
993
+ const limitsPx = `${(l.limits[0] * l.straight).toFixed(1)}–${(l.limits[1] * l.straight).toFixed(1)}px`;
994
+ const clampedFrames = count(l.low) + count(l.high);
995
+ const free = l.free !== undefined
996
+ ? `; 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)")}`
997
+ : clampedFrames > 0 && legLength !== null ? `; its leg free on none of its ${clampedFrames} clamped frame(s)` : '';
998
+ 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
999
  }
794
1000
 
795
1001
  /** 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 +1029,144 @@ export function readReportInput(file:string):ReportInputSnapshot {
823
1029
  export function assertReportInputs(inputs:readonly ReportInputSnapshot[]):void {
824
1030
  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
1031
  }
1032
+
1033
+ /**
1034
+ * #216: the seam reading end to end — the declared pairs, each pair whose part
1035
+ * has no slot of its name left unread (rigc would refuse the whole render for
1036
+ * it), one `rigc render --seam` over the rest, and its projection. Whatever it
1037
+ * finds, or fails to read, it says so and refuses nothing.
1038
+ */
1039
+ function readSeams(decl: { path: string; pairs: SeamPair[] } | null, rig: string, slotNames: string[], build: string, animation: string, dir: string, fps: number): ReturnType<typeof projectSeams> & { lines: string[]; frames: unknown } {
1040
+ if (decl === null || decl.pairs.length === 0) {
1041
+ let why: string;
1042
+ if (decl !== null) why = `${decl.path} holds no limb segment below another (a part's "limb" record), so it declares no pair`;
1043
+ else {
1044
+ // @rig-c/parts writes rig/ and parts.json side by side under its --out; a rig spec itself carries no pair.
1045
+ const home = dirname(dirname(rig));
1046
+ why = `a rig spec carries none and no --parts was named${existsSync(join(home, 'parts.json')) ? ` (${home} holds parts.json: --parts ${home} reads its limbs)` : ''}`;
1047
+ }
1048
+ return { seams: [], seamPairs: [], frames: null, lines: [`no neighbour pair declared — ${why}; seams: []`] };
1049
+ }
1050
+ const have = new Set(slotNames);
1051
+ const unread = new Map<string, string>();
1052
+ for (const p of decl.pairs) {
1053
+ const missing = [p.a, p.b].filter((n) => !have.has(n));
1054
+ if (missing.length > 0) unread.set(p.pair, `${missing.map((n) => `part "${n}"`).join(' and ')} has no slot of its name in the rig (a part is drawn by the slot of its name)`);
1055
+ }
1056
+ const toRead = decl.pairs.filter((p) => !unread.has(p.pair));
1057
+ let rows: ReturnType<typeof readSeamDoc> = [];
1058
+ let frames: unknown = null;
1059
+ if (toRead.length > 0) {
1060
+ const s = seamRender(build, animation, dir, fps, toRead.map((p) => [p.a, p.b] as const));
1061
+ let why: string | null = null;
1062
+ if (s.seam === null) {
1063
+ const said = s.run.out.split('\n').map((l) => l.trim()).find((l) => l.startsWith('rigc: ') && !l.startsWith('rigc: wrote'));
1064
+ why = `rigc render --seam did not read (exit ${s.run.status})${said === undefined ? '' : `: ${said}`}`;
1065
+ } else {
1066
+ try {
1067
+ rows = readSeamDoc(s.seam.text, s.seam.path);
1068
+ frames = s.frames;
1069
+ } catch (e) {
1070
+ if (!(e instanceof Refusal)) throw e;
1071
+ why = e.message;
1072
+ }
1073
+ }
1074
+ if (why !== null) for (const p of toRead) unread.set(p.pair, why);
1075
+ }
1076
+ const projected = projectSeams(decl.pairs, rows, unread);
1077
+ return { ...projected, frames, lines: seamLines(projected, fps) };
1078
+ }
1079
+
1080
+ /** 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). */
1081
+ export function defaultReportPath(out: string): string {
1082
+ return `${out.endsWith('.json') ? out.slice(0, -'.json'.length) : out}.report.json`;
1083
+ }
1084
+
1085
+ /** rig-c/pose#239: the line a directory this run made prints — the directory, and the paths that named it. */
1086
+ export function madeLine(dir: string, flags: readonly string[]): string {
1087
+ return ` made directory ${dir} — it did not exist; ${flags.join(' and ')} ${flags.length === 1 ? 'is' : 'are'} written into it`;
1088
+ }
1089
+
1090
+ /** rig-c/pose#239: the report's `wrote` line — whether it was the default, and the mtime of the report it replaced. */
1091
+ export function reportLine(report: string, byDefault: boolean, previous: Date | null): string {
1092
+ 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()}`}`;
1093
+ }
1094
+
1095
+ /**
1096
+ * rig-c/pose#239: `--rig` naming a rig.json reads no motion spec, even with one beside it. When the base
1097
+ * gate is red that way, the refusal says the motion beside it was not read and names the two forms that read it.
1098
+ */
1099
+ export function unreadMotionHint(rigArg: string, motion: string | null): string {
1100
+ if (motion !== null) return '';
1101
+ const beside = join(dirname(rigArg), 'motion.json');
1102
+ if (!existsSync(beside) || statSync(rigArg).isDirectory()) return '';
1103
+ 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}`;
1104
+ }
1105
+
1106
+ /** The SCALE line's own clause (rig-c/pose#240): the median it divides by, against the segment at its longest. */
1107
+ export function segmentClause(f: ScaleFit): string {
1108
+ const sg = f.segment;
1109
+ 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`;
1110
+ }
1111
+
1112
+ /**
1113
+ * The SCALE reading's remedy lines (rig-c/pose#240): the scale segment's median
1114
+ * against its 95th percentile, the legs' and hips' bands beside the factor (and
1115
+ * the line naming a factor outside both), and the hip's travel, pose beside rig.
1116
+ */
1117
+ export function scaleFitLines(factor: number, f: ScaleFit): string[] {
1118
+ const out: string[] = [];
1119
+ const sg = f.segment;
1120
+ const band = (b: ScaleBand) => `${b.lo.toFixed(5)}–${b.hi.toFixed(5)}`;
1121
+ const within = (w: boolean) => (w ? 'within' : 'outside');
1122
+ const leg = f.leg === null ? 'leg not read (needs both legs)' : `leg ${band(f.leg.band)} (${within(f.leg.within)})`;
1123
+ const hip = f.hip === null ? 'hip not read (needs both legs and both hips)' : `hip ${band(f.hip.band)} (${within(f.hip.within)})`;
1124
+ 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)`);
1125
+ if (f.outside === true) {
1126
+ 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`);
1127
+ }
1128
+ const t = f.travel;
1129
+ 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`}`;
1130
+ 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)}`);
1131
+ return out;
1132
+ }
1133
+
1134
+ /**
1135
+ * The SHEAR line's remedy clause (rig-c/pose#240): where the shear comes from
1136
+ * and the rig spec's `inherit` setting that removes it. Nothing on a shear that
1137
+ * prints as 0.00°, the line's own precision.
1138
+ */
1139
+ export function shearRemedy(x: { bone: string; shear: number }, rest: Rest, measured: ReadonlyArray<{ bone: string }>): string {
1140
+ if (x.shear.toFixed(2) === '0.00') return '';
1141
+ const b = rest.get(x.bone);
1142
+ if (b === undefined) return '';
1143
+ const inherit = b.inherit ?? 'normal';
1144
+ const parent = b.parent;
1145
+ const carried = parent !== null && measured.some((m) => m.bone === parent);
1146
+ if (inherit === 'normal' && carried) {
1147
+ 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)`;
1148
+ }
1149
+ if (inherit !== 'normal') return `; bone "${x.bone}" inherits "${inherit}", so the shear is not carried from "${parent ?? 'its parent'}"`;
1150
+ return `; its parent ${parent === null ? 'is none' : `"${parent}" is not scaled`}, so the shear is not carried from above`;
1151
+ }
1152
+
1153
+ /** rig-c/pose#240: how far --reduce may leave one ankle off the dense run's, as the landing check applies it. */
1154
+ export interface AnkleDrift {
1155
+ /** The ankle point, as its LAND line names it (`<bone>:tip`). */
1156
+ bone: string;
1157
+ side: 'r' | 'l' | null;
1158
+ /** The plant's own tolerance (without --reduce). */
1159
+ plant: number;
1160
+ /** `footReduce`: the most the reduced bones above it may move it. */
1161
+ reduce: number;
1162
+ /** plant + reduce, world units — the LAND line's tolerance. */
1163
+ units: number;
1164
+ /** `units` in that side's rig leg at setup (thigh origin → ankle); null without both legs. */
1165
+ legLengths: number | null;
1166
+ }
1167
+
1168
+ /** The drift line: the allowance on one frame, and what it lets a planted ankle slide between two. */
1169
+ export function ankleDriftLine(d: AnkleDrift): string {
1170
+ const legs = (v: number) => (d.legLengths === null ? '' : ` = ${(d.legLengths * v / d.units).toPrecision(3)} leg lengths`);
1171
+ 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`;
1172
+ }