145 lines
6.2 KiB
JavaScript
145 lines
6.2 KiB
JavaScript
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');
|