From 12a77051d68d2b03379d053465cf2084cebbdb37 Mon Sep 17 00:00:00 2001 From: Kenso Grimm Date: Thu, 17 Sep 2026 22:33:35 +0200 Subject: [PATCH] feat(showroom): Dummy aus Mannequin, Clip, Fingerhaltung und Waffe Co-Authored-By: Claude Opus 5 --- src/showroom/dummies.js | 62 ++++++++++++++++++++++++ tests/dummies.test.js | 101 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 src/showroom/dummies.js create mode 100644 tests/dummies.test.js diff --git a/src/showroom/dummies.js b/src/showroom/dummies.js new file mode 100644 index 0000000..5505ed6 --- /dev/null +++ b/src/showroom/dummies.js @@ -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 }; +} diff --git a/tests/dummies.test.js b/tests/dummies.test.js new file mode 100644 index 0000000..dc4cdac --- /dev/null +++ b/tests/dummies.test.js @@ -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/); + }); +});