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/); }); });