335 lines
17 KiB
JavaScript
335 lines
17 KiB
JavaScript
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);
|
||
});
|
||
});
|