Source code for simvx.core.animation.track

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

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

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()}