Source code for simvx.core.animation.skeletal

"""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) -> 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. """ 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) return t, r, s
[docs] def sample(self, time: float) -> np.ndarray: """Interpolate keyframes at given time -> 4x4 local transform matrix.""" t, r, s = self.sample_trs(time) 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) -> 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}
[docs] def evaluate_trs(self, time: float) -> 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. """ return {track.bone_index: track.sample_trs(time) for track 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 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, )