@rig-c/pose 0.16.0 → 0.18.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 +77 -14
- package/cli.ts +73 -17
- package/package.json +1 -1
- package/src/clean.ts +346 -10
- package/src/legs.ts +423 -41
- package/src/map.ts +49 -3
- package/src/pose.ts +322 -24
- package/src/reduce.ts +167 -7
- package/src/retarget.ts +159 -48
- package/src/run.ts +251 -27
- package/src/switch.ts +31 -8
- package/src/verify.ts +29 -4
package/src/run.ts
CHANGED
|
@@ -9,16 +9,16 @@ import { tmpdir } from 'node:os';
|
|
|
9
9
|
import { dirname, join, resolve } from 'node:path';
|
|
10
10
|
import { readMap, resolveMap, type JointMap, type Rest } from './map.ts';
|
|
11
11
|
import { cleanLine, inferredSource } from './clean.ts';
|
|
12
|
-
import { convertCoco17, personTrack, readPoseSequence, type CameraFrame } from './pose.ts';
|
|
12
|
+
import { convertCoco17, personTrack, readPoseSequence, TRAVEL_IN_PLACE, type CameraFrame } from './pose.ts';
|
|
13
13
|
import { isObject, lineOf, parseJson, Problems, Refusal, type Problem } from './problems.ts';
|
|
14
14
|
import { boneIdentity, droppedLine, keptOutLine, mergeProblems, readFragment, readIdle, targetOf, trackLabel, type IdleReading, type RigcTrack, type Target } from './idle.ts';
|
|
15
15
|
import { bendLine, endOnLine, flipLine, holdLine, rangeLine, retarget, shortLine, turnLine, type MotionTrack, type RetargetOptions } from './retarget.ts';
|
|
16
16
|
import { attachmentBones, baseMotionOf, buildInto, rigcVersion, folds, gate, readBuild, resolveRig, restOf, slotsOf } from './rigc.ts';
|
|
17
17
|
import { artRangeLine, chainsMoving, raisedLine, RAISED_WITHIN, reachLine, switchLine, uncoveredFolds } from './switch.ts';
|
|
18
18
|
import { landed, measureFeet, measureLanding, measurePosture, measureShear, poseFrames, postureLines } from './verify.ts';
|
|
19
|
-
import { legsOf } from './legs.ts';
|
|
20
|
-
import { reduceKeys, type ReduceBone, type Reduced } from './reduce.ts';
|
|
21
|
-
import type { LegScale, Stance } from './legs.ts';
|
|
19
|
+
import { legsOf, PLANT_SWITCH, STANCE_BOUNDS } from './legs.ts';
|
|
20
|
+
import { M2_IDENTITY, mulM2, reduceKeys, reduceScale, reduceUnderScale, SCALE_REDUCE, setupLocal, type M2, type ReduceBone, type Reduced, type ReduceRow, type UnderBone } from './reduce.ts';
|
|
21
|
+
import type { BlendHold, Closure, Floor, LegScale, Lowered, Side, Stance } from './legs.ts';
|
|
22
22
|
|
|
23
23
|
export interface RetargetArgs extends RetargetOptions {
|
|
24
24
|
rig: string;
|
|
@@ -107,7 +107,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
107
107
|
const slots = slotsOf(doc0);
|
|
108
108
|
resolveMap(map, rest, a.map, slots);
|
|
109
109
|
|
|
110
|
-
const r = retarget(rest, map, frames, times, a, slots);
|
|
110
|
+
const r = retarget(rest, map, frames, times, { ...a, ...(seq.travel !== undefined ? { travel: seq.travel } : {}) }, slots);
|
|
111
111
|
say(`rig-pose retarget: ${seq.frames.length} frame(s), ${r.animation.duration.toFixed(3)}s, onto "${input.name}" as "${a.name}" — ${r.animation.tracks.length} track(s); min-score ${a.minScore}, flip ${a.flip === 'off' ? 'off' : `${a.flip}°`}, short ${a.short}, bends ${a.bends} (tolerance ${a.bendTolerance}°)${a.legScale !== undefined && a.legScale !== 'off' ? `, leg-scale ${a.legScale[0]}–${a.legScale[1]}` : ''}${a.plantFeet === true ? ', plant-feet' : ''}${a.stance !== undefined && a.stance !== 'off' ? `, stance ${a.stance}` : ''}${a.raisedByHead === true ? ', raised-by-head' : ''}${a.carryIdle === true ? ', carry-idle' : ''}${a.merge !== undefined ? `, merge ${a.merge}` : ''}${a.reduce !== undefined ? `, reduce ${a.reduce}°${a.reduceHip !== undefined ? ` (hip ${a.reduceHip} units)` : ''}` : ''}`);
|
|
112
112
|
say(` RIGC rig-c ${rigc.version} (${rigc.entry}) gates and poses this run`);
|
|
113
113
|
say(` CLEAN ${cleanLine(seq.cleaned!)}`);
|
|
@@ -133,6 +133,9 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
133
133
|
if (a.raisedByHead === true && r.raised.length === 0) say(` RAISED none — no set drawing an arm within ${RAISED_WITHIN}° of straight up showed; no key moved`);
|
|
134
134
|
for (const e of r.raised) say(` RAISED ${raisedLine(e, r.times)}`);
|
|
135
135
|
for (const e of r.raised) if (e.short.length > 0) say(` REACH ${reachLine(e, r.times)}`);
|
|
136
|
+
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`);
|
|
137
|
+
for (const e of r.byHip) say(` BY-HIP ${raisedLine(e, r.times)}`);
|
|
138
|
+
for (const e of r.byHip) if (e.short.length > 0) say(` REACH ${reachLine(e, r.times)}`);
|
|
136
139
|
for (const e of r.artRange) say(` ART-RANGE ${artRangeLine(e, r.times)}`);
|
|
137
140
|
for (const l of r.legScale) say(` LEG-SCALE ${legScaleLine(l, r.times)}`);
|
|
138
141
|
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`);
|
|
@@ -142,16 +145,27 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
142
145
|
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)`);
|
|
143
146
|
say(` FEET supports: ${f.supports.map((x) => `${x.side} ${span(x.frames)}`).join(', ') || 'none'}`);
|
|
144
147
|
say(` FEET jumps (both ankles and mid_hip > 0.05 leg lengths above their rolling floor, ≥ 2 frames): ${f.jumps.map(span).join(', ') || 'none'}`);
|
|
145
|
-
|
|
148
|
+
if (f.lowered !== undefined) say(` FEET ${loweredLine(f.lowered)}`);
|
|
149
|
+
if (r.closure !== null) say(` FEET ${travelLine(r.closure)}`);
|
|
150
|
+
else say(` FEET steps (the supporting ankle > 0.03 leg lengths a frame; re-planted after 2 frames < 0.01): ${f.steps.map((x) => `${x.side} ${span(x.frames)} → ${x.to === null ? 'the pose\'s travel' : `${x.to} supports`}, ${x.replant === null ? 'never re-planted' : `re-planted at ${x.replant}`}`).join('; ') || 'none'}`);
|
|
151
|
+
if (r.floor !== null) {
|
|
152
|
+
const legBones = legBonesOf(map);
|
|
153
|
+
if (legBones !== null) say(` FEET ${floorLine(r.floor, setupLegs(rest, legBones))}`);
|
|
154
|
+
}
|
|
155
|
+
if (r.blendHold !== null) for (const l of blendLines(r.blendHold, r.times)) say(` FEET ${l}`);
|
|
146
156
|
}
|
|
147
157
|
if (r.stance !== null) for (const l of stanceLines(r.stance, r.times)) say(` STANCE ${l}`);
|
|
148
158
|
|
|
149
159
|
// --reduce (#32): the dense keys thinned top-down, before anything reads the tracks.
|
|
150
160
|
const reduced = a.reduce === undefined ? null : reduceTracks(r, map, rest, a.reduce, a.reduceHip);
|
|
151
161
|
if (reduced !== null) {
|
|
152
|
-
for (const row of reduced.rows)
|
|
153
|
-
|
|
154
|
-
|
|
162
|
+
for (const row of reduced.rows) {
|
|
163
|
+
const [who, track, what, unit] = row.kind === 'angle' ? ['bone', 'rotate', 'aim', '°'] : row.kind === 'origin' ? ['hip', 'translate', 'origin', ' units'] : ['bone', 'scalex', 'scale', ' of its setup length'];
|
|
164
|
+
say(` REDUCE ${who} "${row.bone}" ${track}: ${row.before} → ${row.after} key(s); worst ${what} error ${row.worst.toPrecision(3)}${unit} at frame ${row.frame} (t=${r.times[row.frame]!.toFixed(3)}s)`);
|
|
165
|
+
}
|
|
166
|
+
const sum = (kind: ReduceRow['kind'], k: 'before' | 'after') => reduced.rows.filter((x) => x.kind === kind).reduce((t, x) => t + x[k], 0);
|
|
167
|
+
const scales = reduced.rows.some((x) => x.kind === 'scale') ? `; scalex keys ${sum('scale', 'before')} → ${sum('scale', 'after')} within ${SCALE_REDUCE} of the setup length` : '';
|
|
168
|
+
say(` REDUCE rotate keys ${sum('angle', 'before')} → ${sum('angle', 'after')} (${(sum('angle', 'before') / sum('angle', 'after')).toFixed(2)}×) within ${a.reduce}°${scales}${a.reduceHip === undefined ? '; the hip stays dense (no --reduce-hip)' : ''}`);
|
|
155
169
|
}
|
|
156
170
|
|
|
157
171
|
// The idle against the new animation (#43 item 2): what it drops, and with --carry-idle, looped back in.
|
|
@@ -204,8 +218,13 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
204
218
|
|
|
205
219
|
const doc = readBuild(g1.build);
|
|
206
220
|
const posed = poseFrames(doc, a.name, r.times);
|
|
207
|
-
// A reduced track is held to the dense target plus its tolerance (#32)
|
|
208
|
-
|
|
221
|
+
// A reduced track is held to the dense target plus its tolerance (#32); an ankle, to what the
|
|
222
|
+
// reduced bones above it may move it by (#94, `footReduce`). A scale keeps its own: it is reduced within half of it.
|
|
223
|
+
const landings = measureLanding(doc, a.name, map, r, posed).map((l) => (a.reduce === undefined ? l
|
|
224
|
+
: l.kind === 'angle' ? { ...l, tolerance: l.tolerance + a.reduce }
|
|
225
|
+
: l.kind === 'origin' && a.reduceHip !== undefined ? { ...l, tolerance: l.tolerance + a.reduceHip }
|
|
226
|
+
: l.kind === 'foot' ? { ...l, tolerance: l.tolerance + footReduce(rest, r, l.bone.replace(/:tip$/, ''), a.reduce, a.reduceHip) }
|
|
227
|
+
: l));
|
|
209
228
|
const p = new Problems();
|
|
210
229
|
// 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).
|
|
211
230
|
const carriedAbove = (bone: string): string => {
|
|
@@ -228,7 +247,8 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
228
247
|
p.throwIfAny();
|
|
229
248
|
|
|
230
249
|
const lengths = new Map(doc.bones.map((b) => [b.name, b.length ?? 0]));
|
|
231
|
-
const
|
|
250
|
+
const hips = seq.cleaned?.params.hipsInferred;
|
|
251
|
+
const posture = measurePosture(posed, lengths, map, r, frames, a.minScore, inferredSource(seq.cleaned), hips?.by === 'measured' ? hips : null);
|
|
232
252
|
if (posture !== null) for (const l of postureLines(posture)) say(` POSTURE ${l}`);
|
|
233
253
|
const legBones = legBonesOf(map);
|
|
234
254
|
const feetReading = legBones === null ? null : measureFeet(posed, lengths, r, legBones);
|
|
@@ -276,7 +296,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
276
296
|
frames: seq.frames.length,
|
|
277
297
|
duration: r.animation.duration,
|
|
278
298
|
cleaned: seq.cleaned,
|
|
279
|
-
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', raisedByHead: a.raisedByHead === true, idle: idleName, carryIdle: a.carryIdle === true, merge: a.merge ?? null, reduce: a.reduce ?? 'off', reduceHip: a.reduceHip ?? 'off' },
|
|
299
|
+
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' },
|
|
280
300
|
scale: r.scale,
|
|
281
301
|
holds: r.holds,
|
|
282
302
|
short: r.short,
|
|
@@ -293,10 +313,12 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
293
313
|
range: r.range,
|
|
294
314
|
artRange: r.artRange,
|
|
295
315
|
raised: r.raised,
|
|
316
|
+
// Only when a set declares `place` (#93): a map without one writes the report it always has.
|
|
317
|
+
...(map.alternates.some((x) => x.place !== undefined && x.place !== null) ? { byHip: r.byHip } : {}),
|
|
296
318
|
legScale: r.legScale.map(({ scale: _scale, ...rest }) => rest),
|
|
297
319
|
pivot: r.pivot,
|
|
298
|
-
stance: r.stance === null ? null : { tolerance: r.stance.tolerance, bounds: r.stance.bounds, runs: r.stance.runs, over: r.stance.over },
|
|
299
|
-
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, reading: feetReading },
|
|
320
|
+
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 },
|
|
321
|
+
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 }), 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 },
|
|
300
322
|
shear,
|
|
301
323
|
idleDropped: idle === null ? null : idle.dropped,
|
|
302
324
|
idleKeptOut: idle === null ? null : idle.keptOut,
|
|
@@ -313,9 +335,18 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
313
335
|
|
|
314
336
|
/**
|
|
315
337
|
* The retarget's rotate tracks (and with a hip tolerance, its translate) thinned
|
|
316
|
-
* in place (#32)
|
|
317
|
-
*
|
|
318
|
-
*
|
|
338
|
+
* in place (#32), on the final keys: the plant, the one floor, the stretches and
|
|
339
|
+
* the in-place closure all live inside `retarget`, so they are done (#94). Kept
|
|
340
|
+
* regardless, per bone: the frames either side of each switch boundary on the
|
|
341
|
+
* chains it changes, and the edges of every FLIP spread, SHORT span and RANGE
|
|
342
|
+
* run on it (the first and last frame the reducer keeps).
|
|
343
|
+
*
|
|
344
|
+
* With scaled bones (`--leg-scale`, the stretches; #94): a scaled bone's
|
|
345
|
+
* `scalex` is thinned within `SCALE_REDUCE`, and a mapped bone under a scaled
|
|
346
|
+
* bone — keyed through its parent's posed matrix (legs.ts) — is re-solved
|
|
347
|
+
* through its parent as reduced and thinned with its error read as the world
|
|
348
|
+
* aim and length it lands on (`reduceUnderScale`). With none, this is #32's
|
|
349
|
+
* reduction, unchanged.
|
|
319
350
|
*/
|
|
320
351
|
function reduceTracks(r: ReturnType<typeof retarget>, map: JointMap, rest: Rest, tolerance: number, hipTolerance: number | undefined): Reduced {
|
|
321
352
|
const n = r.times.length;
|
|
@@ -335,8 +366,19 @@ function reduceTracks(r: ReturnType<typeof retarget>, map: JointMap, rest: Rest,
|
|
|
335
366
|
return null;
|
|
336
367
|
};
|
|
337
368
|
const rotate = new Map<string, MotionTrack>();
|
|
338
|
-
|
|
339
|
-
|
|
369
|
+
const scalex = new Map<string, MotionTrack>();
|
|
370
|
+
for (const tr of r.animation.tracks) {
|
|
371
|
+
if (!('bone' in tr)) continue;
|
|
372
|
+
if (tr.property === 'rotate') rotate.set(tr.bone, tr);
|
|
373
|
+
else if (tr.property === 'scalex') scalex.set(tr.bone, tr);
|
|
374
|
+
}
|
|
375
|
+
/** A bone with a scaled bone above it: keyed through its parent's posed matrix (legs.ts), not by the key formula. */
|
|
376
|
+
const under = (b: string): boolean => {
|
|
377
|
+
for (let p = rest.get(b)!.parent; p !== null; p = rest.get(p)!.parent) if (scalex.has(p)) return true;
|
|
378
|
+
return false;
|
|
379
|
+
};
|
|
380
|
+
const dense = new Map([...rotate].map(([b, tr]) => [b, tr.keys.map((k) => k.v[0]!)]));
|
|
381
|
+
const bones: ReduceBone[] = [...rotate.values()].filter((tr) => !under(tr.bone)).map((tr) => ({ bone: tr.bone, above: above(tr.bone), dense: dense.get(tr.bone)!, keep: keep.get(tr.bone) ?? new Set<number>() }));
|
|
340
382
|
const hipTrack = r.origin === null || hipTolerance === undefined ? undefined : r.animation.tracks.find((tr): tr is MotionTrack => 'bone' in tr && tr.property === 'translate' && tr.bone === r.origin!.bone && tr.keys.every((k) => k.ease === undefined));
|
|
341
383
|
const parent = hipTrack === undefined ? null : rest.get(hipTrack.bone)!.parent;
|
|
342
384
|
const pm = parent === null ? null : rest.get(parent)!;
|
|
@@ -349,7 +391,101 @@ function reduceTracks(r: ReturnType<typeof retarget>, map: JointMap, rest: Rest,
|
|
|
349
391
|
});
|
|
350
392
|
for (const [bone, keys] of out.rotate) rotate.get(bone)!.keys = keys;
|
|
351
393
|
if (hipTrack !== undefined && out.hip !== null) hipTrack.keys = out.hip;
|
|
352
|
-
return out;
|
|
394
|
+
if (scalex.size === 0) return out;
|
|
395
|
+
|
|
396
|
+
// #94: the scales of the bones the key formula holds for, then the bones under a scaled bone, parents first.
|
|
397
|
+
const rows = new Map<string, ReduceRow>();
|
|
398
|
+
for (const row of out.rows) if (row.kind === 'angle') rows.set(`rotate ${row.bone}`, row);
|
|
399
|
+
const scaleAt = new Map<string, number[]>();
|
|
400
|
+
const keepOf = (b: string) => keep.get(b) ?? new Set<number>();
|
|
401
|
+
for (const [bone, tr] of scalex) {
|
|
402
|
+
if (under(bone)) continue;
|
|
403
|
+
const red = reduceScale(r.times, bone, r.scales.get(bone) ?? tr.keys.map((k) => k.v[0]!), keepOf(bone), SCALE_REDUCE);
|
|
404
|
+
tr.keys = red.keys;
|
|
405
|
+
scaleAt.set(bone, red.at);
|
|
406
|
+
rows.set(`scalex ${bone}`, red.row);
|
|
407
|
+
}
|
|
408
|
+
/** A bone the key formula holds for: its world turn from setup on every frame as reduced — its dense turn (the keys summed down the mapped chain) plus its world error; an unmapped bone turns with the nearest mapped bone above it. */
|
|
409
|
+
const turnOf = (b: string): number[] => {
|
|
410
|
+
const m = mapped.has(b) ? b : above(b);
|
|
411
|
+
if (m === null) return new Array<number>(n).fill(0);
|
|
412
|
+
const denseTurn = (x: string): number[] => {
|
|
413
|
+
const up = above(x);
|
|
414
|
+
const own = dense.get(x)!;
|
|
415
|
+
if (up === null) return [...own];
|
|
416
|
+
const t = denseTurn(up);
|
|
417
|
+
return own.map((v, i) => v + t[i]!);
|
|
418
|
+
};
|
|
419
|
+
const e = out.errors.get(m)!;
|
|
420
|
+
return denseTurn(m).map((v, i) => v + e[i]!);
|
|
421
|
+
};
|
|
422
|
+
const world0 = (b: string): M2 => {
|
|
423
|
+
const x = rest.get(b)!;
|
|
424
|
+
return [x.a, x.b, x.c, x.d];
|
|
425
|
+
};
|
|
426
|
+
const depth = (b: string): number => {
|
|
427
|
+
let d = 0;
|
|
428
|
+
for (let p = rest.get(b)!.parent; p !== null; p = rest.get(p)!.parent) d++;
|
|
429
|
+
return d;
|
|
430
|
+
};
|
|
431
|
+
const underBones = [...rotate.keys()].filter(under).sort((x, y) => depth(x) - depth(y));
|
|
432
|
+
const given: UnderBone[] = underBones.map((b) => {
|
|
433
|
+
if (!scalex.has(b)) throw new Refusal([{ code: 'POSE_REDUCE', text: `bone "${b}" hangs under a scaled bone and has no scalex track, so it was not keyed through its parent's posed matrix; --reduce re-solves only a bone the legs keyed that way` }]);
|
|
434
|
+
// Up through the unmapped bones between (their setup locals), to a bone given before it or one the key formula holds for.
|
|
435
|
+
let via: M2 = M2_IDENTITY;
|
|
436
|
+
let p = rest.get(b)!.parent!;
|
|
437
|
+
while (under(p) && !mapped.has(p)) {
|
|
438
|
+
via = mulM2(setupLocal(world0(rest.get(p)!.parent!), world0(p)), via);
|
|
439
|
+
p = rest.get(p)!.parent!;
|
|
440
|
+
}
|
|
441
|
+
const parent: UnderBone['parent'] = under(p) ? { under: p, via } : { rigid: { turn: turnOf(p), world0: world0(p), scale: scaleAt.get(p) ?? new Array<number>(n).fill(1) }, via };
|
|
442
|
+
const w0 = world0(b);
|
|
443
|
+
return {
|
|
444
|
+
bone: b,
|
|
445
|
+
parent,
|
|
446
|
+
local: setupLocal(world0(rest.get(b)!.parent!), w0),
|
|
447
|
+
len0: Math.hypot(w0[0], w0[2]),
|
|
448
|
+
target: r.targets.get(b)!,
|
|
449
|
+
asked: r.scales.get(b) ?? new Array<number>(n).fill(1),
|
|
450
|
+
dense: dense.get(b)!,
|
|
451
|
+
keep: keepOf(b),
|
|
452
|
+
};
|
|
453
|
+
});
|
|
454
|
+
const red = reduceUnderScale(r.times, given, tolerance, SCALE_REDUCE);
|
|
455
|
+
for (const [bone, keys] of red.rotate) rotate.get(bone)!.keys = keys;
|
|
456
|
+
for (const [bone, keys] of red.scale) scalex.get(bone)!.keys = keys;
|
|
457
|
+
for (const row of red.rows) rows.set(`${row.kind === 'angle' ? 'rotate' : 'scalex'} ${row.bone}`, row);
|
|
458
|
+
// The rows: every rotate track in the animation's order, the hip, then every scalex track.
|
|
459
|
+
const hipRow = out.rows.filter((x) => x.kind === 'origin');
|
|
460
|
+
return {
|
|
461
|
+
...out,
|
|
462
|
+
rows: [...[...rotate.keys()].map((b) => rows.get(`rotate ${b}`)!), ...hipRow, ...[...scalex.keys()].map((b) => rows.get(`scalex ${b}`)!)],
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* How far `--reduce` may move an ankle (the tip of `ankle`), world units (#94):
|
|
468
|
+
* the hip's tolerance when its translate is reduced, plus every segment from
|
|
469
|
+
* the legs' top down to the ankle — each bone's setup offset from its parent,
|
|
470
|
+
* and the ankle bone's length — times the largest scale asked of any bone, each
|
|
471
|
+
* turned by at most the angle tolerance (a vector of length ℓ turned by θ moves
|
|
472
|
+
* its end ≤ ℓ·θ) and lengthened by at most `SCALE_REDUCE`. Every reduced bone's
|
|
473
|
+
* world aim is within the angle tolerance of its dense one, top-down, so the
|
|
474
|
+
* segments' errors are each bounded and the sum bounds the ankle's.
|
|
475
|
+
*/
|
|
476
|
+
export function footReduce(rest: Rest, r: ReturnType<typeof retarget>, ankle: string, tolerance: number, hipTolerance: number | undefined): number {
|
|
477
|
+
const top = r.origin?.bone ?? null;
|
|
478
|
+
let sMax = 1;
|
|
479
|
+
for (const s of r.scales.values()) for (const v of s) sMax = Math.max(sMax, v);
|
|
480
|
+
const a = rest.get(ankle)!;
|
|
481
|
+
let lever = a.length * Math.hypot(a.a, a.c);
|
|
482
|
+
for (let b: string | null = ankle; b !== null && b !== top; b = rest.get(b)!.parent) {
|
|
483
|
+
const x = rest.get(b)!;
|
|
484
|
+
const p = x.parent === null ? null : rest.get(x.parent)!;
|
|
485
|
+
if (p === null) break;
|
|
486
|
+
lever += Math.hypot(x.worldX - p.worldX, x.worldY - p.worldY);
|
|
487
|
+
}
|
|
488
|
+
return (hipTolerance ?? 0) + lever * sMax * ((tolerance * Math.PI) / 180 + SCALE_REDUCE);
|
|
353
489
|
}
|
|
354
490
|
|
|
355
491
|
function spans(ts: number[], step: number): string {
|
|
@@ -416,7 +552,7 @@ export function cameraLine(camera: readonly CameraFrame[], times: readonly numbe
|
|
|
416
552
|
const fastest = [...steps].sort((x, y) => Math.abs(y.d) - Math.abs(x.d) || x.to - y.to).slice(0, 3);
|
|
417
553
|
const sign = (d: number) => `${d < 0 ? '−' : '+'}${Math.abs(d).toFixed(4)}`;
|
|
418
554
|
const inl = camera.reduce((m, c) => Math.min(m, c.inliers), Infinity);
|
|
419
|
-
return `${source ?? '(source not stated)'}: the sequence carries the picture's camera against frame 0's; its joints are normalised by it (p′ = (p − t) / s): s ${ss[lo]!.toFixed(4)} (frame ${lo}) … ${ss[hi]!.toFixed(4)} (frame ${hi}) over ${camera.length} frames; s changes fastest at ${fastest.map((x) => `frame ${x.to - 1}→${x.to} (t=${times[x.to]!.toFixed(3)}s) ${sign(x.d)}`).join(', ') || 'no frame (one frame only)'}${source?.startsWith('hand
|
|
555
|
+
return `${source ?? '(source not stated)'}: the sequence carries the picture's camera against frame 0's; its joints are normalised by it (p′ = (p − t) / s): s ${ss[lo]!.toFixed(4)} (frame ${lo}) … ${ss[hi]!.toFixed(4)} (frame ${hi}) over ${camera.length} frames; s changes fastest at ${fastest.map((x) => `frame ${x.to - 1}→${x.to} (t=${times[x.to]!.toFixed(3)}s) ${sign(x.d)}`).join(', ') || 'no frame (one frame only)'}${source?.startsWith('hand') === true ? '' : `; fewest background inliers ${inl}`}`;
|
|
420
556
|
}
|
|
421
557
|
|
|
422
558
|
/** Each leg's first bone (its root, the thigh) and last bone (whose tip is the ankle), when the map has both. */
|
|
@@ -426,20 +562,108 @@ function legBonesOf(map: JointMap): { r: { root: string; ankle: string }; l: { r
|
|
|
426
562
|
return { r: { root: l.legs.r.chain.bones[0]!, ankle: l.legs.r.ankleBone }, l: { root: l.legs.l.chain.bones[0]!, ankle: l.legs.l.ankleBone } };
|
|
427
563
|
}
|
|
428
564
|
|
|
565
|
+
/** Each side's rig leg at setup, world units: its thigh's origin to its ankle (the leg's last bone's tip), by rig-c's setup pose. */
|
|
566
|
+
export function setupLegs(rest: Rest, legs: { r: { root: string; ankle: string }; l: { root: string; ankle: string } }): { r: number; l: number } {
|
|
567
|
+
const of = (s: 'r' | 'l') => {
|
|
568
|
+
const o = rest.get(legs[s].root)!;
|
|
569
|
+
const b = rest.get(legs[s].ankle)!;
|
|
570
|
+
return Math.hypot(b.worldX + b.a * b.length - o.worldX, b.worldY + b.c * b.length - o.worldY);
|
|
571
|
+
};
|
|
572
|
+
return { r: of('r'), l: of('l') };
|
|
573
|
+
}
|
|
574
|
+
|
|
429
575
|
/**
|
|
430
|
-
* The
|
|
431
|
-
*
|
|
576
|
+
* The FEET floor line (#85): the floor's world y and where it came from, each
|
|
577
|
+
* support span's shift onto it in world units and in that side's rig leg at
|
|
578
|
+
* setup, and the shifts' range in the two legs' mean.
|
|
579
|
+
*/
|
|
580
|
+
export function floorLine(floor: Floor, leg: { r: number; l: number }): string {
|
|
581
|
+
const span = (x: [number, number]) => (x[0] === x[1] ? `${x[0]}` : `${x[0]}–${x[1]}`);
|
|
582
|
+
const sign = (v: number, d: number) => `${v < 0 ? '−' : '+'}${Math.abs(v).toFixed(d)}`;
|
|
583
|
+
const ss = floor.shifts.map((x) => x.shift);
|
|
584
|
+
const lo = Math.min(...ss);
|
|
585
|
+
const hi = Math.max(...ss);
|
|
586
|
+
const mean = (leg.r + leg.l) / 2;
|
|
587
|
+
return `floor: world y ${floor.y.toFixed(2)} (${floor.given ? 'given by --floor' : `the median of the ${floor.shifts.length} support span(s)' planted heights`}); each span's supporting ankle moved onto it, the hip and everything above with it — shift per span in units (leg lengths: the rig's at setup, thigh origin → ankle, r ${leg.r.toFixed(1)}, l ${leg.l.toFixed(1)} units): ${floor.shifts.map((x) => `${x.side} ${span(x.frames)} ${sign(x.shift, 2)} (${sign(x.shift / leg[x.side], 4)})`).join(', ')}; range ${sign(lo, 2)} … ${sign(hi, 2)} units = ${((hi - lo) / mean).toFixed(4)} leg lengths`;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* The FEET blend lines (#85): one per run of blend frames whose supporting leg
|
|
592
|
+
* was stretched to keep its ankle on the floor, with the stretch keyed; one per
|
|
593
|
+
* run whose stretch fell past its bounds (`past the bounds`, as STANCE says it).
|
|
594
|
+
*/
|
|
595
|
+
/**
|
|
596
|
+
* The lower-ankle line (rig-c/pose#113): under --plant-feet on a sequence not
|
|
597
|
+
* declared in place, the frames whose supporting foot differs from the reading
|
|
598
|
+
* before #113 — today's side, then the side read from the lower ankle ("none":
|
|
599
|
+
* no foot supports, the hip follows the pose).
|
|
600
|
+
*/
|
|
601
|
+
const CAUSE: Record<NonNullable<Lowered['cause']>, string> = {
|
|
602
|
+
released: 'released: the lower ankle could not take support, released by a step or moving',
|
|
603
|
+
higher: 'the only foot free to take support was the higher one',
|
|
604
|
+
};
|
|
605
|
+
function loweredLine(lowered: readonly Lowered[]): string {
|
|
606
|
+
const side = (s: Side | null) => s ?? 'none';
|
|
607
|
+
const span = (x: [number, number]) => (x[0] === x[1] ? `${x[0]}` : `${x[0]}–${x[1]}`);
|
|
608
|
+
const head = `support read from the lower ankle (a foot ≥ ${PLANT_SWITCH} leg lengths higher than the other does not take support, rig-c/pose#113)`;
|
|
609
|
+
if (lowered.length === 0) return `${head}: no frame reads differently from before`;
|
|
610
|
+
const frames = lowered.reduce((k, x) => k + x.frames[1] - x.frames[0] + 1, 0);
|
|
611
|
+
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(', ')}`;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** The in-place line (rig-c/pose#92): what --plant-feet did instead of holding the foot, and the loop's closure. */
|
|
615
|
+
export function travelLine(c: Closure): string {
|
|
616
|
+
const per = c.frames > 1 ? Math.abs(c.drift) / (c.frames - 1) : 0;
|
|
617
|
+
return `in place (the sequence's travel "${TRAVEL_IN_PLACE}"): each supporting foot follows its own ankle's picture x, scaled, instead of being held where it planted, and no step releases it; the hip's x travelled ${c.drift >= 0 ? '+' : ''}${c.drift.toFixed(2)} units from the first frame to the last, closed — spread back over the ${c.frames} frames (${per.toFixed(3)} units a frame), so the last frame's hip x is the first's`;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
export function blendLines(h: BlendHold, times: readonly number[]): string[] {
|
|
621
|
+
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)`);
|
|
622
|
+
const out: string[] = [];
|
|
623
|
+
for (const r of h.runs) out.push(`blend held on the floor: supporting foot ${r.side} ${span(r.frames)} — the hip blends between two derivations there, so its leg is stretched by k ${r.k.min.toFixed(4)}–${r.k.max.toFixed(4)} within ${boundText([r.lower ?? h.bounds[0], h.bounds[1]])} to keep the ankle on the floor`);
|
|
624
|
+
if (h.runs.length === 0) out.push('blend held on the floor: no blend frame left a supporting ankle off the floor');
|
|
625
|
+
if (h.range !== undefined) out.push(legRangeLine('supporting', h.range, h.bounds));
|
|
626
|
+
for (const o of h.over) out.push(`past the bounds: supporting 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 ?? h.bounds)}; held at the bound`}, the foot left up to ${o.left.toFixed(2)} units from the floor`);
|
|
627
|
+
return out;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* The STANCE lines (#60): one per run of planted-both, lifted or (#114) below
|
|
632
|
+
* frames, with the tolerance and the stretch keyed; one per run a stretch was
|
|
633
|
+
* capped.
|
|
432
634
|
*/
|
|
433
635
|
export function stanceLines(st: Stance, times: readonly number[]): string[] {
|
|
434
636
|
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)`);
|
|
435
637
|
const tol = `the tolerance ${st.tolerance} leg lengths`;
|
|
436
|
-
const
|
|
638
|
+
const release = `planted again under ${st.release} of it, ${(st.release * st.tolerance).toFixed(4)}`;
|
|
639
|
+
const NAME: Record<Stance['runs'][number]['state'], string> = { both: 'planted-both', lifted: 'lifted', below: 'below' };
|
|
640
|
+
// An absorbed run lies inside a run of the state it took; the line names that state.
|
|
641
|
+
const around = (r: Stance['runs'][number]) => st.runs.find((x) => x.absorbed !== true && x.side === r.side && x.frames[0] <= r.frames[0] && r.frames[1] <= x.frames[1]);
|
|
642
|
+
const other = (r: Stance['runs'][number]) => NAME[around(r)?.state ?? (r.state === 'both' ? 'lifted' : 'both')];
|
|
643
|
+
const where = (r: Stance['runs'][number]) => (r.state === 'below' ? `its ankle up to ${r.maxDepth!.toFixed(4)} leg lengths below the supporting one in the picture` : `its ankle up to ${r.maxHeight.toFixed(4)} leg lengths above the supporting one in the picture`);
|
|
644
|
+
const out = st.runs.map((r) => (r.absorbed === true
|
|
645
|
+
? `absorbed: free foot ${r.side} ${span(r.frames)} — read ${r.state === 'both' ? 'planted both' : r.state === 'below' ? 'past the tolerance below' : 'lifted'} (${where(r)}; ${tol}, ${release}) for ${r.frames[1] - r.frames[0] + 1} frame(s), under ${st.minSeconds} s between two ${other(r)} runs; it takes their state`
|
|
646
|
+
: r.state === 'both'
|
|
437
647
|
? `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)})`
|
|
438
|
-
:
|
|
439
|
-
|
|
648
|
+
: r.state === 'below'
|
|
649
|
+
? `past the tolerance below: free foot ${r.side} ${span(r.frames)} — ${where(r)}, past ${tol} (${release}); a free foot that far below the supporting one is a support error, not a stance: never planted both, the pose's height kept`
|
|
650
|
+
: `lifted: free foot ${r.side} ${span(r.frames)} — ${where(r)}, over ${tol} (${release}); the pose's height kept`));
|
|
651
|
+
if (st.range !== undefined) out.push(legRangeLine('free', st.range, st.bounds));
|
|
652
|
+
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`);
|
|
440
653
|
return out;
|
|
441
654
|
}
|
|
442
655
|
|
|
656
|
+
/** A bound as the lines print it: the default's figures as written, a derived lower bound to 4 places (#95). */
|
|
657
|
+
function boundText(b: readonly [number, number]): string {
|
|
658
|
+
return `[${b[0] === STANCE_BOUNDS[0] ? `${b[0]}` : b[0].toFixed(4)}, ${b[1]}]`;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/** The #95 line: where the picture folds a leg below the default lower bound, that fold is the frame's lower bound. */
|
|
662
|
+
function legRangeLine(which: 'free' | 'supporting', range: Partial<Record<'r' | 'l', number>>, bounds: readonly [number, number]): string {
|
|
663
|
+
const sides = (['r', 'l'] as const).filter((s) => range[s] !== undefined).map((s) => `${s} ${range[s]!.toFixed(4)}`);
|
|
664
|
+
return `bound from the leg's own range: on a frame where the picture folds the ${which} leg below ${bounds[0]} of its straight length (hip → ankle, its 95th percentile), the lower bound is that fold — lowest ${sides.join(', ')}; the upper stays ${bounds[1]}`;
|
|
665
|
+
}
|
|
666
|
+
|
|
443
667
|
/** A LEG-SCALE line: the segment, its straight length, its ratio's spread, and the frames clamped. */
|
|
444
668
|
function legScaleLine(l: LegScale, times: readonly number[]): string {
|
|
445
669
|
const span = (x: [number, number]) => (x[0] === x[1] ? `${x[0]}` : `${x[0]}–${x[1]}`);
|
package/src/switch.ts
CHANGED
|
@@ -131,6 +131,8 @@ export interface ArtRange {
|
|
|
131
131
|
clamped: boolean;
|
|
132
132
|
/** --raised-by-head (#62): on a held upper arm, the most the re-aimed forearm left the wrist short of its placed point, eye distances; `turn` and `maxOverflow` are then the upper arm's on that frame. */
|
|
133
133
|
shortfall?: number;
|
|
134
|
+
/** `place: "by-hip"` (#93): the shortfall is in hip widths, the wrist placed against the hip. Absent: against the head, eye distances. */
|
|
135
|
+
anchor?: 'hip';
|
|
134
136
|
}
|
|
135
137
|
|
|
136
138
|
export interface Uncovered {
|
|
@@ -583,7 +585,7 @@ function moveText(m: Move): string {
|
|
|
583
585
|
|
|
584
586
|
export function artRangeLine(e: ArtRange, times: readonly number[]): string {
|
|
585
587
|
if (e.shortfall !== undefined) {
|
|
586
|
-
return `set "${e.set}" chain "${e.chain}" bone "${e.bone}" held at its turn (range [${e.limits[0]}, ${e.limits[1]}]) ${spanOf(e.frames, times)}: ${e.seconds.toFixed(3)}s the re-aimed forearm leaves the wrist up to ${e.shortfall.toFixed(2)} eye distances short of its placed point (the upper arm asked ${sg(e.turn)} from drawn, ${deg(e.maxOverflow)} past) — demand for another drawing of the set`;
|
|
588
|
+
return `set "${e.set}" chain "${e.chain}" bone "${e.bone}" held at its turn (range [${e.limits[0]}, ${e.limits[1]}]) ${spanOf(e.frames, times)}: ${e.seconds.toFixed(3)}s the re-aimed forearm leaves the wrist up to ${e.shortfall.toFixed(2)} ${e.anchor === 'hip' ? 'hip widths' : 'eye distances'} short of its placed point (the upper arm asked ${sg(e.turn)} from drawn, ${deg(e.maxOverflow)} past) — demand for another drawing of the set`;
|
|
587
589
|
}
|
|
588
590
|
return `set "${e.set}" chain "${e.chain}" bone "${e.bone}" turned ${sg(e.turn)} from drawn (range [${e.limits[0]}, ${e.limits[1]}]) ${spanOf(e.frames, times)}: ${e.seconds.toFixed(3)}s, up to ${deg(e.maxOverflow)} past it; ${e.clamped ? 'held at the limit' : 'the bone follows the pose'} — demand for another drawing of the set`;
|
|
589
591
|
}
|
|
@@ -747,6 +749,17 @@ export interface Raised {
|
|
|
747
749
|
reach: { min: number; max: number; farthest: number };
|
|
748
750
|
}
|
|
749
751
|
|
|
752
|
+
/**
|
|
753
|
+
* One span of a `place: "by-hip"` set on one of its placed chains: its wrist
|
|
754
|
+
* placed against the hip (rig-c/pose#93), as `Raised` against the head, in hip
|
|
755
|
+
* widths. `reference`, `placed`, `before` and `after` are the wrist's offset
|
|
756
|
+
* from the mid-hip, x right and y up; `short` is in hip widths.
|
|
757
|
+
*/
|
|
758
|
+
export interface ByHip extends Omit<Raised, 'eyes'> {
|
|
759
|
+
/** The unit: the hip width's median over the span, the rig's in world units, the reference's in picture px. */
|
|
760
|
+
hips: { rig: number; reference: number };
|
|
761
|
+
}
|
|
762
|
+
|
|
750
763
|
const xy = (v: P2) => `(${v[0].toFixed(2)}, ${v[1].toFixed(2)})`;
|
|
751
764
|
const framesText = (fs: readonly number[]): string => {
|
|
752
765
|
const flag: boolean[] = [];
|
|
@@ -754,16 +767,26 @@ const framesText = (fs: readonly number[]): string => {
|
|
|
754
767
|
return runsOf(Array.from({ length: flag.length }, (_, i) => flag[i] === true), 0).map(([a, b]) => (a === b ? `${a}` : `${a}–${b}`)).join(', ');
|
|
755
768
|
};
|
|
756
769
|
|
|
757
|
-
|
|
770
|
+
/** The words a placement's lines use: what the wrist is placed against, its centre, and the unit. */
|
|
771
|
+
const ANCHOR = {
|
|
772
|
+
head: { against: 'the head', centre: 'head centre', unit: 'eye distances', unitOne: 'eye distance', relative: 'head-relative' },
|
|
773
|
+
hip: { against: 'the hip', centre: 'mid-hip', unit: 'hip widths', unitOne: 'hip width', relative: 'hip-relative' },
|
|
774
|
+
} as const;
|
|
775
|
+
const anchorOf = (e: Raised | ByHip) => ('hips' in e ? ANCHOR.hip : ANCHOR.head);
|
|
776
|
+
|
|
777
|
+
export function raisedLine(e: Raised | ByHip, times: readonly number[]): string {
|
|
778
|
+
const w = anchorOf(e);
|
|
779
|
+
const units = 'hips' in e ? e.hips : e.eyes;
|
|
758
780
|
const med = (vs: readonly P2[]): P2 => [median(vs.map((v) => v[0])), median(vs.map((v) => v[1]))];
|
|
759
781
|
const held = e.upperHeld.length === 0 ? 'the upper arm within its turn on every frame' : `the upper arm held by the set's turn on ${e.upperHeld.length} frame(s) (${framesText(e.upperHeld)}), the forearm re-aimed`;
|
|
760
782
|
const out = e.short.length === 0 ? 'every target in reach' : `out of reach on ${e.short.reduce((k, s) => k + s.frames[1] - s.frames[0] + 1, 0)} frame(s) (REACH)`;
|
|
761
|
-
return `set "${e.set}" chain "${e.chain}" ${spanOf(e.frames, times)}: the wrist placed against
|
|
783
|
+
return `set "${e.set}" chain "${e.chain}" ${spanOf(e.frames, times)}: the wrist placed against ${w.against}; ${w.unitOne} (median) rig ${units.rig.toFixed(2)} units, reference ${units.reference.toFixed(2)} px; wrist − ${w.centre} in ${w.unit} (x right, y up), median reference ${xy(med(e.reference))}, placed ${xy(med(e.placed))}, before ${xy(med(e.before))}, after ${xy(med(e.after))}; worst |rig − reference| before ${xy(e.worst.before)}, after ${xy(e.worst.after)}; worst |after − placed| ${e.worst.placed.toFixed(2)}; ${held}; ${out}`;
|
|
762
784
|
}
|
|
763
785
|
|
|
764
|
-
/** The REACH line: a
|
|
765
|
-
export function reachLine(e: Raised, times: readonly number[]): string {
|
|
766
|
-
const
|
|
767
|
-
const
|
|
768
|
-
|
|
786
|
+
/** The REACH line: a placed span (raised, or by the hip) whose target was out of the arm's reach on some frames — the proportions, not the art's range, were the limit. */
|
|
787
|
+
export function reachLine(e: Raised | ByHip, times: readonly number[]): string {
|
|
788
|
+
const w = anchorOf(e);
|
|
789
|
+
const why = { far: `farther than l0 + l1 = ${e.reach.max.toFixed(1)}`, near: `nearer than |l0 − l1| = ${e.reach.min.toFixed(1)}`, missed: `and the ray from the ${w.centre} never enters the reach; placed at the reach's point nearest it` } as const;
|
|
790
|
+
const runs = e.short.map((s) => `${spanOf(s.frames, times)} ${s.how === 'missed' ? 'out of reach' : why[s.how]}${s.how === 'missed' ? `, ${why.missed}` : ''}: up to ${s.shortfall.max.toFixed(2)} ${w.unit} short (median ${s.shortfall.median.toFixed(2)})`);
|
|
791
|
+
return `set "${e.set}" chain "${e.chain}": the target out of the arm's reach from the shoulder — ${runs.join('; ')}; the wrist placed at the reach limit along the ${w.relative} direction (the farthest target ${e.reach.farthest.toFixed(1)} units from the shoulder). Not ART-RANGE: the rig's proportions, not the art's range, were the limit`;
|
|
769
792
|
}
|
package/src/verify.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import { poseRawAnimation, poseRawSetup, type CompiledDocument, type RawBone } from 'rig-c/core';
|
|
12
12
|
import type { Body18 } from './joints.ts';
|
|
13
13
|
import { aimAngle, pointAt, type JointMap } from './map.ts';
|
|
14
|
-
import type { Joint } from './pose.ts';
|
|
14
|
+
import type { HipsInferred, Joint, Run } from './pose.ts';
|
|
15
15
|
import type { Retarget } from './retarget.ts';
|
|
16
16
|
import { pictureAngle, pointOn, wrap } from './retarget.ts';
|
|
17
17
|
|
|
@@ -259,6 +259,14 @@ export interface Posture {
|
|
|
259
259
|
* how many such frames the tilt left out, and where the state came from.
|
|
260
260
|
*/
|
|
261
261
|
hipsInferred?: { frames: number; source: string };
|
|
262
|
+
/**
|
|
263
|
+
* Present only when the hips were measured (#86, `hipsInferred.by` is
|
|
264
|
+
* `measured`): the method, how many frames the tilt was read on — the
|
|
265
|
+
* record's read, interpolated and held frames with both hips good, each
|
|
266
|
+
* class counted — and the record's held spans. Any other frame with an
|
|
267
|
+
* inferred hip is left out and counted in `hipsInferred`.
|
|
268
|
+
*/
|
|
269
|
+
tiltMeasured?: { method: string; frames: number; read: number; interpolated: number; held: number; heldSpans: Run[] };
|
|
262
270
|
/**
|
|
263
271
|
* Sway: the x-range of mid_hip − mid_shoulder, in rig units (the reference's
|
|
264
272
|
* picture px × the figure scale) and in each body's own shoulder widths.
|
|
@@ -302,8 +310,14 @@ const medianOf = (xs: number[]): number => {
|
|
|
302
310
|
* from the clean record). On some frames only, the tilt is read over the rest
|
|
303
311
|
* and the line counts the frames left out. Sway is read as before, inferred
|
|
304
312
|
* hips included: it is a different measure, and the card leaves it as it is.
|
|
313
|
+
*
|
|
314
|
+
* Measured hips (#86, `measured`): the hips are inferred on every frame, and
|
|
315
|
+
* turned to a measured angle: read off the picture, interpolated between two
|
|
316
|
+
* close reads, or held from the earlier read. Those three are the measurement,
|
|
317
|
+
* so the tilt is read on them; any other frame with an inferred hip is left
|
|
318
|
+
* out and counted, as above. The line names the held spans.
|
|
305
319
|
*/
|
|
306
|
-
export function measurePosture(posed: Array<Map<string, RawBone>>, lengths: ReadonlyMap<string, number>, map: JointMap, r: Retarget, frames: ReadonlyArray<Partial<Record<Body18, Joint>>>, minScore: number, inferredSource = 'source not stated'): Posture | null {
|
|
320
|
+
export function measurePosture(posed: Array<Map<string, RawBone>>, lengths: ReadonlyMap<string, number>, map: JointMap, r: Retarget, frames: ReadonlyArray<Partial<Record<Body18, Joint>>>, minScore: number, inferredSource = 'source not stated', measured: Extract<HipsInferred, { by: 'measured' }> | null = null): Posture | null {
|
|
307
321
|
const pelvis = map.chains.find((c) => c.joints.length === 2 && c.joints[0] === 'r_hip' && c.joints[1] === 'l_hip');
|
|
308
322
|
if (pelvis === undefined || map.translate === null || r.origin === null || r.scale === null) return null;
|
|
309
323
|
const s = r.scale.factor;
|
|
@@ -315,11 +329,18 @@ export function measurePosture(posed: Array<Map<string, RawBone>>, lengths: Read
|
|
|
315
329
|
const refSway: number[] = [];
|
|
316
330
|
const refSw: number[] = [];
|
|
317
331
|
let inferred = 0;
|
|
332
|
+
const inClass = (rs: Run[], i: number) => rs.some(([a, b]) => a <= i && i <= b);
|
|
333
|
+
const counts = { read: 0, interpolated: 0, held: 0 };
|
|
318
334
|
frames.forEach((f, i) => {
|
|
319
335
|
const rh = at(i, 'r_hip');
|
|
320
336
|
const lh = at(i, 'l_hip');
|
|
321
337
|
if (rh === null || lh === null) return;
|
|
322
|
-
|
|
338
|
+
const isInferred = f.r_hip?.state === 'inferred' || f.l_hip?.state === 'inferred';
|
|
339
|
+
const cls = measured === null ? undefined : (['read', 'interpolated', 'held'] as const).find((c) => inClass(measured[c], i));
|
|
340
|
+
if (isInferred && cls !== undefined) {
|
|
341
|
+
counts[cls]++;
|
|
342
|
+
refTilt.push(pictureAngle(rh, lh));
|
|
343
|
+
} else if (isInferred) inferred++;
|
|
323
344
|
else refTilt.push(pictureAngle(rh, lh));
|
|
324
345
|
const rs = at(i, 'r_shoulder');
|
|
325
346
|
const ls = at(i, 'l_shoulder');
|
|
@@ -374,6 +395,7 @@ export function measurePosture(posed: Array<Map<string, RawBone>>, lengths: Read
|
|
|
374
395
|
tilt,
|
|
375
396
|
...(tilt === null ? { tiltWhy: why } : {}),
|
|
376
397
|
...(inferred > 0 ? { hipsInferred: { frames: inferred, source: inferredSource } } : {}),
|
|
398
|
+
...(measured !== null ? { tiltMeasured: { method: measured.method, frames: counts.read + counts.interpolated + counts.held, ...counts, heldSpans: measured.held.map(([a, b]): Run => [a, b]) } } : {}),
|
|
377
399
|
sway,
|
|
378
400
|
};
|
|
379
401
|
}
|
|
@@ -386,11 +408,14 @@ const sg = (x: number) => `${x >= 0 ? '+' : '−'}${Math.abs(x).toFixed(1)}`;
|
|
|
386
408
|
export function postureLines(q: Posture): string[] {
|
|
387
409
|
const over = (k: number) => (k === q.frames.of ? '' : ` over ${k} of ${q.frames.of} frames`);
|
|
388
410
|
const t = q.tilt;
|
|
411
|
+
const m = q.tiltMeasured;
|
|
412
|
+
const heldSpans = m === undefined || m.heldSpans.length === 0 ? 'none' : m.heldSpans.map(([a, b]) => (a === b ? `${a}` : `${a}–${b}`)).join(', ');
|
|
413
|
+
const measured = m === undefined ? '' : `; tilt measured (${m.method}) on ${m.frames} frame(s): ${m.read} read, ${m.interpolated} interpolated, ${m.held} held (held: ${heldSpans})`;
|
|
389
414
|
const left = q.hipsInferred === undefined ? '' : `; ${q.hipsInferred.frames} frame(s) with an inferred hip not read (${q.hipsInferred.source})`;
|
|
390
415
|
const out = [
|
|
391
416
|
t === null
|
|
392
417
|
? `hip tilt not read: ${q.tiltWhy}`
|
|
393
|
-
: `hip tilt range (chain "${q.pelvis}", r_hip → l_hip, Spine degrees): reference ${f1(t.reference.range)}° (${sg(t.reference.min)}° … ${sg(t.reference.max)}°${over(q.frames.tilt)}), rig ${f1(t.rig.range)}° (${sg(t.rig.min)}° … ${sg(t.rig.max)}°) — rig/reference ${f3(t.ratio)}${left}`,
|
|
418
|
+
: `hip tilt range (chain "${q.pelvis}", r_hip → l_hip, Spine degrees): reference ${f1(t.reference.range)}° (${sg(t.reference.min)}° … ${sg(t.reference.max)}°${over(q.frames.tilt)}), rig ${f1(t.rig.range)}° (${sg(t.rig.min)}° … ${sg(t.rig.max)}°) — rig/reference ${f3(t.ratio)}${measured}${left}`,
|
|
394
419
|
];
|
|
395
420
|
const w = q.sway;
|
|
396
421
|
if (w === null) out.push('sway: the reference has no frame with both hips and both shoulders good; not measured');
|