@rig-c/pose 0.40.0 → 0.42.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 +18 -8
- package/cli.ts +19 -6
- package/package.json +1 -1
- package/src/clean.ts +5 -4
- package/src/contacts.ts +250 -0
- package/src/depth.ts +53 -13
- package/src/draworder.ts +161 -23
- package/src/legs.ts +7 -0
- package/src/outdir.ts +5 -5
- package/src/pelvis.ts +205 -3
- package/src/pose.ts +1 -1
- package/src/reduce.ts +19 -1
- package/src/retarget.ts +17 -9
- package/src/run.ts +114 -14
- package/src/switch.ts +11 -4
package/src/pelvis.ts
CHANGED
|
@@ -31,6 +31,11 @@
|
|
|
31
31
|
* scan runs down to where the colour was found. The first patch that holds
|
|
32
32
|
* and is not the background is the garment; with none, the midpoint's median
|
|
33
33
|
* is read as it is.
|
|
34
|
+
* The line is fitted through the band's top edge (rig-c/pose#318): where a
|
|
35
|
+
* strip of the garment is too thin for the colour test (a waistband drawn as
|
|
36
|
+
* two ink lines), the tops step between the strip's lines, and each column
|
|
37
|
+
* is followed up to its topmost garment pixel; an edge that cuts across the
|
|
38
|
+
* band under it (a fold) is declined.
|
|
34
39
|
*
|
|
35
40
|
* The forearms are not read: pixels near the segments elbow → wrist (and the
|
|
36
41
|
* hands) are masked, since a hanging arm passes the hips in a walk. A frame
|
|
@@ -76,13 +81,17 @@ export const MAX_RMS = 2.5;
|
|
|
76
81
|
export const ARM_RADIUS = 0.15;
|
|
77
82
|
/** Hips closer than this in the picture, px, have no span to read across (a figure seen side-on). */
|
|
78
83
|
export const MIN_SPAN = 6;
|
|
84
|
+
/** The band's top is taken above the scan's tops only when their climbs to it differ by more than this many rows (rig-c/pose#318): each end of a clean edge's climb is found to a row. */
|
|
85
|
+
export const CLIMB_SPREAD = 2;
|
|
86
|
+
/** The band's top is climbed against at most this many garment colours (rig-c/pose#318): a paletted garment's own, or a painted one's fullest cells. */
|
|
87
|
+
export const MAX_CLOTH = 16;
|
|
79
88
|
|
|
80
89
|
/**
|
|
81
90
|
* One frame's reading: the tilt (Spine degrees, r_hip → l_hip), or why it was declined. A waistband
|
|
82
91
|
* read through the window mean says so (`mean`), and one whose colour was found below the hips says
|
|
83
|
-
* how far (`below`, px).
|
|
92
|
+
* how far (`below`, px); one whose band top lay above the colour's edge in some columns says how many (`raised`, #318).
|
|
84
93
|
*/
|
|
85
|
-
export type Reading = { tilt: number; cover: number; rms: number; mean?: true; below?: number } | { kind: string; why: string };
|
|
94
|
+
export type Reading = { tilt: number; cover: number; rms: number; mean?: true; below?: number; raised?: number } | { kind: string; why: string };
|
|
86
95
|
|
|
87
96
|
/** The frame as RGB over white, its background colour, and which pixels are masked (forearms, hands). */
|
|
88
97
|
interface Frame {
|
|
@@ -321,6 +330,7 @@ function waistband(f: Frame, c: Pt, span: number, x0: number, x1: number, cols:
|
|
|
321
330
|
}
|
|
322
331
|
}
|
|
323
332
|
}
|
|
333
|
+
const raised = toBandTop(f, tops, g, top, colourAt);
|
|
324
334
|
const share = tops.length / cols;
|
|
325
335
|
if (share < MIN_COVER) {
|
|
326
336
|
return above > cols / 2 ? { kind: 'garment above the band', why: `the garment runs above the band read (${above} of ${cols} columns carry its colour ${(1.5 * span).toFixed(0)} px above the hips); no waistband edge` } : { kind: 'waistband edge too short', why: `the garment's top edge is found in ${tops.length} of the ${cols} columns between the hips (${(100 * share).toFixed(0)}% < ${100 * MIN_COVER}%)` };
|
|
@@ -329,7 +339,197 @@ function waistband(f: Frame, c: Pt, span: number, x0: number, x1: number, cols:
|
|
|
329
339
|
if (fit === null) return { kind: 'no line', why: 'the garment\'s top edge does not fit a line (fewer than three columns)' };
|
|
330
340
|
if (fit.rms > MAX_RMS) return { kind: 'not a straight waistband', why: `the garment's top edge lies ${fit.rms.toFixed(2)} px from its line (root mean square > ${MAX_RMS}); not a straight waistband` };
|
|
331
341
|
if (Math.abs(Math.atan(fit.m)) * (180 / Math.PI) > 45) return { kind: 'waistband too steep', why: `the garment's top edge runs ${(Math.atan(fit.m) * (180 / Math.PI)).toFixed(1)}° from level; a waistband is within 45°` };
|
|
332
|
-
|
|
342
|
+
const off = notTheBand(f, fit, tops, top, colourAt);
|
|
343
|
+
if (off !== null) return off;
|
|
344
|
+
return { tilt: tiltOf(fit.m, rHip, lHip), cover: share, rms: fit.rms, ...(isMean ? { mean: true as const } : {}), ...(below > 0 ? { below } : {}), ...(raised > 0 ? { raised } : {}) };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* rig-c/pose#318: the band's top edge, the topmost garment boundary in each column. The three-row test misses garment
|
|
349
|
+
* above the row it finds when a strip of it is too thin to carry the colour three rows deep (through the window mean,
|
|
350
|
+
* a strip between two ink lines never does), so the tops step between a strip's top line and the cloth under its lower
|
|
351
|
+
* line. The garment's colours are the picture's own: the pixels of three rows in each column `DITHER_RADIUS + 1` below
|
|
352
|
+
* the row the scan found, clear of the edge's blend (an outline there would lend the colour above to the garment). The
|
|
353
|
+
* thing above has one colour, `a`: the median, over the columns read, of the rows from the scan's top down to
|
|
354
|
+
* `3 · (DITHER_RADIUS + 1)` above the topmost top — clear of the edge's blend, an outline and a window, and by area, so
|
|
355
|
+
* a line crossing it or a tilted band's outline in a few of its rows does not move it. From each top
|
|
356
|
+
* the column is followed up, pixel by pixel, while a pixel is no nearer to `a` than to the nearest garment pixel (cloth
|
|
357
|
+
* of any shade, and the ink lines on it, are); a climb that reaches the scan's top or a forearm does not see the top.
|
|
358
|
+
* The band's top is the topmost cloth pixel on the path (within `GARMENT_DIFF` of a garment pixel), from where the climb
|
|
359
|
+
* stopped down to the scan's three rows: an outline above it, or a line leaving the band upward, is ink, not garment;
|
|
360
|
+
* a thin strip's cloth between its two lines is garment. A clean edge climbs
|
|
361
|
+
* about the same rows in every column — the distance between two boundaries each found to a row — so only when the
|
|
362
|
+
* climbs differ by more than `CLIMB_SPREAD` rows (90th less 10th percentile) do the columns take their topmost garment
|
|
363
|
+
* row — and a column whose top was not seen is then not read, since its scan top is the lower level; otherwise the
|
|
364
|
+
* tops stay exactly as read. Nothing is done when the colour above is the garment's (its median
|
|
365
|
+
* within `GARMENT_DIFF`). Rewrites `tops` in place; returns how many moved.
|
|
366
|
+
*/
|
|
367
|
+
function toBandTop(f: Frame, tops: Pt[], g: RGB, top: number, colourAt: (x: number, y: number) => RGB | null): number {
|
|
368
|
+
if (tops.length === 0) return 0;
|
|
369
|
+
const lift = DITHER_RADIUS + 1;
|
|
370
|
+
const ya = Math.min(...tops.map((p) => p[1])) - 3 * lift;
|
|
371
|
+
if (ya < top) return 0;
|
|
372
|
+
// The garment's colours, at most `MAX_CLOTH`: a paletted garment's few, as they are; a painted one's many, as the mean
|
|
373
|
+
// colours of the `MAX_CLOTH` fullest cells of a `GARMENT_DIFF / 2` grid (first filled first among equals). The climb
|
|
374
|
+
// compares each pixel against them all, so their number is bounded whatever the span.
|
|
375
|
+
const exact = new Map<string, RGB>();
|
|
376
|
+
const cells = new Map<string, { s: RGB; n: number }>();
|
|
377
|
+
const step = GARMENT_DIFF / 2;
|
|
378
|
+
for (const [x, y0] of tops) {
|
|
379
|
+
for (let k = 0; k < 3; k++) {
|
|
380
|
+
const y = y0 + lift + k;
|
|
381
|
+
if (y < 0 || y >= f.h || f.masked(x, y)) continue;
|
|
382
|
+
const p = f.rgb(x, y);
|
|
383
|
+
if (exact.size <= MAX_CLOTH) exact.set(p.join(','), p);
|
|
384
|
+
const key = p.map((v) => Math.floor(v / step)).join(',');
|
|
385
|
+
const c = cells.get(key);
|
|
386
|
+
if (c === undefined) cells.set(key, { s: [p[0], p[1], p[2]], n: 1 });
|
|
387
|
+
else {
|
|
388
|
+
c.s[0] += p[0];
|
|
389
|
+
c.s[1] += p[1];
|
|
390
|
+
c.s[2] += p[2];
|
|
391
|
+
c.n++;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
const cloth: RGB[] = exact.size <= MAX_CLOTH ? [...exact.values()] : [...cells.values()].sort((u, v) => v.n - u.n).slice(0, MAX_CLOTH).map((c) => [c.s[0] / c.n, c.s[1] / c.n, c.s[2] / c.n]);
|
|
396
|
+
if (cloth.length === 0) return 0;
|
|
397
|
+
// The colour above: the median of the region, channel by channel, read twice over it rather than held.
|
|
398
|
+
const a = regionMedian((visit) => {
|
|
399
|
+
for (const [x] of tops) {
|
|
400
|
+
for (let y = top; y <= ya; y++) {
|
|
401
|
+
const p = f.masked(x, y) ? null : colourAt(x, y);
|
|
402
|
+
if (p !== null) visit(p);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
if (a === null) return 0;
|
|
407
|
+
if (diff(a, g) <= GARMENT_DIFF) return 0;
|
|
408
|
+
const nearest = (p: RGB, cs: RGB[]) => Math.min(...cs.map((q) => diff(p, q)));
|
|
409
|
+
const climbs = tops.map(([x, y0]) => {
|
|
410
|
+
let y = y0;
|
|
411
|
+
while (true) {
|
|
412
|
+
if (y - 1 < top || f.masked(x, y - 1)) return null;
|
|
413
|
+
const p = f.rgb(x, y - 1);
|
|
414
|
+
if (diff(p, a) < nearest(p, cloth)) break;
|
|
415
|
+
y--;
|
|
416
|
+
}
|
|
417
|
+
// The band's top: the topmost cloth pixel on the path, from where the climb stopped down to the scan's three rows.
|
|
418
|
+
for (let v = y; v <= y0 + 2; v++) if (v < f.h && nearest(f.rgb(x, v), cloth) <= GARMENT_DIFF) return y0 - v;
|
|
419
|
+
return null;
|
|
420
|
+
});
|
|
421
|
+
const ks = climbs.filter((k): k is number => k !== null).sort((p, q) => p - q);
|
|
422
|
+
if (ks.length === 0) return 0;
|
|
423
|
+
const rank = (q: number) => ks[Math.min(ks.length - 1, Math.max(0, Math.ceil(q * ks.length) - 1))]!;
|
|
424
|
+
if (rank(0.9) - rank(0.1) <= CLIMB_SPREAD) return 0;
|
|
425
|
+
let moved = 0;
|
|
426
|
+
const read = tops.flatMap((t, i): Pt[] => {
|
|
427
|
+
const k = climbs[i];
|
|
428
|
+
if (k === null || k === undefined) return [];
|
|
429
|
+
if (k !== 0) moved++;
|
|
430
|
+
return [[t[0], t[1] - k]];
|
|
431
|
+
});
|
|
432
|
+
tops.splice(0, tops.length, ...read);
|
|
433
|
+
return moved;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* The median, channel by channel, of the colours a region yields (`median`'s value: the middle one, or the mean of the
|
|
438
|
+
* two middle ones), without holding them: a first pass counts each channel's values by whole step (0–255), which names
|
|
439
|
+
* the step the middle rank falls in; a second keeps only the values in that step, as distinct values and counts. Memory
|
|
440
|
+
* is the 256 counts and the distinct values of one step. Null for an empty region.
|
|
441
|
+
*/
|
|
442
|
+
function regionMedian(each: (visit: (p: RGB) => void) => void): RGB | null {
|
|
443
|
+
const bin = (v: number) => Math.max(0, Math.min(255, Math.floor(v)));
|
|
444
|
+
const hist = [new Float64Array(256), new Float64Array(256), new Float64Array(256)];
|
|
445
|
+
let n = 0;
|
|
446
|
+
each((p) => {
|
|
447
|
+
n++;
|
|
448
|
+
for (let k = 0; k < 3; k++) hist[k]![bin(p[k]!)]!++;
|
|
449
|
+
});
|
|
450
|
+
if (n === 0) return null;
|
|
451
|
+
const m = n >> 1;
|
|
452
|
+
const ranks = n % 2 === 1 ? [m] : [m - 1, m];
|
|
453
|
+
// For each channel and rank: the step it falls in and its rank within the step.
|
|
454
|
+
const want = [0, 1, 2].map((k) => ranks.map((r) => {
|
|
455
|
+
let below = 0;
|
|
456
|
+
for (let b = 0; b < 256; b++) {
|
|
457
|
+
const c = hist[k]![b]!;
|
|
458
|
+
if (r < below + c) return { b, r: r - below };
|
|
459
|
+
below += c;
|
|
460
|
+
}
|
|
461
|
+
throw new Error('unreachable: a rank beyond the count');
|
|
462
|
+
}));
|
|
463
|
+
const kept = [0, 1, 2].map(() => new Map<number, Map<number, number>>());
|
|
464
|
+
for (let k = 0; k < 3; k++) for (const w of want[k]!) kept[k]!.set(w.b, new Map());
|
|
465
|
+
each((p) => {
|
|
466
|
+
for (let k = 0; k < 3; k++) {
|
|
467
|
+
const vs = kept[k]!.get(bin(p[k]!));
|
|
468
|
+
if (vs !== undefined) vs.set(p[k]!, (vs.get(p[k]!) ?? 0) + 1);
|
|
469
|
+
}
|
|
470
|
+
});
|
|
471
|
+
const at = (k: number, w: { b: number; r: number }) => {
|
|
472
|
+
let below = 0;
|
|
473
|
+
for (const [v, c] of [...kept[k]!.get(w.b)!.entries()].sort((u, z) => u[0] - z[0])) {
|
|
474
|
+
if (w.r < below + c) return v;
|
|
475
|
+
below += c;
|
|
476
|
+
}
|
|
477
|
+
throw new Error('unreachable: a rank beyond the step');
|
|
478
|
+
};
|
|
479
|
+
return [0, 1, 2].map((k) => {
|
|
480
|
+
const vs = want[k]!.map((w) => at(k, w));
|
|
481
|
+
return vs.length === 1 ? vs[0]! : (vs[0]! + vs[1]!) / 2;
|
|
482
|
+
}) as RGB;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* rig-c/pose#318: the edge the waistband reader fitted is declined when it cuts across the band instead of running
|
|
487
|
+
* along it — a fold's edge under a band the colour test passed over. Above the edge, in each column read, the colour
|
|
488
|
+
* `DITHER_RADIUS + 1` rows up (past the outline and the window's blend) is followed up while it holds within
|
|
489
|
+
* `GARMENT_DIFF`; where it stops is the band's top. When at least `MIN_COVER` of the columns read show such a band (not
|
|
490
|
+
* the ground, not open up to the scan's top), its top is fitted as a line. The two lines bound a strip of thickness
|
|
491
|
+
* `ta` and `tb` at the read columns' ends. A line inside a band of thickness t across a width W departs from the band's
|
|
492
|
+
* direction by at most atan(t / W); an edge whose strip more than doubles across the hips (|ta − tb| > min(ta, tb)), or
|
|
493
|
+
* crosses the band's top, cuts across the garment instead of running along the band.
|
|
494
|
+
*
|
|
495
|
+
* Null when the edge is the band's (or nothing above it is a band to hold it to).
|
|
496
|
+
*/
|
|
497
|
+
function notTheBand(f: Frame, fit: { m: number; q: number }, tops: Pt[], top: number, colourAt: (x: number, y: number) => RGB | null): Reading | null {
|
|
498
|
+
const edgeAt = (x: number) => fit.m * x + fit.q;
|
|
499
|
+
const bandTops: Pt[] = [];
|
|
500
|
+
for (const [x, y] of tops) {
|
|
501
|
+
const s = y - (DITHER_RADIUS + 1);
|
|
502
|
+
if (s < top || f.masked(x, s)) continue;
|
|
503
|
+
const c0 = colourAt(x, s);
|
|
504
|
+
if (c0 === null || diff(c0, f.bg) <= FIGURE_DIFF) continue;
|
|
505
|
+
let v = s;
|
|
506
|
+
let closed = false;
|
|
507
|
+
while (v - 1 >= top) {
|
|
508
|
+
if (f.masked(x, v - 1)) break;
|
|
509
|
+
const p = colourAt(x, v - 1);
|
|
510
|
+
if (p === null) break;
|
|
511
|
+
if (diff(p, c0) > GARMENT_DIFF) {
|
|
512
|
+
closed = true;
|
|
513
|
+
break;
|
|
514
|
+
}
|
|
515
|
+
v--;
|
|
516
|
+
}
|
|
517
|
+
if (closed) bandTops.push([x, v]);
|
|
518
|
+
}
|
|
519
|
+
if (bandTops.length < MIN_COVER * tops.length) return null;
|
|
520
|
+
const band = fitLine(bandTops);
|
|
521
|
+
if (band === null || band.rms > MAX_RMS) return null;
|
|
522
|
+
const xa = Math.min(...tops.map((p) => p[0]));
|
|
523
|
+
const xb = Math.max(...tops.map((p) => p[0]));
|
|
524
|
+
const ta = edgeAt(xa) - (band.m * xa + band.q);
|
|
525
|
+
const tb = edgeAt(xb) - (band.m * xb + band.q);
|
|
526
|
+
if (Math.abs(ta - tb) <= Math.min(ta, tb)) return null;
|
|
527
|
+
const deg = (m: number) => Math.atan(m) * (180 / Math.PI);
|
|
528
|
+
const allow = Math.max(0, Math.min(ta, tb));
|
|
529
|
+
return {
|
|
530
|
+
kind: 'edge leaves the band',
|
|
531
|
+
why: `the garment's edge fitted across the hips runs ${Math.abs(deg(fit.m) - deg(band.m)).toFixed(1)}° from the top of the band above it (${bandTops.length} of ${tops.length} columns; the band ${ta.toFixed(1)} px thick at x ${xa} and ${tb.toFixed(1)} px at x ${xb}); an edge inside a band ${allow.toFixed(1)} px thick at its thinnest departs from it by at most ${deg(allow / (xb - xa)).toFixed(1)}° across the ${xb - xa} px read, so this edge cuts across the garment (a fold), not along the band`,
|
|
532
|
+
};
|
|
333
533
|
}
|
|
334
534
|
|
|
335
535
|
/**
|
|
@@ -420,6 +620,8 @@ function viaNote(readings: Reading[]): string {
|
|
|
420
620
|
const parts: string[] = [];
|
|
421
621
|
if (mean > 0) parts.push(`${mean} through the ${2 * DITHER_RADIUS + 1}×${2 * DITHER_RADIUS + 1} window mean (the garment's colour does not hold pixel by pixel: a dithered or paletted picture)`);
|
|
422
622
|
if (below > 0) parts.push(`${below} with the colour found below the hips (up to ${Math.max(...ok.map((r) => r.below ?? 0)).toFixed(1)} px; the hips on the band's edge)`);
|
|
623
|
+
const raised = ok.filter((r) => r.raised !== undefined);
|
|
624
|
+
if (raised.length > 0) parts.push(`${raised.length} with the band's top read above the colour's edge in some columns (up to ${Math.max(...raised.map((r) => r.raised ?? 0))} columns: a strip too thin to carry the colour, read as its top line)`);
|
|
423
625
|
return parts.length === 0 ? '' : `; of the frames read, ${parts.join(', ')}`;
|
|
424
626
|
}
|
|
425
627
|
|
package/src/pose.ts
CHANGED
|
@@ -74,7 +74,7 @@ export interface SegmentDepth {
|
|
|
74
74
|
source: DepthSource;
|
|
75
75
|
}
|
|
76
76
|
export type FrameDepth = Partial<Record<DepthSegment, SegmentDepth>>;
|
|
77
|
-
/** The gait `clean --depth` was told the clip is: it decides the
|
|
77
|
+
/** The gait `clean --depth` was told the clip is (rig-c/pose#336): the signs are the same for both; it decides the joint `--draw-order depth` orders the legs by. */
|
|
78
78
|
export const DEPTH_GAITS = ['walk', 'run'] as const;
|
|
79
79
|
export type DepthGait = (typeof DEPTH_GAITS)[number];
|
|
80
80
|
/**
|
package/src/reduce.ts
CHANGED
|
@@ -80,6 +80,12 @@ export interface Reduced {
|
|
|
80
80
|
rows: ReduceRow[];
|
|
81
81
|
/** Each bone's world-aim error on every frame (reduced less dense, degrees), as the poser evaluates the reduced keys. */
|
|
82
82
|
errors: Map<string, number[]>;
|
|
83
|
+
/**
|
|
84
|
+
* rig-c/pose#320: each trunk root whose smoothing (`smoothTrunk`, #116) was not kept because it left a bone it
|
|
85
|
+
* manages past that bone's own tolerance — the bone, its smoothed worst error and its tolerance (degrees); the
|
|
86
|
+
* fitted keys stand. Absent when every smoothing was kept.
|
|
87
|
+
*/
|
|
88
|
+
unsmoothed?: Array<{ root: string; bone: string; worst: number; tolerance: number }>;
|
|
83
89
|
}
|
|
84
90
|
|
|
85
91
|
// ---------------------------------------------------------------------------
|
|
@@ -607,7 +613,9 @@ function solveLimbPreservingJitter(
|
|
|
607
613
|
* above the sequence's own. Optimizing knot values and handle y-coordinates
|
|
608
614
|
* jointly within the ±tolerance world-aim tube removes those C0 breaks while
|
|
609
615
|
* keeping any limb chains hanging from the trunk within 0.04 of their landing
|
|
610
|
-
* and jitter.
|
|
616
|
+
* and jitter. The tube's bounds are a penalty, not a constraint: a root whose
|
|
617
|
+
* smoothing leaves any bone it manages past that bone's own tolerance keeps its
|
|
618
|
+
* fitted keys instead, recorded in `out.unsmoothed` (rig-c/pose#320).
|
|
611
619
|
*/
|
|
612
620
|
export function smoothTrunk(
|
|
613
621
|
times: readonly number[],
|
|
@@ -737,6 +745,16 @@ export function smoothTrunk(
|
|
|
737
745
|
}
|
|
738
746
|
};
|
|
739
747
|
|
|
748
|
+
// rig-c/pose#320: the smoothing is kept only within every managed bone's own tolerance; past it, the fitted keys
|
|
749
|
+
// (within it by construction) stand, and the run says so.
|
|
750
|
+
const past = allManaged.map((b) => {
|
|
751
|
+
const e = b === root ? eRoot : eSub.get(b)!;
|
|
752
|
+
return { b, worst: Math.max(...e.map(Math.abs)) };
|
|
753
|
+
}).find((x) => x.worst > tolOf(x.b));
|
|
754
|
+
if (past !== undefined) {
|
|
755
|
+
(out.unsmoothed ??= []).push({ root, bone: past.b, worst: past.worst, tolerance: tolOf(past.b) });
|
|
756
|
+
continue;
|
|
757
|
+
}
|
|
740
758
|
updateBone(root, smRoot.keys, eRoot);
|
|
741
759
|
for (const b of [...headBones, ...arm0Bones, ...arm1Bones]) {
|
|
742
760
|
updateBone(b, smSub.get(b)!.keys, eSub.get(b)!);
|
package/src/retarget.ts
CHANGED
|
@@ -80,8 +80,9 @@ import type { Joint, Travel } from './pose.ts';
|
|
|
80
80
|
import { TRAVEL_IN_PLACE } from './pose.ts';
|
|
81
81
|
import { Problems, Refusal } from './problems.ts';
|
|
82
82
|
import type { ArtRange, AttachmentKey, ByHip, Raised, ReachShort, SetRuns, SlotTrack, Switch, SwitchOptions } from './switch.ts';
|
|
83
|
-
import { clampArt, coveredChains, isRaised, measureArt, overflow, phaseGroupProblems, RAISED_WRIST_EYES, RANGE_SLACK, reachLimit, runsOf, selectSwitches, twoBone } from './switch.ts';
|
|
83
|
+
import { clampArt, coveredChains, isRaised, keyAtFrame, measureArt, overflow, phaseGroupProblems, RAISED_WRIST_EYES, RANGE_SLACK, reachLimit, runsOf, selectSwitches, twoBone } from './switch.ts';
|
|
84
84
|
import type { BlendHold, Closure, Feet, Floor, LegScale, LegSegmentRatio, PathPose, Posing, Side, Stance } from './legs.ts';
|
|
85
|
+
import { contactKeysOf, type ContactKeys } from './contacts.ts';
|
|
85
86
|
import { figureScaleOf, freeAnkleShiftOf, holdBlend, isLegSegment, legScaleOf, legsOf, pathFrom, percentile, plantHip, posePath, rootOf, scaleSupport, shiftOver, SIDES, stanceOf, stanceRangeOf, STRAIGHT_PERCENTILE, supportsOf, tipOf, unshortenStanceShin } from './legs.ts';
|
|
86
87
|
import type { BodyReading, FaceReading, YawSetSwitch } from './face.ts';
|
|
87
88
|
import { readBody, readFace, readFaceAngles, selectYawSets } from './face.ts';
|
|
@@ -181,6 +182,12 @@ export interface RetargetOptions extends SwitchOptions {
|
|
|
181
182
|
* the hip line on the frames it does not; absent, from the hip line.
|
|
182
183
|
*/
|
|
183
184
|
pelvis?: { source: string; tilt: ReadonlyArray<number | null> };
|
|
185
|
+
/**
|
|
186
|
+
* rig-c/pose#303: the hand contact keys (`--contact-keys`), read against the
|
|
187
|
+
* sequence. Applied last over the supports read (`contactKeysOf`): on every
|
|
188
|
+
* frame they cover they decide the support. Absent, nothing changes.
|
|
189
|
+
*/
|
|
190
|
+
contactKeys?: ContactKeys;
|
|
184
191
|
}
|
|
185
192
|
|
|
186
193
|
/**
|
|
@@ -1020,6 +1027,11 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
1020
1027
|
else if (plantOn) p.add('LEGS_MAP', `--plant-feet reads both hips and both ankles from the pose; the map's chains use ${['r_hip', 'l_hip', 'r_ankle', 'l_ankle'].filter((j) => !series.has(j)).join(', ')} nowhere`);
|
|
1021
1028
|
}
|
|
1022
1029
|
}
|
|
1030
|
+
// rig-c/pose#303: the contact keys decide last, over every reading above; with no support read they have nothing to decide, so they are refused.
|
|
1031
|
+
if (opts.contactKeys !== undefined) {
|
|
1032
|
+
if (feet !== null) feet = contactKeysOf(feet, opts.contactKeys);
|
|
1033
|
+
else p.add('CONTACT_KEYS', `--contact-keys sets which foot supports, and no support is read: ${legs !== null && 'problems' in legs ? legs.problems.join('; ') : `the map's chains use ${['r_hip', 'l_hip', 'r_ankle', 'l_ankle'].filter((j) => !series.has(j)).join(', ')} nowhere`}`);
|
|
1034
|
+
}
|
|
1023
1035
|
const heldPt = (name: string): Pt[] | null => {
|
|
1024
1036
|
const known = series.get(name);
|
|
1025
1037
|
if (known !== undefined) return known;
|
|
@@ -3048,7 +3060,6 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
3048
3060
|
if (!slotOrder.includes(sl)) slotOrder.push(sl);
|
|
3049
3061
|
}
|
|
3050
3062
|
}
|
|
3051
|
-
const yawExactAt = new Map<string, Set<number>>();
|
|
3052
3063
|
for (const s of activeYawSets) {
|
|
3053
3064
|
const def = known.get(s.set);
|
|
3054
3065
|
if (def === undefined) continue;
|
|
@@ -3058,10 +3069,6 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
3058
3069
|
const row = sw.shown.get(sl)!;
|
|
3059
3070
|
for (const [lo, hi] of s.spans) {
|
|
3060
3071
|
for (let i = lo; i <= hi; i++) row[i] = v;
|
|
3061
|
-
const exact = yawExactAt.get(sl) ?? new Set<number>();
|
|
3062
|
-
exact.add(lo);
|
|
3063
|
-
if (hi + 1 < n) exact.add(hi + 1);
|
|
3064
|
-
yawExactAt.set(sl, exact);
|
|
3065
3072
|
}
|
|
3066
3073
|
}
|
|
3067
3074
|
}
|
|
@@ -3071,11 +3078,12 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
3071
3078
|
if (row === undefined) continue;
|
|
3072
3079
|
let prev = slots.get(sl)?.setup ?? null;
|
|
3073
3080
|
const keys: AttachmentKey[] = [];
|
|
3074
|
-
const exact = yawExactAt.get(sl);
|
|
3075
3081
|
row.forEach((v, i) => {
|
|
3076
3082
|
if (v === prev) return;
|
|
3077
|
-
|
|
3078
|
-
|
|
3083
|
+
// Half a frame before the frame it shows on, as every discrete key pose writes (`keyAtFrame`, rig-c/pose#334):
|
|
3084
|
+
// written on their frames' exact times, 7 of the 10 yaw swaps of the 24 fps head turn in test/retarget.test.ts
|
|
3085
|
+
// showed a frame late under the render's clock.
|
|
3086
|
+
keys.push({ t: keyAtFrame(keyTimes, i), v });
|
|
3079
3087
|
prev = v;
|
|
3080
3088
|
});
|
|
3081
3089
|
if (keys.length > 0) rebuilt.push({ slot: sl, property: 'attachment', keys });
|
package/src/run.ts
CHANGED
|
@@ -10,8 +10,8 @@ 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
12
|
import { cleanLine, cleanSummaryLine, inferredSource } from './clean.ts';
|
|
13
|
-
import { depthLine, layersOf, type FrameLayers } from './depth.ts';
|
|
14
|
-
import { drawOrderFromDepth, drawOrderKeys, drawOrderLines, type DrawOrderReading } from './draworder.ts';
|
|
13
|
+
import { depthLine, depthRecleanText, forwardShinFrames, layersOf, type FrameLayers } from './depth.ts';
|
|
14
|
+
import { drawGapLines, drawOrderFromDepth, drawOrderKeys, drawOrderLines, type DrawOrderReading } from './draworder.ts';
|
|
15
15
|
import { convertCoco17, personTrack, readPoseSequence, TRAVEL_IN_PLACE, type CameraFrame } from './pose.ts';
|
|
16
16
|
import { isObject, lineOf, parseJson, Problems, Refusal, type Problem } from './problems.ts';
|
|
17
17
|
import { boneIdentity, droppedLine, keptOutLine, mergeProblems, readFragment, readIdle, targetOf, trackLabel, type IdleReading, type RigcTrack, type Target } from './idle.ts';
|
|
@@ -27,6 +27,7 @@ import { reportRenderDomain, writeReport, type ReportInput } from './report.ts';
|
|
|
27
27
|
import { PROVENANCE_FILE, provenanceText } from './provenance.ts';
|
|
28
28
|
import { madeLine, planOutDirs } from './outdir.ts';
|
|
29
29
|
import { bodyLine, faceLine } from './face.ts';
|
|
30
|
+
import { contactKeysLines, readContactKeys } from './contacts.ts';
|
|
30
31
|
|
|
31
32
|
export interface RetargetArgs extends RetargetOptions {
|
|
32
33
|
rig: string;
|
|
@@ -81,6 +82,8 @@ export interface RetargetArgs extends RetargetOptions {
|
|
|
81
82
|
output?: 'default' | 'verbose' | 'summary';
|
|
82
83
|
/** `--draw-order depth` (rig-c/pose#304): key the animation's draw order from the sequence's depth. Absent: no draw-order key, the output as before. */
|
|
83
84
|
drawOrder?: 'depth';
|
|
85
|
+
/** `--contact-keys` (rig-c/pose#303): the hand contact keys' file, `rig-pose-contact-keys/1`. Absent: the support as read, the output as before. */
|
|
86
|
+
contactKeysFile?: string;
|
|
84
87
|
}
|
|
85
88
|
|
|
86
89
|
export interface RunResult {
|
|
@@ -104,7 +107,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
104
107
|
throw new Refusal(problems);
|
|
105
108
|
};
|
|
106
109
|
const args = new Problems();
|
|
107
|
-
for (const [flag, path] of [['--map', a.map], ['--pose', a.pose], ['--merge', a.merge]] as const) {
|
|
110
|
+
for (const [flag, path] of [['--map', a.map], ['--pose', a.pose], ['--merge', a.merge], ['--contact-keys', a.contactKeysFile]] as const) {
|
|
108
111
|
if (path !== undefined && !existsSync(path)) args.add('ARG_PATH', `${flag} ${path} does not exist`);
|
|
109
112
|
}
|
|
110
113
|
// rig-c/pose#239: the report is written unless asked elsewhere — beside the motion, as <out>.report.json.
|
|
@@ -134,13 +137,22 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
134
137
|
const mapSnapshot = readReportInput(a.map), poseSnapshot = readReportInput(a.pose);
|
|
135
138
|
const map = readMap(mapSnapshot.bytes.toString('utf8'), a.map);
|
|
136
139
|
// #283: each chain --reduce names must be the map's; a bone takes its chain's tolerance (the tighter, if two chains name it).
|
|
137
|
-
|
|
140
|
+
let reduceOf = a.reduce === undefined ? null : reduceTolerances(map, a.reduce, a.reduceChains, a.map);
|
|
138
141
|
const poseText = poseSnapshot.bytes.toString('utf8');
|
|
139
142
|
const seq = a.coco ? convertCoco17(poseText, a.pose) : readPoseSequence(poseText, a.pose);
|
|
140
143
|
if (seq.cleaned === undefined) refuse([{ code: 'POSE_NOT_CLEANED', text: notCleaned(a.pose, a.coco) }]);
|
|
141
144
|
// #304: the draw order is read off the sequence's depth; asked for on a sequence without it, refused by name.
|
|
142
145
|
if (a.drawOrder === 'depth' && !seq.frames.some((x) => x.depth !== undefined)) refuse([{ code: 'DRAWORDER_NO_DEPTH', text: `--draw-order depth keys the draw order from the sequence's depth, and ${a.pose} carries none (rig-pose-sequence/${seq.cleaned!.params.depth === undefined ? '1 or 1.1, no "depth" on any frame' : '1.2 with no frame naming a segment'}); clean it with --depth walk|run, or retarget without --draw-order` }]);
|
|
143
146
|
const frames = personTrack(seq, a.person, a.pose);
|
|
147
|
+
// #336: a derived depth whose shins lean the way the trunk faces was cleaned with the table before #336 (a walk's lifted
|
|
148
|
+
// foot forward); the draw order now reads a walk at the ankle, and would draw that foot in front. Refused by name.
|
|
149
|
+
if (a.drawOrder === 'depth') {
|
|
150
|
+
const old = forwardShinFrames(frames, seq.frames);
|
|
151
|
+
if (old.length > 0) refuse([{ code: 'DEPTH_RECLEAN', text: depthRecleanText(a.pose, seq.cleaned!.params.depth?.gait ?? null, old) }]);
|
|
152
|
+
}
|
|
153
|
+
// rig-c/pose#303: the contact keys, read against the sequence's frames before any gate runs.
|
|
154
|
+
const contactKeysSnapshot = a.contactKeysFile === undefined ? null : readFileSync(a.contactKeysFile);
|
|
155
|
+
const contactKeys = contactKeysSnapshot === null ? null : readContactKeys(contactKeysSnapshot.toString('utf8'), a.contactKeysFile!, seq.frames.length);
|
|
144
156
|
const times = seq.frames.map((f) => f.t);
|
|
145
157
|
|
|
146
158
|
const scratch = mkdtempSync(join(tmpdir(), 'rig-pose-'));
|
|
@@ -156,7 +168,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
156
168
|
const slots = slotsOf(doc0);
|
|
157
169
|
resolveMap(map, rest, a.map, slots, input.sets);
|
|
158
170
|
|
|
159
|
-
const r = retarget(rest, map, frames, times, { ...a, ...(seq.travel !== undefined ? { travel: seq.travel } : {}), ...(seq.cleaned!.params.loop ? { loop: true } : {}), ...(seq.pelvisSource !== undefined ? { pelvis: { source: seq.pelvisSource, tilt: seq.frames.map((f) => f.pelvisTilt ?? null) } } : {}) }, slots, input.sets);
|
|
171
|
+
const r = retarget(rest, map, frames, times, { ...a, ...(seq.travel !== undefined ? { travel: seq.travel } : {}), ...(seq.cleaned!.params.loop ? { loop: true } : {}), ...(seq.pelvisSource !== undefined ? { pelvis: { source: seq.pelvisSource, tilt: seq.frames.map((f) => f.pelvisTilt ?? null) } } : {}), ...(contactKeys !== null ? { contactKeys } : {}) }, slots, input.sets);
|
|
160
172
|
if (fragment.length > 0 && ((r.face?.sets?.length ?? 0) > 0 || (r.body?.sets?.length ?? 0) > 0)) {
|
|
161
173
|
const known = knownSetsOf(map, input.sets);
|
|
162
174
|
for (const s of [...(r.face?.sets ?? []), ...(r.body?.sets ?? [])]) {
|
|
@@ -227,9 +239,9 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
227
239
|
// rig-c/pose#281: the limbs' front-to-back order from the sequence's depth, only when it carries depth.
|
|
228
240
|
const layers: FrameLayers[] | null = seq.frames.some((x) => x.depth !== undefined) ? frames.map((j, i) => layersOf(j, seq.frames[i]!.depth ?? {})) : null;
|
|
229
241
|
if (layers !== null) say(` ${depthLine(layers, seq.frames, seq.cleaned!.params.depth?.gait ?? null)}`);
|
|
230
|
-
// rig-c/pose#304: with --draw-order depth, the legs' draw order keyed from
|
|
231
|
-
const drawOrder: DrawOrderReading | null = a.drawOrder === 'depth' && layers !== null ? drawOrderFromDepth(layers, frames, seq.frames.map((x) => x.depth), r.times, map, rest, doc0.slots, input.sets, seq.cleaned!.params.loop) : null;
|
|
232
|
-
if (drawOrder !== null) for (const l of drawOrderLines(drawOrder)) say(` ${l}`);
|
|
242
|
+
// rig-c/pose#304: with --draw-order depth, the legs' draw order keyed from the depth (#336: at the joint the recorded gait names).
|
|
243
|
+
const drawOrder: DrawOrderReading | null = a.drawOrder === 'depth' && layers !== null ? drawOrderFromDepth(layers, frames, seq.frames.map((x) => x.depth), r.times, map, rest, doc0.slots, input.sets, seq.cleaned!.params.loop, seq.cleaned!.params.depth?.gait ?? null) : null;
|
|
244
|
+
if (drawOrder !== null) for (const l of [...drawOrderLines(drawOrder), ...drawGapLines(drawOrder)]) say(` ${l}`);
|
|
233
245
|
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`);
|
|
234
246
|
if (r.feet !== null) {
|
|
235
247
|
const f = r.feet;
|
|
@@ -237,7 +249,9 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
237
249
|
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)`);
|
|
238
250
|
for (const sg of r.segments) say(` FEET ${segmentLine(sg)}`);
|
|
239
251
|
say(` FEET supports: ${f.supports.map((x) => `${x.side} ${span(x.frames)}`).join(', ') || 'none'}`);
|
|
240
|
-
|
|
252
|
+
// rig-c/pose#303: with contact keys, their stride lines stand in for #280's (the keys decide the frames they cover).
|
|
253
|
+
const signal = f.signal === undefined ? [] : signalLines(f.signal, f.supports);
|
|
254
|
+
for (const l of f.contactKeys === undefined ? signal : [...signal.filter((x) => !x.startsWith('stride ')), ...contactKeysLines(f)]) say(` FEET ${l}`);
|
|
241
255
|
say(` FEET jumps (both ankles and mid_hip > 0.05 leg lengths above their rolling floor, ≥ 2 frames): ${f.jumps.map(span).join(', ') || 'none'}`);
|
|
242
256
|
if (f.lowered !== undefined) say(` FEET ${loweredLine(f.lowered)}`);
|
|
243
257
|
if (f.tracked !== undefined) say(` FEET tracked (the lower foot supports on the floor, following its scaled ankle track until still, rig-c/pose#119): ${f.tracked.map((x) => `${x.side} ${span(x.frames)}`).join(', ')}`);
|
|
@@ -256,14 +270,23 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
256
270
|
if (r.face !== null) say(` FACE ${faceLine(r.face)}`);
|
|
257
271
|
if (r.body !== null) say(` BODY ${bodyLine(r.body)}`);
|
|
258
272
|
|
|
273
|
+
// #320: a mapped bone above a planted or aimed chain --reduce names is held to that chain's tolerance when its own is looser.
|
|
274
|
+
const held = reduceOf === null || a.reduceChains === undefined ? [] : holdParents(map, rest, new Set(r.targets.keys()), a.reduceChains, reduceOf, r.ankles === null ? [] : [r.ankles.bones.r, r.ankles.bones.l]);
|
|
275
|
+
if (held.length > 0) {
|
|
276
|
+
const first = reduceOf!;
|
|
277
|
+
reduceOf = (bone) => held.find((h) => h.bone === bone)?.to ?? first(bone);
|
|
278
|
+
for (const h of held) say(` REDUCE ${heldLine(h)}`);
|
|
279
|
+
}
|
|
259
280
|
// --reduce (#32): the dense keys thinned top-down, before anything reads the tracks.
|
|
260
281
|
const reduced = a.reduce === undefined ? null : a.reduceChains === undefined ? reduceTracks(r, map, rest, a.reduce, a.reduceHip, a.reduceKeepPlanted === true) : reduceTracks(r, map, rest, a.reduce, a.reduceHip, a.reduceKeepPlanted === true, reduceOf!);
|
|
261
282
|
if (reduced !== null) {
|
|
262
283
|
for (const row of reduced.rows) {
|
|
263
284
|
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'];
|
|
264
|
-
const
|
|
285
|
+
const hold = held.find((h) => h.bone === row.bone && row.kind === 'angle');
|
|
286
|
+
const own = row.kind === 'angle' && reduceOf!(row.bone) !== a.reduce ? (hold === undefined ? ` — within its chain's ${reduceOf!(row.bone)}°` : ` — held to ${hold.to}° for ${hold.for.map((c) => `chain "${c.chain}"`).join(', ')} below it`) : '';
|
|
265
287
|
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)${own}`);
|
|
266
288
|
}
|
|
289
|
+
for (const u of reduced.unsmoothed ?? []) say(` REDUCE ${unsmoothedLine(u)}`);
|
|
267
290
|
const sum = (kind: ReduceRow['kind'], k: 'before' | 'after') => reduced.rows.filter((x) => x.kind === kind).reduce((t, x) => t + x[k], 0);
|
|
268
291
|
const scales = reduced.rows.some((x) => x.kind === 'scale') ? `; scalex keys ${sum('scale', 'before')} → ${sum('scale', 'after')} within ${SCALE_REDUCE} of the setup length` : '';
|
|
269
292
|
say(` REDUCE rotate keys ${sum('angle', 'before')} → ${sum('angle', 'after')} (${(sum('angle', 'before') / sum('angle', 'after')).toFixed(2)}×) within ${a.reduce}°${chainsText(a.reduceChains)}${scales}${a.reduceHip === undefined ? '; the hip stays dense (no --reduce-hip)' : ''}`);
|
|
@@ -414,7 +437,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
414
437
|
|
|
415
438
|
assertReportInputs([rigSnapshot,mapSnapshot,poseSnapshot]);
|
|
416
439
|
// Every retarget option as resolved: the report's `options`, and with the name the build record's `flags` (#270).
|
|
417
|
-
const 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', ...(a.reduceChains === undefined ? {} : { reduceChains: a.reduceChains }), reduceHip: a.reduceHip ?? 'off', ...(a.reduceKeepPlanted === true ? { reduceKeepPlanted: true } : {}), ...(a.drawOrder !== undefined ? { drawOrder: a.drawOrder } : {}) };
|
|
440
|
+
const 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', ...(a.reduceChains === undefined ? {} : { reduceChains: a.reduceChains }), reduceHip: a.reduceHip ?? 'off', ...(a.reduceKeepPlanted === true ? { reduceKeepPlanted: true } : {}), ...(a.drawOrder !== undefined ? { drawOrder: a.drawOrder } : {}), ...(a.contactKeysFile !== undefined ? { contactKeys: a.contactKeysFile } : {}) };
|
|
418
441
|
const reportText = writeReport({
|
|
419
442
|
spec: 'rig-pose-report/1',
|
|
420
443
|
rigc,
|
|
@@ -476,15 +499,18 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
476
499
|
pelvis: r.pelvis,
|
|
477
500
|
// Only when the sequence carries depth (#281): a sequence without it writes the report it always has.
|
|
478
501
|
...(layers !== null ? { layers: layers.map((l, frame) => ({ frame, order: l.order, depth: l.depth })) } : {}),
|
|
479
|
-
// Only with --draw-order depth (#304).
|
|
480
|
-
|
|
502
|
+
// Only with --draw-order depth (#304). A key's `t` here is its frame's time, the report's clock (#214); the
|
|
503
|
+
// motion's key is written half a frame before it (#325).
|
|
504
|
+
...(drawOrder !== null ? { drawOrder: { source: 'depth', pairs: drawOrder.pairs, keys: drawOrder.keys.map((k) => ({ ...k, t: r.times[k.frame]! })), spans: drawOrder.spans, gaps: drawOrder.gaps ?? [] } } : {}),
|
|
505
|
+
// Only with --contact-keys (#303).
|
|
506
|
+
...(r.feet?.contactKeys !== undefined && contactKeysSnapshot !== null ? { contactKeys: { path: a.contactKeysFile, sha256: createHash('sha256').update(contactKeysSnapshot).digest('hex'), keys: r.feet.contactKeys.keys.length, from: r.feet.contactKeys.from, changed: r.feet.contactKeys.changed, kept: r.feet.contactKeys.kept, unreached: r.feet.contactKeys.unreached } } : {}),
|
|
481
507
|
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 },
|
|
482
508
|
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 },
|
|
483
509
|
shear,
|
|
484
510
|
idleDropped: idle === null ? null : idle.dropped,
|
|
485
511
|
idleKeptOut: idle === null ? null : idle.keptOut,
|
|
486
512
|
merged: fragment.map((tr) => ({ track: trackLabel(tr, groups), keys: tr.keys.length })),
|
|
487
|
-
reduce: reduced === null ? null : { tolerance: a.reduce, ...(a.reduceChains === undefined ? {} : { chains: a.reduceChains }), hipTolerance: a.reduceHip ?? null, rows: reduced.rows, ankleDrift: drift },
|
|
513
|
+
reduce: reduced === null ? null : { tolerance: a.reduce, ...(a.reduceChains === undefined ? {} : { chains: a.reduceChains }), ...(held.length === 0 ? {} : { held }), ...(reduced.unsmoothed === undefined ? {} : { unsmoothed: reduced.unsmoothed }), hipTolerance: a.reduceHip ?? null, rows: reduced.rows, ankleDrift: drift },
|
|
488
514
|
}, 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});
|
|
489
515
|
if (a.build !== undefined) {
|
|
490
516
|
// Built again where it will live, not copied: rigc's page paths are relative to the build (#18).
|
|
@@ -1375,6 +1401,80 @@ export function reduceTolerances(map: JointMap, tolerance: number, chains: Recor
|
|
|
1375
1401
|
return (bone) => by.get(bone) ?? tolerance;
|
|
1376
1402
|
}
|
|
1377
1403
|
|
|
1404
|
+
/** rig-c/pose#320: a mapped bone held to the tolerance of a planted or aimed chain below it. */
|
|
1405
|
+
export interface ReduceHeld {
|
|
1406
|
+
/** The held bone, and the map chain it is a bone of (null for a mapped bone no chain lists). */
|
|
1407
|
+
bone: string;
|
|
1408
|
+
chain: string | null;
|
|
1409
|
+
/** Its tolerance as --reduce gave it (the run's, or its own chain's), degrees. */
|
|
1410
|
+
from: number;
|
|
1411
|
+
/** The tolerance it is held to: the tightest planted or aimed chain --reduce names below it, degrees. */
|
|
1412
|
+
to: number;
|
|
1413
|
+
/**
|
|
1414
|
+
* Each such chain below it reduced within `to`, in the map's order: its name, what holds it (`ankle`: it carries
|
|
1415
|
+
* a planted ankle's landing; `aim`: the map aims it), its first bone, and the lever — that bone's origin's setup
|
|
1416
|
+
* distance from the held bone's origin, rig units.
|
|
1417
|
+
*/
|
|
1418
|
+
for: Array<{ chain: string; by: 'ankle' | 'aim'; bone: string; lever: number }>;
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
/**
|
|
1422
|
+
* rig-c/pose#320: every mapped bone above a planted or aimed chain `chains`
|
|
1423
|
+
* names, whose own tolerance (`tolOf`) is looser than that chain's, held to it
|
|
1424
|
+
* — to the tightest such chain when several hang below it. A chain is planted
|
|
1425
|
+
* when it carries an ankle the landing check holds (`ankles`), aimed when the
|
|
1426
|
+
* map gives it an `aim` other than "x".
|
|
1427
|
+
*
|
|
1428
|
+
* The lever. A turn error θ on a bone turns everything under it rigidly: a
|
|
1429
|
+
* chain below has its first bone moved by θ·L (L = `lever`) and its aim turned
|
|
1430
|
+
* by θ. The reduction refits the chain against the turn (`target = dense −
|
|
1431
|
+
* e_above`), so the aim is the chain's own error, but not the move: the ankle
|
|
1432
|
+
* allowance (`footReduce`) sums every segment from the legs' top down — the
|
|
1433
|
+
* held bone's lever among them — times the leg's own degrees. θ·L ≤ θ_leg·L is
|
|
1434
|
+
* θ ≤ θ_leg whatever L is. An aimed chain is refitted, and on the trunk
|
|
1435
|
+
* smoothed (#116), against its parent's error as it stands: held to the
|
|
1436
|
+
* chain's degrees, that error never asks the chain to absorb more than its own
|
|
1437
|
+
* tolerance. A bent chain (an arm, a leg with no ankle check) has only its aim
|
|
1438
|
+
* checked, which the refit holds, so it holds nothing above it. Only a
|
|
1439
|
+
* per-chain entry can be tighter than a bone above it: with none, nothing is held.
|
|
1440
|
+
*/
|
|
1441
|
+
export function holdParents(map: JointMap, rest: Rest, mapped: ReadonlySet<string>, chains: Record<string, number>, tolOf: (bone: string) => number, ankles: readonly string[]): ReduceHeld[] {
|
|
1442
|
+
const held: ReduceHeld[] = [];
|
|
1443
|
+
for (const c of map.chains) {
|
|
1444
|
+
const t = Object.hasOwn(chains, c.name) ? chains[c.name] : undefined;
|
|
1445
|
+
const top = c.bones[0];
|
|
1446
|
+
const by = c.bones.some((b) => ankles.includes(b)) ? ('ankle' as const) : c.aim.some((x) => x !== 'x') ? ('aim' as const) : null;
|
|
1447
|
+
if (t === undefined || top === undefined || by === null || !rest.has(top)) continue;
|
|
1448
|
+
const at = rest.get(top)!;
|
|
1449
|
+
for (let p = at.parent; p !== null; p = rest.get(p)?.parent ?? null) {
|
|
1450
|
+
if (!mapped.has(p) || c.bones.includes(p) || !(tolOf(p) > t)) continue;
|
|
1451
|
+
const was = held.findIndex((h) => h.bone === p);
|
|
1452
|
+
if (was >= 0 && held[was]!.to < t) continue;
|
|
1453
|
+
const x = rest.get(p)!;
|
|
1454
|
+
const child = { chain: c.name, by, bone: top, lever: Number(Math.hypot(at.worldX - x.worldX, at.worldY - x.worldY).toPrecision(6)) };
|
|
1455
|
+
if (was >= 0 && held[was]!.to === t) held[was]!.for.push(child);
|
|
1456
|
+
else if (was >= 0) held[was] = { ...held[was]!, to: t, for: [child] };
|
|
1457
|
+
else held.push({ bone: p, chain: map.chains.find((m) => m.bones.includes(p))?.name ?? null, from: tolOf(p), to: t, for: [child] });
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
return held;
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
/** rig-c/pose#320: the REDUCE line naming a held bone, the chains it was held for, and why. */
|
|
1464
|
+
export function heldLine(h: ReduceHeld): string {
|
|
1465
|
+
const below = h.for.map((c) => `chain "${c.chain}" (${c.by === 'ankle' ? 'a planted ankle' : 'aimed'}; L = ${c.lever} units to bone "${c.bone}")`).join(', ');
|
|
1466
|
+
const why = [
|
|
1467
|
+
...(h.for.some((c) => c.by === 'ankle') ? ['the ankle allowance counts L at the leg\'s own degrees'] : []),
|
|
1468
|
+
...(h.for.some((c) => c.by === 'aim') ? ['an aimed chain refits against its parent\'s error within its own'] : []),
|
|
1469
|
+
].join(' and ');
|
|
1470
|
+
return `held: bone "${h.bone}"${h.chain === null ? '' : ` (chain "${h.chain}")`} reduced within ${h.to}°, not ${h.from}° — for ${below} below it, reduced within ${h.to}°: a turn error θ on bone "${h.bone}" moves a chain below it by θ·L and turns it by θ, and ${why}, so θ ≤ ${h.to}°`;
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
/** rig-c/pose#320: the REDUCE line for a trunk smoothing (#116) not kept. */
|
|
1474
|
+
export function unsmoothedLine(u: { root: string; bone: string; worst: number; tolerance: number }): string {
|
|
1475
|
+
return `smoothing (rig-c/pose#116) from bone "${u.root}" down not kept: it left bone "${u.bone}" at ${u.worst.toPrecision(3)}°, past its ${u.tolerance}°; the fitted keys stand, each within its tolerance`;
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1378
1478
|
/** rig-c/pose#283: the per-chain tolerances as the run and REDUCE lines print them; empty without. */
|
|
1379
1479
|
export function chainsText(chains: Record<string, number> | undefined): string {
|
|
1380
1480
|
if (chains === undefined) return '';
|