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');