feat(showroom): Dummy aus Mannequin, Clip, Fingerhaltung und Waffe
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
149ec9c5d9
commit
12a77051d6
2 files changed
+163
No files matched your search
@@ -0,0 +1,62 @@
|
|||||||
|
// Ein Dummy der Animations-Testseite: geklontes Mannequin, Mocap-Clip in Dauerschleife, dazu eine feste
|
||||||
|
// Fingerhaltung und wahlweise eine Waffe im Handknochen
|
||||||
|
// (docs/superpowers/specs/2026-09-17-animationen-design.md, "Dummies").
|
||||||
|
import { AnimationClip, AnimationMixer, LoopRepeat, MathUtils, QuaternionKeyframeTrack } from "three";
|
||||||
|
import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js";
|
||||||
|
|
||||||
|
/** Fingerknochen des Mannequins ohne die Blattknochen. Die Mocap-Daten haben keine Finger. */
|
||||||
|
export const FINGER_BONE = /^(thumb|index|middle|ring|pinky)_0[1-3]_[lr]$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Macht aus einem Clip des Mannequins (Pistol_Idle_Loop für die Waffenhand, Idle_Loop für leere Hände) eine feste
|
||||||
|
* Fingerhaltung: nur die Drehungen der Fingerknochen, eingefroren auf ihren ersten Keyframe. Läuft als zweite
|
||||||
|
* Aktion auf demselben Mixer — sie berührt keinen Knochen, den der Mocap-Clip bewegt.
|
||||||
|
*/
|
||||||
|
export function fingerPoseClip(source, name = `${source.name}_Finger`) {
|
||||||
|
const tracks = source.tracks
|
||||||
|
.filter((track) => track.name.endsWith(".quaternion")
|
||||||
|
&& FINGER_BONE.test(track.name.slice(0, track.name.lastIndexOf("."))))
|
||||||
|
.map((track) => {
|
||||||
|
const first = [...track.values.slice(0, 4)];
|
||||||
|
return new QuaternionKeyframeTrack(track.name, [0, 1], [...first, ...first]);
|
||||||
|
});
|
||||||
|
if (tracks.length === 0) throw new Error(`${source.name}: keine Fingerspuren`);
|
||||||
|
return new AnimationClip(name, 1, tracks);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lage der Waffe im Handknochen: Position in Metern, Drehung in Grad (XYZ). */
|
||||||
|
export function applyGrip(object, grip) {
|
||||||
|
object.position.fromArray(grip.position);
|
||||||
|
object.rotation.set(...grip.rotation.map((degrees) => MathUtils.degToRad(degrees)), "XYZ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Baut einen Dummy. template ist das geladene Mannequin, weapon die geladene Waffenszene (wird geklont). */
|
||||||
|
export function createDummy({ template, clip, fingers, weapon = null, hand = "r", grip = null }) {
|
||||||
|
const root = cloneSkeleton(template);
|
||||||
|
root.traverse((object) => {
|
||||||
|
if (!object.isMesh) return;
|
||||||
|
object.castShadow = true;
|
||||||
|
object.receiveShadow = true;
|
||||||
|
// Der Bogenschütze wandert bis 3 m von seinem Platz; die Hülle der Ruhepose schnitte ihn am Bildrand weg.
|
||||||
|
object.frustumCulled = false;
|
||||||
|
});
|
||||||
|
const mixer = new AnimationMixer(root);
|
||||||
|
mixer.clipAction(clip).setLoop(LoopRepeat, Infinity).play();
|
||||||
|
mixer.clipAction(fingers).play();
|
||||||
|
|
||||||
|
let held = null;
|
||||||
|
if (weapon) {
|
||||||
|
const boneName = hand === "l" ? "hand_l" : "hand_r";
|
||||||
|
const bone = root.getObjectByName(boneName);
|
||||||
|
if (!bone) throw new Error(`Dummy ohne Knochen ${boneName}`);
|
||||||
|
held = weapon.clone(true);
|
||||||
|
held.traverse((object) => {
|
||||||
|
if (!object.isMesh) return;
|
||||||
|
object.castShadow = true;
|
||||||
|
object.receiveShadow = true;
|
||||||
|
});
|
||||||
|
applyGrip(held, grip);
|
||||||
|
bone.add(held);
|
||||||
|
}
|
||||||
|
return { root, mixer, clip, weapon: held };
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
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";
|
||||||
|
root.add(pelvis);
|
||||||
|
pelvis.add(right);
|
||||||
|
pelvis.add(left);
|
||||||
|
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";
|
||||||
|
root.add(pelvis);
|
||||||
|
expect(() => createDummy({ template: root, clip, fingers, weapon, hand: "r", grip: WEAPON_GRIP }))
|
||||||
|
.toThrow(/Dummy ohne Knochen hand_r/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user