"""Skeletal animation: bone tracks and skeletal clips."""
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 add_position_key(self, time: float, position: np.ndarray) -> None:
"""Insert a position (vec3) keyframe, keeping the list time-sorted."""
self.position_keys.append((float(time), np.asarray(position, dtype=np.float32).reshape(3)))
self.position_keys.sort(key=lambda kf: kf[0])
[docs]
def add_rotation_key(self, time: float, rotation: np.ndarray) -> None:
"""Insert a rotation (quaternion xyzw) keyframe, keeping the list time-sorted."""
self.rotation_keys.append((float(time), np.asarray(rotation, dtype=np.float32).reshape(4)))
self.rotation_keys.sort(key=lambda kf: kf[0])
[docs]
def add_scale_key(self, time: float, scale: np.ndarray) -> None:
"""Insert a scale (vec3) keyframe, keeping the list time-sorted."""
self.scale_keys.append((float(time), np.asarray(scale, dtype=np.float32).reshape(3)))
self.scale_keys.sort(key=lambda kf: kf[0])
[docs]
def sample_trs(self, time: float, rest: tuple | None = None) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Interpolate keyframes at *time* -> (translation, rotation_xyzw, scale).
Components are returned in TRS space (not composed into a matrix) so
callers can blend two poses correctly: SLERP rotation, LERP position
and scale. :meth:`sample` builds the matrix from these.
glTF animation channels are sparse: a bone may animate only its rotation,
leaving translation/scale at the bone's BIND values for the whole clip.
Pass ``rest`` as the bone's bind ``(t, r, s)`` so a channel with no keys
falls back to that rest value; without it, a missing channel falls back to
the neutral default (0 / identity / 1), which collapses such rigs.
"""
rt, rr, rs = rest if rest is not None else (None, None, None)
if self.position_keys:
t = _interp_vec3(self.position_keys, time)
else:
t = rt if rt is not None else np.zeros(3, dtype=np.float32)
if self.rotation_keys:
r = _interp_quat(self.rotation_keys, time)
else:
r = rr if rr is not None else np.array([0, 0, 0, 1], dtype=np.float32)
if self.scale_keys:
s = _interp_vec3(self.scale_keys, time)
else:
s = rs if rs is not None else np.ones(3, dtype=np.float32)
return t, r, s
[docs]
def sample(self, time: float, rest: tuple | None = None) -> np.ndarray:
"""Interpolate keyframes at given time -> 4x4 local transform matrix."""
t, r, s = self.sample_trs(time, rest)
return compose_trs(t, r, s)
[docs]
def to_dict(self) -> dict:
"""Serialize the bone track. Arrays are stored as plain lists."""
return {
"bone_index": self.bone_index,
"position_keys": [(float(t), v.tolist()) for t, v in self.position_keys],
"rotation_keys": [(float(t), v.tolist()) for t, v in self.rotation_keys],
"scale_keys": [(float(t), v.tolist()) for t, v in self.scale_keys],
}
[docs]
@classmethod
def from_dict(cls, data: dict) -> BoneTrack:
"""Deserialize a bone track, restoring keys as float32 arrays."""
track = cls(int(data["bone_index"]))
track.position_keys = [(float(t), np.asarray(v, dtype=np.float32)) for t, v in data.get("position_keys", [])]
track.rotation_keys = [(float(t), np.asarray(v, dtype=np.float32)) for t, v in data.get("rotation_keys", [])]
track.scale_keys = [(float(t), np.asarray(v, dtype=np.float32)) for t, v in data.get("scale_keys", [])]
return track
[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, rest_poses: dict | None = None) -> dict[int, np.ndarray]:
"""Evaluate all bone tracks at given time.
Returns dict mapping bone_index -> 4x4 local transform. ``rest_poses``
(``{bone_index: (t, r, s)}``) supplies each bone's bind TRS so sparse
channels fall back to rest (see :meth:`BoneTrack.sample_trs`).
"""
rest_poses = rest_poses or {}
return {t.bone_index: t.sample(time, rest_poses.get(t.bone_index)) for t in self.bone_tracks}
[docs]
def evaluate_trs(
self, time: float, rest_poses: dict | None = None
) -> dict[int, tuple[np.ndarray, np.ndarray, np.ndarray]]:
"""Evaluate all bone tracks at *time* as TRS components per bone.
Returns dict mapping bone_index -> (translation, rotation_xyzw, scale).
Used by crossfade blending, which must operate in TRS space. ``rest_poses``
supplies each bone's bind TRS so sparse channels fall back to rest.
"""
rest_poses = rest_poses or {}
return {t.bone_index: t.sample_trs(time, rest_poses.get(t.bone_index)) for t in self.bone_tracks}
[docs]
def to_dict(self) -> dict:
"""Serialize the skeletal clip. ``kind`` tags it for polymorphic loading."""
return {
"kind": "skeletal",
"name": self.name,
"duration": self.duration,
"bone_tracks": [track.to_dict() for track in self.bone_tracks],
}
[docs]
@classmethod
def from_dict(cls, data: dict) -> SkeletalAnimationClip:
"""Deserialize a skeletal clip."""
clip = cls(data["name"], data["duration"])
for track_data in data.get("bone_tracks", []):
clip.add_bone_track(BoneTrack.from_dict(track_data))
return clip
# ============================================================================
# TRS composition / blending
# ============================================================================
[docs]
def compose_trs(translation: np.ndarray, rotation_xyzw: np.ndarray, scale: np.ndarray) -> np.ndarray:
"""Compose translation, rotation (quat xyzw) and scale into a 4x4 matrix."""
mat = np.eye(4, dtype=np.float32)
rot = _quat_to_mat3(rotation_xyzw)
mat[:3, :3] = rot @ np.diag(scale)
mat[0, 3], mat[1, 3], mat[2, 3] = translation[0], translation[1], translation[2]
return mat
[docs]
def decompose_trs(mat: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Inverse of :func:`compose_trs`: a 4x4 matrix -> (translation, rotation_xyzw, scale).
Used to recover a bone's bind TRS from its rest ``local_transform`` so sparse
animation channels can fall back to the correct rest value.
"""
t = np.array(mat[:3, 3], dtype=np.float32)
m3 = np.array(mat[:3, :3], dtype=np.float64)
s = np.linalg.norm(m3, axis=0) # compose_trs scales the rotation columns by scale
s_safe = np.where(np.abs(s) < 1e-8, 1.0, s)
r = _mat3_to_quat(m3 / s_safe)
return t, r, s.astype(np.float32)
[docs]
def blend_trs(
a: tuple[np.ndarray, np.ndarray, np.ndarray],
b: tuple[np.ndarray, np.ndarray, np.ndarray],
t: float,
) -> np.ndarray:
"""Blend two TRS poses by factor *t* in [0, 1] -> composed 4x4 matrix.
Position and scale are linearly interpolated; rotation uses SLERP. Blending
happens in TRS space (never by lerping matrices) so the result stays a valid
rigid-plus-scale transform.
"""
ta, ra, sa = a
tb, rb, sb = b
pos = ta + (tb - ta) * t
scale = sa + (sb - sa) * t
rot = _slerp(ra, rb, t)
return compose_trs(pos, rot, scale)
# ============================================================================
# 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,
)
def _mat3_to_quat(m: np.ndarray) -> np.ndarray:
"""Convert a 3x3 rotation matrix to quaternion [x, y, z, w] (inverse of _quat_to_mat3)."""
trace = m[0, 0] + m[1, 1] + m[2, 2]
if trace > 0.0:
s = 0.5 / np.sqrt(trace + 1.0)
w, x, y, z = 0.25 / s, (m[2, 1] - m[1, 2]) * s, (m[0, 2] - m[2, 0]) * s, (m[1, 0] - m[0, 1]) * s
elif m[0, 0] > m[1, 1] and m[0, 0] > m[2, 2]:
s = 2.0 * np.sqrt(1.0 + m[0, 0] - m[1, 1] - m[2, 2])
w, x, y, z = (m[2, 1] - m[1, 2]) / s, 0.25 * s, (m[0, 1] + m[1, 0]) / s, (m[0, 2] + m[2, 0]) / s
elif m[1, 1] > m[2, 2]:
s = 2.0 * np.sqrt(1.0 + m[1, 1] - m[0, 0] - m[2, 2])
w, x, y, z = (m[0, 2] - m[2, 0]) / s, (m[0, 1] + m[1, 0]) / s, 0.25 * s, (m[1, 2] + m[2, 1]) / s
else:
s = 2.0 * np.sqrt(1.0 + m[2, 2] - m[0, 0] - m[1, 1])
w, x, y, z = (m[1, 0] - m[0, 1]) / s, (m[0, 2] + m[2, 0]) / s, (m[1, 2] + m[2, 1]) / s, 0.25 * s
return np.array([x, y, z, w], dtype=np.float32)