@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/README.md +42 -19
- package/cli.ts +84 -25
- package/package.json +1 -1
- package/src/clean.ts +23 -2
- package/src/legs.ts +93 -0
- package/src/map.ts +35 -4
- package/src/problems.ts +9 -2
- package/src/retarget.ts +461 -27
- package/src/run.ts +332 -46
- package/src/verify.ts +111 -2
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
|
|
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
|
-
//
|
|
79
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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)
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
|
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
|
-
|
|
313
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
461
|
+
const previous = existsSync(report) ? statSync(report).mtime : null;
|
|
462
|
+
writeFileSync(report, reportText);
|
|
414
463
|
|
|
415
|
-
if (
|
|
416
|
-
|
|
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,
|
|
759
|
-
: `lifted: free foot ${r.side} ${span(r.frames)} — ${where(r)}, over ${tol} (${release});
|
|
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
|
-
|
|
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
|
+
}
|