@convai/embody 1.0.1-beta.0 → 1.1.0-beta.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.
Files changed (60) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +12 -8
  3. package/dist/character/createCharacter.d.ts +66 -5
  4. package/dist/character/createCharacter.js +93 -22
  5. package/dist/character/pipeline.d.ts +9 -5
  6. package/dist/clips/animations.d.ts +13 -1
  7. package/dist/clips/animations.js +28 -6
  8. package/dist/clips/retarget.d.ts +168 -0
  9. package/dist/clips/retarget.js +856 -0
  10. package/dist/clips/sources.d.ts +70 -0
  11. package/dist/clips/sources.js +119 -0
  12. package/dist/connect/session.d.ts +11 -0
  13. package/dist/connect/session.js +22 -0
  14. package/dist/index.d.ts +22 -1
  15. package/dist/index.js +12 -0
  16. package/dist/inspect/inspectModel.d.ts +14 -0
  17. package/dist/inspect/inspectModel.js +26 -0
  18. package/dist/inspect/morphMap.d.ts +54 -0
  19. package/dist/inspect/morphMap.js +140 -0
  20. package/dist/inspect/report.d.ts +88 -0
  21. package/dist/inspect/report.js +193 -0
  22. package/dist/motion/eyeAim.d.ts +94 -0
  23. package/dist/motion/eyeAim.js +276 -0
  24. package/dist/motion/eyeGaze.d.ts +17 -0
  25. package/dist/motion/eyeGaze.js +42 -0
  26. package/dist/motion/footLock.d.ts +67 -0
  27. package/dist/motion/footLock.js +176 -0
  28. package/dist/motion/gaze.d.ts +135 -180
  29. package/dist/motion/gaze.js +257 -491
  30. package/dist/motion/hair.d.ts +27 -0
  31. package/dist/motion/hair.js +127 -0
  32. package/dist/motion/hairClearance.d.ts +39 -0
  33. package/dist/motion/hairClearance.js +125 -0
  34. package/dist/motion/morphNormals.d.ts +38 -0
  35. package/dist/motion/morphNormals.js +237 -0
  36. package/dist/motion/types.d.ts +15 -0
  37. package/dist/react/CharacterModel.d.ts +3 -1
  38. package/dist/react/CharacterModel.js +9 -1
  39. package/dist/react/ConvaiCharacter.js +4 -1
  40. package/dist/react/PortraitLights.d.ts +3 -0
  41. package/dist/react/PortraitLights.js +44 -0
  42. package/dist/render/faceMaterials.d.ts +60 -3
  43. package/dist/render/faceMaterials.js +320 -96
  44. package/dist/render/portraitRig.d.ts +28 -0
  45. package/dist/render/portraitRig.js +55 -0
  46. package/dist/render/surfaces.d.ts +181 -0
  47. package/dist/render/surfaces.js +1020 -0
  48. package/dist/rig/resolve.d.ts +8 -2
  49. package/dist/rig/resolve.js +7 -3
  50. package/dist/rig/units.d.ts +35 -16
  51. package/dist/rig/units.js +143 -42
  52. package/dist/speech/expression.d.ts +20 -0
  53. package/dist/speech/expression.js +118 -0
  54. package/dist/speech/lipsync.d.ts +68 -2
  55. package/dist/speech/lipsync.js +205 -5
  56. package/dist/speech/mouthFinish.d.ts +46 -0
  57. package/dist/speech/mouthFinish.js +235 -0
  58. package/dist/speech/voiceClock.d.ts +94 -0
  59. package/dist/speech/voiceClock.js +176 -0
  60. package/package.json +1 -1
