@rig-c/pose 0.9.0 → 0.10.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 +27 -1
- package/cli.ts +17 -2
- package/package.json +1 -1
- package/src/idle.ts +421 -0
- package/src/run.ts +73 -7
package/README.md
CHANGED
|
@@ -54,9 +54,10 @@ and what the run cannot decide for you it lists:
|
|
|
54
54
|
- `PIVOT` — when the map translates a bone and has both legs: the translated bone's origin against its thigh roots' midpoint at setup, in units and hip widths — the pivot every hip turn swings the legs about.
|
|
55
55
|
- `FEET` — when the map has both legs: the supports and jumps read off the reference ankles (planted or not), and on the posed build, outside the jumps, the lowest ankle's vertical range and each support span's worst vertical range and horizontal drift of the supporting ankle, in leg lengths.
|
|
56
56
|
- `SHEAR` — with `--leg-scale`: every scaled bone and every bone under one, how far its art is sheared and how long its world axes are against setup. A reading.
|
|
57
|
+
- `IDLE`, `IDLE-DROPPED`, `IDLE-KEPT-OUT`, `MERGE` — the rig's idle against the new animation, and what `--carry-idle` and `--merge` put back ([below](#the-idle-what-the-retarget-drops)).
|
|
57
58
|
- `TURN` — a line across the body (hips, shoulders, eyes) shorter in the picture than 0.5 of its median length, or reversed, for at least 0.15 s: the body or the head turned. A reading of the pose, made whether or not the map uses the line; it changes no key. The demand for a turned-body drawing, as `END-ON` is for a foreshortened limb.
|
|
58
59
|
|
|
59
|
-
`--report <file>` writes all of it as JSON (`rig-pose-report/1`): `options` (every figure the run used, `switchTolerance`, `switchMin`, `switchAt`, `legScale` and `plantFeet` included), `scale`, `holds`, `short`, `endOn`, `bends`, `flips`, `gate`, `landings`, `posture`, `folds`, `legScale`, `pivot`, `feet` (`planted`, `legLength`, `floor`, `supports`, `jumps`, `changes`, and the posed `reading`), `shear`, and the demand lists:
|
|
60
|
+
`--report <file>` writes all of it as JSON (`rig-pose-report/1`): `options` (every figure the run used, `switchTolerance`, `switchMin`, `switchAt`, `legScale` and `plantFeet` included), `scale`, `holds`, `short`, `endOn`, `bends`, `flips`, `gate`, `landings`, `posture`, `folds`, `legScale`, `pivot`, `feet` (`planted`, `legLength`, `floor`, `supports`, `jumps`, `changes`, and the posed `reading`), `shear`, `idleDropped`, `idleKeptOut` and `merged` ([below](#the-idle-what-the-retarget-drops)), and the demand lists:
|
|
60
61
|
|
|
61
62
|
- `switches: [{ set, chains, slots, hides, offsets, from, to, frames: [first, last], distance: { max, median }, artTurn: { <bone>: { max, median } }, moved: { on, off } }]` — one per span shown; `from` is the first frame's time, `to` the time the base art comes back (the animation's end when the span runs to it), `distance` in degrees over the frames shown, `artTurn` |wrap(target − drawn)| in degrees per bone the set has a `drawn` angle for. `moved.on` is the boundary the span starts on (null from the first frame), `moved.off` the one the base art or the next set comes back on (null to the end), each `{ frame: [selected, keyed], frames, seconds, window: [first, last], speed: { old, new } }`: signed frames and seconds, the frames it could move to, and the speed in degrees per frame on the old and the new frame. A boundary between two sets is the same entry in both.
|
|
62
63
|
- `uncovered: [{ slot, attachment, chains, times }]` — every entry of `folds` on the times its slot still shows that mesh, with the map's chains that move it (the deepest: the arm, not the trunk it hangs from). `rigc render --folds` reads only what the pose shows, so a mesh swapped out or hidden by a switch is not read on those frames at all; `uncovered` is the fold times less the switched ones.
|
|
@@ -109,6 +110,31 @@ rig-c's `scalex` multiplies the bone's x axis, and a child with the `normal` inh
|
|
|
109
110
|
|
|
110
111
|
Both are checked on the gated build: every scale lands, each ankle lands where the keys put it, and the hip's origin where the plant put it. The figures were fixed in #47 before the dancer was measured; the defaults stay off until they pass.
|
|
111
112
|
|
|
113
|
+
### The idle: what the retarget drops
|
|
114
|
+
|
|
115
|
+
A rig's motion spec usually has an `idle` — a blink, a breath, hair and hem sway — and the new animation keys none of it ([#43](https://github.com/rig-c/pose/issues/43), item 2). The run reads that animation (`--idle <name>` names another; naming one the spec lacks is `IDLE_NAME`) track by track against the new one, channel by channel — `translate` poses `translatex` and `translatey`, a `group` track poses each of its members, a slot's `rgba` its `rgb` and `alpha`:
|
|
116
|
+
|
|
117
|
+
- `IDLE-DROPPED` — a track the new animation keys on none of its channels, and that turns no mapped bone (below): its target, property, keys and the idle's duration. The report lists them under `idleDropped` (`target`, `members`, `property`, `keys`, `duration`, `carried`, `notCarried`).
|
|
118
|
+
- `IDLE-KEPT-OUT` — never carried (`idleKeptOut`), with the reason: a track the new animation keys itself, naming the track that keys it; or a `rotate`, `scale` or `shear` (any axis) of a bone **above** a mapped bone — between mapped bones, or above the first one (the map's chains and its translated bone) — naming the mapped bones below it: the retarget's keys hold every unmapped bone at its setup pose, so such a track would turn them off the angles the pose asks. A `translate` of such a bone is carried: it moves the bones under it and turns none. The exception is a translate of a bone above the **translated** bone (the hip), which is kept out because the retarget places that bone's origin. (Folding a carried parent's motion into the mapped bones' keys is a later card.) The idle's `drawOrder`, `events`, `ik`, `transform`, `deform` and `sequence` timelines are not tracks, and are listed here too.
|
|
119
|
+
|
|
120
|
+
**`--carry-idle`** (off by default: a blink keyed over a set's art may not suit it, and that is the author's call) loops every dropped track into the new animation: the idle's keys repeated every idle duration and cut at the new animation's end, with the value interpolated there.
|
|
121
|
+
|
|
122
|
+
- A key's `ease` or `curve` describes the segment to the next key, so a loop is built key by key. Where the idle's last key is at its duration and its first at 0, the two are one key at each seam (the next cycle's first; if their values differ, the line says where the loop jumps, as a player looping the idle does). Where the last key is earlier, it is keyed `stepped`, so it holds to the cycle's end as it does in the idle. Where the first key is later than 0, each later cycle opens with a stepped setup key: 0 for a bone's translate, shear and rotate, 1 for its scale, the slot's setup attachment; for any other property such a track is not carried, and the line says why.
|
|
123
|
+
- A raw `curve` states absolute times, so a repeated one is shifted with its key. The segment the cut falls in is split there: linear and stepped as they are, a Bézier (a raw `curve`, or a named easing turned into one as rigc does) by de Casteljau, so the part kept is the same cubic and the last key carries neither ease nor curve. rig-c draws a Bézier as Spine's polyline, so a split segment and the idle's whole one sit off the cubic, and off each other, by that alone (measured on the test's eased segment: the idle's own up to 2.6e-3, the carried part up to 9.3e-4).
|
|
124
|
+
- A track with `lag` or `stagger`, or whose values are per-member maps or models, is not carried, and the line says so.
|
|
125
|
+
|
|
126
|
+
Every bone still has to land with the carried tracks in. A carried translate above the translated bone moves its origin off the pose's travel, and the run refuses with `POSE_LANDS`, naming the carried tracks above the bone that missed.
|
|
127
|
+
|
|
128
|
+
On HQ's dancer (`--flip 90 --short 0.35 --bends clamp --carry-idle`) the idle has 26 tracks. Kept out: the four arm rotates, which the dance keys, and `chest_ctl` scalex, `neck` rotate and `head_ctl` rotate, which sit above the mapped chest, head and arms — carried, they missed the chest, the head and the arms by 0.16°–1.37°. Carried: the other 19, the blink (`group "eyes"` scaley) and the breath (`bone "chest_ctl"` translatey) among them. The gate is green, every bone lands (worst 0.00167°), and rig-c's poser shows the blink at every cycle (scaleY 0.12 at 2.37 s, 5.70 s, 9.08 s and 12.41 s, against the idle's at t mod 3.333 s within 8.9e-6) and the breath's chest_ctl offset (5.02 at 0.833, 4.167, 7.5, 10.833 s; 0 at 2.5, 5.833, 9.167, 12.5 s; 1.035 at the cut, 13 s).
|
|
129
|
+
|
|
130
|
+
**`--merge <file>`** merges a timed track fragment into the new animation, at absolute times from its start — blinks read off the video, for example:
|
|
131
|
+
|
|
132
|
+
```json
|
|
133
|
+
{ "spec": "rig-pose-tracks/1", "tracks": [ { "group": "eyes", "property": "scaley", "keys": [ { "t": 2.3, "v": [1] }, { "t": 2.37, "v": [0.12] }, { "t": 2.45, "v": [1] } ] } ] }
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Each track is a rigc-motion track. One posing a channel the new animation (or a carried idle track, or an earlier fragment track) already keys is refused by name (`MERGE_CONFLICT`): a fragment adds a track, it never replaces one. So is a key before 0 or past the animation's duration (`MERGE_TIME`), and a file of any other shape (`MERGE_SPEC`). What the track means — its target, property and values — is rigc's to judge: its gate checks every merged track like any other (`RIG_GATE`). `MERGE` lists each track merged; the report lists them under `merged`.
|
|
137
|
+
|
|
112
138
|
Exit codes: `0` written, `1` refused (every reason printed, nothing written), `2` bad invocation. `rig-pose --help` has every flag.
|
|
113
139
|
|
|
114
140
|
## The pose: `rig-pose-sequence/1`
|
package/cli.ts
CHANGED
|
@@ -64,6 +64,17 @@ retarget
|
|
|
64
64
|
travel and re-plants on landing; a supporting ankle moving > 0.03 leg lengths a frame
|
|
65
65
|
releases its plant to the other foot, or to the pose's travel, until it is still
|
|
66
66
|
(< 0.01 for 2 frames) (FEET lines)
|
|
67
|
+
--idle <name> the rig's animation read as its idle (default: idle). Every idle track the new
|
|
68
|
+
animation does not key, on any channel, is listed as IDLE-DROPPED (a blink, a
|
|
69
|
+
breath); one it keys is IDLE-KEPT-OUT, naming the track that keys it, and so is a
|
|
70
|
+
rotate, scale or shear of a bone above a mapped bone (a translate of one is carried)
|
|
71
|
+
--carry-idle loop the IDLE-DROPPED tracks into the new animation: the idle's keys repeated
|
|
72
|
+
every idle duration and cut at the new animation's end, with the value
|
|
73
|
+
interpolated there (default: off — a blink keyed over a set's art is the author's call)
|
|
74
|
+
--merge <file> merge rig-pose-tracks/1 into the new animation: { "spec": "rig-pose-tracks/1",
|
|
75
|
+
"tracks": [ <rigc-motion track> … ] } at absolute times from its start (blinks read
|
|
76
|
+
off the video); a track on a target and property it already keys is refused
|
|
77
|
+
(MERGE_CONFLICT), and so is a key past its duration (MERGE_TIME)
|
|
67
78
|
--switch-tolerance <d> a set of alternate drawings (the map's "alternates") may show on a frame where
|
|
68
79
|
every segment of its chains is within d degrees of its key skeleton (default: 25)
|
|
69
80
|
--switch-min <s> a run of one set shorter than s seconds is dropped (default: 0.25)
|
|
@@ -75,7 +86,8 @@ retarget
|
|
|
75
86
|
--build <dir> keep the gated build (skeleton.json, .atlas, .model.json) here
|
|
76
87
|
--render <dir> keep rigc's render of the animation (frames, contact.png, folds.json) here
|
|
77
88
|
--report <file> write the run's measurements (holds, short, endOn, flips, bends, landings, folds,
|
|
78
|
-
switches, uncovered, turns, range, artRange
|
|
89
|
+
switches, uncovered, turns, range, artRange,
|
|
90
|
+
idleDropped, idleKeptOut, merged) as JSON
|
|
79
91
|
|
|
80
92
|
It writes nothing until rigc's gate is green with the animation in, every mapped bone lands on
|
|
81
93
|
the angle the pose asks for (posed by rig-c's own poser), and no hinge bends the wrong way.
|
|
@@ -179,7 +191,7 @@ function legScaleOf(f: Map<string, string>): [number, number] | 'off' {
|
|
|
179
191
|
}
|
|
180
192
|
|
|
181
193
|
function retargetArgs(argv: string[]): RetargetArgs {
|
|
182
|
-
const f = parseFlags(argv, ['rig', 'map', 'pose', 'out', 'name', 'person', 'base-motion', 'images', 'min-score', 'smooth', 'flip', 'short', 'bends', 'bend-tolerance', 'switch-tolerance', 'switch-min', 'switch-at', 'profile', 'build', 'render', 'report', 'leg-scale'], ['coco', 'plant-feet']);
|
|
194
|
+
const f = parseFlags(argv, ['rig', 'map', 'pose', 'out', 'name', 'person', 'base-motion', 'images', 'min-score', 'smooth', 'flip', 'short', 'bends', 'bend-tolerance', 'switch-tolerance', 'switch-min', 'switch-at', 'profile', 'build', 'render', 'report', 'leg-scale', 'idle', 'merge'], ['coco', 'plant-feet', 'carry-idle']);
|
|
183
195
|
const bends = f.get('bends') ?? 'refuse';
|
|
184
196
|
if (bends !== 'refuse' && bends !== 'clamp') throw new Usage(`--bends is "${bends}"; it must be refuse or clamp`);
|
|
185
197
|
const switchAt = f.get('switch-at') ?? 'first';
|
|
@@ -213,6 +225,9 @@ function retargetArgs(argv: string[]): RetargetArgs {
|
|
|
213
225
|
build: f.get('build'),
|
|
214
226
|
render: f.get('render'),
|
|
215
227
|
report: f.get('report'),
|
|
228
|
+
idle: f.get('idle'),
|
|
229
|
+
carryIdle: f.has('carry-idle'),
|
|
230
|
+
merge: f.get('merge'),
|
|
216
231
|
};
|
|
217
232
|
}
|
|
218
233
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rig-c/pose",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Puts motion from a body-18 pose sequence onto a rig built with rig-c: bone keys in rig-c's motion format, checked through rig-c's gate before anything is written. A CLI for agents that cannot see the rig.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/idle.ts
ADDED
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tracks a retarget drops, and the two ways to put them back (rig-c/pose#43
|
|
3
|
+
* item 2): the rig's idle read against the new animation, its dropped tracks
|
|
4
|
+
* looped into it with `--carry-idle`, and a timed fragment merged with `--merge`.
|
|
5
|
+
*
|
|
6
|
+
* Pure: the motion spec's own track objects in, track objects out. What a
|
|
7
|
+
* merged or carried track means (its bone, its property, its values) is rig-c's
|
|
8
|
+
* to judge, and its gate judges it with the animation in.
|
|
9
|
+
*
|
|
10
|
+
* A key's `ease` or `curve` describes the segment from that key to the next
|
|
11
|
+
* (rig-c docs/AUTHORING.md §4.5), so a loop is built key by key: where one
|
|
12
|
+
* cycle meets the next, the key that ends a cycle and the one that starts the
|
|
13
|
+
* next must not share a time, and the segment into the seam must ease toward
|
|
14
|
+
* the value the loop shows there. A raw `curve` states absolute times, so a
|
|
15
|
+
* repeated one is shifted with its key. The last key of the whole track carries
|
|
16
|
+
* neither.
|
|
17
|
+
*/
|
|
18
|
+
import { isObject, Problems, typeName, type Problem } from './problems.ts';
|
|
19
|
+
|
|
20
|
+
/** A rigc-motion track as the motion spec states it: one target field, a property, keys, and whatever else it carries. */
|
|
21
|
+
export type RigcTrack = Record<string, unknown> & { property: string; keys: RigcKey[] };
|
|
22
|
+
export type RigcKey = Record<string, unknown> & { t: number; v: unknown; ease?: string; curve?: number[] };
|
|
23
|
+
|
|
24
|
+
/** The six target fields of a track (AUTHORING §4.4). */
|
|
25
|
+
const TARGET_FIELDS = ['bone', 'slot', 'group', 'physics', 'path', 'slider'] as const;
|
|
26
|
+
type TargetKind = (typeof TARGET_FIELDS)[number];
|
|
27
|
+
|
|
28
|
+
/** What each bone and slot property poses, channel by channel: two tracks that share a channel key the same thing. */
|
|
29
|
+
const BONE_CHANNELS: Readonly<Record<string, readonly string[]>> = {
|
|
30
|
+
translate: ['translatex', 'translatey'], translatex: ['translatex'], translatey: ['translatey'],
|
|
31
|
+
scale: ['scalex', 'scaley'], scalex: ['scalex'], scaley: ['scaley'],
|
|
32
|
+
shear: ['shearx', 'sheary'], shearx: ['shearx'], sheary: ['sheary'],
|
|
33
|
+
rotate: ['rotate'], inherit: ['inherit'],
|
|
34
|
+
};
|
|
35
|
+
const SLOT_CHANNELS: Readonly<Record<string, readonly string[]>> = {
|
|
36
|
+
attachment: ['attachment'], rgba: ['rgb', 'alpha'], rgb: ['rgb'], alpha: ['alpha'], rgba2: ['rgb', 'alpha', 'dark'], rgb2: ['rgb', 'dark'],
|
|
37
|
+
};
|
|
38
|
+
/** A bone property whose setup value, as a key states it, is known without reading the rig: translate and shear add to setup (0), scale multiplies it (1). */
|
|
39
|
+
const BONE_IDENTITY: Readonly<Record<string, number>> = {
|
|
40
|
+
translate: 0, translatex: 0, translatey: 0, shear: 0, shearx: 0, sheary: 0, rotate: 0, scale: 1, scalex: 1, scaley: 1,
|
|
41
|
+
};
|
|
42
|
+
/** The bone properties that only move a bone: a child's angle is unchanged, so one on a bone above a mapped bone is carried. */
|
|
43
|
+
const TRANSLATES = new Set(['translate', 'translatex', 'translatey']);
|
|
44
|
+
/** Timelines stepped by the format: a key carries no ease and no curve, and no value lies between two keys. */
|
|
45
|
+
const STEPPED_BY_NATURE = new Set(['attachment', 'inherit', 'reset']);
|
|
46
|
+
|
|
47
|
+
/** A target and its name: `bone "neck"`, `group "eyes"`. */
|
|
48
|
+
export interface Target {
|
|
49
|
+
kind: TargetKind;
|
|
50
|
+
name: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function targetOf(track: Record<string, unknown>): Target | null {
|
|
54
|
+
const found = TARGET_FIELDS.filter((f) => typeof track[f] === 'string');
|
|
55
|
+
return found.length === 1 ? { kind: found[0]!, name: track[found[0]!] as string } : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** `group "eyes" (eye_r, eye_l) scaley`, `bone "chest_ctl" translatey`. */
|
|
59
|
+
export function trackLabel(track: RigcTrack, groups: Readonly<Record<string, readonly string[]>>): string {
|
|
60
|
+
const t = targetOf(track);
|
|
61
|
+
if (t === null) return `a track with no single target, property ${JSON.stringify(track.property)}`;
|
|
62
|
+
const members = t.kind === 'group' && groups[t.name] !== undefined ? ` (${groups[t.name]!.join(', ')})` : '';
|
|
63
|
+
return `${t.kind} "${t.name}"${members} ${track.property}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Every channel a track poses, as `family:name:channel`: a group's members are
|
|
68
|
+
* bones, slots or physics constraints by its property (AUTHORING §4.3), so a
|
|
69
|
+
* group track collides with a bone track on one of its members.
|
|
70
|
+
*/
|
|
71
|
+
function cellsOf(track: RigcTrack, groups: Readonly<Record<string, readonly string[]>>): string[] {
|
|
72
|
+
const t = targetOf(track);
|
|
73
|
+
if (t === null) return [];
|
|
74
|
+
const p = track.property;
|
|
75
|
+
const family = (kind: TargetKind): 'bone' | 'slot' | 'physics' | 'path' | 'slider' => {
|
|
76
|
+
if (kind !== 'group') return kind;
|
|
77
|
+
if (p in BONE_CHANNELS) return 'bone';
|
|
78
|
+
if (p in SLOT_CHANNELS) return 'slot';
|
|
79
|
+
return 'physics';
|
|
80
|
+
};
|
|
81
|
+
const fam = family(t.kind);
|
|
82
|
+
const channels = fam === 'bone' ? BONE_CHANNELS[p] ?? [p] : fam === 'slot' ? SLOT_CHANNELS[p] ?? [p] : [p];
|
|
83
|
+
const names = t.kind === 'group' ? groups[t.name] ?? [] : [t.name];
|
|
84
|
+
return names.flatMap((n) => channels.map((c) => `${fam}:${n}:${c}`));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Which channels the tracks pose, each with the label of the track posing it. */
|
|
88
|
+
export function keyedBy(tracks: readonly RigcTrack[], groups: Readonly<Record<string, readonly string[]>>): Map<string, string> {
|
|
89
|
+
const out = new Map<string, string>();
|
|
90
|
+
for (const tr of tracks) for (const c of cellsOf(tr, groups)) if (!out.has(c)) out.set(c, trackLabel(tr, groups));
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The labels of the tracks in `keyed` that pose a channel `track` poses. */
|
|
95
|
+
function collisions(track: RigcTrack, keyed: ReadonlyMap<string, string>, groups: Readonly<Record<string, readonly string[]>>): string[] {
|
|
96
|
+
return [...new Set(cellsOf(track, groups).flatMap((c) => (keyed.has(c) ? [keyed.get(c)!] : [])))];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface IdleDropped {
|
|
100
|
+
target: Target;
|
|
101
|
+
/** A group's members, as the motion spec's `groups` names them. */
|
|
102
|
+
members: string[] | null;
|
|
103
|
+
property: string;
|
|
104
|
+
/** The track's keys in the idle. */
|
|
105
|
+
keys: number;
|
|
106
|
+
/** The idle's duration, seconds. */
|
|
107
|
+
duration: number;
|
|
108
|
+
/** With --carry-idle: the looped track's keys, the cycles they span, and where a seam jumps. */
|
|
109
|
+
carried: { keys: number; cycles: number; seams: string | null } | null;
|
|
110
|
+
/** With --carry-idle: why this track was not carried. */
|
|
111
|
+
notCarried: string | null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface IdleKeptOut {
|
|
115
|
+
target: Target;
|
|
116
|
+
property: string;
|
|
117
|
+
reason: string;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface IdleReading {
|
|
121
|
+
animation: string;
|
|
122
|
+
duration: number;
|
|
123
|
+
tracks: number;
|
|
124
|
+
dropped: IdleDropped[];
|
|
125
|
+
keptOut: IdleKeptOut[];
|
|
126
|
+
/** The idle's timelines that are not tracks (drawOrder, events, ik, transform, deform, sequence): never compared, never carried. */
|
|
127
|
+
notTracks: string[];
|
|
128
|
+
/** With --carry-idle: the looped tracks, in the idle's order. */
|
|
129
|
+
carriedTracks: RigcTrack[];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const NOT_TRACKS = ['drawOrder', 'events', 'ik', 'transform', 'deform', 'sequence'] as const;
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The idle against the new animation: every idle track the new one does not
|
|
136
|
+
* key on any channel is dropped (and, with `carry`, looped into it); every one
|
|
137
|
+
* it keys is kept out, naming the track that keys it.
|
|
138
|
+
*/
|
|
139
|
+
export function readIdle(
|
|
140
|
+
name: string,
|
|
141
|
+
/** The new animation's name. */
|
|
142
|
+
into: string,
|
|
143
|
+
idle: Record<string, unknown>,
|
|
144
|
+
newTracks: readonly RigcTrack[],
|
|
145
|
+
duration: number,
|
|
146
|
+
motion: { groups: Readonly<Record<string, readonly string[]>>; easings: Readonly<Record<string, readonly number[]>> },
|
|
147
|
+
/** The setup value a key on this target and property states (`sample` is one of its keys' values), or undefined when it is not read. */
|
|
148
|
+
setupOf: (target: Target, property: string, sample: unknown) => unknown,
|
|
149
|
+
carry: boolean,
|
|
150
|
+
/** The mapped bones (chains and the translated bone) under a bone, in map order. */
|
|
151
|
+
mappedBelow: (bone: string) => readonly string[] = () => [],
|
|
152
|
+
/** The translated bone (the hip) when it lies under this bone, else null: a translate above it moves the origin the retarget places. */
|
|
153
|
+
translatedBelow: (bone: string) => string | null = () => null,
|
|
154
|
+
): IdleReading {
|
|
155
|
+
const D = idle.duration as number;
|
|
156
|
+
const tracks = (Array.isArray(idle.tracks) ? idle.tracks : []) as RigcTrack[];
|
|
157
|
+
const keyed = keyedBy(newTracks, motion.groups);
|
|
158
|
+
const dropped: IdleDropped[] = [];
|
|
159
|
+
const keptOut: IdleKeptOut[] = [];
|
|
160
|
+
const carriedTracks: RigcTrack[] = [];
|
|
161
|
+
for (const tr of tracks) {
|
|
162
|
+
const target = targetOf(tr);
|
|
163
|
+
if (target === null) continue;
|
|
164
|
+
const hit = collisions(tr, keyed, motion.groups);
|
|
165
|
+
if (hit.length > 0) {
|
|
166
|
+
keptOut.push({ target, property: tr.property, reason: `"${into}"'s own ${hit.join(', ')} keys it` });
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
// A rotate, scale or shear above a mapped bone turns it off the angle its keys assume (the key formula holds unmapped bones at setup).
|
|
170
|
+
const bones = target.kind === 'bone' ? [target.name] : target.kind === 'group' && tr.property in BONE_CHANNELS ? [...(motion.groups[target.name] ?? [])] : [];
|
|
171
|
+
const under = TRANSLATES.has(tr.property) ? [] : [...new Set(bones.flatMap((b) => mappedBelow(b)))];
|
|
172
|
+
const hipBelow = TRANSLATES.has(tr.property) ? bones.map((b) => translatedBelow(b)).find((h) => h !== null) ?? null : null;
|
|
173
|
+
if (hipBelow !== null) {
|
|
174
|
+
keptOut.push({ target, property: tr.property, reason: `moves the translated bone "${hipBelow}" below it; the retarget places its origin` });
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (under.length > 0) {
|
|
178
|
+
keptOut.push({ target, property: tr.property, reason: `moves mapped bone(s) ${under.map((b) => `"${b}"`).join(', ')} below it; the retarget's keys assume it at setup` });
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const entry: IdleDropped = { target, members: target.kind === 'group' ? [...(motion.groups[target.name] ?? [])] : null, property: tr.property, keys: tr.keys.length, duration: D, carried: null, notCarried: null };
|
|
182
|
+
if (carry) {
|
|
183
|
+
const c = loopTrack(tr, D, duration, motion.easings, () => setupOf(target, tr.property, tr.keys[0]?.v));
|
|
184
|
+
if ('reason' in c) entry.notCarried = c.reason;
|
|
185
|
+
else {
|
|
186
|
+
entry.carried = { keys: c.track.keys.length, cycles: c.cycles, seams: c.seams };
|
|
187
|
+
carriedTracks.push(c.track);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
dropped.push(entry);
|
|
191
|
+
}
|
|
192
|
+
const notTracks = NOT_TRACKS.filter((k) => idle[k] !== undefined);
|
|
193
|
+
return { animation: name, duration: D, tracks: tracks.length, dropped, keptOut, notTracks, carriedTracks };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const r6 = (v: number): number => Math.round(v * 1e6) / 1e6;
|
|
197
|
+
/** Two times this close are one time (key times are written to 1e-6 s). */
|
|
198
|
+
const EPS = 1e-7;
|
|
199
|
+
|
|
200
|
+
const numeric = (v: unknown): v is number[] => Array.isArray(v) && v.length > 0 && v.every((x) => typeof x === 'number' && Number.isFinite(x));
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* One idle track looped to `T` seconds: its keys repeated every `D` seconds and
|
|
204
|
+
* cut at `T`, with the value interpolated there.
|
|
205
|
+
*
|
|
206
|
+
* - Where the idle's last key is at `D` and its first at 0 (a closed loop), the
|
|
207
|
+
* two are one key at each seam: the next cycle's first. A last key that
|
|
208
|
+
* differs from the first is a jump in the idle as a player loops it (at `D`
|
|
209
|
+
* it shows the first key), and the seam keys the first; `seams` says so.
|
|
210
|
+
* - Where the last key is before `D`, the player holds it to `D`: it is keyed
|
|
211
|
+
* `stepped` (a timeline stepped by the format holds anyway).
|
|
212
|
+
* - Where the first key is after 0, the player shows the setup value from each
|
|
213
|
+
* cycle's start to that key: a stepped setup key opens each later cycle. That
|
|
214
|
+
* needs the setup value as a key states it, which this reads only for a bone's
|
|
215
|
+
* translate, scale, shear and rotate (0, 1, 0, 0) and a slot's attachment.
|
|
216
|
+
*/
|
|
217
|
+
export function loopTrack(
|
|
218
|
+
track: RigcTrack,
|
|
219
|
+
D: number,
|
|
220
|
+
T: number,
|
|
221
|
+
easings: Readonly<Record<string, readonly number[]>>,
|
|
222
|
+
setup: () => unknown,
|
|
223
|
+
): { track: RigcTrack; cycles: number; seams: string | null } | { reason: string } {
|
|
224
|
+
if (track.lag !== undefined || track.stagger !== undefined) return { reason: `it carries ${track.lag !== undefined ? '"lag"' : '"stagger"'}, which shifts its keys per track or per member; a loop is cut by key time, so it is not carried` };
|
|
225
|
+
const keys = track.keys;
|
|
226
|
+
if (keys.length === 0) return { reason: 'it has no keys' };
|
|
227
|
+
const nature = STEPPED_BY_NATURE.has(track.property);
|
|
228
|
+
if (!nature && !keys.every((k) => numeric(k.v))) return { reason: 'a key\'s value is not a list of numbers (a per-member map or a model), and a cut needs the value between two keys' };
|
|
229
|
+
if (!(D > 0)) return { reason: `the idle's duration is ${D}` };
|
|
230
|
+
const first = keys[0]!.t;
|
|
231
|
+
const last = keys[keys.length - 1]!.t;
|
|
232
|
+
let setupKey: unknown;
|
|
233
|
+
if (first > EPS) {
|
|
234
|
+
setupKey = setup();
|
|
235
|
+
if (setupKey === undefined) return { reason: `its first key is at t=${first}s, so a loop shows the setup value of ${track.property} before it in every cycle, and this package does not read that value for this target` };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const shift = (k: RigcKey, off: number): RigcKey => {
|
|
239
|
+
const out: RigcKey = { ...k, t: r6(off + k.t) };
|
|
240
|
+
if (Array.isArray(k.curve)) out.curve = k.curve.map((x, i) => (i % 2 === 0 ? r6(off + x) : x));
|
|
241
|
+
return out;
|
|
242
|
+
};
|
|
243
|
+
const hold = (k: RigcKey): void => {
|
|
244
|
+
if (nature) return;
|
|
245
|
+
delete k.curve;
|
|
246
|
+
k.ease = 'stepped';
|
|
247
|
+
};
|
|
248
|
+
const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
|
|
249
|
+
const out: RigcKey[] = [];
|
|
250
|
+
const jumps: number[] = [];
|
|
251
|
+
let cycles = 0;
|
|
252
|
+
for (let k = 0; k * D <= T + EPS; k++) {
|
|
253
|
+
const off = k * D;
|
|
254
|
+
if (k > 0) {
|
|
255
|
+
const prev = out[out.length - 1]!;
|
|
256
|
+
const opens: unknown = first > EPS ? setupKey : keys[0]!.v;
|
|
257
|
+
if (prev.t >= r6(off) - EPS) {
|
|
258
|
+
// The key that ends the last cycle sits on the seam: the loop shows the next cycle's start there.
|
|
259
|
+
out.pop();
|
|
260
|
+
if (!same(prev.v, opens)) jumps.push(r6(off));
|
|
261
|
+
} else hold(prev);
|
|
262
|
+
if (first > EPS) {
|
|
263
|
+
const s: RigcKey = { t: r6(off), v: setupKey };
|
|
264
|
+
if (!nature) s.ease = 'stepped';
|
|
265
|
+
out.push(s);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
for (const key of keys) out.push(shift(key, off));
|
|
269
|
+
cycles++;
|
|
270
|
+
if (out[out.length - 1]!.t > T + EPS) break;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// The cut at T.
|
|
274
|
+
const kept = out.filter((x) => x.t <= T + EPS);
|
|
275
|
+
if (kept.length === 0) return { reason: `its first key, at t=${first}s, is past the new animation's ${T}s` };
|
|
276
|
+
for (const x of kept) if (Math.abs(x.t - T) <= EPS) x.t = T;
|
|
277
|
+
const after = out[kept.length];
|
|
278
|
+
const end = kept[kept.length - 1]!;
|
|
279
|
+
if (after !== undefined && end.t < T - EPS && !nature) {
|
|
280
|
+
const c = cutAt(end, after, T, easings);
|
|
281
|
+
if ('reason' in c) return c;
|
|
282
|
+
kept.push({ t: T, v: c.v });
|
|
283
|
+
}
|
|
284
|
+
const tail = kept[kept.length - 1]!;
|
|
285
|
+
delete tail.ease;
|
|
286
|
+
delete tail.curve;
|
|
287
|
+
const seams = jumps.length === 0 ? null : `the idle's last key (t=${last}s, ${JSON.stringify(keys[keys.length - 1]!.v)}) is not what it shows at its start (${JSON.stringify(first > EPS ? setupKey : keys[0]!.v)}); the loop keys the start at each seam, so it jumps at t=${jumps.map((t) => t.toFixed(3)).join(', ')}s`;
|
|
288
|
+
return { track: { ...track, keys: kept }, cycles, seams };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* The value at `T` inside the segment from `a` to `b`, and `a`'s segment cut
|
|
293
|
+
* there: stepped holds `a`; linear interpolates; a Bézier (a raw `curve`, or a
|
|
294
|
+
* named easing turned into one as rigc turns it, AUTHORING §4.1) is split at
|
|
295
|
+
* `T` by de Casteljau, so the part kept is the same curve.
|
|
296
|
+
*/
|
|
297
|
+
function cutAt(a: RigcKey, b: RigcKey, T: number, easings: Readonly<Record<string, readonly number[]>>): { v: number[] } | { reason: string } {
|
|
298
|
+
const va = a.v as number[];
|
|
299
|
+
const vb = b.v as number[];
|
|
300
|
+
if (a.ease === 'stepped') return { v: [...va] };
|
|
301
|
+
let curve: number[] | null = null;
|
|
302
|
+
if (Array.isArray(a.curve)) curve = a.curve;
|
|
303
|
+
else if (typeof a.ease === 'string') {
|
|
304
|
+
const h = easings[a.ease];
|
|
305
|
+
if (h === undefined || h.length !== 4) return { reason: `its easing "${a.ease}" is not in the motion spec's easings` };
|
|
306
|
+
curve = va.flatMap((v0, c) => [a.t + (b.t - a.t) * h[0]!, v0 + (vb[c]! - v0) * h[1]!, a.t + (b.t - a.t) * h[2]!, v0 + (vb[c]! - v0) * h[3]!]);
|
|
307
|
+
}
|
|
308
|
+
if (curve === null) return { v: va.map((v0, c) => r6(v0 + ((T - a.t) / (b.t - a.t)) * (vb[c]! - v0))) };
|
|
309
|
+
const kept: number[] = [];
|
|
310
|
+
const v: number[] = [];
|
|
311
|
+
va.forEach((v0, c) => {
|
|
312
|
+
const [x1, y1, x2, y2] = curve.slice(c * 4, c * 4 + 4) as [number, number, number, number];
|
|
313
|
+
const bez = (p0: number, p1: number, p2: number, p3: number, s: number) => {
|
|
314
|
+
const u = 1 - s;
|
|
315
|
+
return u * u * u * p0 + 3 * u * u * s * p1 + 3 * u * s * s * p2 + s * s * s * p3;
|
|
316
|
+
};
|
|
317
|
+
let lo = 0;
|
|
318
|
+
let hi = 1;
|
|
319
|
+
for (let i = 0; i < 60; i++) {
|
|
320
|
+
const mid = (lo + hi) / 2;
|
|
321
|
+
if (bez(a.t, x1, x2, b.t, mid) < T) lo = mid;
|
|
322
|
+
else hi = mid;
|
|
323
|
+
}
|
|
324
|
+
const s = (lo + hi) / 2;
|
|
325
|
+
const lerp = (p: number, q: number) => p + (q - p) * s;
|
|
326
|
+
const split = (p0: number, p1: number, p2: number, p3: number): [number, number, number] => {
|
|
327
|
+
const p01 = lerp(p0, p1);
|
|
328
|
+
const p12 = lerp(p1, p2);
|
|
329
|
+
const p23 = lerp(p2, p3);
|
|
330
|
+
const p012 = lerp(p01, p12);
|
|
331
|
+
return [p01, p012, lerp(p012, lerp(p12, p23))];
|
|
332
|
+
};
|
|
333
|
+
const xs = split(a.t, x1, x2, b.t);
|
|
334
|
+
const ys = split(v0, y1, y2, vb[c]!);
|
|
335
|
+
kept.push(r6(xs[0]), r6(ys[0]), r6(xs[1]), r6(ys[1]));
|
|
336
|
+
v.push(r6(ys[2]));
|
|
337
|
+
});
|
|
338
|
+
delete a.ease;
|
|
339
|
+
a.curve = kept;
|
|
340
|
+
return { v };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** The setup value a key states for a bone's translate, scale, shear and rotate (`numbers` channels), or undefined. */
|
|
344
|
+
export function boneIdentity(property: string, channels: number): number[] | undefined {
|
|
345
|
+
const v = BONE_IDENTITY[property];
|
|
346
|
+
return v === undefined ? undefined : Array.from({ length: channels }, () => v);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// --- the fragment ------------------------------------------------------------
|
|
350
|
+
|
|
351
|
+
export const FRAGMENT_SPEC = 'rig-pose-tracks/1';
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* A `rig-pose-tracks/1` fragment: `{ "spec": "rig-pose-tracks/1", "tracks": [ … ] }`,
|
|
355
|
+
* each a rigc-motion track at absolute times from the animation's start. The
|
|
356
|
+
* shape is read here (one target, a property, keys with a time); what a track
|
|
357
|
+
* means is rigc's gate's to judge.
|
|
358
|
+
*/
|
|
359
|
+
export function readFragment(doc: unknown, where: string): RigcTrack[] {
|
|
360
|
+
const p = new Problems();
|
|
361
|
+
if (!isObject(doc) || doc.spec !== FRAGMENT_SPEC || !Array.isArray(doc.tracks)) {
|
|
362
|
+
p.add('MERGE_SPEC', `${where}: "spec" is ${isObject(doc) ? typeName(doc.spec) : typeName(doc)} and "tracks" ${isObject(doc) ? typeName(doc.tracks) : '-'}; a fragment is { "spec": "${FRAGMENT_SPEC}", "tracks": [ <rigc-motion track> … ] }`);
|
|
363
|
+
p.throwIfAny();
|
|
364
|
+
}
|
|
365
|
+
const tracks = (doc as { tracks: unknown[] }).tracks;
|
|
366
|
+
if (tracks.length === 0) p.add('MERGE_SPEC', `${where}: "tracks" is empty; a fragment adds at least one track`);
|
|
367
|
+
tracks.forEach((tr, i) => {
|
|
368
|
+
const at = `${where} tracks[${i}]`;
|
|
369
|
+
if (!isObject(tr)) {
|
|
370
|
+
p.add('MERGE_SPEC', `${at} is ${typeName(tr)}; a track is an object`);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
const fields = TARGET_FIELDS.filter((f) => tr[f] !== undefined);
|
|
374
|
+
if (fields.length !== 1 || typeof tr[fields[0]!] !== 'string') p.add('MERGE_SPEC', `${at} names ${fields.length === 0 ? 'no target' : fields.map((f) => `"${f}" (${typeName(tr[f])})`).join(' and ')}; a track names exactly one of ${TARGET_FIELDS.join(', ')}, as a string`);
|
|
375
|
+
if (typeof tr.property !== 'string') p.add('MERGE_SPEC', `${at} "property" is ${typeName(tr.property)}; it is the timeline's name, a string`);
|
|
376
|
+
if (!Array.isArray(tr.keys) || tr.keys.length === 0) {
|
|
377
|
+
p.add('MERGE_SPEC', `${at} "keys" is ${typeName(tr.keys)}; it is a non-empty array of { "t": <seconds>, "v": … }`);
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
tr.keys.forEach((k, j) => {
|
|
381
|
+
if (!isObject(k) || typeof k.t !== 'number' || !Number.isFinite(k.t)) p.add('MERGE_SPEC', `${at} keys[${j}] ${isObject(k) ? `"t" is ${typeName(k.t)}` : `is ${typeName(k)}`}; a key states its time "t" in seconds`);
|
|
382
|
+
});
|
|
383
|
+
});
|
|
384
|
+
p.throwIfAny();
|
|
385
|
+
return tracks as RigcTrack[];
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* A fragment against the new animation: a track posing a channel the animation
|
|
390
|
+
* (or an earlier fragment track) already keys is MERGE_CONFLICT, and a key
|
|
391
|
+
* before 0 or past the duration is MERGE_TIME — each named.
|
|
392
|
+
*/
|
|
393
|
+
export function mergeProblems(fragment: readonly RigcTrack[], where: string, animation: string, tracks: readonly RigcTrack[], duration: number, groups: Readonly<Record<string, readonly string[]>>): Problem[] {
|
|
394
|
+
const out: Problem[] = [];
|
|
395
|
+
const keyed = keyedBy(tracks, groups);
|
|
396
|
+
fragment.forEach((tr, i) => {
|
|
397
|
+
const label = trackLabel(tr, groups);
|
|
398
|
+
const hit = collisions(tr, keyed, groups);
|
|
399
|
+
if (hit.length > 0) out.push({ code: 'MERGE_CONFLICT', text: `${where} tracks[${i}] (${label}) — "${animation}" already keys it: ${hit.join(', ')}; a fragment adds a track where nothing keys that target and property, it never replaces one` });
|
|
400
|
+
for (const c of cellsOf(tr, groups)) if (!keyed.has(c)) keyed.set(c, `${where} tracks[${i}] (${label})`);
|
|
401
|
+
tr.keys.forEach((k, j) => {
|
|
402
|
+
if (k.t < 0 || k.t > duration) out.push({ code: 'MERGE_TIME', text: `${where} tracks[${i}] (${label}) keys[${j}] at t=${k.t}s is ${k.t < 0 ? 'before 0' : `past "${animation}"'s duration ${duration}s`}; a fragment's times are absolute, from the animation's start, within [0, ${duration}]` });
|
|
403
|
+
});
|
|
404
|
+
});
|
|
405
|
+
return out;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// --- lines ---------------------------------------------------------------
|
|
409
|
+
|
|
410
|
+
const targetText = (t: Target, members: readonly string[] | null) => `${t.kind} "${t.name}"${members !== null && members.length > 0 ? ` (${members.join(', ')})` : ''}`;
|
|
411
|
+
|
|
412
|
+
export function droppedLine(d: IdleDropped, animation: string, carry: boolean, duration: number): string {
|
|
413
|
+
const head = `${targetText(d.target, d.members)} ${d.property}: ${d.keys} key(s) over the idle's ${d.duration.toFixed(3)}s; "${animation}" does not key it`;
|
|
414
|
+
if (!carry) return `${head} — not carried (no --carry-idle)`;
|
|
415
|
+
if (d.carried !== null) return `${head} — carried: ${d.carried.keys} key(s), ${d.carried.cycles} cycle(s), cut at ${duration.toFixed(3)}s${d.carried.seams === null ? '' : `; ${d.carried.seams}`}`;
|
|
416
|
+
return `${head} — not carried: ${d.notCarried}`;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export function keptOutLine(k: IdleKeptOut): string {
|
|
420
|
+
return `${targetText(k.target, null)} ${k.property}: ${k.reason}, so the idle's track is not carried`;
|
|
421
|
+
}
|
package/src/run.ts
CHANGED
|
@@ -9,7 +9,8 @@ import { tmpdir } from 'node:os';
|
|
|
9
9
|
import { dirname, join, resolve } from 'node:path';
|
|
10
10
|
import { readMap, resolveMap, type JointMap } from './map.ts';
|
|
11
11
|
import { convertCoco17, personTrack, readPoseSequence } from './pose.ts';
|
|
12
|
-
import { lineOf, Problems, Refusal, type Problem } from './problems.ts';
|
|
12
|
+
import { isObject, lineOf, parseJson, Problems, Refusal, type Problem } from './problems.ts';
|
|
13
|
+
import { boneIdentity, droppedLine, keptOutLine, mergeProblems, readFragment, readIdle, targetOf, trackLabel, type IdleReading, type RigcTrack, type Target } from './idle.ts';
|
|
13
14
|
import { bendLine, endOnLine, flipLine, holdLine, rangeLine, retarget, shortLine, turnLine, type RetargetOptions } from './retarget.ts';
|
|
14
15
|
import { attachmentBones, baseMotionOf, buildInto, rigcVersion, folds, gate, readBuild, resolveRig, restOf, slotsOf } from './rigc.ts';
|
|
15
16
|
import { artRangeLine, chainsMoving, switchLine, uncoveredFolds } from './switch.ts';
|
|
@@ -35,6 +36,12 @@ export interface RetargetArgs extends RetargetOptions {
|
|
|
35
36
|
render?: string;
|
|
36
37
|
/** Write the run's measurements as JSON here. */
|
|
37
38
|
report?: string;
|
|
39
|
+
/** The rig's animation read as its idle (default: idle); naming one the motion spec lacks is refused. */
|
|
40
|
+
idle?: string;
|
|
41
|
+
/** Loop the idle's dropped tracks into the new animation (#43 item 2). */
|
|
42
|
+
carryIdle?: boolean;
|
|
43
|
+
/** A rig-pose-tracks/1 fragment merged into the new animation. */
|
|
44
|
+
merge?: string;
|
|
38
45
|
}
|
|
39
46
|
|
|
40
47
|
export interface RunResult {
|
|
@@ -52,8 +59,8 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
52
59
|
throw new Refusal(problems);
|
|
53
60
|
};
|
|
54
61
|
const args = new Problems();
|
|
55
|
-
for (const [flag, path] of [['--map', a.map], ['--pose', a.pose]] as const) {
|
|
56
|
-
if (!existsSync(path)) args.add('ARG_PATH', `${flag} ${path} does not exist`);
|
|
62
|
+
for (const [flag, path] of [['--map', a.map], ['--pose', a.pose], ['--merge', a.merge]] as const) {
|
|
63
|
+
if (path !== undefined && !existsSync(path)) args.add('ARG_PATH', `${flag} ${path} does not exist`);
|
|
57
64
|
}
|
|
58
65
|
// A file this run writes goes into a directory that is there already; --build and --render make theirs.
|
|
59
66
|
for (const [flag, path] of [['--out', a.out], ['--report', a.report]] as const) {
|
|
@@ -67,6 +74,11 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
67
74
|
const base = baseMotionOf(input);
|
|
68
75
|
const animations = base.animations as Record<string, unknown>;
|
|
69
76
|
if (a.name in animations) refuse([{ code: 'MOTION_NAME_FREE', text: `the rig's motion spec already has an animation "${a.name}" (it has ${Object.keys(animations).join(', ')}); name this one with --name` }]);
|
|
77
|
+
const idleName = a.idle ?? 'idle';
|
|
78
|
+
if (!(idleName in animations) && (a.idle !== undefined || a.carryIdle === true)) {
|
|
79
|
+
refuse([{ code: 'IDLE_NAME', text: `${a.idle !== undefined ? `--idle "${idleName}"` : '--carry-idle reads the animation "idle", and'} the rig's motion spec ${input.motion === null ? '— there is none, so it has no animations' : `${input.motion} has no animation "${idleName}" (it has ${Object.keys(animations).join(', ') || 'none'})`}; name the rig's idle with --idle` }]);
|
|
80
|
+
}
|
|
81
|
+
const fragment = a.merge === undefined ? [] : readFragment(parseJson(readFileSync(a.merge, 'utf8'), a.merge, 'MERGE_JSON'), a.merge);
|
|
70
82
|
const map = readMap(readFileSync(a.map, 'utf8'), a.map);
|
|
71
83
|
const poseText = readFileSync(a.pose, 'utf8');
|
|
72
84
|
const seq = a.coco ? convertCoco17(poseText, a.pose) : readPoseSequence(poseText, a.pose);
|
|
@@ -87,7 +99,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
87
99
|
resolveMap(map, rest, a.map, slots);
|
|
88
100
|
|
|
89
101
|
const r = retarget(rest, map, frames, times, a, slots);
|
|
90
|
-
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}°)${a.legScale !== undefined && a.legScale !== 'off' ? `, leg-scale ${a.legScale[0]}–${a.legScale[1]}` : ''}${a.plantFeet === true ? ', plant-feet' : ''}`);
|
|
102
|
+
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}°)${a.legScale !== undefined && a.legScale !== 'off' ? `, leg-scale ${a.legScale[0]}–${a.legScale[1]}` : ''}${a.plantFeet === true ? ', plant-feet' : ''}${a.carryIdle === true ? ', carry-idle' : ''}${a.merge !== undefined ? `, merge ${a.merge}` : ''}`);
|
|
91
103
|
say(` RIGC rig-c ${rigc.version} (${rigc.entry}) gates and poses this run`);
|
|
92
104
|
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)})`);
|
|
93
105
|
for (const h of r.holds) say(` HOLD ${holdLine(h, r.times)}`);
|
|
@@ -117,7 +129,48 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
117
129
|
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'}`);
|
|
118
130
|
}
|
|
119
131
|
|
|
120
|
-
|
|
132
|
+
// The idle against the new animation (#43 item 2): what it drops, and with --carry-idle, looped back in.
|
|
133
|
+
const groups = (isObject(base.groups) ? base.groups : {}) as Record<string, string[]>;
|
|
134
|
+
const easings = (isObject(base.easings) ? base.easings : {}) as Record<string, number[]>;
|
|
135
|
+
const own = r.animation.tracks as unknown as RigcTrack[];
|
|
136
|
+
const T = r.animation.duration;
|
|
137
|
+
let idle: IdleReading | null = null;
|
|
138
|
+
if (!(idleName in animations)) say(` IDLE ${input.motion === null ? 'the rig has no motion spec' : `the rig's motion spec has no animation "idle" (it has ${Object.keys(animations).join(', ') || 'none'})`}; no idle read (--idle names another)`);
|
|
139
|
+
else {
|
|
140
|
+
const setupOf = (t: Target, property: string, sample: unknown): unknown => {
|
|
141
|
+
if (t.kind === 'slot' && property === 'attachment') return slots.get(t.name)?.setup;
|
|
142
|
+
if ((t.kind === 'bone' || t.kind === 'group') && Array.isArray(sample)) return boneIdentity(property, sample.length);
|
|
143
|
+
return undefined;
|
|
144
|
+
};
|
|
145
|
+
const mapped = [...map.chains.flatMap((c) => c.bones), ...(map.translate === null ? [] : [map.translate.bone])];
|
|
146
|
+
const below = new Map<string, string[]>();
|
|
147
|
+
for (const m of new Set(mapped)) {
|
|
148
|
+
for (let b = rest.get(m)?.parent ?? null; b !== null; b = rest.get(b)?.parent ?? null) {
|
|
149
|
+
const list = below.get(b) ?? [];
|
|
150
|
+
if (!list.includes(m)) list.push(m);
|
|
151
|
+
below.set(b, list);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
idle = readIdle(idleName, a.name, animations[idleName] as Record<string, unknown>, own, T, { groups, easings }, setupOf, a.carryIdle === true, (bone) => below.get(bone) ?? [], (bone) => {
|
|
155
|
+
const hip = map.translate?.bone;
|
|
156
|
+
if (hip === undefined) return null;
|
|
157
|
+
for (let b = rest.get(hip)?.parent ?? null; b !== null; b = rest.get(b)?.parent ?? null) if (b === bone) return hip;
|
|
158
|
+
return null;
|
|
159
|
+
});
|
|
160
|
+
say(` IDLE animation "${idleName}" (${idle.duration.toFixed(3)}s, ${idle.tracks} track(s)) against "${a.name}": ${idle.dropped.length} dropped (${a.carryIdle === true ? `${idle.carriedTracks.length} carried, looped to ${T.toFixed(3)}s` : 'not carried; --carry-idle loops them in'}), ${idle.keptOut.length} kept out`);
|
|
161
|
+
for (const d of idle.dropped) say(` IDLE-DROPPED ${droppedLine(d, a.name, a.carryIdle === true, T)}`);
|
|
162
|
+
for (const k of idle.keptOut) say(` IDLE-KEPT-OUT ${keptOutLine(k)}`);
|
|
163
|
+
for (const n of idle.notTracks) say(` IDLE-KEPT-OUT "${idleName}"'s ${n} timeline(s): not a track; neither compared nor carried`);
|
|
164
|
+
}
|
|
165
|
+
const carried = idle?.carriedTracks ?? [];
|
|
166
|
+
if (fragment.length > 0) {
|
|
167
|
+
const problems = mergeProblems(fragment, a.merge!, a.name, [...own, ...carried], T, groups);
|
|
168
|
+
if (problems.length > 0) refuse(problems);
|
|
169
|
+
for (const tr of fragment) say(` MERGE ${a.merge}: ${trackLabel(tr, groups)}, ${tr.keys.length} key(s) at t=${tr.keys[0]!.t}${tr.keys.length > 1 ? `–${tr.keys[tr.keys.length - 1]!.t}` : ''}s`);
|
|
170
|
+
}
|
|
171
|
+
const added = [...carried, ...fragment];
|
|
172
|
+
const animation = added.length === 0 ? r.animation : { ...r.animation, tracks: [...own, ...added] };
|
|
173
|
+
const motion = { ...base, animations: { ...animations, [a.name]: animation } };
|
|
121
174
|
const g1 = gate(input.rig, motion, join(scratch, 'with'), a.profile, a.images);
|
|
122
175
|
if (!g1.green) {
|
|
123
176
|
refuse([{ code: 'RIG_GATE', text: `rigc build --profile ${a.profile} is red with "${a.name}" in (exit ${g1.run.status}); nothing written` }, ...g1.fails.map((f) => ({ code: 'RIGC', text: f }))]);
|
|
@@ -128,13 +181,23 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
128
181
|
const posed = poseFrames(doc, a.name, r.times);
|
|
129
182
|
const landings = measureLanding(doc, a.name, map, r, posed);
|
|
130
183
|
const p = new Problems();
|
|
184
|
+
// 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).
|
|
185
|
+
const carriedAbove = (bone: string): string => {
|
|
186
|
+
const above = new Set<string>();
|
|
187
|
+
for (let b = rest.get(bone)?.parent ?? null; b !== null; b = rest.get(b)?.parent ?? null) above.add(b);
|
|
188
|
+
const hits = carried.filter((tr) => {
|
|
189
|
+
const t = targetOf(tr);
|
|
190
|
+
return t !== null && (t.kind === 'bone' ? above.has(t.name) : t.kind === 'group' && (groups[t.name] ?? []).some((m) => above.has(m)));
|
|
191
|
+
});
|
|
192
|
+
return hits.length === 0 ? '' : `; --carry-idle carried ${hits.map((tr) => trackLabel(tr, groups)).join(', ')} onto bones above it`;
|
|
193
|
+
};
|
|
131
194
|
for (const l of landings) {
|
|
132
195
|
const unit = l.kind === 'angle' ? '°' : l.kind === 'scale' ? ' of its setup length' : ' units';
|
|
133
196
|
const what = l.kind === 'angle' ? 'aim' : l.kind === 'scale' ? 'scale' : l.kind === 'foot' ? 'ankle' : 'origin';
|
|
134
197
|
const tol = l.kind === 'foot' ? Number(l.tolerance.toPrecision(3)) : l.tolerance;
|
|
135
198
|
const line = `bone "${l.bone}" ${what} — worst |reached − asked| ${l.worst.toPrecision(3)}${unit} at frame ${l.frame} (t=${r.times[l.frame]!.toFixed(3)}s); the tolerance is ${tol}${unit}`;
|
|
136
199
|
if (landed(l)) say(` LAND ${line}`);
|
|
137
|
-
else p.add('POSE_LANDS', `${line} — the rig does not turn the way the key formula assumes (a constraint, a scaled or sheared parent, a non-normal inherit mode)`);
|
|
200
|
+
else p.add('POSE_LANDS', `${line} — the rig does not turn the way the key formula assumes (a constraint, a scaled or sheared parent, a non-normal inherit mode)${carriedAbove(l.bone)}`);
|
|
138
201
|
}
|
|
139
202
|
p.throwIfAny();
|
|
140
203
|
|
|
@@ -186,7 +249,7 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
186
249
|
animation: a.name,
|
|
187
250
|
frames: seq.frames.length,
|
|
188
251
|
duration: r.animation.duration,
|
|
189
|
-
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, switchAt: a.switchAt, profile: a.profile, legScale: a.legScale ?? 'off', plantFeet: a.plantFeet === true },
|
|
252
|
+
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, switchAt: a.switchAt, profile: a.profile, legScale: a.legScale ?? 'off', plantFeet: a.plantFeet === true, idle: idleName, carryIdle: a.carryIdle === true, merge: a.merge ?? null },
|
|
190
253
|
scale: r.scale,
|
|
191
254
|
holds: r.holds,
|
|
192
255
|
short: r.short,
|
|
@@ -206,6 +269,9 @@ export function runRetarget(a: RetargetArgs, log: (line: string) => void): RunRe
|
|
|
206
269
|
pivot: r.pivot,
|
|
207
270
|
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 },
|
|
208
271
|
shear,
|
|
272
|
+
idleDropped: idle === null ? null : idle.dropped,
|
|
273
|
+
idleKeptOut: idle === null ? null : idle.keptOut,
|
|
274
|
+
merged: fragment.map((tr) => ({ track: trackLabel(tr, groups), keys: tr.keys.length })),
|
|
209
275
|
}, null, 2)}\n`);
|
|
210
276
|
}
|
|
211
277
|
say(` wrote ${a.out} — animations: ${Object.keys(motion.animations).join(', ')}`);
|