diff --git a/tests/motions.test.js b/tests/motions.test.js index 3f4f9bb..e9a09dd 100644 --- a/tests/motions.test.js +++ b/tests/motions.test.js @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { MathUtils, Quaternion, Vector3 } from "three"; -import { BONE_MAP, clipId, createRetargeter, rigWorld } from "../tools/assets/motions.mjs"; +import { BLEND_MAX, BLEND_MIN, blendDuration, BONE_MAP, clipId, closeLoop, createRetargeter, faceForward, + headingOf, removeDrift, rigWorld, sampleTimes, seamAngle, SOURCE_HIPS } from "../tools/assets/motions.mjs"; const turn = (x, y, z, degrees) => new Quaternion().setFromAxisAngle(new Vector3(x, y, z).normalize(), MathUtils.degToRad(degrees)); @@ -116,7 +117,97 @@ describe("createRetargeter", () => { 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); + }); +}); diff --git a/tools/assets/motions.mjs b/tools/assets/motions.mjs index 5dd6bda..9701033 100644 --- a/tools/assets/motions.mjs +++ b/tools/assets/motions.mjs @@ -6,7 +6,7 @@ // // Das Zip bleibt außerhalb des Repos. Die Rechenschritte sind reine Funktionen (tests/motions.test.js), main() // liest nur Dateien und schreibt das Ergebnis. -import { Matrix4, Quaternion, Vector3 } from "three"; +import { MathUtils, Matrix4, Quaternion, Vector3 } from "three"; /** * MVN-Knochen -> Mannequin-Knochen, zugeordnet nach gemessener Höhe in der Ruhepose. Spine2 (1,187 m) hat kein @@ -112,3 +112,75 @@ export function createRetargeter(rig, { boneMap = BONE_MAP, hipsScale = 1 } = {} return { local, hips }; }; } + +export const SAMPLE_RATE = 30; +/** Überblendzeit am Schleifenende: Naht in Grad ÷ BLEND_SPEED (°/s), begrenzt auf BLEND_MIN bis BLEND_MAX Sekunden. */ +export const BLEND_SPEED = 90; +export const BLEND_MIN = 0.25; +export const BLEND_MAX = 1.5; + +/** n = round((end − start) · rate) + 1 gleichmäßig verteilte Zeitpunkte von start bis end, beide eingeschlossen. */ +export function sampleTimes(start, end, rate = SAMPLE_RATE) { + if (!(end > start)) throw new Error(`Leerer Zeitraum ${start}–${end}`); + const count = Math.round((end - start) * rate) + 1; + return Array.from({ length: count }, (_, i) => start + ((end - start) * i) / (count - 1)); +} + +/** Winkel des Vorwärtsvektors (+Z) einer Drehung gegen +Z in der Waagrechten, im Bogenmaß, positiv zu +X. */ +export function headingOf(quaternion) { + const forward = new Vector3(0, 0, 1).applyQuaternion(quaternion); + return Math.atan2(forward.x, forward.z); +} + +/** Dreht alle Bilder um die Hochachse, bis die Hüfte im ersten Bild nach +Z schaut. */ +export function faceForward(frames) { + const angle = -headingOf(frames[0].rotations.get(SOURCE_HIPS)); + const turn = new Quaternion().setFromAxisAngle(new Vector3(0, 1, 0), angle); + return frames.map((frame) => ({ + rotations: new Map([...frame.rotations].map(([name, rotation]) => [name, turn.clone().multiply(rotation)])), + hips: frame.hips.clone().applyQuaternion(turn), + })); +} + +/** Legt die Hüfte im ersten Bild waagrecht auf 0 und zieht die Drift linear ab, sodass das letzte Bild dort endet. */ +export function removeDrift(frames) { + const first = frames[0].hips; + const last = frames.at(-1).hips; + return frames.map((frame, i) => { + const along = frames.length > 1 ? i / (frames.length - 1) : 0; + const hips = frame.hips.clone(); + hips.x -= first.x + along * (last.x - first.x); + hips.z -= first.z + along * (last.z - first.z); + return { rotations: frame.rotations, hips }; + }); +} + +/** Größte Abweichung der Weltdrehung zwischen letztem und erstem Bild über alle Knochen, in Grad. */ +export function seamAngle(frames) { + const last = frames.at(-1).rotations; + let largest = 0; + for (const [name, rotation] of frames[0].rotations) largest = Math.max(largest, rotation.angleTo(last.get(name))); + return MathUtils.radToDeg(largest); +} + +export function blendDuration(seamDegrees) { + return MathUtils.clamp(seamDegrees / BLEND_SPEED, BLEND_MIN, BLEND_MAX); +} + +/** + * Schließt die Schleife: In den letzten `blend` Sekunden werden lokale Drehungen (slerp) und die Hüfte (lerp) mit + * smoothstep-Gewicht zum ersten Bild gezogen. Das letzte Bild gleicht danach dem ersten. poses: [{ local, hips }]. + */ +export function closeLoop(times, poses, blend) { + const end = times.at(-1); + const first = poses[0]; + return poses.map((pose, i) => { + if (times[i] <= end - blend) return pose; + const weight = MathUtils.smoothstep(times[i], end - blend, end); + return { + local: new Map([...pose.local].map(([name, rotation]) => + [name, rotation.clone().slerp(first.local.get(name), weight)])), + hips: pose.hips.clone().lerp(first.hips, weight), + }; + }); +}