// Überträgt die Mocap-Clips aus MotionLibrary_Weapons.zip (Xsens MVN, 24 Knochen, cm) auf das Skelett des // Mannequins aus public/models/ual.glb (docs/superpowers/specs/2026-09-17-animationen-design.md, "Umbau der // Animationen"): // // node tools/assets/motions.mjs -> public/models/motions/weapons-mocap.glb // // Das Zip bleibt außerhalb des Repos. Die Rechenschritte sind reine Funktionen (tests/motions.test.js), main() // liest nur Dateien und schreibt das Ergebnis. import { Matrix4, Quaternion, Vector3 } from "three"; /** * MVN-Knochen -> Mannequin-Knochen, zugeordnet nach gemessener Höhe in der Ruhepose. Spine2 (1,187 m) hat kein * Gegenstück: Die Übertragung rechnet mit Weltdrehungen, seine Drehung steckt deshalb in der von spine_03. */ export const BONE_MAP = { Hips: "pelvis", Spine: "spine_01", Spine1: "spine_02", Spine3: "spine_03", Neck: "neck_01", Head: "Head", LeftShoulder: "clavicle_l", LeftArm: "upperarm_l", LeftForeArm: "lowerarm_l", LeftHand: "hand_l", RightShoulder: "clavicle_r", RightArm: "upperarm_r", RightForeArm: "lowerarm_r", RightHand: "hand_r", LeftUpLeg: "thigh_l", LeftLeg: "calf_l", LeftFoot: "foot_l", LeftToeBase: "ball_l", RightUpLeg: "thigh_r", RightLeg: "calf_r", RightFoot: "foot_r", RightToeBase: "ball_r", }; export const SOURCE_HIPS = "Hips"; /** Clip-Name aus dem Dateinamen: 20210715_s231_idle_loadGun-Hand_tk01_ERJA-mvn359.fbx -> idle_loadGun-Hand_tk01. */ export function clipId(fileName) { const match = /^\d{8}_s\d+_(.+)_ERJA-mvn\d+\.fbx$/.exec(fileName); if (!match) throw new Error(`Unbekannter Dateiname: ${fileName}`); return match[1]; } /** * Ruhepose eines Zielskeletts in Weltkoordinaten. rig ist eine Liste in Eltern-vor-Kind-Reihenfolge: * { name, parent, position: [x, y, z], quaternion: [x, y, z, w], scale: [x, y, z] }. Liefert je Knochen die * Weltmatrix und die Weltdrehung (Drehungen verkettet, das Mannequin hat überall Maßstab 1). */ export function rigWorld(rig) { const world = new Map(); for (const bone of rig) { const parent = bone.parent === null ? null : world.get(bone.parent); if (bone.parent !== null && !parent) throw new Error(`Rig: ${bone.parent} steht nicht vor ${bone.name}`); const rotation = new Quaternion().fromArray(bone.quaternion); const local = new Matrix4().compose( new Vector3().fromArray(bone.position), rotation, new Vector3().fromArray(bone.scale ?? [1, 1, 1]), ); world.set(bone.name, { matrix: parent ? parent.matrix.clone().multiply(local) : local, quaternion: parent ? parent.quaternion.clone().multiply(rotation) : rotation.clone(), }); } return world; } /** * Baut die Übertragung eines MVN-Bilds auf das Zielskelett. Ein Bild ist { rotations: Map, hips: Weltposition der Hüfte in cm }. Beide Skelette ruhen in derselben T-Pose mit Blick nach +Z, * und jede MVN-Ruhe-Weltdrehung ist 0°. Deshalb gilt je zugeordnetem Knochen: * * Weltdrehung Ziel = Weltdrehung MVN · Ruhe-Weltdrehung Ziel, lokal = (Weltdrehung Eltern)⁻¹ · Weltdrehung * * Knochen ohne Zuordnung (root, Finger, Blattknochen) gehen mit ihrer Ruhedrehung in die Kette ein und bekommen * keine Spur. Die Hüfte: cm -> m, mal hipsScale (Beinlänge Ziel ÷ Beinlänge Quelle), dann in den Raum ihres * Elternknochens, der dafür unbewegt sein muss. */ export function createRetargeter(rig, { boneMap = BONE_MAP, hipsScale = 1 } = {}) { const rest = rigWorld(rig); const sourceOf = new Map(Object.entries(boneMap).map(([source, target]) => [target, source])); const hipsBone = rig.find((bone) => bone.name === boneMap[SOURCE_HIPS]); if (!hipsBone) throw new Error(`Rig ohne Hüftknochen ${boneMap[SOURCE_HIPS]}`); if (hipsBone.parent !== null && sourceOf.has(hipsBone.parent)) { throw new Error(`Elternknochen der Hüfte (${hipsBone.parent}) darf nicht animiert sein`); } const hipsParentInverse = hipsBone.parent === null ? new Matrix4() : rest.get(hipsBone.parent).matrix.clone().invert(); return function retarget(frame) { const world = new Map(); const local = new Map(); for (const bone of rig) { const parentWorld = bone.parent === null ? new Quaternion() : world.get(bone.parent); const source = sourceOf.get(bone.name); if (source === undefined) { world.set(bone.name, parentWorld.clone().multiply(new Quaternion().fromArray(bone.quaternion))); continue; } const sourceWorld = frame.rotations.get(source); if (!sourceWorld) throw new Error(`Bild ohne Drehung für ${source}`); const targetWorld = sourceWorld.clone().multiply(rest.get(bone.name).quaternion); world.set(bone.name, targetWorld); local.set(bone.name, parentWorld.clone().invert().multiply(targetWorld)); } const hips = frame.hips.clone().multiplyScalar(hipsScale / 100).applyMatrix4(hipsParentInverse); return { local, hips }; }; }