import { describe, expect, it } from "vitest"; import { AnimationClip, Bone, Group, MathUtils, Quaternion, QuaternionKeyframeTrack, Vector3, VectorKeyframeTrack } from "three"; import { Document } from "@gltf-transform/core"; import { addClip, BLEND_MAX, BLEND_MIN, blendDuration, BONE_MAP, clipId, closeLoop, createRetargeter, faceForward, flattenPoses, headingOf, removeDrift, rigFromScene, rigWorld, sampleFbx, sampleTimes, seamAngle, SOURCE_HIPS, stripToSkeleton } 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); }); // Bruch: Hüftposition in bewegten Elternraum gerechnet — die Figur wanderte mit jedem Bild zusätzlich mit ihrem Hüft-Elternknochen. it("verlangt einen unbewegten Elternknochen über der Hüfte", () => { expect(() => createRetargeter(RIG, { boneMap: { ...MAP, Reference: "root" } })).toThrow(/darf nicht animiert sein/); }); }); describe("sampleTimes", () => { // Bruch: ungleichmäßige Abstände — der Clip liefe stellenweise zu schnell. it("verteilt round(Dauer · 30) + 1 Zeiten gleichmäßig über den Clip", () => { const times = sampleTimes(0.008, 6.56, 30); expect(times).toHaveLength(Math.round(6.552 * 30) + 1); expect(times[0]).toBe(0.008); expect(times.at(-1)).toBeCloseTo(6.56, 12); const steps = times.slice(1).map((time, i) => time - times[i]); expect(Math.max(...steps) - Math.min(...steps)).toBeLessThan(1e-12); }); // Bruch: unbemerkt leerer Zeitraum — der Clip hätte nur ein Bild. it("lehnt einen leeren Zeitraum ab", () => { expect(() => sampleTimes(2, 2)).toThrow(/Leerer Zeitraum/); }); }); describe("faceForward", () => { const sideways = () => [0, 1].map(() => ({ rotations: new Map([[SOURCE_HIPS, turn(0, 1, 0, -30)], ["Head", turn(1, 0, 0, 10)]]), hips: new Vector3(10, 90, 0), })); // Bruch: Blickrichtung nicht gedreht — die Dummies stünden schräg zur Spielfigur (bis 44°). it("dreht den Clip, bis die Hüfte im ersten Bild nach +Z schaut", () => { const faced = faceForward(sideways()); expect(headingOf(faced[0].rotations.get(SOURCE_HIPS))).toBeCloseTo(0, 9); }); it("dreht Positionen und alle Knochen mit derselben Drehung", () => { const faced = faceForward(sideways()); expect(faced[1].hips.x).toBeCloseTo(8.660254, 5); expect(faced[1].hips.z).toBeCloseTo(-5, 5); expect(faced[1].hips.y).toBeCloseTo(90, 9); expectSameRotation(faced[1].rotations.get("Head"), turn(0, 1, 0, 30).multiply(turn(1, 0, 0, 10))); }); }); describe("removeDrift", () => { // Bruch: Drift bleibt — der Dummy wanderte bei jeder Schleife weiter von seinem Platz. it("legt das erste Bild auf 0 und zieht die Drift linear ab", () => { const frames = [[4, 90, 2], [6, 91, 5], [10, 92, 8]].map(([x, y, z]) => ({ rotations: new Map(), hips: new Vector3(x, y, z) })); expect(removeDrift(frames).map((frame) => frame.hips.toArray())) .toEqual([[0, 90, 0], [-1, 91, 0], [0, 92, 0]]); }); }); describe("seamAngle und blendDuration", () => { const frame = (degrees) => ({ rotations: new Map([[SOURCE_HIPS, new Quaternion()], ["LeftFoot", turn(1, 0, 0, degrees)]]), hips: new Vector3(), }); it("misst die größte Abweichung zwischen letztem und erstem Bild", () => { expect(seamAngle([frame(0), frame(5), frame(91)])).toBeCloseTo(91, 6); }); // Bruch: feste Überblendzeit — die 91°-Naht des Bogenschützen ruckte sichtbar. it("bemisst die Überblendung an der Naht und hält die Grenzen ein", () => { expect(blendDuration(91)).toBeCloseTo(91 / 90, 9); expect(blendDuration(19)).toBe(BLEND_MIN); expect(blendDuration(400)).toBe(BLEND_MAX); }); }); describe("closeLoop", () => { const times = [0, 0.5, 1, 1.5, 2]; const poses = () => times.map((time) => ({ local: new Map([["pelvis", turn(0, 1, 0, 40 * time)]]), hips: new Vector3(time, 1, 0), })); // Bruch: Schleife nicht geschlossen — jeder Rücksprung wäre ein sichtbarer Ruck. it("macht das letzte Bild gleich dem ersten", () => { const closed = closeLoop(times, poses(), 1); expectSameRotation(closed.at(-1).local.get("pelvis"), poses()[0].local.get("pelvis")); expect(closed.at(-1).hips.toArray()).toEqual([0, 1, 0]); }); it("lässt alles vor der Überblendung unberührt und blendet weich", () => { const original = poses(); const closed = closeLoop(times, original, 1); expect(closed[0]).toBe(original[0]); expect(closed[2]).toBe(original[2]); expect(closed[3].hips.x).toBeCloseTo(0.75, 9); }); }); describe("sampleFbx", () => { /** Nachbau eines MVN-FBX: Reference -> Hips -> Spine, Frame 0 in T-Pose, danach Bewegung. */ function fakeFbx() { const root = new Group(); const reference = new Bone(); reference.name = "Reference"; const hips = new Bone(); hips.name = "Hips"; hips.position.set(0, 90, 0); const spine = new Bone(); spine.name = "Spine"; spine.position.set(0, 10, 0); root.add(reference); reference.add(hips); hips.add(spine); const quarter = turn(0, 1, 0, 90).toArray(); root.animations = [new AnimationClip("take", 1.008, [ new VectorKeyframeTrack("Hips.position", [0, 0.008, 1.008], [0, 90, 0, 5, 90, 0, 5, 90, 30]), new QuaternionKeyframeTrack("Hips.quaternion", [0, 0.008, 1.008], [0, 0, 0, 1, 0, 0, 0, 1, ...quarter]), new QuaternionKeyframeTrack("Spine.quaternion", [0, 0.008, 1.008], [0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1]), ])]; return root; } // Bruch: Frame 0 mitgenommen — jede Schleife begänne mit einem Zucken in die T-Pose. it("lässt Frame 0 weg, tastet bis zum Ende ab und liest die Hüfthöhe der Ruhepose", () => { const { times, frames, hipsRestHeight } = sampleFbx(fakeFbx(), 30, { Hips: "pelvis", Spine: "spine_01" }); expect(hipsRestHeight).toBe(90); expect(times).toHaveLength(31); expect(times[0]).toBe(0); expect(times.at(-1)).toBeCloseTo(1, 9); expect(frames[0].hips.x).toBeCloseTo(5, 6); expect(frames.at(-1).hips.z).toBeCloseTo(30, 6); }); it("liest Weltdrehungen, nicht lokale", () => { const { frames } = sampleFbx(fakeFbx(), 30, { Hips: "pelvis", Spine: "spine_01" }); expectSameRotation(frames.at(-1).rotations.get("Spine"), turn(0, 1, 0, 90)); }); }); describe("rigFromScene und stripToSkeleton", () => { function skeletonDocument() { const document = new Document(); const root = document.createNode("root").setRotation(turn(1, 0, 0, -90).toArray()); const pelvis = document.createNode("pelvis").setTranslation([0, 0.05, 0.917]); const leaf = document.createNode("Head"); root.addChild(pelvis); pelvis.addChild(leaf); document.getRoot().setDefaultScene(document.createScene("Armature").addChild(root)); return document; } it("liest die Knoten in Eltern-vor-Kind-Reihenfolge", () => { const document = skeletonDocument(); const rig = rigFromScene(document.getRoot().listScenes()[0]); expect(rig.map((bone) => [bone.name, bone.parent])) .toEqual([["root", null], ["pelvis", "root"], ["Head", "pelvis"]]); expect(rig[1].position).toEqual([0, 0.05, 0.917]); }); // Bruch: Blattknoten weggeprunt — Head und die Fingerknochen fehlten als Ziel der Clips. it("räumt Clips und Geometrie weg, behält aber jeden Knoten", async () => { const document = skeletonDocument(); const mesh = document.createMesh("Mannequin"); document.getRoot().listNodes()[1].setMesh(mesh); document.createAnimation("Idle_Loop"); await stripToSkeleton(document); expect(document.getRoot().listAnimations()).toHaveLength(0); expect(document.getRoot().listMeshes()).toHaveLength(0); expect(document.getRoot().listNodes().map((node) => node.getName())).toEqual(["root", "pelvis", "Head"]); }); }); describe("addClip", () => { it("legt einen Zeit-Accessor, Drehungen je Knochen und die Hüfte als Translation an", () => { const document = new Document(); const pelvis = document.createNode("pelvis"); const spine = document.createNode("spine_01"); document.getRoot().setDefaultScene(document.createScene().addChild(pelvis.addChild(spine))); const poses = [0, 1].map((i) => ({ local: new Map([["pelvis", new Quaternion()], ["spine_01", turn(0, 0, 1, 10 * i)]]), hips: new Vector3(0, 0.9 + i, 0), })); const animation = addClip(document, "test_tk01", [0, 0.5], poses, "pelvis"); expect(animation.getName()).toBe("test_tk01"); expect(animation.listChannels()).toHaveLength(3); expect(new Set(animation.listSamplers().map((sampler) => sampler.getInput())).size).toBe(1); const translation = animation.listChannels().find((channel) => channel.getTargetPath() === "translation"); expect(translation.getTargetNode().getName()).toBe("pelvis"); // Float32Array-Speicher: 0.9 und 1.9 runden beim Schreiben, darum hier durch Math.fround vergleichen statt // mit den float64-Literalen (Abweichung vom Brief, siehe task-5-report.md). expect([...translation.getSampler().getOutput().getArray()]) .toEqual([0, Math.fround(0.9), 0, 0, Math.fround(1.9), 0]); }); it("meldet einen fehlenden Knoten", () => { const document = new Document(); document.getRoot().setDefaultScene(document.createScene().addChild(document.createNode("pelvis"))); const poses = [{ local: new Map([["fehlt", new Quaternion()]]), hips: new Vector3() }]; expect(() => addClip(document, "test", [0], poses, "pelvis")).toThrow(/Skelett ohne Knoten fehlt/); }); }); describe("flattenPoses", () => { it("reiht Drehungen als xyzw und die Hüfte als xyz je Bild auf", () => { const poses = [ { local: new Map([["pelvis", new Quaternion(0, 0, 0, 1)]]), hips: new Vector3(1, 2, 3) }, { local: new Map([["pelvis", new Quaternion(0.5, 0, 0, 0.8660254)]]), hips: new Vector3(4, 5, 6) }, ]; const { rotations, hips } = flattenPoses(poses); expect([...hips]).toEqual([1, 2, 3, 4, 5, 6]); expect([...rotations.get("pelvis")].slice(0, 4)).toEqual([0, 0, 0, 1]); expect([...rotations.get("pelvis")][4]).toBeCloseTo(0.5, 6); }); });