From 070bb7959305443be029d8f395e873dfa4bbf7eb Mon Sep 17 00:00:00 2001 From: Kenso Grimm Date: Thu, 17 Sep 2026 21:33:28 +0200 Subject: [PATCH] =?UTF-8?q?feat(assets):=20Mocap-Knochen=20auf=20das=20Man?= =?UTF-8?q?nequin=20=C3=BCbertragen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 --- tests/motions.test.js | 122 +++++++++++++++++++++++++++++++++++++++ tools/assets/motions.mjs | 114 ++++++++++++++++++++++++++++++++++++ 2 files changed, 236 insertions(+) create mode 100644 tests/motions.test.js create mode 100644 tools/assets/motions.mjs diff --git a/tests/motions.test.js b/tests/motions.test.js new file mode 100644 index 0000000..3f4f9bb --- /dev/null +++ b/tests/motions.test.js @@ -0,0 +1,122 @@ +import { describe, expect, it } from "vitest"; +import { MathUtils, Quaternion, Vector3 } from "three"; +import { BONE_MAP, clipId, createRetargeter, rigWorld } from "../tools/assets/motions.mjs"; + +const turn = (x, y, z, degrees) => + new Quaternion().setFromAxisAngle(new Vector3(x, y, z).normalize(), MathUtils.degToRad(degrees)); + +/** + * Kleines Zielskelett im Bau des Mannequins: root dreht −90° um X (Blender-Z-oben), darunter Becken, Brust, + * Schlüsselbein, Oberarm und eine nicht zugeordnete Hand. Die Ruhedrehungen sind absichtlich schief, damit eine + * vertauschte Reihenfolge der Quaternionen auffällt. + */ +const RIG = [ + { name: "root", parent: null, position: [0, 0, 0], quaternion: turn(1, 0, 0, -90).toArray(), scale: [1, 1, 1] }, + { name: "pelvis", parent: "root", position: [0, 0.05, 0.917], quaternion: turn(1, 0, 0, 104.5).toArray(), scale: [1, 1, 1] }, + { name: "spine_03", parent: "pelvis", position: [0, 0.4, 0], quaternion: turn(1, 0, 0, -12).toArray(), scale: [1, 1, 1] }, + { name: "clavicle_l", parent: "spine_03", position: [0.03, 0.2, 0], quaternion: turn(0.3, -0.7, 0.2, 80).toArray(), scale: [1, 1, 1] }, + { name: "upperarm_l", parent: "clavicle_l", position: [0, 0.15, 0], quaternion: turn(0, 0, 1, 15).toArray(), scale: [1, 1, 1] }, + { name: "hand_l", parent: "upperarm_l", position: [0, 0.27, 0], quaternion: turn(0, 1, 0, 5).toArray(), scale: [1, 1, 1] }, +]; +const MAP = { Hips: "pelvis", Spine3: "spine_03", LeftShoulder: "clavicle_l", LeftArm: "upperarm_l" }; +const restFrame = () => ({ + rotations: new Map(Object.keys(MAP).map((name) => [name, new Quaternion()])), + hips: new Vector3(0, 90, 0), +}); +const restOf = (name) => new Quaternion().fromArray(RIG.find((bone) => bone.name === name).quaternion); +/** Quaternionen vergleichen, ohne dass acos nahe 0 die Genauigkeit frisst. */ +const expectSameRotation = (actual, expected) => expect(1 - Math.abs(actual.dot(expected))).toBeLessThan(1e-9); + +describe("clipId", () => { + it("liest den Namen aus jedem der elf Dateinamen", () => { + expect(clipId("20210715_s231_idle_loadGun-Hand_tk01_ERJA-mvn359.fbx")).toBe("idle_loadGun-Hand_tk01"); + expect(clipId("20210715_s231_idle_loadGun-Rifle_tk04_ERJA-mvn362.fbx")).toBe("idle_loadGun-Rifle_tk04"); + expect(clipId("20210714_s022_hunter_bowArrow_tk01_ERJA-mvn006.fbx")).toBe("hunter_bowArrow_tk01"); + expect(clipId("20210714_s055_leftHandThrow_grenade_tk02_ERJA-mvn052.fbx")).toBe("leftHandThrow_grenade_tk02"); + expect(clipId("20210714_s077_taunt_shootSelfInHead_tk01_ERJA-mvn072.fbx")).toBe("taunt_shootSelfInHead_tk01"); + expect(clipId("20210714_s085_stabTwist_Knife_tk02_ERJA-mvn086.fbx")).toBe("stabTwist_Knife_tk02"); + }); + + // Bruch: eine fremde Datei im Ordner würde sonst einen unbrauchbaren Clipnamen erzeugen. + it("lehnt fremde Dateinamen ab", () => { + expect(() => clipId("SM_Pistol.fbx")).toThrow(/Unbekannter Dateiname/); + }); +}); + +describe("BONE_MAP", () => { + it("ordnet 22 Knochen zu, ohne Spine2 und ohne doppeltes Ziel", () => { + const targets = Object.values(BONE_MAP); + expect(targets).toHaveLength(22); + expect(new Set(targets).size).toBe(22); + expect(BONE_MAP.Spine2).toBeUndefined(); + expect(BONE_MAP.Hips).toBe("pelvis"); + expect(BONE_MAP.LeftToeBase).toBe("ball_l"); + }); +}); + +describe("rigWorld", () => { + it("verkettet Ruhedrehungen und Ruhepositionen", () => { + const world = rigWorld(RIG); + const pelvis = new Vector3().setFromMatrixPosition(world.get("pelvis").matrix); + expect(pelvis.x).toBeCloseTo(0, 9); + expect(pelvis.y).toBeCloseTo(0.917, 9); + expect(pelvis.z).toBeCloseTo(-0.05, 9); + expectSameRotation(world.get("pelvis").quaternion, restOf("root").clone().multiply(restOf("pelvis"))); + }); + + // Bruch: Reihenfolge im rig vertauscht — die Kette rechnete mit einem noch leeren Elternknochen. + it("verlangt Eltern vor Kindern", () => { + expect(() => rigWorld([RIG[1], RIG[0]])).toThrow(/steht nicht vor/); + }); +}); + +describe("createRetargeter", () => { + // Bruch: falsche Formel — die Figur stünde dauerhaft verdreht statt in ihrer Ruhepose. + it("macht aus der MVN-Ruhepose die Ruhepose des Mannequins", () => { + const { local } = createRetargeter(RIG, { boneMap: MAP })(restFrame()); + for (const name of Object.values(MAP)) expectSameRotation(local.get(name), restOf(name)); + }); + + it("gibt nicht zugeordneten Knochen keine Spur", () => { + const { local } = createRetargeter(RIG, { boneMap: MAP })(restFrame()); + expect(local.has("hand_l")).toBe(false); + expect(local.has("root")).toBe(false); + expect(local.size).toBe(4); + }); + + it("überträgt eine Weltdrehung am Oberarm eins zu eins", () => { + const rotation = turn(0, 0, 1, 90); + const frame = restFrame(); + frame.rotations.set("LeftArm", rotation); + const { local } = createRetargeter(RIG, { boneMap: MAP })(frame); + const rest = rigWorld(RIG); + // Der Schlüsselbein-Knochen bleibt in Ruhe, also ist seine Weltdrehung die Ruhe-Weltdrehung. + const world = rest.get("clavicle_l").quaternion.clone().multiply(local.get("upperarm_l")); + expectSameRotation(world, rotation.clone().multiply(rest.get("upperarm_l").quaternion)); + }); + + // Bruch: Elternkette übersehen — eine Drehung der Brust würde Arme und Kopf zusätzlich verbiegen. + it("lässt die lokale Drehung der Kinder unverändert, wenn sich nur der Elternknochen dreht", () => { + const rotation = turn(0, 1, 0, 40); + const frame = restFrame(); + for (const name of ["Spine3", "LeftShoulder", "LeftArm"]) frame.rotations.set(name, rotation); + const { local } = createRetargeter(RIG, { boneMap: MAP })(frame); + expectSameRotation(local.get("clavicle_l"), restOf("clavicle_l")); + expectSameRotation(local.get("upperarm_l"), restOf("upperarm_l")); + expect(1 - Math.abs(local.get("spine_03").dot(restOf("spine_03")))).toBeGreaterThan(1e-3); + }); + + // Bruch: cm als m gelesen oder Raum verwechselt — die Figur stünde 90 m über dem Boden oder läge flach. + it("rechnet die Hüfte in Meter um, skaliert sie und legt sie in den Raum von root", () => { + const frame = restFrame(); + frame.hips.set(10, 90, 20); + const { hips } = createRetargeter(RIG, { boneMap: MAP, hipsScale: 1.02 })(frame); + expect(hips.x).toBeCloseTo(0.102, 9); + expect(hips.y).toBeCloseTo(-0.204, 9); + expect(hips.z).toBeCloseTo(0.918, 9); + }); + + it("verlangt einen unbewegten Elternknochen über der Hüfte", () => { + expect(() => createRetargeter(RIG, { boneMap: { ...MAP, Reference: "root" } })).toThrow(/darf nicht animiert sein/); + }); +}); diff --git a/tools/assets/motions.mjs b/tools/assets/motions.mjs new file mode 100644 index 0000000..5dd6bda --- /dev/null +++ b/tools/assets/motions.mjs @@ -0,0 +1,114 @@ +// Ü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 }; + }; +}