@rig-c/pose 0.3.0 → 0.5.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 +59 -2
- package/cli.ts +13 -2
- package/package.json +1 -1
- package/src/map.ts +207 -6
- package/src/retarget.ts +202 -21
- package/src/rigc.ts +36 -1
- package/src/run.ts +30 -9
- package/src/switch.ts +455 -0
package/src/retarget.ts
CHANGED
|
@@ -39,20 +39,39 @@
|
|
|
39
39
|
* target is aimed later, from posed positions, and is neither held short nor
|
|
40
40
|
* guarded.
|
|
41
41
|
*
|
|
42
|
+
* Then the switches (rig-c/pose#6, `switch.ts`): with every target known —
|
|
43
|
+
* reaches included — each alternate set's distance to the pose is read, and the
|
|
44
|
+
* spans each set shows are chosen. Only then the range clamp: outside the spans
|
|
45
|
+
* of a set depicting its chain, a bone whose rotate key would leave its
|
|
46
|
+
* `range` is clamped at the limit (its target becomes the clamped world angle),
|
|
47
|
+
* in depth order, so a bone below takes the clamped parent into its own key
|
|
48
|
+
* and still follows the pose; a reach below is aimed again from where the
|
|
49
|
+
* clamped bones put its origin. Every overflow is listed as `range` demand.
|
|
50
|
+
* Inside a span nothing is clamped, but a bone with a `drawn` angle is read
|
|
51
|
+
* against it (`artTurn`, and `artRange` where it leaves its range).
|
|
52
|
+
*
|
|
53
|
+
* The lines across the body — hips, shoulders, eyes — are read from the pose
|
|
54
|
+
* after holds and smoothing, whether the map uses them or not: a line under
|
|
55
|
+
* `TURN_FRACTION` of its median picture length, or reversed (pointing more
|
|
56
|
+
* than 90° from the subject's right to left), for ≥ `TURN_SECONDS`, is a `TURN`:
|
|
57
|
+
* the body or head turned, a pose a flat rig cannot draw. It changes no key.
|
|
58
|
+
*
|
|
42
59
|
* What this cannot know is held to account afterwards: `verify.ts` poses the
|
|
43
60
|
* gated build with rig-c's poser and measures where each bone landed.
|
|
44
61
|
*/
|
|
45
62
|
import { screenToSpineDegrees } from 'rig-c/transform';
|
|
46
63
|
import type { Body18 } from './joints.ts';
|
|
47
64
|
import { isBody18, VIRTUAL } from './joints.ts';
|
|
48
|
-
import type { Chain, JointMap, Rest } from './map.ts';
|
|
65
|
+
import type { Chain, JointMap, Rest, RigSlots } from './map.ts';
|
|
49
66
|
import { aimAngle, rigPoint } from './map.ts';
|
|
50
67
|
import type { Joint } from './pose.ts';
|
|
51
68
|
import { Problems } from './problems.ts';
|
|
69
|
+
import type { ArtRange, SlotTrack, Switch, SwitchOptions } from './switch.ts';
|
|
70
|
+
import { clampArt, coveredChains, measureArt, overflow, RANGE_SLACK, runsOf, selectSwitches } from './switch.ts';
|
|
52
71
|
|
|
53
72
|
const DEG = 180 / Math.PI;
|
|
54
73
|
|
|
55
|
-
export interface RetargetOptions {
|
|
74
|
+
export interface RetargetOptions extends SwitchOptions {
|
|
56
75
|
/** A joint with a score below this is not good on that frame (a joint with no score is). */
|
|
57
76
|
minScore: number;
|
|
58
77
|
/** Savitzky–Golay window over joint positions, in frames: 1 = none, else odd and ≥ 5. */
|
|
@@ -145,6 +164,8 @@ export const END_ON_GAP = 1;
|
|
|
145
164
|
export interface MotionKey {
|
|
146
165
|
t: number;
|
|
147
166
|
v: number[];
|
|
167
|
+
/** `stepped`: the value holds until the next key (the offsets' translate keys). */
|
|
168
|
+
ease?: 'stepped';
|
|
148
169
|
}
|
|
149
170
|
export interface MotionTrack {
|
|
150
171
|
bone: string;
|
|
@@ -153,9 +174,51 @@ export interface MotionTrack {
|
|
|
153
174
|
}
|
|
154
175
|
export interface Animation {
|
|
155
176
|
duration: number;
|
|
156
|
-
tracks
|
|
177
|
+
/** The bone tracks, then any slot `attachment` tracks the switches add (`switch.ts`). */
|
|
178
|
+
tracks: Array<MotionTrack | SlotTrack>;
|
|
157
179
|
}
|
|
158
180
|
|
|
181
|
+
/** A line across the body turned: short in the picture, or reversed, long enough to be a pose (the demand for a turned-body drawing). */
|
|
182
|
+
export interface Turn {
|
|
183
|
+
/** `hip`, `shoulder` or `eye`. */
|
|
184
|
+
line: string;
|
|
185
|
+
/** The subject's right joint, then its left twin. */
|
|
186
|
+
joints: [string, string];
|
|
187
|
+
/** First and last frame of the run (merged across a gap of `TURN_GAP` frame). */
|
|
188
|
+
frames: [number, number];
|
|
189
|
+
seconds: number;
|
|
190
|
+
/** The lowest picture length on the run's turned frames, as a fraction of the line's median. */
|
|
191
|
+
minFraction: number;
|
|
192
|
+
/** Whether the line points from the right joint to the left more than 90° off the subject's right-to-left on any frame of the run. */
|
|
193
|
+
reversed: boolean;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** A bone's rotate key past its range outside every span of a set depicting its chain: clamped there, and the demand for an alternate. */
|
|
197
|
+
export interface RangeDemand {
|
|
198
|
+
chain: string;
|
|
199
|
+
bone: string;
|
|
200
|
+
/** First and last frame of the run (merged across a gap of one frame; a gap frame inside is within range or inside a span). */
|
|
201
|
+
frames: [number, number];
|
|
202
|
+
seconds: number;
|
|
203
|
+
/** Degrees past the range, the most on any frame of the run. */
|
|
204
|
+
maxOverflow: number;
|
|
205
|
+
/** The rotate key the pose asked for on that frame, degrees relative to setup, before the clamp. */
|
|
206
|
+
key: number;
|
|
207
|
+
/** The bone's range, degrees. */
|
|
208
|
+
limits: [number, number];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** The lines across the body a `TURN` is read on: the subject's right joint, then its left twin. */
|
|
212
|
+
export const TURN_LINES: ReadonlyArray<[string, string, string]> = [['hip', 'r_hip', 'l_hip'], ['shoulder', 'r_shoulder', 'l_shoulder'], ['eye', 'r_eye', 'l_eye']];
|
|
213
|
+
/** A line shorter in the picture than this fraction of its median is turned (rig-c/pose#6: between the front-facing dance's floor, 0.81, and a side-on body's 0.17–0.49). */
|
|
214
|
+
export const TURN_FRACTION = 0.5;
|
|
215
|
+
/** A turned run lasting at least this long, in seconds, is listed (as `endOn`). */
|
|
216
|
+
export const TURN_SECONDS = 0.15;
|
|
217
|
+
/** Turned spans this many frames apart or fewer merge into one run. */
|
|
218
|
+
export const TURN_GAP = 1;
|
|
219
|
+
/** Range overflow and art-range spans this many frames apart or fewer merge into one run. */
|
|
220
|
+
export const RANGE_GAP = 1;
|
|
221
|
+
|
|
159
222
|
export interface Retarget {
|
|
160
223
|
animation: Animation;
|
|
161
224
|
/** Key times, seconds from the first frame, as written. */
|
|
@@ -173,6 +236,20 @@ export interface Retarget {
|
|
|
173
236
|
flips: Flip[];
|
|
174
237
|
/** The figure scale, rig units per picture pixel, and the lengths it came from. */
|
|
175
238
|
scale: { factor: number; pose: number; rig: number } | null;
|
|
239
|
+
/** The spans each alternate set shows, with their art turn. */
|
|
240
|
+
switches: Switch[];
|
|
241
|
+
/** Per switched or hidden slot, what it shows on each frame (null: nothing). */
|
|
242
|
+
shown: Map<string, Array<string | null>>;
|
|
243
|
+
/** Per set, its distance to the pose on each frame, degrees. */
|
|
244
|
+
distances: Map<string, number[]>;
|
|
245
|
+
/** The lines across the body turned long enough to be a pose. */
|
|
246
|
+
turns: Turn[];
|
|
247
|
+
/** The lines across the body that could not be read, and why. */
|
|
248
|
+
turnsUnread: Array<{ line: string; joints: [string, string]; why: string }>;
|
|
249
|
+
/** Every run a bone's key was clamped at its range. */
|
|
250
|
+
range: RangeDemand[];
|
|
251
|
+
/** Every run a bone inside a span turned from its drawn angle past its range. */
|
|
252
|
+
artRange: ArtRange[];
|
|
176
253
|
}
|
|
177
254
|
|
|
178
255
|
type Pt = [number, number];
|
|
@@ -286,7 +363,7 @@ const r3 = (v: number) => {
|
|
|
286
363
|
* good, when a hinge's side cannot be read off a straight rest, and — with
|
|
287
364
|
* `bends: 'refuse'` — when a hinge bends the wrong way.
|
|
288
365
|
*/
|
|
289
|
-
export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partial<Record<Body18, Joint>>>, times: readonly number[], opts: RetargetOptions): Retarget {
|
|
366
|
+
export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partial<Record<Body18, Joint>>>, times: readonly number[], opts: RetargetOptions, slots: RigSlots = new Map()): Retarget {
|
|
290
367
|
const p = new Problems();
|
|
291
368
|
const n = frames.length;
|
|
292
369
|
const t0 = times[0]!;
|
|
@@ -294,6 +371,9 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
294
371
|
for (let i = 1; i < n; i++) {
|
|
295
372
|
if (keyTimes[i]! <= keyTimes[i - 1]!) p.add('POSE_TIME', `frames ${i - 1} and ${i} are ${keyTimes[i - 1]}s and ${keyTimes[i]}s after rounding to the microsecond; keys must strictly increase`);
|
|
296
373
|
}
|
|
374
|
+
/** The end of frame i: the next frame's key time; the last frame lasts as long as the one before it. */
|
|
375
|
+
const endOf = (i: number): number => (i + 1 < n ? keyTimes[i + 1]! : n > 1 ? 2 * keyTimes[i]! - keyTimes[i - 1]! : keyTimes[i]!);
|
|
376
|
+
const secondsOf = (run: [number, number]): number => Math.round((endOf(run[1]) - keyTimes[run[0]]!) * 1e6) / 1e6;
|
|
297
377
|
|
|
298
378
|
// Every point the map reads, held where it is not good.
|
|
299
379
|
const needed = new Set<string>();
|
|
@@ -305,15 +385,13 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
305
385
|
const series = new Map<string, Pt[]>();
|
|
306
386
|
const good = new Map<string, boolean[]>();
|
|
307
387
|
const holds: Hold[] = [];
|
|
308
|
-
|
|
388
|
+
/** A point held where it is not good, then smoothed; or why it is good on no frame. */
|
|
389
|
+
const heldOf = (name: string): { at: Pt[]; good: boolean[]; holds: Hold[] } | { never: string } => {
|
|
309
390
|
const raw = frames.map((f) => pointOn(f, name, opts.minScore));
|
|
310
391
|
const firstGood = raw.findIndex((v) => typeof v !== 'string');
|
|
311
|
-
if (firstGood < 0) {
|
|
312
|
-
const why = [...new Set(raw as string[])].slice(0, 3).join('; ');
|
|
313
|
-
p.add('POSE_POINT_NEVER_GOOD', `point "${name}" is good on none of the ${n} frames (${why}); the map needs it, and there is no value to hold`);
|
|
314
|
-
continue;
|
|
315
|
-
}
|
|
392
|
+
if (firstGood < 0) return { never: [...new Set(raw as string[])].slice(0, 3).join('; ') };
|
|
316
393
|
const out: Pt[] = [];
|
|
394
|
+
const spans: Hold[] = [];
|
|
317
395
|
let last = firstGood;
|
|
318
396
|
let span: Hold | null = null;
|
|
319
397
|
for (let i = 0; i < n; i++) {
|
|
@@ -328,14 +406,66 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
328
406
|
if (span !== null && span.source === last && span.why === v) span.to = i;
|
|
329
407
|
else {
|
|
330
408
|
span = { point: name, from: i, to: i, source: last, why: v };
|
|
331
|
-
|
|
409
|
+
spans.push(span);
|
|
332
410
|
}
|
|
333
411
|
}
|
|
334
|
-
|
|
335
|
-
|
|
412
|
+
return { at: savitzkyGolay(out, opts.smooth), good: raw.map((v) => typeof v !== 'string'), holds: spans };
|
|
413
|
+
};
|
|
414
|
+
for (const name of [...needed].sort()) {
|
|
415
|
+
const h = heldOf(name);
|
|
416
|
+
if ('never' in h) {
|
|
417
|
+
p.add('POSE_POINT_NEVER_GOOD', `point "${name}" is good on none of the ${n} frames (${h.never}); the map needs it, and there is no value to hold`);
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
holds.push(...h.holds);
|
|
421
|
+
series.set(name, h.at);
|
|
422
|
+
good.set(name, h.good);
|
|
336
423
|
}
|
|
337
424
|
p.throwIfAny();
|
|
338
425
|
|
|
426
|
+
// TURN: the lines across the body, read from the pose (held and smoothed as the
|
|
427
|
+
// map's points are, whether or not the map uses them). A reading; no key moves.
|
|
428
|
+
const turns: Turn[] = [];
|
|
429
|
+
const turnsUnread: Retarget['turnsUnread'] = [];
|
|
430
|
+
for (const [line, a, b] of TURN_LINES) {
|
|
431
|
+
const ends: Pt[][] = [];
|
|
432
|
+
for (const j of [a, b]) {
|
|
433
|
+
const known = series.get(j);
|
|
434
|
+
if (known !== undefined) ends.push(known);
|
|
435
|
+
else {
|
|
436
|
+
const h = heldOf(j);
|
|
437
|
+
if ('never' in h) {
|
|
438
|
+
turnsUnread.push({ line, joints: [a, b], why: `${j} is good on none of the ${n} frames (${h.never})` });
|
|
439
|
+
break;
|
|
440
|
+
}
|
|
441
|
+
ends.push(h.at);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
if (ends.length < 2) continue;
|
|
445
|
+
const [A, B] = ends as [Pt[], Pt[]];
|
|
446
|
+
const lengths = A.map((f, i) => Math.hypot(B[i]![0] - f[0], B[i]![1] - f[1]));
|
|
447
|
+
const med = median(lengths);
|
|
448
|
+
if (med < 1e-6) {
|
|
449
|
+
turnsUnread.push({ line, joints: [a, b], why: `its median picture length is ${med}px; a fraction of it is not a measure` });
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
const fraction = lengths.map((l) => l / med);
|
|
453
|
+
const reversed = A.map((f, i) => lengths[i]! > 1e-9 && Math.abs(pictureAngle(f, B[i]!)) > 90);
|
|
454
|
+
const turned = fraction.map((f, i) => f < TURN_FRACTION || reversed[i]!);
|
|
455
|
+
for (const run of runsOf(turned, TURN_GAP)) {
|
|
456
|
+
const seconds = secondsOf(run);
|
|
457
|
+
if (seconds < TURN_SECONDS - 1e-9) continue;
|
|
458
|
+
let minFraction = Infinity;
|
|
459
|
+
let rev = false;
|
|
460
|
+
for (let i = run[0]; i <= run[1]; i++) {
|
|
461
|
+
if (!turned[i]) continue;
|
|
462
|
+
minFraction = Math.min(minFraction, fraction[i]!);
|
|
463
|
+
rev ||= reversed[i]!;
|
|
464
|
+
}
|
|
465
|
+
turns.push({ line, joints: [a, b], frames: run, seconds, minFraction, reversed: rev });
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
339
469
|
// The hip: the travel of one point, scaled, in its parent's axes. It comes
|
|
340
470
|
// first, because a reach is aimed from where the hip has put its bone.
|
|
341
471
|
let origin: Retarget['origin'] = null;
|
|
@@ -416,8 +546,6 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
416
546
|
const endOn: EndOn[] = [];
|
|
417
547
|
if (opts.short !== 'off') {
|
|
418
548
|
const cutoff = opts.short;
|
|
419
|
-
/** The end of frame i: the next frame's key time; the last frame lasts as long as the one before it. */
|
|
420
|
-
const endOf = (i: number): number => (i + 1 < n ? keyTimes[i + 1]! : n > 1 ? 2 * keyTimes[i]! - keyTimes[i - 1]! : keyTimes[i]!);
|
|
421
549
|
for (const c of map.chains) {
|
|
422
550
|
if (c.reach) continue;
|
|
423
551
|
c.bones.forEach((b, k) => {
|
|
@@ -474,6 +602,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
474
602
|
return d;
|
|
475
603
|
};
|
|
476
604
|
const reaches = map.chains.filter((c) => c.reach).sort((x, y) => depth(x.bones[0]!) - depth(y.bones[0]!));
|
|
605
|
+
let aimReach: ((c: Chain) => void) | null = null;
|
|
477
606
|
if (reaches.length > 0 && t !== null && scale !== null) {
|
|
478
607
|
const s = scale.factor;
|
|
479
608
|
const tb = rest.get(t.bone)!;
|
|
@@ -495,7 +624,7 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
495
624
|
const oy = rest.get(b)!.worldY - rest.get(parent)!.worldY;
|
|
496
625
|
return [po[0] + Math.cos(r) * ox - Math.sin(r) * oy, po[1] + Math.sin(r) * ox + Math.cos(r) * oy];
|
|
497
626
|
};
|
|
498
|
-
|
|
627
|
+
aimReach = (c: Chain): void => {
|
|
499
628
|
const b = c.bones[0]!;
|
|
500
629
|
const anchor = series.get(c.joints[0]!)!;
|
|
501
630
|
const end = series.get(c.joints[1]!)!;
|
|
@@ -509,20 +638,57 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
509
638
|
}
|
|
510
639
|
fillLeading(angles, () => p.add('POSE_SEGMENT_ZERO', `chain "${c.name}" — bone "${b}" reaches for ${c.joints[1]}, which the pose puts on the bone's posed origin on every frame; it has no direction`));
|
|
511
640
|
targets.set(b, angles);
|
|
512
|
-
}
|
|
641
|
+
};
|
|
642
|
+
for (const c of reaches) aimReach(c);
|
|
513
643
|
}
|
|
514
644
|
p.throwIfAny();
|
|
515
645
|
|
|
516
|
-
//
|
|
646
|
+
// The switches: with every target known, each set's distance and its spans.
|
|
647
|
+
const sw = selectSwitches(map, targets, keyTimes, keyTimes[n - 1]!, slots, opts);
|
|
648
|
+
|
|
649
|
+
// The range clamp, outside the spans of a set depicting the bone's chain. Parents
|
|
650
|
+
// first, so a bone below takes its clamped parent into its key; a reach is aimed
|
|
651
|
+
// again from where the clamped bones above put its origin.
|
|
517
652
|
const mappedAbove = (b: string): string | null => {
|
|
518
653
|
for (let a = rest.get(b)!.parent; a !== null; a = rest.get(a)!.parent) if (targets.has(a)) return a;
|
|
519
654
|
return null;
|
|
520
655
|
};
|
|
521
|
-
const
|
|
656
|
+
const turn = (bone: string, i: number) => targets.get(bone)![i]! - restOf.get(bone)!;
|
|
657
|
+
const covered = coveredChains(sw.switches, n);
|
|
658
|
+
const range: RangeDemand[] = [];
|
|
659
|
+
const order = map.chains.flatMap((c) => c.bones.map((b) => ({ c, b }))).filter(({ c, b }) => c.reach || b in c.range);
|
|
660
|
+
order.sort((x, y) => depth(x.b) - depth(y.b));
|
|
661
|
+
for (const { c, b } of order) {
|
|
662
|
+
if (c.reach && aimReach !== null) aimReach(c);
|
|
663
|
+
const limits = c.range[b];
|
|
664
|
+
if (limits === undefined) continue;
|
|
665
|
+
const above = mappedAbove(b);
|
|
666
|
+
const tg = targets.get(b)!;
|
|
667
|
+
const cov = covered.get(c.name);
|
|
668
|
+
const over: number[] = [];
|
|
669
|
+
const asked: number[] = [];
|
|
670
|
+
for (let i = 0; i < n; i++) {
|
|
671
|
+
const key = wrap(turn(b, i) - (above === null ? 0 : turn(above, i)));
|
|
672
|
+
asked.push(key);
|
|
673
|
+
const o = cov?.[i] === true ? 0 : overflow(key, limits);
|
|
674
|
+
over.push(o);
|
|
675
|
+
if (o > RANGE_SLACK) tg[i] = wrap(tg[i]! + Math.min(Math.max(key, limits[0]), limits[1]) - key);
|
|
676
|
+
}
|
|
677
|
+
for (const run of runsOf(over.map((o) => o > RANGE_SLACK), RANGE_GAP)) {
|
|
678
|
+
let worst = run[0];
|
|
679
|
+
for (let i = run[0]; i <= run[1]; i++) if (over[i]! > over[worst]!) worst = i;
|
|
680
|
+
range.push({ chain: c.name, bone: b, frames: run, seconds: secondsOf(run), maxOverflow: r3(over[worst]!), key: r3(asked[worst]!), limits });
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
p.throwIfAny();
|
|
684
|
+
const artRange = measureArt(sw.switches, map, targets, endOf, keyTimes, RANGE_GAP);
|
|
685
|
+
clampArt(sw.switches, map, targets);
|
|
686
|
+
|
|
687
|
+
// Keys: the bone's turn less the turn of the nearest mapped bone above it.
|
|
688
|
+
const tracks: Array<MotionTrack | SlotTrack> = [];
|
|
522
689
|
for (const c of map.chains) {
|
|
523
690
|
for (const b of c.bones) {
|
|
524
691
|
const above = mappedAbove(b);
|
|
525
|
-
const turn = (bone: string, i: number) => targets.get(bone)![i]! - restOf.get(bone)!;
|
|
526
692
|
let prev = 0;
|
|
527
693
|
const keys: MotionKey[] = [];
|
|
528
694
|
for (let i = 0; i < n; i++) {
|
|
@@ -535,8 +701,13 @@ export function retarget(rest: Rest, map: JointMap, frames: ReadonlyArray<Partia
|
|
|
535
701
|
}
|
|
536
702
|
}
|
|
537
703
|
if (translateTrack !== null) tracks.push(translateTrack);
|
|
704
|
+
tracks.push(...sw.offsetTracks, ...sw.slotTracks);
|
|
538
705
|
|
|
539
|
-
return {
|
|
706
|
+
return {
|
|
707
|
+
animation: { duration: keyTimes[n - 1]!, tracks },
|
|
708
|
+
times: keyTimes, targets, origin, holds, bends, short, endOn, flips, scale,
|
|
709
|
+
switches: sw.switches, shown: sw.shown, distances: sw.distances, turns, turnsUnread, range, artRange,
|
|
710
|
+
};
|
|
540
711
|
}
|
|
541
712
|
|
|
542
713
|
type ShortSpan = Omit<Short, 'chain' | 'bone' | 'joints'>;
|
|
@@ -606,6 +777,16 @@ export function shortLine(s: Short, times: readonly number[], cutoff: number): s
|
|
|
606
777
|
return `chain "${s.chain}" bone "${s.bone}" (${s.joints[0]} → ${s.joints[1]}) ${spanOf(s.frames, times)}: picture length down to ${s.minFraction.toFixed(2)} of its median, under ${cutoff}; its direction is not read — ${held}`;
|
|
607
778
|
}
|
|
608
779
|
|
|
780
|
+
export function turnLine(e: Turn, times: readonly number[]): string {
|
|
781
|
+
const how = e.reversed ? `reversed (pointing more than 90° from ${e.joints[0]} → ${e.joints[1]}'s own way), picture length down to ${e.minFraction.toFixed(2)} of its median` : `picture length down to ${e.minFraction.toFixed(2)} of its median, under ${TURN_FRACTION}`;
|
|
782
|
+
return `line "${e.line}" (${e.joints[0]} → ${e.joints[1]}) ${spanOf(e.frames, times)}: ${e.seconds.toFixed(3)}s ${how}, ≥ ${TURN_SECONDS}s — the body or head turned, a pose the rig cannot draw`;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
export function rangeLine(e: RangeDemand, times: readonly number[]): string {
|
|
786
|
+
const sg = (v: number) => `${v >= 0 ? '+' : '−'}${Math.abs(v).toFixed(1)}°`;
|
|
787
|
+
return `chain "${e.chain}" bone "${e.bone}" ${spanOf(e.frames, times)}: ${e.seconds.toFixed(3)}s the pose asks its rotate key past its range [${e.limits[0]}, ${e.limits[1]}] (${sg(e.key)}, up to ${e.maxOverflow.toFixed(1)}° past); clamped at the limit — demand for an alternate`;
|
|
788
|
+
}
|
|
789
|
+
|
|
609
790
|
export function endOnLine(e: EndOn, times: readonly number[], cutoff: number): string {
|
|
610
791
|
return `chain "${e.chain}" bone "${e.bone}" (${e.joints[0]} → ${e.joints[1]}) ${spanOf(e.frames, times)}: ${e.seconds.toFixed(3)}s under ${cutoff} of its median picture length (down to ${e.minFraction.toFixed(2)}), ≥ ${END_ON_SECONDS}s — seen end-on, a pose the rig cannot draw`;
|
|
611
792
|
}
|
package/src/rigc.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'no
|
|
|
13
13
|
import { dirname, join, resolve } from 'node:path';
|
|
14
14
|
import { fileURLToPath } from 'node:url';
|
|
15
15
|
import { poseRawSetup, readModel, type CompiledDocument } from 'rig-c/core';
|
|
16
|
-
import type { Rest, RestBone } from './map.ts';
|
|
16
|
+
import type { Rest, RestBone, RigSlot, RigSlots } from './map.ts';
|
|
17
17
|
import { isObject, parseJson, Problems, Refusal, typeName } from './problems.ts';
|
|
18
18
|
|
|
19
19
|
export interface RigInput {
|
|
@@ -137,6 +137,41 @@ export function restOf(doc: CompiledDocument): Rest {
|
|
|
137
137
|
return out;
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
+
/** Every slot of the build: what it shows at setup, as the compiled model states it, and every attachment any skin gives it. */
|
|
141
|
+
export function slotsOf(doc: CompiledDocument): RigSlots {
|
|
142
|
+
const out = new Map<string, RigSlot>();
|
|
143
|
+
for (const s of doc.slots) {
|
|
144
|
+
const attachments = new Set<string>();
|
|
145
|
+
for (const k of doc.skins) for (const name of Object.keys(k.attachments[s.name] ?? {})) attachments.add(name);
|
|
146
|
+
out.set(s.name, { setup: s.setup, attachments });
|
|
147
|
+
}
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The bones that move an attachment: a weighted mesh's bindings (a linked mesh
|
|
153
|
+
* its source's), or the slot's bone for anything else — what an uncovered fold
|
|
154
|
+
* names its chains by.
|
|
155
|
+
*/
|
|
156
|
+
export function attachmentBones(doc: CompiledDocument, slot: string, attachment: string): string[] {
|
|
157
|
+
const slotBone = doc.slots.find((s) => s.name === slot)?.bone;
|
|
158
|
+
const find = (sl: string, name: string) => {
|
|
159
|
+
for (const k of doc.skins) {
|
|
160
|
+
const g = k.attachments[sl]?.[name]?.geometry;
|
|
161
|
+
if (g !== undefined) return g;
|
|
162
|
+
}
|
|
163
|
+
return undefined;
|
|
164
|
+
};
|
|
165
|
+
let g = find(slot, attachment);
|
|
166
|
+
if (g?.kind === 'linkedmesh') g = find(g.slot, g.source);
|
|
167
|
+
if (g !== undefined && 'vertices' in g && g.vertices.weighted) {
|
|
168
|
+
const bones = new Set<string>();
|
|
169
|
+
for (const v of g.vertices.bindings) for (const b of v) bones.add(b.bone);
|
|
170
|
+
return [...bones];
|
|
171
|
+
}
|
|
172
|
+
return slotBone === undefined ? [] : [slotBone];
|
|
173
|
+
}
|
|
174
|
+
|
|
140
175
|
export interface MeshFolds {
|
|
141
176
|
slot: string;
|
|
142
177
|
attachment: string;
|
package/src/run.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `rig-pose retarget`, end to end: read, gate the rig as it is, retarget
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* `rig-pose retarget`, end to end: read, gate the rig as it is, retarget (the
|
|
3
|
+
* switches and the range clamp included), gate it with the animation in,
|
|
4
|
+
* measure where the rig landed, read the folds (and which no switch covers),
|
|
5
|
+
* and write — only when every check passed.
|
|
5
6
|
*/
|
|
6
7
|
import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
7
8
|
import { tmpdir } from 'node:os';
|
|
@@ -9,8 +10,9 @@ import { dirname, join, resolve } from 'node:path';
|
|
|
9
10
|
import { readMap, resolveMap } from './map.ts';
|
|
10
11
|
import { convertCoco17, personTrack, readPoseSequence } from './pose.ts';
|
|
11
12
|
import { lineOf, Problems, Refusal, type Problem } from './problems.ts';
|
|
12
|
-
import { bendLine, endOnLine, flipLine, holdLine, retarget, shortLine, type RetargetOptions } from './retarget.ts';
|
|
13
|
-
import { baseMotionOf, buildInto, folds, gate, readBuild, resolveRig, restOf } from './rigc.ts';
|
|
13
|
+
import { bendLine, endOnLine, flipLine, holdLine, rangeLine, retarget, shortLine, turnLine, type RetargetOptions } from './retarget.ts';
|
|
14
|
+
import { attachmentBones, baseMotionOf, buildInto, folds, gate, readBuild, resolveRig, restOf, slotsOf } from './rigc.ts';
|
|
15
|
+
import { artRangeLine, chainsMoving, switchLine, uncoveredFolds } from './switch.ts';
|
|
14
16
|
import { landed, measureLanding, measurePosture, poseFrames, postureLines, ANGLE_TOLERANCE, ORIGIN_TOLERANCE } from './verify.ts';
|
|
15
17
|
|
|
16
18
|
export interface RetargetArgs extends RetargetOptions {
|
|
@@ -74,10 +76,12 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
74
76
|
if (!g0.green) {
|
|
75
77
|
refuse([{ code: 'RIG_GATE_BASE', text: `${input.rig} with ${input.motion ?? 'no motion spec'} is red under rigc build --profile ${a.profile} before any animation is added (exit ${g0.run.status})` }, ...g0.fails.map((f) => ({ code: 'RIGC', text: f }))]);
|
|
76
78
|
}
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
+
const doc0 = readBuild(g0.build);
|
|
80
|
+
const rest = restOf(doc0);
|
|
81
|
+
const slots = slotsOf(doc0);
|
|
82
|
+
resolveMap(map, rest, a.map, slots);
|
|
79
83
|
|
|
80
|
-
const r = retarget(rest, map, frames, times, a);
|
|
84
|
+
const r = retarget(rest, map, frames, times, a, slots);
|
|
81
85
|
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}, smooth ${a.smooth}, flip ${a.flip === 'off' ? 'off' : `${a.flip}°`}, short ${a.short}, bends ${a.bends} (tolerance ${a.bendTolerance}°)`);
|
|
82
86
|
if (r.scale !== null) say(` SCALE ${r.scale.factor.toFixed(5)} rig units per picture px (rig ${r.scale.rig.toFixed(2)} / pose median ${r.scale.pose.toFixed(2)})`);
|
|
83
87
|
for (const h of r.holds) say(` HOLD ${holdLine(h, r.times)}`);
|
|
@@ -87,6 +91,14 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
87
91
|
}
|
|
88
92
|
if (a.flip !== 'off') for (const fl of r.flips) say(` FLIP ${flipLine(fl, r.times, a.flip)}`);
|
|
89
93
|
for (const e of r.bends) say(` BEND ${bendLine(e)} — clamped straight`);
|
|
94
|
+
for (const e of r.turns) say(` TURN ${turnLine(e, r.times)}`);
|
|
95
|
+
for (const u of r.turnsUnread) say(` TURN line "${u.line}" (${u.joints[0]} → ${u.joints[1]}) not measured: ${u.why}`);
|
|
96
|
+
if (map.alternates.length > 0) {
|
|
97
|
+
if (r.switches.length === 0) say(` SWITCH none — no set within ${a.switchTolerance}° of the pose for ${a.switchMin}s`);
|
|
98
|
+
for (const s of r.switches) say(` SWITCH ${switchLine(s, r.times)}`);
|
|
99
|
+
}
|
|
100
|
+
for (const e of r.range) say(` RANGE ${rangeLine(e, r.times)}`);
|
|
101
|
+
for (const e of r.artRange) say(` ART-RANGE ${artRangeLine(e, r.times)}`);
|
|
90
102
|
|
|
91
103
|
const motion = { ...base, animations: { ...animations, [a.name]: r.animation } };
|
|
92
104
|
const g1 = gate(input.rig, motion, join(scratch, 'with'), a.profile);
|
|
@@ -119,6 +131,10 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
119
131
|
else if (f.meshes === null) say(` FOLD rigc render --folds did not read (exit ${f.run.status}); folds not measured`);
|
|
120
132
|
else if (f.meshes.length === 0) say(` FOLD rigc render --folds at ${fps} fps: no mesh folds`);
|
|
121
133
|
else for (const m of f.meshes) say(` FOLD mesh "${m.attachment}" (slot "${m.slot}") folds on ${m.times.length} frame(s) at ${fps} fps: t=${spans(m.times, 1 / fps)}`);
|
|
134
|
+
const uncovered = f.meshes === null ? null : uncoveredFolds(f.meshes, r.shown, r.times, slots, (slot, att) => chainsMoving(attachmentBones(doc, slot, att), rest, map));
|
|
135
|
+
if (uncovered !== null && map.alternates.length > 0) {
|
|
136
|
+
for (const u of uncovered) say(` UNCOVERED mesh "${u.attachment}" (slot "${u.slot}"; chains ${u.chains.length === 0 ? 'none' : u.chains.join(', ')}) folds with no switch covering it on ${u.times.length} frame(s): t=${spans(u.times, 1 / fps)}`);
|
|
137
|
+
}
|
|
122
138
|
|
|
123
139
|
if (a.build !== undefined) {
|
|
124
140
|
// Built again where it will live, not copied: rigc's page paths are relative to the build (#18).
|
|
@@ -138,7 +154,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
138
154
|
animation: a.name,
|
|
139
155
|
frames: seq.frames.length,
|
|
140
156
|
duration: r.animation.duration,
|
|
141
|
-
options: { minScore: a.minScore, smooth: a.smooth, flip: a.flip, short: a.short, bends: a.bends, bendTolerance: a.bendTolerance, profile: a.profile },
|
|
157
|
+
options: { minScore: a.minScore, smooth: a.smooth, flip: a.flip, short: a.short, bends: a.bends, bendTolerance: a.bendTolerance, switchTolerance: a.switchTolerance, switchMin: a.switchMin, profile: a.profile },
|
|
142
158
|
scale: r.scale,
|
|
143
159
|
holds: r.holds,
|
|
144
160
|
short: r.short,
|
|
@@ -149,6 +165,11 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
149
165
|
landings,
|
|
150
166
|
posture,
|
|
151
167
|
folds: f.meshes,
|
|
168
|
+
switches: r.switches,
|
|
169
|
+
uncovered,
|
|
170
|
+
turns: r.turns,
|
|
171
|
+
range: r.range,
|
|
172
|
+
artRange: r.artRange,
|
|
152
173
|
}, null, 2)}\n`);
|
|
153
174
|
}
|
|
154
175
|
say(` wrote ${a.out} — animations: ${Object.keys(motion.animations).join(', ')}`);
|