feat(assets): elf Mocap-Clips als weapons-mocap.glb für das Mannequin
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
aa6cde79d3
commit
4422432998
4 files changed
+345
-3
No files matched your search
+124
-3
@@ -1,7 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { MathUtils, Quaternion, Vector3 } from "three";
|
||||
import { BLEND_MAX, BLEND_MIN, blendDuration, BONE_MAP, clipId, closeLoop, createRetargeter, faceForward,
|
||||
headingOf, removeDrift, rigWorld, sampleTimes, seamAngle, SOURCE_HIPS } from "../tools/assets/motions.mjs";
|
||||
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));
|
||||
@@ -211,3 +214,121 @@ describe("closeLoop", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user