Source code for simvx.core.animation.track

"""Keyframe animation tracks and clips."""

from __future__ import annotations

import logging
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any

from ..math.types import Quat, Vec2, Vec3
from ._interpolate import _interpolate
from .tween import ease_linear

log = logging.getLogger(__name__)


# ============================================================================
# AnimationEvent
# ============================================================================


[docs] @dataclass class AnimationEvent: """Event that fires when playback crosses a specific timestamp. Attributes: time: Timestamp in seconds when the event fires. callback: Function to call when the event triggers. args: Positional arguments passed to callback. """ time: float callback: Callable args: tuple = ()
# ============================================================================ # Track # ============================================================================
[docs] class Track: """Property animation track with keyframe interpolation. Stores (time, value) keyframes and interpolates between them. Optionally holds AnimationEvents that fire when playback crosses their timestamp. """ def __init__(self, property_name: str): self.property_name = property_name self.keyframes: list[tuple[float, Any]] = [] # [(time, value), ...] self.easing = ease_linear self.events: list[AnimationEvent] = []
[docs] def add_keyframe(self, time: float, value: Any): """Add keyframe at given time.""" self.keyframes.append((time, value)) # Keep sorted by time self.keyframes.sort(key=lambda kf: kf[0])
[docs] def add_event(self, time: float, callback: Callable, *args): """Add an event that fires when playback crosses the given timestamp. Args: time: Timestamp in seconds. callback: Function to invoke. *args: Additional arguments forwarded to callback. """ self.events.append(AnimationEvent(time, callback, args)) self.events.sort(key=lambda e: e.time)
[docs] def fire_events(self, prev_time: float, cur_time: float) -> None: """Fire events whose timestamps fall in the half-open interval (prev_time, cur_time]. Args: prev_time: Playback time at previous frame. cur_time: Playback time at current frame. """ for evt in self.events: if prev_time < evt.time <= cur_time: try: evt.callback(*evt.args) except Exception: log.exception("Animation event at t=%.3f raised", evt.time)
[docs] def evaluate(self, time: float) -> Any: """Evaluate track at given time.""" if not self.keyframes: return None # Before first keyframe if time <= self.keyframes[0][0]: return self.keyframes[0][1] # After last keyframe if time >= self.keyframes[-1][0]: return self.keyframes[-1][1] # Find surrounding keyframes for i in range(len(self.keyframes) - 1): t0, v0 = self.keyframes[i] t1, v1 = self.keyframes[i + 1] if t0 <= time <= t1: # Interpolate duration = t1 - t0 if duration <= 0: return v1 t = (time - t0) / duration eased_t = self.easing(t) return _interpolate(v0, v1, eased_t) return self.keyframes[-1][1]
# ============================================================================ # AnimationClip # ============================================================================
[docs] class AnimationClip: """Timeline-based animation with multiple property tracks. Example: clip = AnimationClip("jump", duration=1.0) clip.add_track("position", [ (0.0, Vec3(0, 0, 0)), (0.5, Vec3(0, 5, 0)), (1.0, Vec3(0, 0, 0)), ]) clip.add_track("rotation", [ (0.0, 0.0), (1.0, 360.0), ]) """ def __init__(self, name: str, duration: float): self.name = name self.duration = duration self.tracks: dict[str, Track] = {}
[docs] def add_track(self, property_name: str, keyframes: list[tuple[float, Any]], easing=ease_linear): """Add property track with keyframes.""" track = Track(property_name) track.easing = easing for time, value in keyframes: track.add_keyframe(time, value) self.tracks[property_name] = track
[docs] def evaluate(self, time: float) -> dict[str, Any]: """Evaluate all tracks at given time.""" return {prop: track.evaluate(time) for prop, track in self.tracks.items()}
[docs] def to_dict(self) -> dict: """Serialize clip.""" return { "name": self.name, "duration": self.duration, "tracks": { prop: {"keyframes": [(t, self._serialize_value(v)) for t, v in track.keyframes]} for prop, track in self.tracks.items() }, }
@staticmethod def _serialize_value(value: Any) -> Any: """Serialize keyframe value.""" if isinstance(value, (Vec2, Vec3)): return list(value) elif isinstance(value, Quat): return [value.x, value.y, value.z, value.w] return value
[docs] @classmethod def from_dict(cls, data: dict): """Deserialize clip.""" clip = cls(data["name"], data["duration"]) for prop, track_data in data.get("tracks", {}).items(): keyframes = [] for t, v in track_data["keyframes"]: # Type inference from first keyframe if isinstance(v, list): if len(v) == 2: v = Vec2(*v) elif len(v) == 3: v = Vec3(*v) elif len(v) == 4: v = Quat(v[3], v[0], v[1], v[2]) # w,x,y,z from x,y,z,w storage keyframes.append((t, v)) clip.add_track(prop, keyframes) return clip