"""Skeletal animation: bone tracks and skeletal clips."""
from __future__ import annotations
import numpy as np
[docs]
class BoneTrack:
"""Animation track for a single bone: position, rotation, scale keyframes.
Rotation keyframes use quaternions [x, y, z, w] for spherical interpolation.
"""
def __init__(self, bone_index: int):
self.bone_index = bone_index
self.position_keys: list[tuple[float, np.ndarray]] = [] # (time, vec3)
self.rotation_keys: list[tuple[float, np.ndarray]] = [] # (time, quat xyzw)
self.scale_keys: list[tuple[float, np.ndarray]] = [] # (time, vec3)
[docs]
def sample(self, time: float) -> np.ndarray:
"""Interpolate keyframes at given time -> 4x4 local transform matrix."""
t = _interp_vec3(self.position_keys, time) if self.position_keys else np.zeros(3, dtype=np.float32)
r = _interp_quat(self.rotation_keys, time) if self.rotation_keys else np.array([0, 0, 0, 1], dtype=np.float32)
s = _interp_vec3(self.scale_keys, time) if self.scale_keys else np.ones(3, dtype=np.float32)
# Build TRS matrix
mat = np.eye(4, dtype=np.float32)
# Scale
mat[0, 0], mat[1, 1], mat[2, 2] = s[0], s[1], s[2]
# Rotation (quat -> mat3)
rot = _quat_to_mat3(r)
mat[:3, :3] = rot @ np.diag(s)
# Translation
mat[0, 3], mat[1, 3], mat[2, 3] = t[0], t[1], t[2]
return mat
[docs]
class SkeletalAnimationClip:
"""Animation clip with bone tracks for skeletal animation."""
def __init__(self, name: str, duration: float):
self.name = name
self.duration = duration
self.bone_tracks: list[BoneTrack] = []
[docs]
def add_bone_track(self, track: BoneTrack) -> None:
self.bone_tracks.append(track)
[docs]
def evaluate(self, time: float) -> dict[int, np.ndarray]:
"""Evaluate all bone tracks at given time.
Returns dict mapping bone_index -> 4x4 local transform.
"""
return {track.bone_index: track.sample(time) for track in self.bone_tracks}
# ============================================================================
# Internal helpers
# ============================================================================
def _interp_vec3(keys: list[tuple[float, np.ndarray]], time: float) -> np.ndarray:
"""Linear interpolation between vec3 keyframes."""
if not keys:
return np.zeros(3, dtype=np.float32)
if len(keys) == 1 or time <= keys[0][0]:
return keys[0][1].copy()
if time >= keys[-1][0]:
return keys[-1][1].copy()
for i in range(len(keys) - 1):
t0, v0 = keys[i]
t1, v1 = keys[i + 1]
if t0 <= time <= t1:
f = (time - t0) / max(t1 - t0, 1e-10)
return v0 + (v1 - v0) * f
return keys[-1][1].copy()
def _interp_quat(keys: list[tuple[float, np.ndarray]], time: float) -> np.ndarray:
"""Spherical linear interpolation between quaternion keyframes [x,y,z,w]."""
if not keys:
return np.array([0, 0, 0, 1], dtype=np.float32)
if len(keys) == 1 or time <= keys[0][0]:
return keys[0][1].copy()
if time >= keys[-1][0]:
return keys[-1][1].copy()
for i in range(len(keys) - 1):
t0, q0 = keys[i]
t1, q1 = keys[i + 1]
if t0 <= time <= t1:
f = (time - t0) / max(t1 - t0, 1e-10)
return _slerp(q0, q1, f)
return keys[-1][1].copy()
def _slerp(q0: np.ndarray, q1: np.ndarray, t: float) -> np.ndarray:
"""Quaternion spherical linear interpolation."""
dot = float(np.dot(q0, q1))
if dot < 0:
q1 = -q1
dot = -dot
if dot > 0.9995:
result = q0 + t * (q1 - q0)
return result / np.linalg.norm(result)
theta = np.arccos(np.clip(dot, -1, 1))
sin_theta = np.sin(theta)
a = np.sin((1 - t) * theta) / sin_theta
b = np.sin(t * theta) / sin_theta
return a * q0 + b * q1
def _quat_to_mat3(q: np.ndarray) -> np.ndarray:
"""Convert quaternion [x,y,z,w] to 3x3 rotation matrix."""
x, y, z, w = q[0], q[1], q[2], q[3]
x2, y2, z2 = x + x, y + y, z + z
xx, xy, xz = x * x2, x * y2, x * z2
yy, yz, zz = y * y2, y * z2, z * z2
wx, wy, wz = w * x2, w * y2, w * z2
return np.array(
[
[1 - (yy + zz), xy - wz, xz + wy],
[xy + wz, 1 - (xx + zz), yz - wx],
[xz - wy, yz + wx, 1 - (xx + yy)],
],
dtype=np.float32,
)