@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/README.md +46 -20
- package/cli.ts +91 -26
- package/package.json +2 -2
- 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/report.ts +26 -2
- package/src/retarget.ts +461 -27
- package/src/rigc.ts +18 -0
- package/src/run.ts +395 -48
- package/src/seams.ts +241 -0
- package/src/verify.ts +111 -2
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
|
|
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
|
-
//
|
|
79
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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)
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
|
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
|
-
|
|
313
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
475
|
+
const previous = existsSync(report) ? statSync(report).mtime : null;
|
|
476
|
+
writeFileSync(report, reportText);
|
|
414
477
|
|
|
415
|
-
if (
|
|
416
|
-
|
|
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,
|
|
759
|
-
: `lifted: free foot ${r.side} ${span(r.frames)} — ${where(r)}, over ${tol} (${release});
|
|
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
|
-
|
|
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
|
+
}
|