@@ -0,0 +1,856 @@
1
+ /**
2
+ * Load-time retargeting: a clip authored on another skeleton (Mixamo, the
3
+ * Unreal mannequin, Rigify) → a clip on the body's own skeleton.
4
+ *
5
+ * Ported from the coffee-shop studio's camera-tuned retargeter and
6
+ * generalised: every rig is read through one table of canonical joints
7
+ * (Mixamo's names), so neither side's bone names are hard-coded into the
8
+ * method. The posture constants are the ones tuned on convai-mha-1 bodies.
9
+ *
10
+ * Method, per sampled frame:
11
+ * 1. Both rigs are put in one canonical frame (left +X, up +Y, forward +Z),
12
+ * measured from their own hip line and the file's vertical.
13
+ * 2. Rest-pose fix: Mixamo rests in a T-pose, a MetaHuman binds in an
14
+ * A-pose. Each target bone gets the shortest-arc rotation that lays its
15
+ * rest direction (bone → mapped child) onto the source's. Feet are left
16
+ * flat, the neck and head keep their own build, and a clavicle built
17
+ * pointing back (Rigify) contributes only its elevation.
18
+ * 3. Each mapped bone takes the source bone's WORLD rotation change since
19
+ * rest; unmapped bones (twists, correctives, extra spine) keep their
20
+ * bind-pose local rotation.
21
+ * 4. The hips move by the source hips' offset, scaled by hip height.
22
+ * 5. Elbows and hands are kept outside the body's own torso and thighs,
23
+ * measured from its skinned vertices (a clip made for a slimmer body
24
+ * otherwise rests its hands inside this one).
25
+ *
26
+ * The top bone (`pelvis` on convai-mha-1) has a parent outside the skin
27
+ * (`root002`). It is solved against that parent's bind-frame transform,
28
+ * `bindMatrix · meshWorld⁻¹ · parent.matrixWorld`; solving it from skin bones
29
+ * alone writes a constant pelvis and breaks every clip that bends at the hips.
30
+ */
31
+ import * as THREE from 'three';
32
+ // ── canonical joints and the rigs that are read ─────────────────────────────
33
+ const SIDES = [['Left', 'l', 'L'], ['Right', 'r', 'R']];
34
+ const FINGERS = [['Thumb', 'thumb', 'thumb'], ['Index', 'index', 'f_index'], ['Middle', 'middle', 'f_middle'], ['Ring', 'ring', 'f_ring'], ['Pinky', 'pinky', 'f_pinky']];
35
+ /** Every canonical joint: Mixamo's bone names without the `mixamorig:` prefix. */
36
+ const CANON = new Set(['Hips', 'Spine', 'Spine1', 'Spine2', 'Neck', 'Head']);
37
+ for (const [s] of SIDES) {
38
+ for (const j of ['Shoulder', 'Arm', 'ForeArm', 'Hand', 'UpLeg', 'Leg', 'Foot', 'ToeBase'])
39
+ CANON.add(`${s}${j}`);
40
+ for (const [f] of FINGERS)
41
+ for (const i of [1, 2, 3])
42
+ CANON.add(`${s}Hand${f}${i}`);
43
+ }
44
+ /** Which canonical child gives a joint its direction, for the rest-pose fix. */
45
+ const AIM = { Spine: 'Spine1', Spine1: 'Spine2', Spine2: 'Neck', Neck: 'Head' };
46
+ for (const [s] of SIDES) {
47
+ Object.assign(AIM, {
48
+ [`${s}Shoulder`]: `${s}Arm`,
49
+ [`${s}Arm`]: `${s}ForeArm`,
50
+ [`${s}ForeArm`]: `${s}Hand`,
51
+ [`${s}Hand`]: `${s}HandMiddle1`,
52
+ [`${s}UpLeg`]: `${s}Leg`,
53
+ [`${s}Leg`]: `${s}Foot`,
54
+ });
55
+ for (const [f] of FINGERS) {
56
+ AIM[`${s}Hand${f}1`] = `${s}Hand${f}2`;
57
+ AIM[`${s}Hand${f}2`] = `${s}Hand${f}3`;
58
+ }
59
+ }
60
+ /**
61
+ * Joints whose rest pose already matches (every rig stands flat-footed), so
62
+ * they take no rest fix. Aligning ankle→toe directions instead pitches the
63
+ * foot by the difference in the two rigs' ankle heights, and the toes dip
64
+ * into the floor.
65
+ */
66
+ const FLAT = new Set(['LeftFoot', 'RightFoot', 'LeftToeBase', 'RightToeBase']);
67
+ /**
68
+ * Joints whose rest direction differs between rigs by build, not by pose:
69
+ * every rig rests with the head upright, but where the neck starts and how
70
+ * far it leans is anatomy. Copying the source's neck direction pushed the
71
+ * head 10–50° forward of the shoulders. They take the source's rotation
72
+ * change only, on the body's own bind shape.
73
+ */
74
+ const OWN_SHAPE = new Set(['Neck', 'Head']);
75
+ /**
76
+ * Clavicles resting more than this far forward or back of sideways are built
77
+ * that way (Rigify's start at the sternum and point 30–45° back), not posed:
78
+ * the source's rest then counts as sideways at its own elevation. Mixamo's
79
+ * (within ~15°) are taken as they are.
80
+ */
81
+ const CLAVICLE_BUILD_DEG = 20;
82
+ const ELEVATION_ONLY = new Set(['LeftShoulder', 'RightShoulder']);
83
+ /** The joints a source must have to be read at all. */
84
+ const REQUIRED = ['Hips', 'LeftUpLeg', 'RightUpLeg', 'LeftFoot', 'RightFoot'];
85
+ const stripMixamo = (n) => n.replace(/^mixamorig\d*:?/, '');
86
+ // GLTFLoader and FBXLoader drop '.' and ':' from node names
87
+ // (DEF-thigh.L → DEF-thighL, mixamorig:Hips → mixamorigHips): compare without them.
88
+ const key = (n) => n.replace(/[.:]/g, '');
89
+ /**
90
+ * Reads a skeleton's bone names as canonical joints: Mixamo (any
91
+ * `mixamorig` prefix, or none), Unreal (UE4/UE5 mannequin, MetaHuman,
92
+ * convai-mha-1) and Rigify deform bones. Spines are matched bottom, middle,
93
+ * top: a 3-bone spine maps 1/2/3, a 5-bone one 1/3/5. `null` when the hips,
94
+ * thighs and feet cannot all be found.
95
+ */
96
+ export function readRigNames(names) {
97
+ const has = new Set(names.map(key));
98
+ const alias = {};
99
+ let kind = 'mixamo';
100
+ if (has.has('pelvis') && has.has('thigh_l')) {
101
+ kind = 'unreal';
102
+ const five = has.has('spine_05');
103
+ Object.assign(alias, { pelvis: 'Hips', spine_01: 'Spine', [five ? 'spine_03' : 'spine_02']: 'Spine1', [five ? 'spine_05' : 'spine_03']: 'Spine2', neck_01: 'Neck', head: 'Head' });
104
+ for (const [s, t] of SIDES) {
105
+ Object.assign(alias, {
106
+ [`clavicle_${t}`]: `${s}Shoulder`,
107
+ [`upperarm_${t}`]: `${s}Arm`,
108
+ [`lowerarm_${t}`]: `${s}ForeArm`,
109
+ [`hand_${t}`]: `${s}Hand`,
110
+ [`thigh_${t}`]: `${s}UpLeg`,
111
+ [`calf_${t}`]: `${s}Leg`,
112
+ [`foot_${t}`]: `${s}Foot`,
113
+ [`ball_${t}`]: `${s}ToeBase`,
114
+ });
115
+ for (const [f, uf] of FINGERS)
116
+ for (const i of [1, 2, 3])
117
+ alias[`${uf}_0${i}_${t}`] = `${s}Hand${f}${i}`;
118
+ }
119
+ }
120
+ else if (has.has('DEF-hips') || has.has('DEF-spine001')) {
121
+ // Rigify deform bones (Quaternius' Universal Animation Library uses this rig).
122
+ kind = 'rigify';
123
+ const sp = (i) => `DEF-spine.${String(i).padStart(3, '0')}`;
124
+ Object.assign(alias, has.has('DEF-hips')
125
+ ? { 'DEF-hips': 'Hips', [sp(1)]: 'Spine', [sp(2)]: 'Spine1', [sp(3)]: 'Spine2', 'DEF-neck': 'Neck', 'DEF-head': 'Head' }
126
+ : { 'DEF-spine': 'Hips', [sp(1)]: 'Spine', [sp(2)]: 'Spine1', [sp(3)]: 'Spine2', [sp(4)]: 'Neck', [sp(6)]: 'Head' });
127
+ for (const [s, , t] of SIDES) {
128
+ Object.assign(alias, {
129
+ [`DEF-shoulder.${t}`]: `${s}Shoulder`,
130
+ [`DEF-upper_arm.${t}`]: `${s}Arm`,
131
+ [`DEF-forearm.${t}`]: `${s}ForeArm`,
132
+ [`DEF-hand.${t}`]: `${s}Hand`,
133
+ [`DEF-thigh.${t}`]: `${s}UpLeg`,
134
+ [`DEF-shin.${t}`]: `${s}Leg`,
135
+ [`DEF-foot.${t}`]: `${s}Foot`,
136
+ [`DEF-toe.${t}`]: `${s}ToeBase`,
137
+ });
138
+ for (const [f, , rf] of FINGERS)
139
+ for (const i of [1, 2, 3])
140
+ alias[`DEF-${rf}.0${i}.${t}`] = `${s}Hand${f}${i}`;
141
+ }
142
+ }
143
+ const byKey = new Map(Object.entries(alias).map(([k, v]) => [key(k), v]));
144
+ const toCanon = new Map();
145
+ const fromCanon = new Map();
146
+ for (const n of names) {
147
+ const c = kind === 'mixamo' ? stripMixamo(key(n)) : byKey.get(key(n));
148
+ if (!c || !CANON.has(c) || fromCanon.has(c))
149
+ continue;
150
+ toCanon.set(n, c);
151
+ fromCanon.set(c, n);
152
+ }
153
+ if (!REQUIRED.every((c) => fromCanon.has(c)))
154
+ return null;
155
+ return { kind, toCanon, fromCanon };
156
+ }
157
+ /** Every bone under `root`, in traversal order. */
158
+ function bonesUnder(root) {
159
+ const out = [];
160
+ root.traverse((o) => { if (o.isBone)
161
+ out.push(o); });
162
+ return out;
163
+ }
164
+ /** The skeleton under `root` that `readRigNames` reads, or null. */
165
+ export function detectRig(root) {
166
+ return readRigNames(bonesUnder(root).map((b) => b.name));
167
+ }
168
+ /**
169
+ * Rotation taking the rig's frame to canonical (left +X, up +Y, forward +Z).
170
+ * Up is the file's vertical (+Y, both FBX and glTF), not pelvis → head: a
171
+ * MetaHuman binds with her head ~10° off the hip line, and measuring up from
172
+ * the spine added that lean to every upright source pose.
173
+ */
174
+ function canonicalFrame(pos, l, r) {
175
+ const left = pos.get(l).clone().sub(pos.get(r));
176
+ left.y = 0;
177
+ left.normalize();
178
+ const up = new THREE.Vector3(0, 1, 0);
179
+ const fwd = new THREE.Vector3().crossVectors(left, up).normalize();
180
+ left.crossVectors(up, fwd).normalize();
181
+ const basis = new THREE.Matrix4().makeBasis(left, up, fwd); // canonical → rig
182
+ return new THREE.Quaternion().setFromRotationMatrix(basis).invert(); // rig → canonical
183
+ }
184
+ const SECTORS = 16;
185
+ /** Hip height of an average adult above the ankle joint, m: converts bind units to metres. */
186
+ const HIP_HEIGHT_M = 0.92;
187
+ /**
188
+ * Reads the body under `root` as a retarget target: the skinned mesh whose
189
+ * skeleton `readRigNames` reads (the largest, when several are), its bind
190
+ * pose from the inverse bind matrices, and its torso and thighs from every
191
+ * skinned mesh. Nothing is read from the current pose, so a body that is
192
+ * already animating (or scaled and grounded) gives the same rig. `null` when
193
+ * no skeleton is recognised.
194
+ */
195
+ export function createTargetRig(root) {
196
+ let mesh = null;
197
+ let names = null;
198
+ const meshes = [];
199
+ root.traverse((o) => {
200
+ const m = o;
201
+ if (!m.isSkinnedMesh || !m.skeleton)
202
+ return;
203
+ meshes.push(m);
204
+ if (mesh && m.skeleton.bones.length <= mesh.skeleton.bones.length)
205
+ return;
206
+ const n = readRigNames(m.skeleton.bones.map((b) => b.name));
207
+ if (n)
208
+ [mesh, names] = [m, n];
209
+ });
210
+ if (!mesh || !names)
211
+ return null;
212
+ const sk = mesh.skeleton;
213
+ const rig = names;
214
+ const T = (c) => rig.fromCanon.get(c);
215
+ const bones = new Map();
216
+ const bind = { rot: new Map(), pos: new Map() };
217
+ const bindMatrix = new Map();
218
+ const p = new THREE.Vector3();
219
+ const q = new THREE.Quaternion();
220
+ const s = new THREE.Vector3();
221
+ sk.bones.forEach((b, i) => {
222
+ bones.set(b.name, b);
223
+ const m = sk.boneInverses[i].clone().invert();
224
+ bindMatrix.set(b.name, m);
225
+ m.decompose(p, q, s);
226
+ bind.rot.set(b.name, q.clone());
227
+ bind.pos.set(b.name, p.clone());
228
+ });
229
+ // Bind frame = bindMatrix · meshWorld⁻¹ · world (attached bind mode), so a
230
+ // static parent outside the skin can be placed in it from its world matrix.
231
+ root.updateMatrixWorld(true);
232
+ const toBind = mesh.bindMatrix.clone().multiply(mesh.matrixWorld.clone().invert());
233
+ const parentBind = (b) => {
234
+ const parent = b.parent;
235
+ const i = parent ? sk.bones.indexOf(parent) : -1;
236
+ if (i >= 0)
237
+ return sk.boneInverses[i].clone().invert();
238
+ return toBind.clone().multiply(parent ? parent.matrixWorld : new THREE.Matrix4());
239
+ };
240
+ const topParent = new Map();
241
+ const local = new Map();
242
+ const offset = new Map();
243
+ for (const b of sk.bones) {
244
+ const parent = b.parent;
245
+ let pq;
246
+ if (parent && bones.has(parent.name))
247
+ pq = bind.rot.get(parent.name);
248
+ else {
249
+ parentBind(b).decompose(p, q, s);
250
+ pq = q.clone();
251
+ topParent.set(b.name, pq);
252
+ }
253
+ local.set(b.name, pq.clone().invert().multiply(bind.rot.get(b.name)));
254
+ if (parent && bind.pos.has(parent.name))
255
+ offset.set(b.name, bind.pos.get(b.name).clone().sub(bind.pos.get(parent.name)).applyQuaternion(pq.clone().invert()));
256
+ }
257
+ const hips = T('Hips');
258
+ const hipsParentInv = parentBind(bones.get(hips)).invert();
259
+ const toCanon = canonicalFrame(bind.pos, T('LeftUpLeg'), T('RightUpLeg'));
260
+ const y = (n) => bind.pos.get(n).clone().applyQuaternion(toCanon).y;
261
+ const hipHeight = y(hips) - Math.min(y(T('LeftFoot')), y(T('RightFoot')));
262
+ const order = [];
263
+ const top = sk.bones.filter((b) => !b.parent || !bones.has(b.parent.name));
264
+ for (const t of top)
265
+ t.traverse((o) => { if (o.isBone && bones.has(o.name))
266
+ order.push(o); });
267
+ const subtree = new Map();
268
+ for (const b of order) {
269
+ const list = [];
270
+ b.traverse((o) => { if (o.isBone && bones.has(o.name))
271
+ list.push(o.name); });
272
+ subtree.set(b.name, list);
273
+ }
274
+ const partial = { names: rig, bones, bind, bindMatrix, local, toCanon, order, hipHeight, unitsPerM: hipHeight / HIP_HEIGHT_M, topParent, hipsParentInv, offset, subtree };
275
+ return { ...partial, colliders: buildColliders(meshes, partial) };
276
+ }
277
+ /** Hips → neck, bone by bone along the skeleton: [bone, its child on the way up]. */
278
+ function trunk(rig) {
279
+ const hips = rig.names.fromCanon.get('Hips');
280
+ const neck = rig.names.fromCanon.get('Neck');
281
+ if (!neck)
282
+ return [];
283
+ const chain = [];
284
+ for (let o = rig.bones.get(neck) ?? null; o && rig.bones.has(o.name); o = o.parent) {
285
+ chain.unshift(o.name);
286
+ if (o.name === hips)
287
+ break;
288
+ }
289
+ if (chain[0] !== hips)
290
+ return [];
291
+ return chain.slice(0, -1).map((b, i) => [b, chain[i + 1]]);
292
+ }
293
+ /**
294
+ * The segments hands and elbows must stay outside of: the trunk, bone by
295
+ * bone, and each thigh. A vertex belongs to the segment of the nearest
296
+ * collider bone above its main bone, unless a mapped joint (a clavicle, the
297
+ * neck, a knee) is in between: twists and correctives count with their bone,
298
+ * arms and the head do not.
299
+ */
300
+ function buildColliders(meshes, rig) {
301
+ const T = (c) => rig.names.fromCanon.get(c);
302
+ const segs = [...trunk(rig)];
303
+ for (const [s] of SIDES) {
304
+ const th = T(`${s}UpLeg`);
305
+ const ca = T(`${s}Leg`);
306
+ if (th && ca)
307
+ segs.push([th, ca]);
308
+ }
309
+ const segOf = new Map(segs.map(([b], k) => [b, k]));
310
+ const owner = new Map();
311
+ for (const name of rig.bones.keys()) {
312
+ let k = -1;
313
+ for (let o = rig.bones.get(name); o && rig.bones.has(o.name); o = o.parent) {
314
+ if (segOf.has(o.name)) {
315
+ k = segOf.get(o.name);
316
+ break;
317
+ }
318
+ if (rig.names.toCanon.has(o.name))
319
+ break;
320
+ }
321
+ owner.set(name, k);
322
+ }
323
+ // Every skinned part (body, clothing) counts. Each vertex goes into the
324
+ // frame of its main bone through that mesh's own bind, then out into the
325
+ // rig's bind frame, so meshes bound separately still line up.
326
+ const per = segs.map(() => []);
327
+ const v = new THREE.Vector3();
328
+ for (const mesh of meshes) {
329
+ const g = mesh.geometry;
330
+ const pos = g.attributes.position;
331
+ const si = g.attributes.skinIndex;
332
+ const sw = g.attributes.skinWeight;
333
+ if (!pos || !si || !sw)
334
+ continue;
335
+ const bones = mesh.skeleton.bones;
336
+ const boneSeg = bones.map((b) => owner.get(b.name) ?? -1);
337
+ const boneM = bones.map((b, j) => {
338
+ const bm = rig.bindMatrix.get(b.name);
339
+ if (!bm || boneSeg[j] < 0)
340
+ return null;
341
+ return bm.clone().multiply(mesh.skeleton.boneInverses[j]).multiply(mesh.bindMatrix);
342
+ });
343
+ for (let i = 0; i < pos.count; i++) {
344
+ let best = -1;
345
+ let j = 0;
346
+ for (let c = 0; c < 4; c++) {
347
+ const w = sw.getComponent(i, c);
348
+ if (w > best) {
349
+ best = w;
350
+ j = si.getComponent(i, c);
351
+ }
352
+ }
353
+ const k = boneSeg[j] ?? -1;
354
+ if (k < 0)
355
+ continue;
356
+ per[k].push(v.fromBufferAttribute(pos, i).applyMatrix4(boneM[j]).clone());
357
+ }
358
+ }
359
+ const out = [];
360
+ segs.forEach(([bone, tip], k) => {
361
+ const a = rig.bind.pos.get(bone);
362
+ const b = rig.bind.pos.get(tip);
363
+ const verts = per[k];
364
+ if (verts.length < 50)
365
+ return;
366
+ const u = b.clone().sub(a);
367
+ const len = u.length();
368
+ if (len < 1e-9)
369
+ return;
370
+ u.normalize();
371
+ const e1 = new THREE.Vector3(1, 0, 0).applyQuaternion(rig.bind.rot.get(bone));
372
+ e1.addScaledVector(u, -e1.dot(u));
373
+ if (e1.lengthSq() < 1e-8)
374
+ e1.set(0, 1, 0).applyQuaternion(rig.bind.rot.get(bone)).addScaledVector(u, -new THREE.Vector3(0, 1, 0).applyQuaternion(rig.bind.rot.get(bone)).dot(u));
375
+ e1.normalize();
376
+ const e2 = new THREE.Vector3().crossVectors(u, e1);
377
+ const bins = Array.from({ length: SECTORS }, () => []);
378
+ const ts = [];
379
+ const d = new THREE.Vector3();
380
+ for (const p of verts) {
381
+ d.copy(p).sub(a);
382
+ const t = d.dot(u);
383
+ ts.push(t / len);
384
+ d.addScaledVector(u, -t);
385
+ const th = Math.atan2(d.dot(e2), d.dot(e1));
386
+ bins[Math.floor(((th + Math.PI) / (2 * Math.PI)) * SECTORS) % SECTORS].push(d.length());
387
+ }
388
+ const pct = (x, f) => (x.sort((i, j) => i - j), x[Math.min(x.length - 1, Math.floor(x.length * f))]);
389
+ const radius = new Float32Array(SECTORS);
390
+ const all = bins.flat();
391
+ bins.forEach((r, i) => (radius[i] = r.length >= 5 ? pct(r, 0.9) : pct([...all], 0.5)));
392
+ out.push({ bone, tip, radius, t0: pct(ts, 0.05), t1: pct(ts, 0.95), e1 });
393
+ });
394
+ return out;
395
+ }
396
+ /**
397
+ * Keeps elbows and hands outside the torso and thighs. A clip made for a
398
+ * slimmer or differently built body rests its hands on a belly or hips that
399
+ * this one is not, and they sink in. Per frame: an elbow inside swings the
400
+ * upper arm out about the shoulder; a hand inside swings the forearm out
401
+ * about the elbow, each by just the depth plus 1 cm. Works on `world`
402
+ * (bind-frame world rotations) in place; returns the deepest point found
403
+ * before clearing, in bind units.
404
+ */
405
+ function clearArms(rig, world) {
406
+ const T = (c) => rig.names.fromCanon.get(c);
407
+ const margin = 0.01 * rig.unitsPerM;
408
+ let memo = new Map();
409
+ const pos = (n) => {
410
+ const hit = memo.get(n);
411
+ if (hit)
412
+ return hit;
413
+ const parent = rig.bones.get(n).parent?.name ?? '';
414
+ const p = !world.has(parent) || !rig.offset.has(n)
415
+ ? rig.bind.pos.get(n).clone()
416
+ : pos(parent).clone().add(rig.offset.get(n).clone().applyQuaternion(world.get(parent)));
417
+ memo.set(n, p);
418
+ return p;
419
+ };
420
+ const inside = (p) => {
421
+ let depth = -Infinity;
422
+ const normal = new THREE.Vector3();
423
+ for (const c of rig.colliders) {
424
+ const toBind = rig.bind.rot.get(c.bone).clone().multiply(world.get(c.bone).clone().invert());
425
+ const q = p.clone().sub(pos(c.bone)).applyQuaternion(toBind);
426
+ const u = rig.bind.pos.get(c.tip).clone().sub(rig.bind.pos.get(c.bone));
427
+ const len = u.length();
428
+ u.normalize();
429
+ const t = q.dot(u) / len;
430
+ if (t < c.t0 - 0.05 || t > c.t1 + 0.05)
431
+ continue;
432
+ const d = q.addScaledVector(u, -t * len);
433
+ const e2 = new THREE.Vector3().crossVectors(u, c.e1);
434
+ const x = ((Math.atan2(d.dot(e2), d.dot(c.e1)) + Math.PI) / (2 * Math.PI)) * SECTORS - 0.5;
435
+ const i = Math.floor(x);
436
+ const k = x - i;
437
+ const r = c.radius[((i % SECTORS) + SECTORS) % SECTORS] * (1 - k) + c.radius[(((i + 1) % SECTORS) + SECTORS) % SECTORS] * k;
438
+ const dd = r + margin - d.length();
439
+ if (dd > depth) {
440
+ depth = dd;
441
+ normal.copy(d).normalize().applyQuaternion(toBind.invert());
442
+ }
443
+ }
444
+ return { depth, normal };
445
+ };
446
+ /** Swing `bone`'s subtree about its joint so `points` clear the body. */
447
+ const push = (bone, points) => {
448
+ let worst = 0;
449
+ for (let it = 0; it < 4; it++) {
450
+ const pivot = pos(bone);
451
+ let best = null;
452
+ for (const p of points()) {
453
+ const h = inside(p);
454
+ if (h.depth > 0 && (!best || h.depth > best.depth))
455
+ best = { ...h, p };
456
+ }
457
+ if (!best)
458
+ break;
459
+ worst = Math.max(worst, best.depth);
460
+ const arm = best.p.clone().sub(pivot);
461
+ const axis = new THREE.Vector3().crossVectors(arm, best.normal);
462
+ if (axis.lengthSq() < 1e-12 || arm.length() < 1e-9)
463
+ break;
464
+ const r = new THREE.Quaternion().setFromAxisAngle(axis.normalize(), best.depth / arm.length());
465
+ for (const n of rig.subtree.get(bone))
466
+ world.set(n, r.clone().multiply(world.get(n)));
467
+ memo = new Map();
468
+ }
469
+ return worst;
470
+ };
471
+ let deepest = 0;
472
+ for (const [s] of SIDES) {
473
+ const upper = T(`${s}Arm`);
474
+ const lower = T(`${s}ForeArm`);
475
+ const hand = T(`${s}Hand`);
476
+ if (!upper || !lower || !hand || !world.has(upper) || !world.has(lower))
477
+ continue;
478
+ const f1 = T(`${s}HandMiddle1`) ?? hand;
479
+ const f3 = T(`${s}HandMiddle3`) ?? f1;
480
+ deepest = Math.max(deepest, push(upper, () => [pos(lower), pos(upper).clone().lerp(pos(lower), 0.6)]), push(lower, () => [pos(hand), pos(f1), pos(f3), pos(lower).clone().lerp(pos(hand), 0.5)]));
481
+ }
482
+ return deepest;
483
+ }
484
+ /**
485
+ * Wraps a loaded file (an FBXLoader group, or a GLTF's `scene`) as a
486
+ * retarget source, recording its rest pose. Call before anything animates
487
+ * it. `null` when its skeleton is not one `readRigNames` reads.
488
+ */
489
+ export function createAnimationSource(root, clips) {
490
+ const names = detectRig(root);
491
+ if (!names)
492
+ return null;
493
+ const rest = [];
494
+ root.traverse((o) => rest.push([o, o.position.clone(), o.quaternion.clone(), o.scale.clone()]));
495
+ return { root, clips, names, rest };
496
+ }
497
+ function samplePose(root, bones, toCanon) {
498
+ root.updateMatrixWorld(true);
499
+ const invM = root.matrixWorld.clone().invert();
500
+ const pose = { rot: new Map(), pos: new Map() };
501
+ const m = new THREE.Matrix4();
502
+ const s = new THREE.Vector3();
503
+ for (const b of bones) {
504
+ const n = toCanon.get(b.name);
505
+ if (!n)
506
+ continue;
507
+ const p = new THREE.Vector3();
508
+ const q = new THREE.Quaternion();
509
+ m.multiplyMatrices(invM, b.matrixWorld).decompose(p, q, s);
510
+ pose.rot.set(n, q);
511
+ pose.pos.set(n, p);
512
+ }
513
+ return pose;
514
+ }
515
+ // ── posture, tuned on convai-mha-1 bodies against their own clips ───────────
516
+ /**
517
+ * Trunk lean (hips → neck, degrees, + = forward) the convai-mha-1 pack idle
518
+ * holds. Mixamo's idles and talks stand 1–4° further back than Mixamo's rest
519
+ * pose, which reads as a backward lean on these bodies.
520
+ */
521
+ export const UPRIGHT_DEG = -3;
522
+ /** Hips at least this share of standing height: a standing frame. */
523
+ const STANDING = 0.85;
524
+ /**
525
+ * Most the trunk is pitched forward, degrees. Only forward: a clip that
526
+ * already leans forward is left alone, and a deliberate lean back (pointing,
527
+ * laughing) is softened, not erased.
528
+ */
529
+ const MAX_CORRECTION = 6;
530
+ /**
531
+ * Share of the posture correction each spine joint (and everything above it)
532
+ * takes. Leaning the upper back forward carries the neck's base forward of
533
+ * the shoulders, pushing the head out; the neck turns back by the same amount
534
+ * so the head stays over the shoulders as in the source. The head takes none,
535
+ * so the gaze stays where the clip put it.
536
+ */
537
+ const SPINE_SHARE = [['Head', 0], ['Neck', -1], ['Spine2', 1], ['Spine1', 2 / 3], ['Spine', 1 / 3]];
538
+ /**
539
+ * Knee bend (thigh pitch − shin pitch, degrees) a standing clip's median is
540
+ * brought to. Mixamo's idles and talks stand with 30–45° of bend; the pack
541
+ * idle stands at 0°, and the bend pushes the hips back so the body reads as
542
+ * leaning back. Every frame's bend is scaled by the same factor, so stepping
543
+ * and weight shifts keep their shape.
544
+ */
545
+ const KNEE_FLEX_DEG = 8;
546
+ /**
547
+ * Clavicle elevation (clavicle → upper arm above level, degrees) the pack
548
+ * clips hold. Mixamo's shoulders sit 3–8° lower; each side is lifted so the
549
+ * clip's median reaches this, by at most SHOULDER_MAX_DEG and never lowered.
550
+ */
551
+ const SHOULDER_UP_DEG = -3.4;
552
+ const SHOULDER_MAX_DEG = 6;
553
+ /** Quaternions this close (|dot| ≥) are the same rotation. */
554
+ const SAME = 1 - 1e-9;
555
+ /**
556
+ * Retargets one clip of `source` onto `rig`: a clip of local rotations for
557
+ * the target's bones (only those that ever leave their rest) and the hips'
558
+ * position, sampled at `fps`.
559
+ */
560
+ export function retargetClip(source, src, rig, opts = {}) {
561
+ const fps = opts.fps ?? 30;
562
+ const name = opts.name ?? src.name;
563
+ for (const [o, p, q, sc] of source.rest) {
564
+ o.position.copy(p);
565
+ o.quaternion.copy(q);
566
+ o.scale.copy(sc);
567
+ }
568
+ const sRoot = source.root;
569
+ const srcBones = bonesUnder(sRoot);
570
+ const sNames = source.names.toCanon;
571
+ const T = (c) => rig.names.fromCanon.get(c);
572
+ const rest = samplePose(sRoot, srcBones, sNames);
573
+ const sToCanon = canonicalFrame(rest.pos, 'LeftUpLeg', 'RightUpLeg');
574
+ const sy = (pose, n) => pose.pos.get(n).clone().applyQuaternion(sToCanon).y;
575
+ const sHip = sy(rest, 'Hips') - Math.min(sy(rest, 'LeftFoot'), sy(rest, 'RightFoot'));
576
+ if (!(sHip > 1e-9) || !(rig.hipHeight > 1e-9))
577
+ throw new Error('cannot measure hip height (hips not above the feet)');
578
+ const scale = rig.hipHeight / sHip;
579
+ const hips = T('Hips');
580
+ /** Target bone → canonical joint, for joints both rigs have. */
581
+ const mapped = new Map();
582
+ for (const [c, t] of rig.names.fromCanon)
583
+ if (rest.rot.has(c))
584
+ mapped.set(t, c);
585
+ // Rest-pose fix per mapped bone, in canonical space.
586
+ const ident = new THREE.Quaternion();
587
+ const fix = new Map();
588
+ for (const [t, c] of mapped) {
589
+ const aim = AIM[c];
590
+ const tAim = aim ? T(aim) : undefined;
591
+ if (FLAT.has(c) || OWN_SHAPE.has(c) || !aim || !tAim || !rest.pos.has(aim)) {
592
+ fix.set(t, ident.clone());
593
+ continue;
594
+ }
595
+ const ds = rest.pos.get(aim).clone().sub(rest.pos.get(c)).applyQuaternion(sToCanon).normalize();
596
+ const dt = rig.bind.pos.get(tAim).clone().sub(rig.bind.pos.get(t)).applyQuaternion(rig.toCanon).normalize();
597
+ if (ELEVATION_ONLY.has(c) && Math.abs(Math.asin(THREE.MathUtils.clamp(ds.z, -1, 1))) > THREE.MathUtils.degToRad(CLAVICLE_BUILD_DEG))
598
+ ds.set(Math.sign(dt.x) * Math.sqrt(Math.max(0, 1 - ds.y * ds.y)), ds.y, 0);
599
+ fix.set(t, new THREE.Quaternion().setFromUnitVectors(dt, ds));
600
+ }
601
+ // Ends of chains (fingertips, toes) inherit their nearest fixed ancestor's fix.
602
+ for (const [t, c] of mapped) {
603
+ if (FLAT.has(c) || OWN_SHAPE.has(c) || !fix.get(t).equals(ident))
604
+ continue;
605
+ for (let p = rig.bones.get(t).parent; p && rig.bones.has(p.name); p = p.parent) {
606
+ const f = fix.get(p.name);
607
+ if (f && !f.equals(ident)) {
608
+ fix.set(t, f.clone());
609
+ break;
610
+ }
611
+ }
612
+ }
613
+ const mixer = new THREE.AnimationMixer(sRoot);
614
+ const sampler = mixer.clipAction(src);
615
+ // A one-shot that holds its last frame: a looping action wraps
616
+ // t = duration back to 0, which made every clip end on its first pose.
617
+ sampler.setLoop(THREE.LoopOnce, 1);
618
+ sampler.clampWhenFinished = true;
619
+ const sampleAt = (t) => {
620
+ sampler.reset().play();
621
+ mixer.setTime(t);
622
+ return samplePose(sRoot, srcBones, sNames);
623
+ };
624
+ const frames = Math.max(2, Math.round(src.duration * fps) + 1);
625
+ const times = new Float32Array(frames);
626
+ const qTracks = new Map();
627
+ for (const b of rig.order)
628
+ qTracks.set(b.name, new Float32Array(frames * 4));
629
+ const hipsPos = new Float32Array(frames * 3);
630
+ const tCanonInv = rig.toCanon.clone().invert();
631
+ const sCanonInv = sToCanon.clone().invert();
632
+ const world = new Map();
633
+ const restCanonInv = new Map();
634
+ for (const c of mapped.values())
635
+ restCanonInv.set(c, sToCanon.clone().multiply(rest.rot.get(c)).multiply(sCanonInv).invert());
636
+ const restT = new Map();
637
+ for (const t of mapped.keys())
638
+ restT.set(t, rig.toCanon.clone().multiply(rig.bind.rot.get(t)).multiply(tCanonInv));
639
+ const hipsRest = rig.bind.pos.get(hips).clone().applyMatrix4(rig.hipsParentInv);
640
+ const parentLinear = new THREE.Matrix3().setFromMatrix4(rig.hipsParentInv);
641
+ /** A bone's vector to `child` (anywhere below it), in its own bind frame. */
642
+ const segCache = new Map();
643
+ const seg = (bone, child) => {
644
+ const k = `${bone}>${child}`;
645
+ let v = segCache.get(k);
646
+ if (!v)
647
+ segCache.set(k, (v = rig.bind.pos.get(child).clone().sub(rig.bind.pos.get(bone)).applyQuaternion(rig.bind.rot.get(bone).clone().invert())));
648
+ return v;
649
+ };
650
+ /** The hip line for the hips' bind-frame rotation, level, in canonical space: the axis the body leans about. */
651
+ const hipLine = seg(hips, T('LeftUpLeg')).clone().sub(seg(hips, T('RightUpLeg')));
652
+ const hipAxis = (hipsWorld) => {
653
+ const v = hipLine.clone().applyQuaternion(hipsWorld).applyQuaternion(rig.toCanon);
654
+ v.y = 0;
655
+ return v.normalize();
656
+ };
657
+ const share = new Map();
658
+ const shareBy = new Map(SPINE_SHARE.flatMap(([c, k]) => (T(c) ? [[T(c), k]] : [])));
659
+ for (const b of rig.order) {
660
+ for (let o = b; o; o = o.parent) {
661
+ const k = shareBy.get(o.name);
662
+ if (k !== undefined) {
663
+ share.set(b.name, k);
664
+ break;
665
+ }
666
+ }
667
+ }
668
+ const legs = SIDES.map(([s]) => [T(`${s}UpLeg`), T(`${s}Leg`), T(`${s}Foot`)])
669
+ .filter((l) => l.every((n) => n && mapped.has(n)));
670
+ const clavicles = SIDES.map(([s]) => [T(`${s}Shoulder`), T(`${s}Arm`)])
671
+ .filter((l) => l.every((n) => n && mapped.has(n)));
672
+ const trunkChain = trunk(rig);
673
+ /** Source hips height over the lower foot, as a share of standing height. */
674
+ const hipShare = (pose) => (sy(pose, 'Hips') - Math.min(sy(pose, 'LeftFoot'), sy(pose, 'RightFoot'))) / sHip;
675
+ /** A mapped bone's rotation in canonical space, before any posture correction. */
676
+ const canon = (t, pose) => {
677
+ const c = mapped.get(t);
678
+ return sToCanon.clone().multiply(pose.rot.get(c)).multiply(sCanonInv)
679
+ .multiply(restCanonInv.get(c)).multiply(fix.get(t)).multiply(restT.get(t));
680
+ };
681
+ /** A bone's vector to `child`, in canonical space, for canonical rotation c. */
682
+ const boneVec = (c, bone, child) => seg(bone, child).clone().applyQuaternion(rig.toCanon).applyQuaternion(c);
683
+ const up = new THREE.Vector3(0, 1, 0);
684
+ const pitch = (v, fwd) => THREE.MathUtils.radToDeg(Math.atan2(v.dot(fwd), -v.y));
685
+ const rotAbout = (axis, deg) => new THREE.Quaternion().setFromAxisAngle(axis, THREE.MathUtils.degToRad(deg));
686
+ const clearHands = opts.clearHands !== false && rig.colliders.length > 0;
687
+ /**
688
+ * Target world rotations for the source at time t, with the spine pitched
689
+ * `corr` degrees forward and each knee's bend scaled by `knee` (fading out
690
+ * as the hips drop below standing, so a squat keeps its bend). Returns the
691
+ * source pose, each leg's bend before scaling, how much the straighter
692
+ * standing leg lifts the hips (canonical units) and each clavicle's
693
+ * elevation.
694
+ */
695
+ let handDepth = 0;
696
+ /** Bones whose solved local rotation ever leaves their bind (measured before Float32 storage). */
697
+ const moved = new Set();
698
+ const solve = (t, corr, knee = 1, f = -1, shoulders = {}) => {
699
+ const pose = sampleAt(t);
700
+ world.clear();
701
+ // Legs first: each bone's rotation is absolute, so the thigh and shin can
702
+ // be pitched apart without moving the foot's own orientation.
703
+ const legTurn = new Map();
704
+ const flex = [];
705
+ const hipsC = mapped.has(hips) ? canon(hips, pose) : restT.get(hips) ?? ident;
706
+ const axis = hipAxis(tCanonInv.clone().multiply(hipsC).multiply(rig.toCanon));
707
+ const fwd = new THREE.Vector3().crossVectors(axis, up);
708
+ const stand = THREE.MathUtils.smoothstep(hipShare(pose), STANDING - 0.1, STANDING);
709
+ const k = 1 - (1 - knee) * stand;
710
+ let lift = 0;
711
+ let lowest = -Infinity;
712
+ for (const [thigh, calf, foot] of legs) {
713
+ const tv = boneVec(canon(thigh, pose), thigh, calf);
714
+ const sv = boneVec(canon(calf, pose), calf, foot);
715
+ const bend = pitch(tv, fwd) - pitch(sv, fwd);
716
+ flex.push(bend);
717
+ const turn = (bend * (1 - k)) / 2;
718
+ legTurn.set(thigh, turn);
719
+ legTurn.set(calf, -turn);
720
+ const before = -(tv.y + sv.y);
721
+ if (before > lowest) {
722
+ lowest = before;
723
+ lift = -(tv.clone().applyQuaternion(rotAbout(axis, turn)).y + sv.clone().applyQuaternion(rotAbout(axis, -turn)).y) - before;
724
+ }
725
+ }
726
+ for (const b of rig.order) {
727
+ const n = b.name;
728
+ const parentWorld = (b.parent && world.get(b.parent.name)) || rig.topParent.get(n) || null;
729
+ let w;
730
+ if (mapped.has(n)) {
731
+ // Δ in canonical space, applied to the rest-fixed target rotation.
732
+ let c = canon(n, pose);
733
+ const ks = share.get(n);
734
+ if (ks && corr)
735
+ c = rotAbout(axis, ks * corr).multiply(c);
736
+ const turn = legTurn.get(n);
737
+ if (turn)
738
+ c = rotAbout(axis, turn).multiply(c);
739
+ const lifted = shoulders[n];
740
+ if (lifted) {
741
+ const arm = clavicles.find(([cl]) => cl === n)[1];
742
+ const v = boneVec(c, n, arm);
743
+ c = rotAbout(new THREE.Vector3().crossVectors(v, up).normalize(), lifted).multiply(c);
744
+ }
745
+ w = tCanonInv.clone().multiply(c).multiply(rig.toCanon);
746
+ }
747
+ else {
748
+ w = parentWorld ? parentWorld.clone().multiply(rig.local.get(n)) : rig.bind.rot.get(n).clone();
749
+ }
750
+ world.set(n, w);
751
+ }
752
+ if (f >= 0) {
753
+ if (clearHands)
754
+ handDepth = Math.max(handDepth, clearArms(rig, world));
755
+ const tmp = new THREE.Quaternion();
756
+ for (const b of rig.order) {
757
+ const parentWorld = (b.parent && world.get(b.parent.name)) || rig.topParent.get(b.name) || null;
758
+ const l = parentWorld ? tmp.copy(parentWorld).invert().multiply(world.get(b.name)) : rig.local.get(b.name);
759
+ if (Math.abs(l.dot(rig.local.get(b.name))) < SAME)
760
+ moved.add(b.name);
761
+ l.toArray(qTracks.get(b.name), f * 4);
762
+ }
763
+ }
764
+ const clav = clavicles.map(([cl, arm]) => {
765
+ const v = boneVec(canon(cl, pose), cl, arm);
766
+ return THREE.MathUtils.radToDeg(Math.asin(THREE.MathUtils.clamp(v.y / v.length(), -1, 1)));
767
+ });
768
+ return { pose, flex, lift, clav };
769
+ };
770
+ /** Hips → neck lean of the solved pose, degrees forward of the hips. */
771
+ const trunkLean = () => {
772
+ const v = new THREE.Vector3();
773
+ for (const [b, child] of trunkChain)
774
+ v.add(seg(b, child).clone().applyQuaternion(world.get(b)));
775
+ v.applyQuaternion(rig.toCanon);
776
+ const fwd = new THREE.Vector3().crossVectors(hipAxis(world.get(hips)), up);
777
+ return THREE.MathUtils.radToDeg(Math.atan2(v.dot(fwd), v.y));
778
+ };
779
+ // Neutral lean = median over the frames where the body stands (a pick-up's
780
+ // bend or a sit doesn't count). The trunk turns less than the correction
781
+ // (the lower spine takes a share); the lean is close to linear in it, so
782
+ // one probe frame gives the gain.
783
+ const upright = opts.upright === undefined ? UPRIGHT_DEG : opts.upright;
784
+ const standing = [];
785
+ const bends = [];
786
+ const clavs = clavicles.map(() => []);
787
+ for (let f = 0; f < frames; f++) {
788
+ const { pose, flex, clav } = solve(Math.min(src.duration, f / fps), 0);
789
+ clav.forEach((v, i) => clavs[i].push(v));
790
+ if (hipShare(pose) <= STANDING)
791
+ continue;
792
+ if (trunkChain.length)
793
+ standing.push(trunkLean());
794
+ bends.push(...flex);
795
+ }
796
+ const median = (v) => (v.sort((x, y) => x - y), v.length ? v[Math.floor(v.length / 2)] : NaN);
797
+ const lean0 = median(standing);
798
+ const bend0 = median(bends);
799
+ const isStanding = upright !== null && standing.length >= frames / 2;
800
+ // A clip whose knees bend well past their usual stance (a squat to pick
801
+ // something up) means it; leave those knees alone.
802
+ const crouch = bends.length ? bends[Math.floor(bends.length * 0.95)] - bend0 > 20 : true;
803
+ const knee = isStanding && !crouch && bend0 > KNEE_FLEX_DEG + 4 ? Math.max(0.15, KNEE_FLEX_DEG / bend0) : 1;
804
+ const shoulders = {};
805
+ if (upright !== null)
806
+ clavicles.forEach(([cl], i) => (shoulders[cl] = THREE.MathUtils.clamp(SHOULDER_UP_DEG - median(clavs[i]), 0, SHOULDER_MAX_DEG)));
807
+ let corr = 0;
808
+ if (isStanding && upright - lean0 > 0.25) {
809
+ const probe = (c) => (solve(0, c, knee), trunkLean());
810
+ const guess = upright - lean0;
811
+ const gain = (probe(guess) - probe(0)) / guess;
812
+ if (gain > 1e-3)
813
+ corr = Math.min(MAX_CORRECTION, guess) / gain;
814
+ }
815
+ for (let f = 0; f < frames; f++) {
816
+ const t = Math.min(src.duration, f / fps);
817
+ times[f] = t;
818
+ const { pose, lift } = solve(t, corr, knee, f, shoulders);
819
+ // Hips: the source hips' offset from rest, in canonical space, scaled,
820
+ // raised by what straightening the standing leg added.
821
+ const d = pose.pos.get('Hips').clone().sub(rest.pos.get('Hips')).applyQuaternion(sToCanon).multiplyScalar(scale);
822
+ d.y += lift;
823
+ // Into the hips' parent's frame (bind frame → parent local, with its scale).
824
+ d.applyQuaternion(tCanonInv).applyMatrix3(parentLinear);
825
+ hipsRest.clone().add(d).toArray(hipsPos, f * 3);
826
+ }
827
+ mixer.stopAllAction();
828
+ mixer.uncacheRoot(sRoot);
829
+ for (const [o, p, q, sc] of source.rest) {
830
+ o.position.copy(p);
831
+ o.quaternion.copy(q);
832
+ o.scale.copy(sc);
833
+ }
834
+ // Only bones that ever leave their bind get a track: the rest stay as the
835
+ // scene holds them, and the mixer has fewer bindings to drive.
836
+ const tracks = [];
837
+ for (const [n, values] of qTracks)
838
+ if (moved.has(n))
839
+ tracks.push(new THREE.QuaternionKeyframeTrack(`${n}.quaternion`, times, values));
840
+ tracks.push(new THREE.VectorKeyframeTrack(`${hips}.position`, times, hipsPos));
841
+ for (const t of tracks)
842
+ if (t.values.some((v) => !Number.isFinite(v)))
843
+ throw new Error(`non-finite values on ${t.name}`);
844
+ const clip = new THREE.AnimationClip(name, src.duration, tracks);
845
+ const report = {
846
+ source: source.names.kind,
847
+ neutralLeanDeg: lean0,
848
+ trunkCorrectionDeg: corr && upright !== null ? Math.min(MAX_CORRECTION, upright - lean0) : 0,
849
+ kneeBendDeg: bend0,
850
+ kneeScale: knee,
851
+ shoulderLiftDeg: shoulders,
852
+ handsClearedCm: (handDepth / rig.unitsPerM) * 100,
853
+ };
854
+ clip.userData = { ...clip.userData, retarget: report };
855
+ return clip;
856
+ }