animations
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
import * as THREE from 'three';
|
||||
import { buildSkeleton } from '../src/character/skeleton.js';
|
||||
import { buildStick } from '../src/character/stick.js';
|
||||
import {
|
||||
applyTiltAnimation,
|
||||
applyTiltClip,
|
||||
captureSkeletonKeyframe,
|
||||
clipAsModule,
|
||||
createTiltClip,
|
||||
deleteClipKeyframe,
|
||||
frameSpan,
|
||||
sanitizeTiltClip,
|
||||
sampleTiltStick,
|
||||
setClipKeyframe,
|
||||
smoothTiltClip,
|
||||
} from '../src/anim/clip.js';
|
||||
import { done, ok, section } from './harness.mjs';
|
||||
|
||||
section('Tilt clips capture and interpolate the native skeleton');
|
||||
{
|
||||
const skel = buildSkeleton();
|
||||
const clip = createTiltClip({ name: 'test-motion', loop: false, shotSide: 'left' });
|
||||
skel.bones.upperArmL.quaternion.setFromAxisAngle(new THREE.Vector3(1, 0, 0), 0);
|
||||
setClipKeyframe(clip, captureSkeletonKeyframe(skel, 0));
|
||||
skel.bones.upperArmL.quaternion.setFromAxisAngle(new THREE.Vector3(1, 0, 0), Math.PI / 2);
|
||||
setClipKeyframe(clip, captureSkeletonKeyframe(skel, 1));
|
||||
|
||||
skel.bones.upperArmL.quaternion.identity();
|
||||
ok(applyTiltClip(skel, clip, 0.5), 'a populated clip applies');
|
||||
const angle = 2 * Math.acos(skel.bones.upperArmL.quaternion.w);
|
||||
ok(Math.abs(angle - Math.PI / 4) < 1e-5, 'quaternions slerp halfway');
|
||||
ok(frameSpan(clip, 2).a.time === 1, 'non-looping clips clamp at the end');
|
||||
ok(sanitizeTiltClip(clip).shotSide === 'left', 'clip preserves its authored stick socket side');
|
||||
}
|
||||
|
||||
section('clip validation, replacement, smoothing, and export');
|
||||
{
|
||||
const skel = buildSkeleton();
|
||||
const clip = createTiltClip({ name: 'hip check' });
|
||||
setClipKeyframe(clip, captureSkeletonKeyframe(skel, 0));
|
||||
setClipKeyframe(clip, captureSkeletonKeyframe(skel, 0));
|
||||
ok(clip.keyframes.length === 1, 'setting the same time replaces a key');
|
||||
setClipKeyframe(clip, captureSkeletonKeyframe(skel, 0.5));
|
||||
setClipKeyframe(clip, captureSkeletonKeyframe(skel, 1));
|
||||
smoothTiltClip(clip);
|
||||
const copy = sanitizeTiltClip(JSON.parse(JSON.stringify(clip)));
|
||||
ok(copy.keyframes.length === 3 && copy.rig === 'tilt-23', 'serialized clips validate');
|
||||
const module = clipAsModule(copy);
|
||||
ok(module.includes('export const hip_check') && module.includes('applyTiltAnimation'), 'module export is drop-in runtime JavaScript');
|
||||
ok(deleteClipKeyframe(copy, 0.5) && copy.keyframes.length === 2, 'keys can be deleted');
|
||||
}
|
||||
|
||||
section('stick landmarks survive storage and interpolate with the pose');
|
||||
{
|
||||
const skel = buildSkeleton();
|
||||
const clip = createTiltClip({ loop: false });
|
||||
const a = captureSkeletonKeyframe(skel, 0);
|
||||
a.stick = { butt: [0.4, 0.2], grip: [0.45, 0.4], blade: [0.5, 0.8], target: [-0.2, 0.03, 0.6], roll: 0, confidence: 0.8 };
|
||||
const b = captureSkeletonKeyframe(skel, 1);
|
||||
b.stick = { butt: [0.6, 0.2], grip: [0.6, 0.4], blade: [0.8, 0.8], target: [0.4, 0.2, 1.0], roll: 0.4, confidence: 1 };
|
||||
setClipKeyframe(clip, a);
|
||||
setClipKeyframe(clip, b);
|
||||
const stick = sampleTiltStick(clip, 0.5);
|
||||
ok(Math.abs(stick.blade[0] - 0.65) < 1e-6, 'blade landmark interpolates');
|
||||
ok(Math.abs(stick.target[0] - 0.1) < 1e-6, 'rig-local blade target interpolates');
|
||||
ok(Number.isFinite(stick.angle), 'legacy butt/blade marks derive a stable shaft angle');
|
||||
ok(sanitizeTiltClip(JSON.parse(JSON.stringify(clip))).keyframes[0].stick.confidence === 0.8, 'stick metadata serializes');
|
||||
}
|
||||
|
||||
section('applied stick guides constrain the 3D shaft through both hands');
|
||||
{
|
||||
const skel = buildSkeleton();
|
||||
const mover = new THREE.Group();
|
||||
mover.add(skel.rootBone);
|
||||
const stick = buildStick(null, null, 0);
|
||||
stick.attachTo(skel.bones.handR);
|
||||
const clip = createTiltClip({ loop: false });
|
||||
const frame = captureSkeletonKeyframe(skel, 0);
|
||||
frame.stick = {
|
||||
butt: [0.2, 0.2], grip: [0.3, 0.3], blade: [0.8, 0.8],
|
||||
target: [0, 0, 0], angle: 0.5, roll: 0.2, alignHands: true, confidence: 1,
|
||||
};
|
||||
setClipKeyframe(clip, frame);
|
||||
const skater = { skelData: skel, mover, stick };
|
||||
ok(applyTiltAnimation(skater, clip, 0), 'two-hand guide applies');
|
||||
|
||||
const shaftA = new THREE.Vector3();
|
||||
const shaftB = new THREE.Vector3();
|
||||
const lowerHand = skel.bones.handL.getWorldPosition(new THREE.Vector3());
|
||||
stick.shaftSegment(shaftA, shaftB);
|
||||
const shaft = shaftB.clone().sub(shaftA);
|
||||
const t = THREE.MathUtils.clamp(lowerHand.clone().sub(shaftA).dot(shaft) / shaft.lengthSq(), 0, 1);
|
||||
const closest = shaftA.clone().addScaledVector(shaft, t);
|
||||
ok(shaftA.distanceTo(skel.bones.handR.getWorldPosition(new THREE.Vector3())) < 1e-6, 'shaft starts at the socket hand');
|
||||
ok(closest.distanceTo(lowerHand) < 1e-6, 'shaft crosses the lower hand in depth as well as screen space');
|
||||
ok(sanitizeTiltClip(JSON.parse(JSON.stringify(clip))).keyframes[0].stick.alignHands, 'two-hand constraint survives save/load');
|
||||
}
|
||||
|
||||
section('equivalent stick-line directions do not bake a false half-turn');
|
||||
{
|
||||
const skel = buildSkeleton();
|
||||
const clip = createTiltClip({ loop: false });
|
||||
for (const [time, angle] of [[0, 0.2], [0.5, Math.PI - 0.1], [1, 0.3]]) {
|
||||
const frame = captureSkeletonKeyframe(skel, time);
|
||||
frame.stick = {
|
||||
butt: [0.2, 0.2], grip: [0.3, 0.3], blade: [0.8, 0.8],
|
||||
target: [0, 0, 0], angle, roll: 0, confidence: 1,
|
||||
};
|
||||
setClipKeyframe(clip, frame);
|
||||
}
|
||||
const clean = sanitizeTiltClip(clip);
|
||||
const delta = Math.abs(clean.keyframes[1].stick.angle - clean.keyframes[0].stick.angle);
|
||||
ok(delta < Math.PI / 2, 'a PI-flipped detector result stays on the nearest shaft-line branch');
|
||||
}
|
||||
|
||||
section('stick playback follows the actual socket bone');
|
||||
{
|
||||
const skel = buildSkeleton();
|
||||
const mover = new THREE.Group();
|
||||
mover.add(skel.rootBone);
|
||||
const stickGroup = new THREE.Group();
|
||||
skel.bones.handL.add(stickGroup);
|
||||
const clip = createTiltClip({ loop: false, shotSide: 'left' });
|
||||
const frame = captureSkeletonKeyframe(skel, 0);
|
||||
frame.stick = {
|
||||
butt: [0.2, 0.2], grip: [0.3, 0.3], blade: [0.8, 0.8],
|
||||
target: [0, 0, 0], angle: -0.4, roll: 0, confidence: 1,
|
||||
};
|
||||
setClipKeyframe(clip, frame);
|
||||
let actualHand = null;
|
||||
const skater = {
|
||||
skelData: skel,
|
||||
mover,
|
||||
stick: {
|
||||
group: stickGroup,
|
||||
aimAt(_target, hand) { actualHand = hand.clone(); },
|
||||
},
|
||||
};
|
||||
applyTiltAnimation(skater, clip, 0);
|
||||
const expectedHand = skel.bones.handL.getWorldPosition(new THREE.Vector3());
|
||||
ok(actualHand?.distanceTo(expectedHand) < 1e-6, 'left socket playback anchors at handL instead of handR');
|
||||
}
|
||||
|
||||
done('animation clip');
|
||||
Reference in New Issue
Block a user