@genex-ai/cli-demo 0.87.0-dev.218 → 0.88.0-dev.219

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.
@@ -0,0 +1,563 @@
1
+ // rigs.js — the genex motion retarget formulas (AG-823, vendored by
2
+ // `genex motion install`): load an avatar and retarget ARDY CoreSkeleton27
3
+ // locals onto it. THIS FILE IS YOURS — the formulas are meant to be read and
4
+ // tuned in-place; the shipped versions were verified to 0.0-0.3° (VRM) /
5
+ // 0.0-0.4° (Meshy/Mixamo GLB) bone-direction error. Two transport formulas:
6
+ //
7
+ // VRM (three-vrm normalized bones) — normalized bones rest at IDENTITY world
8
+ // orientation, but after rotateVRM0 the whole rig lives in a frame yawed 180°
9
+ // about Y. Transport is frame CONJUGATION per local: q' = M·L·M⁻¹ (M = Y180 —
10
+ // negate quat x,z), root local = G·M, plus the rest-correction sandwich
11
+ // C0(parent)⁻¹ · q' · C0(bone). Unmapped ARDY joints (Spine3, hand ends) fold
12
+ // into the nearest mapped descendant by composing locals down the chain.
13
+ //
14
+ // Mixamo (real GLB skeletons — Meshy/Mixamo characters) — joints rest at
15
+ // arbitrary NON-identity orientations (bones point along +Y of their own local
16
+ // frame) and the hierarchy can differ from ARDY's (3-bone spine, no hand ends),
17
+ // so locals can't transport bone-by-bone. Transport the WORLD rotation instead:
18
+ // ARDY rest orientations are identity, so the source FK world rotation D(j) IS
19
+ // the delta from rest. Target bone world goal: W(b) = D(j) · A(b) · Wrest(b),
20
+ // where A(b) is the constant rest alignment (avatar bind bone direction →
21
+ // ARDY rest bone direction, minimal arc — this is what absorbs an A-pose bind);
22
+ // locals recover top-down: L(b) = W(parentBone)⁻¹ · W(b). Unmapped source
23
+ // joints fold in automatically through the FK product, and unmapped TARGET
24
+ // nodes between bones keep their bind locals (relRest). Frame transport is
25
+ // identity — ARDY and glTF characters both face +Z with left = +X.
26
+ //
27
+ // Verification stays non-circular for both: pages compare scene-graph bone
28
+ // directions (geoNode) against the npz posed_joints via rig.dirErrors().
29
+
30
+ import * as THREE from "three";
31
+ import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
32
+ import { VRMLoaderPlugin, VRMUtils } from "@pixiv/three-vrm";
33
+
34
+ const CORE_TO_VRM = {
35
+ Hips: "hips", Spine: "spine", Spine1: "chest", Spine2: "upperChest",
36
+ Neck: "neck", Head: "head",
37
+ RightShoulder: "rightShoulder", RightArm: "rightUpperArm", RightForeArm: "rightLowerArm", RightHand: "rightHand",
38
+ LeftShoulder: "leftShoulder", LeftArm: "leftUpperArm", LeftForeArm: "leftLowerArm", LeftHand: "leftHand",
39
+ RightUpLeg: "rightUpperLeg", RightLeg: "rightLowerLeg", RightFoot: "rightFoot", RightToeBase: "rightToes",
40
+ LeftUpLeg: "leftUpperLeg", LeftLeg: "leftLowerLeg", LeftFoot: "leftFoot", LeftToeBase: "leftToes",
41
+ };
42
+
43
+ // full-resolution primary chain in ARDY joint names; rest correction and metric
44
+ // segments resolve through unmapped joints by walking this until a mapped one
45
+ const PRIMARY_NEXT = {
46
+ Hips: null,
47
+ Spine: "Spine1", Spine1: "Spine2", Spine2: "Spine3", Spine3: "Neck",
48
+ Neck: "Head", Head: null,
49
+ RightShoulder: "RightArm", RightArm: "RightForeArm", RightForeArm: "RightHand",
50
+ RightHand: "RightHandEnd", RightHandEnd: null, RightHandThumb1: null,
51
+ LeftShoulder: "LeftArm", LeftArm: "LeftForeArm", LeftForeArm: "LeftHand",
52
+ LeftHand: "LeftHandEnd", LeftHandEnd: null, LeftHandThumb1: null,
53
+ RightUpLeg: "RightLeg", RightLeg: "RightFoot", RightFoot: "RightToeBase", RightToeBase: null,
54
+ LeftUpLeg: "LeftLeg", LeftLeg: "LeftFoot", LeftFoot: "LeftToeBase", LeftToeBase: null,
55
+ };
56
+
57
+ // Mixamo/Meshy bone-name aliases (normalized: lowercase, mixamorig prefix and
58
+ // non-alphanumerics stripped). Spine bones are matched STRUCTURALLY, never by
59
+ // name — Meshy names its lowest spine bone "Spine02" and its highest "Spine".
60
+ const MIXAMO_ALIASES = {
61
+ Hips: ["hips", "pelvis"],
62
+ Neck: ["neck", "neck01", "neck1"],
63
+ Head: ["head"],
64
+ LeftShoulder: ["leftshoulder", "leftclavicle"], RightShoulder: ["rightshoulder", "rightclavicle"],
65
+ LeftArm: ["leftarm", "leftupperarm"], RightArm: ["rightarm", "rightupperarm"],
66
+ LeftForeArm: ["leftforearm", "leftlowerarm"], RightForeArm: ["rightforearm", "rightlowerarm"],
67
+ LeftHand: ["lefthand"], RightHand: ["righthand"],
68
+ LeftUpLeg: ["leftupleg", "leftupperleg", "leftthigh"], RightUpLeg: ["rightupleg", "rightupperleg", "rightthigh"],
69
+ LeftLeg: ["leftleg", "leftlowerleg", "leftcalf", "leftshin"], RightLeg: ["rightleg", "rightlowerleg", "rightcalf", "rightshin"],
70
+ LeftFoot: ["leftfoot"], RightFoot: ["rightfoot"],
71
+ LeftToeBase: ["lefttoebase", "lefttoe", "lefttoes"], RightToeBase: ["righttoebase", "righttoe", "righttoes"],
72
+ };
73
+ const SPINE_SLOTS = ["Spine", "Spine1", "Spine2", "Spine3"];
74
+ const normName = s => s.toLowerCase().replace(/^mixamorig\d*[:_]?/, "").replace(/[^a-z0-9]/g, "");
75
+
76
+ class RigBase {
77
+ constructor(gltf, base, kind) {
78
+ this.gltf = gltf;
79
+ this.gltfScene = gltf.scene;
80
+ this.base = base; // { joints, parents, restPositions, hipsRestY }
81
+ this.kind = kind;
82
+ this.missing = [];
83
+ this._qT = new THREE.Quaternion();
84
+ this._qA = new THREE.Quaternion();
85
+ this._q = new THREE.Quaternion();
86
+ this._v = new THREE.Vector3();
87
+ this.gltfScene.traverse(o => { if (o.isSkinnedMesh) o.frustumCulled = false; });
88
+ }
89
+
90
+ // subclass fills nodeOf[] first; derives the shared fold/segment structure
91
+ finishSetup() {
92
+ const { joints, parents } = this.base;
93
+ const J = joints.length;
94
+ this.mapped = [];
95
+ for (let j = 0; j < J; j++) if (this.animNode(j)) this.mapped.push(j);
96
+ this.mappedAncestor = {}; this.chainFor = {};
97
+ for (const j of this.mapped) {
98
+ const seg = [j];
99
+ let p = parents[j];
100
+ while (p !== -1 && !this.animNode(p)) { seg.unshift(p); p = parents[p]; }
101
+ this.chainFor[j] = seg;
102
+ this.mappedAncestor[j] = p; // -1 for hips
103
+ }
104
+ this.primaryOf = {}; // j -> first MAPPED joint down the primary chain, or -1
105
+ for (const j of this.mapped) {
106
+ let n = PRIMARY_NEXT[joints[j]], res = -1;
107
+ while (n) {
108
+ const idx = joints.indexOf(n);
109
+ if (idx >= 0 && this.animNode(idx)) { res = idx; break; }
110
+ n = PRIMARY_NEXT[n];
111
+ }
112
+ this.primaryOf[j] = res;
113
+ }
114
+ // metric segments between mapped joints; "primary" = the parent bone's own
115
+ // direction (controllable); the rest are attachment offsets = body shape
116
+ this.segs = []; this.segPrimary = [];
117
+ for (const j of this.mapped) {
118
+ const a = this.mappedAncestor[j];
119
+ if (a < 0) continue;
120
+ this.segs.push([a, j]);
121
+ this.segPrimary.push(this.primaryOf[a] === j);
122
+ }
123
+ }
124
+
125
+ // drive the real pipeline with identity locals (corr off); whatever pose the
126
+ // scene lands in is the avatar's effective rest — align each bone direction
127
+ // onto ARDY's rest direction. Leaves the probe pose applied so callers can
128
+ // measure rest-pose deltas right after.
129
+ computeCorrection() {
130
+ this.applyFrame((k, out) => out.identity(), false);
131
+ this.setHipsFromArdy(0, this.base.hipsRestY, 0);
132
+ this.update(0);
133
+ this.scene.updateMatrixWorld(true);
134
+ const probe = {};
135
+ for (const j of this.mapped) probe[j] = this.geoNode(j).getWorldPosition(new THREE.Vector3());
136
+ const { joints, restPositions: rp } = this.base;
137
+ const dV = new THREE.Vector3(), dA = new THREE.Vector3();
138
+ this.corr = {};
139
+ const table = [];
140
+ for (const j of this.mapped) { // ascending = parents before children
141
+ if (joints[j] === "Hips") { this.corr[j] = new THREE.Quaternion(); continue; }
142
+ const cj = this.primaryOf[j];
143
+ if (cj < 0) { // leaf: inherit the parent's twist frame
144
+ this.corr[j] = (this.corr[this.mappedAncestor[j]] ?? new THREE.Quaternion()).clone();
145
+ continue;
146
+ }
147
+ dV.copy(probe[cj]).sub(probe[j]).normalize();
148
+ this.mapDir(rp[cj][0] - rp[j][0], rp[cj][1] - rp[j][1], rp[cj][2] - rp[j][2], dA);
149
+ this.corr[j] = new THREE.Quaternion().setFromUnitVectors(dV, dA);
150
+ table.push([`${joints[j]}→${joints[cj]}`, +(dV.angleTo(dA) * 180 / Math.PI).toFixed(1)]);
151
+ }
152
+ return table.sort((a, b) => b[1] - a[1]);
153
+ }
154
+
155
+ applyFrame(readLocal, corr) {
156
+ this.retargetFrame(readLocal, corr, (j, q) => this.animNode(j).quaternion.copy(q));
157
+ }
158
+
159
+ // one THREE.AnimationClip from an ARDY clip { fps, localQuat[F][27][4], rootPos[F][3] }
160
+ buildClip(c, name, corr) {
161
+ const F = c.localQuat.length;
162
+ const times = new Float32Array(F).map((_, i) => i / c.fps);
163
+ const vals = {};
164
+ for (const j of this.mapped) vals[j] = new Float32Array(F * 4);
165
+ const pos = new Float32Array(F * 3);
166
+ for (let f = 0; f < F; f++) {
167
+ this.retargetFrame((k, out) => out.fromArray(c.localQuat[f][k]), corr,
168
+ (j, q) => q.toArray(vals[j], f * 4));
169
+ const r = c.rootPos[f];
170
+ this.hipsLocalFromArdy(r[0], r[1], r[2], this._v);
171
+ pos[f * 3] = this._v.x; pos[f * 3 + 1] = this._v.y; pos[f * 3 + 2] = this._v.z;
172
+ }
173
+ const tracks = [];
174
+ for (const j of this.mapped)
175
+ tracks.push(new THREE.QuaternionKeyframeTrack(`${this.animNode(j).name}.quaternion`, times, vals[j]));
176
+ tracks.push(new THREE.VectorKeyframeTrack(`${this.animNode(0).name}.position`, times, pos));
177
+ return new THREE.AnimationClip(`ardy:${name}`, F / c.fps, tracks);
178
+ }
179
+
180
+ // THE honest metric: scene-graph bone directions vs ARDY posed_joints (one frame)
181
+ dirErrors(positionsFrame) {
182
+ const va = new THREE.Vector3(), vb = new THREE.Vector3(), tb = new THREE.Vector3();
183
+ const { joints } = this.base;
184
+ const rows = [];
185
+ for (let i = 0; i < this.segs.length; i++) {
186
+ const [a, b] = this.segs[i];
187
+ this.geoNode(a).getWorldPosition(va);
188
+ this.geoNode(b).getWorldPosition(vb);
189
+ vb.sub(va).normalize();
190
+ const pa = positionsFrame[a], pb = positionsFrame[b];
191
+ this.mapDir(pb[0] - pa[0], pb[1] - pa[1], pb[2] - pa[2], tb);
192
+ rows.push([`${joints[a]}→${joints[b]}`, vb.angleTo(tb) * 180 / Math.PI, this.segPrimary[i]]);
193
+ }
194
+ return rows;
195
+ }
196
+ }
197
+
198
+ // ---------------------------------------------------------------- VRM adapter
199
+ const Y180 = new THREE.Quaternion(0, 1, 0, 0);
200
+ const conjY180 = q => { q.x = -q.x; q.z = -q.z; return q; };
201
+
202
+ class VrmRig extends RigBase {
203
+ constructor(gltf, vrm, base) {
204
+ super(gltf, base, "vrm");
205
+ this.vrm = vrm;
206
+ VRMUtils.rotateVRM0(vrm); // no-op for VRM 1.x models
207
+ const { joints } = base;
208
+ this._anim = {}; this._geo = {};
209
+ for (let j = 0; j < joints.length; j++) {
210
+ const bn = CORE_TO_VRM[joints[j]];
211
+ if (!bn) continue; // hand ends / Spine3: no VRM slot by design
212
+ const n = vrm.humanoid.getNormalizedBoneNode(bn);
213
+ if (!n) { this.missing.push(`${joints[j]}→${bn}`); continue; }
214
+ this._anim[j] = n;
215
+ this._geo[j] = vrm.humanoid.getRawBoneNode(bn);
216
+ }
217
+ this.finishSetup();
218
+ this.hipsNode = this._anim[0];
219
+ this.hipsRestLocal = this.hipsNode.position.clone();
220
+ }
221
+
222
+ readRest() { // after scene add: rest world hip height fixes the scale ratio
223
+ const w = this.hipsNode.getWorldPosition(new THREE.Vector3());
224
+ this.hipRatio = w.y / this.base.hipsRestY;
225
+ }
226
+
227
+ animNode(j) { return this._anim[j] ?? null; }
228
+ geoNode(j) { return this._geo[j] ?? null; }
229
+ update(dt) { this.vrm.update(dt); }
230
+
231
+ // scene frame = ARDY frame yawed 180° about Y (mirror x,z)
232
+ mapPoint(p, out) {
233
+ return out.set(-p[0] * this.hipRatio - this.hipsRestLocal.x,
234
+ this.hipsRestLocal.y + (p[1] - this.base.hipsRestY) * this.hipRatio,
235
+ -p[2] * this.hipRatio - this.hipsRestLocal.z);
236
+ }
237
+ mapDir(dx, dy, dz, out) { return out.set(-dx, dy, -dz).normalize(); }
238
+
239
+ // hips local = M⁻¹·world_target with the rig root carrying M — so unmirrored
240
+ hipsLocalFromArdy(x, y, z, out) {
241
+ return out.set(this.hipsRestLocal.x + x * this.hipRatio,
242
+ this.hipsRestLocal.y + (y - this.base.hipsRestY) * this.hipRatio,
243
+ this.hipsRestLocal.z + z * this.hipRatio);
244
+ }
245
+ setHipsFromArdy(x, y, z) { this.hipsNode.position.copy(this.hipsLocalFromArdy(x, y, z, this._v)); }
246
+
247
+ retargetFrame(readLocal, corr, emit) {
248
+ for (const j of this.mapped) {
249
+ const q = this._q.identity();
250
+ for (const k of this.chainFor[j]) q.multiply(readLocal(k, this._qT));
251
+ if (this.mappedAncestor[j] === -1) {
252
+ q.multiply(Y180); // root: q = G·M (rig root carries M)
253
+ } else {
254
+ conjY180(q); // transport the local into the scene frame
255
+ if (corr) {
256
+ q.premultiply(this._qA.copy(this.corr[this.mappedAncestor[j]]).invert());
257
+ q.multiply(this.corr[j]);
258
+ }
259
+ }
260
+ emit(j, q);
261
+ }
262
+ }
263
+ }
264
+
265
+ // ------------------------------------------------------------- Mixamo adapter
266
+ class MixamoRig extends RigBase {
267
+ constructor(gltf, base) {
268
+ super(gltf, base, "mixamo");
269
+ const { joints } = base;
270
+ const J = joints.length;
271
+
272
+ // scan the skeleton by normalized names (spines matched structurally below)
273
+ const byNorm = {};
274
+ this.gltfScene.traverse(o => { if (o.name) byNorm[normName(o.name)] ??= o; });
275
+ const bones = {}; // ARDY joint name -> node
276
+ for (const [core, aliases] of Object.entries(MIXAMO_ALIASES)) {
277
+ for (const a of aliases) if (byNorm[a]) { bones[core] = byNorm[a]; break; }
278
+ }
279
+ if (!bones.Hips || !bones.LeftUpLeg || !bones.RightUpLeg)
280
+ throw new Error(`mixamo rig scan failed: hips/legs not found (have: ${Object.keys(bones).join(",")})`);
281
+
282
+ // spine chain = nodes strictly between neck (or head) and hips, hips-adjacent
283
+ // first; ends-anchored assignment onto ARDY's four spine slots
284
+ const top = bones.Neck ?? bones.Head;
285
+ const chain = [];
286
+ for (let p = top?.parent, i = 0; p && p !== bones.Hips && i < 10; p = p.parent, i++) chain.unshift(p);
287
+ if (top && chain.length && chain[0].parent === bones.Hips) {
288
+ const n = chain.length;
289
+ chain.forEach((node, i) => {
290
+ const slot = SPINE_SLOTS[n === 1 ? 3 : Math.round(i * (SPINE_SLOTS.length - 1) / (n - 1))];
291
+ bones[slot] ??= node;
292
+ });
293
+ }
294
+
295
+ this._anim = {};
296
+ for (let j = 0; j < J; j++) {
297
+ if (bones[joints[j]]) this._anim[j] = bones[joints[j]];
298
+ else if (MIXAMO_ALIASES[joints[j]]) this.missing.push(joints[j]); // spines/hand-ends: expected to fold
299
+ }
300
+ this.finishSetup();
301
+ this.hipsNode = this._anim[0];
302
+ }
303
+
304
+ readRest() { // scene-added + updated: the default pose IS the bind pose (asserted offline)
305
+ const J = this.base.joints.length;
306
+ this.restWorld = {}; this.parentBone = {}; this.relRest = {}; this.parentStatic = {};
307
+ const isBoneNode = new Set(this.mapped.map(j => this._anim[j]));
308
+ for (const j of this.mapped) {
309
+ const node = this._anim[j];
310
+ this.restWorld[j] = node.getWorldQuaternion(new THREE.Quaternion());
311
+ // nearest mapped ancestor on the TARGET side + the static bind rotation of
312
+ // any unmapped nodes in between
313
+ const stack = [];
314
+ let p = node.parent;
315
+ while (p && !isBoneNode.has(p)) { stack.unshift(p); p = p.parent; }
316
+ if (p) {
317
+ this.parentBone[j] = this.mapped.find(k => this._anim[k] === p);
318
+ const rel = new THREE.Quaternion();
319
+ for (const s of stack) rel.multiply(s.quaternion);
320
+ this.relRest[j] = rel;
321
+ if (this.parentBone[j] >= j) console.warn(`mixamo rig: ${this.base.joints[j]} parent order anomaly`);
322
+ } else { // hips: everything above is static scenery (Armature etc.)
323
+ this.parentBone[j] = -1;
324
+ this.parentStatic[j] = node.parent.getWorldQuaternion(new THREE.Quaternion());
325
+ }
326
+ }
327
+ this.hipsRestWorld = this.hipsNode.getWorldPosition(new THREE.Vector3());
328
+ this.hipRatio = this.hipsRestWorld.y / this.base.hipsRestY;
329
+ this._hipsParentInv = this.hipsNode.parent.matrixWorld.clone().invert(); // static
330
+ this._D = Array.from({ length: J }, () => new THREE.Quaternion());
331
+ this._W = {};
332
+ for (const j of this.mapped) this._W[j] = new THREE.Quaternion();
333
+ this._qP = new THREE.Quaternion();
334
+ }
335
+
336
+ animNode(j) { return this._anim[j] ?? null; }
337
+ geoNode(j) { return this._anim[j] ?? null; }
338
+ update() {}
339
+
340
+ // scene frame == ARDY frame (both face +Z, left = +X), anchored at the bind hips
341
+ mapPoint(p, out) {
342
+ return out.set(this.hipsRestWorld.x + p[0] * this.hipRatio,
343
+ this.hipsRestWorld.y + (p[1] - this.base.hipsRestY) * this.hipRatio,
344
+ this.hipsRestWorld.z + p[2] * this.hipRatio);
345
+ }
346
+ mapDir(dx, dy, dz, out) { return out.set(dx, dy, dz).normalize(); }
347
+
348
+ hipsLocalFromArdy(x, y, z, out) {
349
+ this.mapPoint([x, y, z], out);
350
+ return out.applyMatrix4(this._hipsParentInv); // world → armature-local (cm)
351
+ }
352
+ setHipsFromArdy(x, y, z) { this.hipsNode.position.copy(this.hipsLocalFromArdy(x, y, z, this._v)); }
353
+
354
+ retargetFrame(readLocal, corr, emit) {
355
+ const { parents } = this.base;
356
+ const J = parents.length;
357
+ for (let k = 0; k < J; k++) { // source FK: D = world rotation = delta from rest
358
+ const p = parents[k];
359
+ if (p === -1) this._D[k].copy(readLocal(k, this._qT));
360
+ else this._D[k].copy(this._D[p]).multiply(readLocal(k, this._qT));
361
+ }
362
+ for (const j of this.mapped) { // ascending: parents emitted before children
363
+ const W = this._W[j].copy(this._D[j]);
364
+ if (corr) W.multiply(this.corr[j]);
365
+ W.multiply(this.restWorld[j]);
366
+ const pb = this.parentBone[j];
367
+ if (pb === -1) this._qP.copy(this.parentStatic[j]);
368
+ else this._qP.copy(this._W[pb]).multiply(this.relRest[j]);
369
+ emit(j, this._qP.invert().multiply(W));
370
+ }
371
+ }
372
+ }
373
+
374
+ // -------------------------------------------------------------------- loader
375
+ export async function loadRig(url, base, scene) {
376
+ if (base.joints[0] !== "Hips") throw new Error("ARDY joint 0 must be Hips");
377
+ const loader = new GLTFLoader();
378
+ loader.register(p => new VRMLoaderPlugin(p));
379
+ const gltf = await loader.loadAsync(url);
380
+ const rig = gltf.userData.vrm ? new VrmRig(gltf, gltf.userData.vrm, base)
381
+ : new MixamoRig(gltf, base);
382
+ scene.add(gltf.scene);
383
+ scene.updateMatrixWorld(true);
384
+ rig.scene = scene;
385
+ rig.readRest();
386
+ return rig;
387
+ }
388
+
389
+ // ===========================================================================
390
+ // LOAD-TIME PER-RIG NORMALIZATIONS (AG-823 §7.3 — the walker.glb session).
391
+ // All four are rigid frame-convention fixes in the allowed op class: constant
392
+ // per rig+set, zero joint-vs-joint edits. Call order matters:
393
+ //
394
+ // const rig = await loadRig(url, set, scene);
395
+ // rig.computeCorrection();
396
+ // const restAnkle = captureRestAnkle(rig, scene); // BEFORE any clip
397
+ // reanchorFeet(rig, set, scene); // BEFORE ClipSet
398
+ // const clipSet = new ClipSet(rig, set, set.gaits);
399
+ // groundCalibrate(rig, clipSet, scene, restAnkle); // AFTER ClipSet
400
+ // curlFingers(rig, scene); // once, at load
401
+ // ===========================================================================
402
+
403
+ /**
404
+ * REST-pose ankle baseline, captured in BIND pose before any clip plays.
405
+ * Settling into the current idle instead would let a floating idle calibrate
406
+ * its own defect away (the marine lesson). `rootY` = the character root's
407
+ * world Y at capture time (0 for a root still at the origin).
408
+ */
409
+ export function captureRestAnkle(rig, scene, rootY = 0) {
410
+ scene.updateMatrixWorld(true);
411
+ const f = new THREE.Vector3(), g = new THREE.Vector3();
412
+ const jr = rig.base.joints.indexOf("RightFoot"), jl = rig.base.joints.indexOf("LeftFoot");
413
+ rig.geoNode(jr).getWorldPosition(f);
414
+ rig.geoNode(jl).getWorldPosition(g);
415
+ return Math.min(f.y, g.y) - rootY;
416
+ }
417
+
418
+ /**
419
+ * FOOT-PITCH re-anchor + rigid-boot toe (mixamo rigs only). corr aligns the
420
+ * foot's bind direction onto ARDY's rest direction, so a planted foot plays
421
+ * at ARDY's foot pitch (27°); a steep-boot rig (walker: 42°, ankle 15 cm
422
+ * above the sole vs ARDY's 6.7 cm) then carries its front sole in the air.
423
+ * For FEET the rig's bind pitch IS ground truth (the sole is authored flat),
424
+ * so fold the constant pitch difference back into the corr slot:
425
+ * corr' = D⁻¹·R·D·corr, measured on the set's idle frame 0 (pitch only,
426
+ * heading stays ARDY's). Then lock the toe bone to the foot at its bind
427
+ * offset (corr[toe] = Dt⁻¹·D·corr[foot]) — a boot doesn't bend at the ball,
428
+ * and ARDY's toe-flex delta would tip the mesh under the floor.
429
+ */
430
+ export function reanchorFeet(rig, set, scene) {
431
+ const idle = (set.gaits ?? set.clips)?.idle;
432
+ if (rig.kind !== "mixamo" || !idle) return;
433
+ const { joints, parents } = rig.base;
434
+ const upV = new THREE.Vector3(0, 1, 0);
435
+ for (const [fN, tN] of [["LeftFoot", "LeftToeBase"], ["RightFoot", "RightToeBase"]]) {
436
+ const jf = joints.indexOf(fN), jt = joints.indexOf(tN);
437
+ const fNode = jf >= 0 ? rig.geoNode(jf) : null, tNode = jt >= 0 ? rig.geoNode(jt) : null;
438
+ if (!fNode || !tNode || tNode.parent !== fNode || !rig.restWorld?.[jf]) continue;
439
+ // bind ankle→toe direction = bind world rotation · toe's local offset
440
+ const bindDir = tNode.position.clone().applyQuaternion(rig.restWorld[jf]).normalize();
441
+ // retarget idle frame 0 with the current corr and measure the live direction
442
+ rig.applyFrame((k, out) => out.fromArray(idle.localQuat[0][k]), true);
443
+ rig.setHipsFromArdy(idle.hipsPos[0][0], idle.hipsPos[0][1], idle.hipsPos[0][2]);
444
+ rig.update(0);
445
+ scene.updateMatrixWorld(true);
446
+ const pa = fNode.getWorldPosition(new THREE.Vector3());
447
+ const cur = tNode.getWorldPosition(new THREE.Vector3()).sub(pa).normalize();
448
+ // target: keep the take's heading, restore the bind pitch
449
+ const bindPitch = Math.asin(THREE.MathUtils.clamp(bindDir.y, -1, 1));
450
+ const h = cur.clone().addScaledVector(upV, -cur.y).normalize();
451
+ const target = h.multiplyScalar(Math.cos(bindPitch)).addScaledVector(upV, Math.sin(bindPitch));
452
+ const theta = cur.angleTo(target);
453
+ if (theta < 0.05) continue; // <3°: rig and ARDY already agree
454
+ const R = new THREE.Quaternion().setFromUnitVectors(cur, target);
455
+ const D = new THREE.Quaternion(), qk = new THREE.Quaternion();
456
+ const chain = [];
457
+ for (let p = jf; p !== -1; p = parents[p]) chain.unshift(p);
458
+ for (const k of chain) D.multiply(qk.fromArray(idle.localQuat[0][k]));
459
+ rig.corr[jf].premultiply(new THREE.Quaternion().copy(D).invert().multiply(R).multiply(D));
460
+ const Dt = new THREE.Quaternion();
461
+ const chainT = [];
462
+ for (let p = jt; p !== -1; p = parents[p]) chainT.unshift(p);
463
+ for (const k of chainT) Dt.multiply(qk.fromArray(idle.localQuat[0][k]));
464
+ rig.corr[jt] = new THREE.Quaternion().copy(Dt).invert().multiply(D).multiply(rig.corr[jf]);
465
+ console.log(`[motion] ${fN}: pitch re-anchored to bind by ${(theta * 180 / Math.PI).toFixed(1)}° + rigid-boot toe`);
466
+ }
467
+ }
468
+
469
+ /**
470
+ * Per-rig GROUND CALIBRATION: hips Y is ARDY-absolute scaled by hipRatio,
471
+ * which assumes the rig shares ARDY's hips:leg proportions. A rig with
472
+ * proportionally shorter legs (walker.glb) hangs its feet above the floor on
473
+ * EVERY clip. Rigid fix: play the idle through the real retarget path,
474
+ * measure its lowest ankle vs the BIND-pose ankle (captureRestAnkle), and
475
+ * shift every track's hips Y by the difference — one constant per rig+set,
476
+ * no-op (<4 mm) on well-proportioned rigs. Call AFTER ClipSet construction.
477
+ */
478
+ export function groundCalibrate(rig, clipSet, scene, restAnkle, rootY = 0) {
479
+ const joints = rig.base.joints;
480
+ const LFOOT = joints.indexOf("LeftFoot"), RFOOT = joints.indexOf("RightFoot");
481
+ const ref = clipSet.tracks.idle ?? clipSet.tracks.stance ?? Object.values(clipSet.tracks)[0];
482
+ if (!ref || LFOOT < 0 || RFOOT < 0) return;
483
+ const v = new THREE.Vector3();
484
+ let minY = Infinity;
485
+ for (let f = 0; f < ref.F; f++) {
486
+ for (const j of rig.mapped) rig.animNode(j).quaternion.fromArray(ref.quat[j], f * 4);
487
+ rig.setHipsFromArdy(ref.hips[f * 3], ref.hips[f * 3 + 1], ref.hips[f * 3 + 2]);
488
+ rig.update(0);
489
+ scene.updateMatrixWorld(true);
490
+ for (const jf of [LFOOT, RFOOT]) {
491
+ rig.geoNode(jf).getWorldPosition(v);
492
+ minY = Math.min(minY, v.y - rootY);
493
+ }
494
+ }
495
+ const delta = minY - restAnkle;
496
+ if (Math.abs(delta) > 0.004) {
497
+ const dArdy = delta / rig.hipRatio;
498
+ for (const t of Object.values(clipSet.tracks))
499
+ for (let f = 0; f < t.F; f++) t.hips[f * 3 + 1] -= dArdy;
500
+ console.log(`[motion] ground-cal: idle ankle ${(delta * 100).toFixed(1)} cm off bind ankle — hips shifted for this rig`);
501
+ }
502
+ }
503
+
504
+ /**
505
+ * GRIP CURL: ARDY's skeleton has no fingers, so retargeted rigs keep their
506
+ * REST hands — splayed-open fingers "holding" a weapon. Curl whatever finger
507
+ * bones the rig actually has, once at load (nothing animates fingers, so it
508
+ * sticks). The curl axis is the knuckle line; its SIGN is probed per hand
509
+ * (closing a finger brings its tip toward the wrist — rig conventions vary).
510
+ * Mitten/finger-less hands (walker.glb) are left alone: prefer weapon-carry
511
+ * looks that read well with flat hands on such rigs.
512
+ */
513
+ export function curlFingers(rig, scene) {
514
+ const joints = rig.base.joints;
515
+ const RHAND = joints.indexOf("RightHand"), LHAND = joints.indexOf("LeftHand");
516
+ const _a = new THREE.Vector3(), _b = new THREE.Vector3(), _hp = new THREE.Vector3();
517
+ const _t0 = new THREE.Vector3(), _cw = new THREE.Quaternion();
518
+ const _cp2 = new THREE.Quaternion(), _cx = new THREE.Quaternion();
519
+ const tipOf = (b) => { let n = b; while (n.children.length) n = n.children[0]; return n; };
520
+ const rotWorld = (bone, axis, ang) => {
521
+ _cx.setFromAxisAngle(axis, ang);
522
+ bone.getWorldQuaternion(_cw).premultiply(_cx);
523
+ bone.parent.getWorldQuaternion(_cp2).invert();
524
+ bone.quaternion.copy(_cp2.multiply(_cw));
525
+ bone.updateMatrixWorld(true);
526
+ };
527
+ for (const handJ of [RHAND, LHAND]) {
528
+ const hand = rig.animNode(handJ);
529
+ if (!hand) continue;
530
+ let roots = hand.children.filter((c) => c.isBone);
531
+ if (!roots.length) roots = [...hand.children];
532
+ roots = roots.filter((c) => !/thumb/i.test(c.name));
533
+ if (roots.length < 2) continue; // mitten hand
534
+ roots[0].getWorldPosition(_a);
535
+ roots[roots.length - 1].getWorldPosition(_b);
536
+ const axis = _b.clone().sub(_a);
537
+ if (axis.lengthSq() < 1e-8) continue;
538
+ axis.normalize();
539
+ const chain = roots[Math.floor(roots.length / 2)];
540
+ const tip = tipOf(chain);
541
+ hand.getWorldPosition(_hp);
542
+ const saved = chain.quaternion.clone();
543
+ let sign = 1, bestD = Infinity;
544
+ for (const s of [1, -1]) {
545
+ rotWorld(chain, axis, s * 0.9);
546
+ tip.getWorldPosition(_t0);
547
+ const dd = _t0.distanceTo(_hp);
548
+ if (dd < bestD) { bestD = dd; sign = s; }
549
+ chain.quaternion.copy(saved);
550
+ chain.updateMatrixWorld(true);
551
+ }
552
+ axis.multiplyScalar(sign);
553
+ const curl = (bone, depth) => {
554
+ if (depth >= 1 && !/thumb/i.test(bone.name)) {
555
+ rotWorld(bone, axis, [0, 0.5, 0.7, 0.8][Math.min(depth, 3)]);
556
+ }
557
+ for (const c of [...bone.children]) curl(c, depth + 1);
558
+ };
559
+ curl(hand, 0);
560
+ }
561
+ rig.update(0);
562
+ scene.updateMatrixWorld(true);
563
+ }