Merge branch 'main' into leonida/strassen

Bringt den Stand von main (Animationen, Showroom) in den Straßenzweig,
damit die zusammengeführte Fassung geprüft werden kann, bevor main auf
diesen Stand vorgezogen wird. main ist in einem zweiten Arbeitsverzeichnis
ausgecheckt und kann hier nicht ausgecheckt werden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Opus 5 committed 2026-09-18 12:09:42 +02:00
commit 4a91e84bfc
23 files changed
+4583

No files matched your search

+121
View File
@@ -0,0 +1,121 @@
import { describe, expect, it } from "vitest";
import { AnimationClip, Bone, BoxGeometry, Group, Mesh, MeshBasicMaterial, Object3D,
QuaternionKeyframeTrack, VectorKeyframeTrack } from "three";
import { applyGrip, createDummy, fingerPoseClip } from "../src/showroom/dummies.js";
const source = new AnimationClip("Pistol_Idle_Loop", 2, [
new QuaternionKeyframeTrack("index_01_r.quaternion", [0, 1], [0, 0, 0.3826834, 0.9238795, 0, 0, 0, 1]),
new QuaternionKeyframeTrack("thumb_03_l.quaternion", [0, 2], [0.1, 0, 0, 0.9949874, 0, 0, 0, 1]),
new QuaternionKeyframeTrack("index_04_leaf_r.quaternion", [0, 1], [0, 0, 0, 1, 0, 0, 0, 1]),
new QuaternionKeyframeTrack("hand_r.quaternion", [0, 1], [0, 0, 0, 1, 0, 0, 0, 1]),
new VectorKeyframeTrack("index_01_r.position", [0, 1], [0, 0, 0, 1, 1, 1]),
]);
describe("fingerPoseClip", () => {
// Bruch: Hand oder Blattknochen mitgenommen — die Fingerhaltung würde die Mocap-Bewegung überschreiben.
it("behält nur die Drehungen der Fingerknochen", () => {
expect(fingerPoseClip(source).tracks.map((track) => track.name).sort())
.toEqual(["index_01_r.quaternion", "thumb_03_l.quaternion"]);
});
it("hält den ersten Keyframe über die ganze Dauer", () => {
const clip = fingerPoseClip(source, "Finger_Waffe");
const track = clip.tracks.find((entry) => entry.name === "index_01_r.quaternion");
expect(clip.name).toBe("Finger_Waffe");
expect(clip.duration).toBe(1);
expect([...track.times]).toEqual([0, 1]);
const expected = Float32Array.from([0, 0, 0.3826834, 0.9238795, 0, 0, 0.3826834, 0.9238795]);
expect([...track.values]).toEqual([...expected]);
});
it("meldet einen Clip ohne Fingerspuren", () => {
const empty = new AnimationClip("Leer", 1, [
new QuaternionKeyframeTrack("hand_r.quaternion", [0, 1], [0, 0, 0, 1, 0, 0, 0, 1]),
]);
expect(() => fingerPoseClip(empty)).toThrow(/keine Fingerspuren/);
});
});
describe("applyGrip", () => {
it("setzt Position in Metern und Drehung in Grad", () => {
const object = new Object3D();
applyGrip(object, { position: [0.01, -0.02, 0.03], rotation: [90, 0, -45] });
expect(object.position.toArray()).toEqual([0.01, -0.02, 0.03]);
expect(object.rotation.x).toBeCloseTo(Math.PI / 2, 12);
expect(object.rotation.z).toBeCloseTo(-Math.PI / 4, 12);
expect(object.rotation.order).toBe("XYZ");
});
});
describe("createDummy", () => {
const WEAPON_GRIP = { position: [0, 0, 0], rotation: [0, 0, 0] };
function template() {
const root = new Group();
const pelvis = new Bone();
pelvis.name = "pelvis";
const right = new Bone();
right.name = "hand_r";
const left = new Bone();
left.name = "hand_l";
// Ziele der Fingerspuren aus `fingers` unten (index_01_r, thumb_03_l) — ohne sie meldet three.js beim
// Binden für jeden Testlauf "No target node found for track".
const indexR = new Bone();
indexR.name = "index_01_r";
const thumbL = new Bone();
thumbL.name = "thumb_03_l";
root.add(pelvis);
pelvis.add(right);
pelvis.add(left);
right.add(indexR);
left.add(thumbL);
return root;
}
const clip = new AnimationClip("take", 1, [
new QuaternionKeyframeTrack("pelvis.quaternion", [0, 1], [0, 0, 0, 1, 0, 0, 0, 1]),
]);
const fingers = fingerPoseClip(source, "Finger_Waffe");
const weapon = new Group();
weapon.add(new Mesh(new BoxGeometry(0.2, 0.05, 0.02), new MeshBasicMaterial()));
// Bruch: Waffe an der falschen Hand oder ohne Griff — sie steckte im Unterarm.
it("hängt eine eigene Kopie der Waffe mit Griff an die gewählte Hand", () => {
const dummy = createDummy({
template: template(), clip, fingers, weapon, hand: "l",
grip: { position: [0.1, 0, 0], rotation: [0, 90, 0] },
});
expect(dummy.weapon.parent.name).toBe("hand_l");
expect(dummy.weapon).not.toBe(weapon);
expect(dummy.weapon.position.x).toBeCloseTo(0.1, 12);
expect(dummy.weapon.rotation.y).toBeCloseTo(Math.PI / 2, 12);
});
it("spielt Clip und Fingerhaltung zugleich, lässt leere Hände leer und gibt den Clip zurück", () => {
const dummy = createDummy({ template: template(), clip, fingers });
expect(dummy.weapon).toBeNull();
expect(dummy.clip).toBe(clip);
expect(dummy.mixer.existingAction(clip).isRunning()).toBe(true);
expect(dummy.mixer.existingAction(fingers).isRunning()).toBe(true);
});
it("meldet eine fehlende Hand", () => {
const root = new Group();
const pelvis = new Bone();
pelvis.name = "pelvis";
const indexR = new Bone();
indexR.name = "index_01_r";
const thumbL = new Bone();
thumbL.name = "thumb_03_l";
root.add(pelvis);
pelvis.add(indexR);
pelvis.add(thumbL);
expect(() => createDummy({ template: root, clip, fingers, weapon, hand: "r", grip: WEAPON_GRIP }))
.toThrow(/Dummy ohne Knochen hand_r/);
});
// Bruch: Waffe ohne Griff — applyGrip bekäme kein Objekt und bräche mit einem rohen TypeError ab.
it("meldet eine Waffe ohne Griff", () => {
expect(() => createDummy({ template: template(), clip, fingers, weapon, hand: "r", grip: null }))
.toThrow(/Waffe ohne Griff/);
});
});
+334
View File
@@ -0,0 +1,334 @@
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);
});
});
+211
View File
@@ -0,0 +1,211 @@
import { statSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { beforeAll, describe, expect, it } from "vitest";
import { NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { getBounds } from "@gltf-transform/functions";
import { MeshoptDecoder } from "meshoptimizer";
import { TEXTURE_SIZE, WEAPON_SOURCES } from "../tools/assets/weapons.mjs";
import { DUMMIES, labelLines, slotPosition, stageTransform, WEAPONS } from "../src/showroom/lineup.js";
describe.each(WEAPON_SOURCES.map((source) => [source.id, source]))("%s.glb", (id, source) => {
const file = fileURLToPath(new URL(`../public/models/weapons/${id}.glb`, import.meta.url));
let document;
beforeAll(async () => {
await MeshoptDecoder.ready;
const io = new NodeIO()
.registerExtensions(ALL_EXTENSIONS)
.registerDependencies({ "meshopt.decoder": MeshoptDecoder });
document = await io.read(file);
}, 30000);
it("hat ein Mesh mit einem Primitive und einem Material", () => {
const root = document.getRoot();
expect(root.listMeshes()).toHaveLength(1);
expect(root.listMeshes()[0].listPrimitives()).toHaveLength(1);
expect(root.listMaterials()).toHaveLength(1);
});
// Bruch: Aufbereitung übersprungen — rohe Geometrie und PNG-Texturen bis 4096 px im Spiel.
it("nutzt meshopt und WebP", () => {
const used = document.getRoot().listExtensionsUsed().map((extension) => extension.extensionName);
expect(used).toEqual(expect.arrayContaining(["EXT_meshopt_compression", "EXT_texture_webp"]));
});
it("hat drei Texturen bis 1024 px", () => {
const textures = document.getRoot().listTextures();
expect(textures).toHaveLength(3);
for (const texture of textures) expect(Math.max(...texture.getSize())).toBeLessThanOrEqual(TEXTURE_SIZE);
});
// Bruch: falsche Einheit — die Waffe wäre hundertmal zu groß oder zu klein in der Hand.
it("ist so lang wie eine echte Waffe", () => {
const { min, max } = getBounds(document.getRoot().listScenes()[0]);
const length = Math.max(...max.map((value, axis) => value - min[axis]));
expect(length).toBeGreaterThanOrEqual(source.length[0]);
expect(length).toBeLessThanOrEqual(source.length[1]);
});
});
/** Die elf Clips, benannt nach den Dateien im Pack. */
const CLIP_IDS = [
"hunter_bowArrow_tk01",
"idle_loadGun-Hand_tk01", "idle_loadGun-Hand_tk02",
"idle_loadGun-Rifle_tk04", "idle_loadGun-Rifle_tk05",
"leftHandThrow_grenade_tk01", "leftHandThrow_grenade_tk02",
"stabTwist_Knife_tk01", "stabTwist_Knife_tk02",
"taunt_shootSelfInHead_tk01", "taunt_shootSelfInHead_tk02",
];
describe("weapons-mocap.glb", () => {
const file = fileURLToPath(new URL("../public/models/motions/weapons-mocap.glb", import.meta.url));
let animations;
beforeAll(async () => {
const document = await new NodeIO().registerExtensions(ALL_EXTENSIONS).read(file);
animations = document.getRoot().listAnimations();
}, 30000);
it("ist kleiner als 3 MB", () => {
expect(statSync(file).size).toBeLessThan(3e6);
});
it("enthält die elf Clips", () => {
expect(animations.map((animation) => animation.getName()).sort()).toEqual([...CLIP_IDS].sort());
});
// Bruch: ein Knochen ohne Spur — die Figur bliebe dort in der Ruhepose stehen.
it("hat je Clip 22 Drehungen und die Beckenposition", () => {
for (const animation of animations) {
const channels = animation.listChannels();
expect(channels.filter((channel) => channel.getTargetPath() === "rotation")).toHaveLength(22);
expect(channels.filter((channel) => channel.getTargetPath() === "translation")
.map((channel) => channel.getTargetNode().getName())).toEqual(["pelvis"]);
}
});
// Bruch: Schleife nicht geschlossen — bei jedem Rücksprung ein sichtbarer Ruck. Bei Drehungen zählt dafür nur
// die Drehung selbst, nicht ihr Vorzeichen: q und −q stellen dieselbe Rotation dar, und three.js' slerp
// liefert bei Gewicht 1 die dem Ausgangswert näherliegende der beiden Varianten (Quaternion.slerp kennt
// keinen Sonderfall für t=1). Darum wird das letzte Element vor dem Vergleich gespiegelt, wenn sein
// Skalarprodukt mit dem ersten negativ ist — eine echte Winkelabweichung am Schleifenende bleibt davon
// unberührt und schlägt weiterhin an. Bei Positionen (kein Vorzeichen-Zwilling) bleibt der Vergleich exakt.
it("endet jeder Kanal dort, wo er beginnt", () => {
for (const animation of animations) {
for (const channel of animation.listChannels()) {
const output = channel.getSampler().getOutput();
const first = output.getElement(0, []);
const last = output.getElement(output.getCount() - 1, []);
const dot = first.reduce((sum, value, i) => sum + value * last[i], 0);
const sign = channel.getTargetPath() === "rotation" && dot < 0 ? -1 : 1;
for (let i = 0; i < first.length; i++) expect(sign * last[i]).toBeCloseTo(first[i], 5);
}
}
});
// Bruch: Drift nicht abgezogen — der Dummy wanderte von seinem Platz. Geprüft werden die lokalen
// Komponenten x und y, weil die Beckenspur im Raum von root liegt (−90° um X) und die Höhe dort auf der
// lokalen z-Achse sitzt, nicht auf y.
it("beginnt jede Beckenbahn waagrecht bei 0", () => {
for (const animation of animations) {
const translation = animation.listChannels().find((channel) => channel.getTargetPath() === "translation");
const [x, y] = translation.getSampler().getOutput().getElement(0, []);
expect(x).toBeCloseTo(0, 5);
expect(y).toBeCloseTo(0, 5);
}
});
it("tastet mit 30 Bildern je Sekunde ab", () => {
for (const animation of animations) {
const times = animation.listSamplers()[0].getInput();
const values = [...times.getArray()];
const duration = values.at(-1);
expect(values).toHaveLength(Math.round(duration * 30) + 1);
expect(values[0]).toBe(0);
}
});
});
describe("Aufstellung", () => {
it("stellt 13 Dummies auf, acht mit Waffe", () => {
expect(DUMMIES).toHaveLength(13);
expect(DUMMIES.filter((dummy) => dummy.weapon)).toHaveLength(8);
});
// Bruch: Tippfehler im Clipnamen — der Dummy bliebe regungslos stehen.
it("nennt nur Clips, die es in weapons-mocap.glb gibt", () => {
for (const dummy of DUMMIES) expect(CLIP_IDS).toContain(dummy.clip);
});
it("spielt jeden Clip mindestens einmal", () => {
expect(new Set(DUMMIES.map((dummy) => dummy.clip)).size).toBe(CLIP_IDS.length);
});
// Bruch: Waffe ohne Datei oder ohne Griff — leere Hand oder Waffe im Boden.
it("gibt jeder Waffe Datei, Beschriftung und Griff", () => {
expect(Object.keys(WEAPONS)).toEqual(WEAPON_SOURCES.map((source) => source.id));
for (const [id, weapon] of Object.entries(WEAPONS)) {
expect(weapon.url).toBe(`./models/weapons/${id}.glb`);
expect(statSync(fileURLToPath(new URL(`../public/models/weapons/${id}.glb`, import.meta.url))).size)
.toBeGreaterThan(0);
expect(weapon.grip.position).toHaveLength(3);
expect(weapon.grip.rotation).toHaveLength(3);
expect(weapon.label.length).toBeGreaterThan(0);
}
});
it("hält jede Waffe in einer Hand und lässt sonst beide leer", () => {
for (const dummy of DUMMIES) {
if (dummy.weapon) {
expect(WEAPONS[dummy.weapon]).toBeDefined();
expect(["r", "l"]).toContain(dummy.hand);
} else {
expect(dummy.hand).toBeNull();
}
}
});
// Bruch: zwei Dummies auf einem Platz oder ineinander — die Schleifen wären nicht mehr auseinanderzuhalten.
it("hält 3,5 m Abstand zwischen allen Plätzen", () => {
const spots = DUMMIES.map((_, index) => slotPosition(index));
for (let i = 0; i < spots.length; i++) {
for (let k = i + 1; k < spots.length; k++) {
expect(Math.hypot(spots[i].x - spots[k].x, spots[i].z - spots[k].z)).toBeGreaterThanOrEqual(3.5);
}
}
});
// Bruch: der Bogenschütze wandert 3,13 m und liefe sonst durch seine Nachbarn.
it("lässt dem Bogenschützen mindestens 4 m Platz", () => {
const index = DUMMIES.findIndex((dummy) => dummy.clip === "hunter_bowArrow_tk01");
const bow = slotPosition(index);
for (let i = 0; i < DUMMIES.length; i++) {
if (i === index) continue;
const other = slotPosition(i);
expect(Math.hypot(bow.x - other.x, bow.z - other.z)).toBeGreaterThanOrEqual(4);
}
});
it("beschriftet mit Clip und Waffe", () => {
expect(labelLines(DUMMIES[0])).toEqual(["Pistole nachladen · Take 1", "Pistole"]);
expect(labelLines(DUMMIES.at(-1))).toEqual(["Bogenschütze · Take 1", "ohne Waffe"]);
});
// Bruch: falsches Vorzeichen bei Position oder Drehwinkel — die Aufstellung stünde seitlich versetzt, oder
// die Dummies schauten an der Figur vorbei statt sie anzusehen.
it("stellt die Aufstellung bei Blick nach +Z 8 × Maßstab vor die Figur und dreht sie um 180°", () => {
const { x, z, rotationY } = stageTransform({ x: 0, z: 0 }, { x: 0, z: 1 }, 1);
expect(x).toBeCloseTo(0, 10);
expect(z).toBeCloseTo(8, 10);
// Math.atan2(-0, -1) liefert −π statt π (Vorzeichen der Null) — dieselbe Halbdrehung, deshalb Betrag.
expect(Math.abs(rotationY)).toBeCloseTo(Math.PI, 10);
});
// Im Browser gemessener Fall (task-8-browsercheck.md): Figur bei (0|0), Maßstab 0,4921, Ergebnis
// (−3,94|0) und 90°.
it("stellt die Aufstellung bei Blick nach −X 8 × Maßstab vor die Figur und dreht sie um 90°", () => {
const { x, z, rotationY } = stageTransform({ x: 0, z: 0 }, { x: -1, z: 0 }, 0.4921);
expect(x).toBeCloseTo(-3.94, 2);
expect(z).toBeCloseTo(0, 10);
expect(rotationY).toBeCloseTo(Math.PI / 2, 10);
});
});
+111
View File
@@ -0,0 +1,111 @@
import { describe, expect, it } from "vitest";
import { BufferAttribute, BufferGeometry, Quaternion, Vector3 } from "three";
import { checkLength, longestExtent, maskToMetallicRoughness, meshArrays, readTga,
TEXTURE_SIZE, weaponTarget, WEAPON_SOURCES } from "../tools/assets/weapons.mjs";
/** Baut eine TGA-Datei wie die Masken im Pack: 18 Byte Kopf, danach BGRA je Pixel in Dateireihenfolge. */
function tgaFile({ width, height, pixels, type = 2, depth = 32, originTop = false }) {
const bytes = new Uint8Array(18 + pixels.length * 4);
bytes[2] = type;
bytes[12] = width;
bytes[14] = height;
bytes[16] = depth;
bytes[17] = 8 | (originTop ? 0x20 : 0);
pixels.forEach(([r, g, b, a], i) => bytes.set([b, g, r, a], 18 + i * 4));
return bytes;
}
describe("readTga", () => {
// Dateireihenfolge: rot, grün, blau, weiß
const pixels = [[255, 0, 0, 10], [0, 255, 0, 20], [0, 0, 255, 30], [255, 255, 255, 40]];
// Bruch: Ursprung unten links übersehen — die Maske läge kopfüber auf der Waffe.
it("dreht die Zeilen um, wenn der Ursprung unten links liegt", () => {
const { width, height, data } = readTga(tgaFile({ width: 2, height: 2, pixels }));
expect([width, height]).toEqual([2, 2]);
expect([...data]).toEqual([0, 0, 255, 30, 255, 255, 255, 40, 255, 0, 0, 10, 0, 255, 0, 20]);
});
it("übernimmt die Reihenfolge, wenn der Ursprung oben liegt", () => {
const { data } = readTga(tgaFile({ width: 2, height: 2, pixels, originTop: true }));
expect([...data]).toEqual([255, 0, 0, 10, 0, 255, 0, 20, 0, 0, 255, 30, 255, 255, 255, 40]);
});
// Bruch: stillschweigend falsch gelesene Datei statt einer verständlichen Meldung.
it("lehnt RLE, 24 Bit und Farbtabellen ab", () => {
expect(() => readTga(tgaFile({ width: 2, height: 2, pixels, type: 10 }))).toThrow(/Typ 10/);
expect(() => readTga(tgaFile({ width: 2, height: 2, pixels, depth: 24 }))).toThrow(/24 Bit/);
const withColorMap = tgaFile({ width: 2, height: 2, pixels });
withColorMap[1] = 1;
expect(() => readTga(withColorMap)).toThrow(/Farbtabelle 1/);
});
// Bruch: Dateigröße nicht geprüft — die Maske würde stillschweigend unvollständig gelesen.
it("meldet eine zu kurze Datei", () => {
expect(() => readTga(tgaFile({ width: 4, height: 4, pixels }))).toThrow(/kürzer/);
});
});
describe("maskToMetallicRoughness", () => {
// Bruch: Kanäle vertauscht — Holz glänzte metallisch, Metall wirkte stumpf.
it("legt den Grauwert nach G (Rauheit) und Alpha nach B (Metall)", () => {
const mask = { width: 2, height: 1, data: Uint8Array.from([217, 217, 217, 56, 242, 242, 242, 179]) };
expect([...maskToMetallicRoughness(mask)]).toEqual([255, 217, 56, 255, 242, 179]);
});
});
describe("meshArrays", () => {
function geometry() {
const result = new BufferGeometry();
result.setAttribute("position", new BufferAttribute(Float32Array.from([1, 0, 0, 0, 1, 0, 0, 0, 1]), 3));
result.setAttribute("normal", new BufferAttribute(Float32Array.from([0, 0, 1, 0, 0, 1, 0, 0, 1]), 3));
result.setAttribute("uv", new BufferAttribute(Float32Array.from([0, 0, 1, 0.25, 0.5, 1]), 2));
return result;
}
// Bruch: v nicht gespiegelt — jede Waffentextur läge senkrecht verkehrt.
it("spiegelt v, weil FBX den UV-Ursprung unten und glTF ihn oben hat", () => {
expect([...meshArrays(geometry(), new Quaternion()).uvs]).toEqual([0, 1, 1, 0.75, 0.5, 0]);
});
// Bruch: Knotendrehung übergangen — die L96 zeigte mit dem Lauf nach hinten.
it("dreht Eckpunkte und Normalen mit der Knotendrehung", () => {
const halfTurn = new Quaternion().setFromAxisAngle(new Vector3(0, 1, 0), Math.PI);
const { positions, normals } = meshArrays(geometry(), halfTurn);
expect(positions[0]).toBeCloseTo(-1, 6);
expect(positions[8]).toBeCloseTo(-1, 6);
expect(normals[2]).toBeCloseTo(-1, 6);
});
});
describe("checkLength", () => {
const pistol = WEAPON_SOURCES.find((source) => source.id === "pistol");
const along = (length) => Float32Array.from([0, 0, 0, length, 0.02, 0.01]);
it("misst die längste Ausdehnung", () => {
expect(longestExtent(along(0.189))).toBeCloseTo(0.189, 6);
});
it("lässt eine 0,19 m lange Pistole durch", () => {
expect(checkLength("pistol", along(0.189), pistol.length)).toBeCloseTo(0.189, 6);
});
// Bruch: Knotenmaßstab 100 der Pistole mitgerechnet — eine 19 m lange Waffe in der Hand.
it("bricht ab, wenn die Einheit nicht stimmt", () => {
expect(() => checkLength("pistol", along(18.9), pistol.length)).toThrow(/falsche Einheit/);
});
});
describe("WEAPON_SOURCES", () => {
it("nennt die fünf Waffen und die AK47 nur als Option 2", () => {
expect(WEAPON_SOURCES.map((source) => source.id)).toEqual(["pistol", "ak47", "m4", "shotgun870", "l96"]);
expect(WEAPON_SOURCES.find((source) => source.id === "ak47").dir).toBe("AK47_Option2(Same Socketing)");
expect(TEXTURE_SIZE).toBe(1024);
});
// Bruch: ID-Validierung übergangen — eine manipulierte ID könnte aus public/models/weapons/ herausführen.
it("legt jede Waffe unter public/models/weapons ab", () => {
expect(weaponTarget("pistol").replace(/\\/g, "/")).toMatch(/public\/models\/weapons\/pistol\.glb$/);
expect(() => weaponTarget("../ual")).toThrow(/Ungültige Waffen-ID/);
});
});