Source code for simvx.core.animation.blend_space

"""Blend spaces for multi-clip animation blending."""

from __future__ import annotations

import math
from typing import Any

from ._interpolate import _blend_values
from .track import AnimationClip

# ============================================================================
# BlendSpace1D
# ============================================================================


[docs] class BlendSpace1D: """Blends between animation clips along a single parameter axis. Each clip is placed at a numeric position on the axis. When the parameter is set, the two nearest clips are blended proportionally. Example: bs = BlendSpace1D() bs.add_point(idle_clip, 0.0) bs.add_point(walk_clip, 0.5) bs.add_point(run_clip, 1.0) bs.set_parameter(0.75) # blend walk+run value = bs.sample("speed", 0.5) # sample at t=0.5 in blended clips """ def __init__(self): self._points: list[tuple[float, AnimationClip]] = [] # sorted by position self._parameter: float = 0.0
[docs] def add_point(self, clip: AnimationClip, position: float) -> None: """Register a clip at a position on the blend axis.""" self._points.append((position, clip)) self._points.sort(key=lambda p: p[0])
[docs] def set_parameter(self, value: float) -> None: """Set the current blend parameter value.""" self._parameter = value
@property def parameter(self) -> float: return self._parameter
[docs] def sample(self, property_path: str, time: float) -> Any: """Sample a property at *time* with the current blend parameter. Returns the blended value by evaluating the two nearest clips. """ if not self._points: return None if len(self._points) == 1: return self._points[0][1].evaluate(time).get(property_path) # Clamp to range val = max(self._points[0][0], min(self._parameter, self._points[-1][0])) # Find the two bracketing points for i in range(len(self._points) - 1): pos_a, clip_a = self._points[i] pos_b, clip_b = self._points[i + 1] if pos_a <= val <= pos_b: span = pos_b - pos_a t = (val - pos_a) / span if span > 0 else 0.0 v0 = clip_a.evaluate(time).get(property_path) v1 = clip_b.evaluate(time).get(property_path) return _blend_values(v0, v1, t) # Fallback: last clip return self._points[-1][1].evaluate(time).get(property_path)
[docs] def sample_all(self, time: float) -> dict[str, Any]: """Sample all properties at *time* using the current blend parameter.""" if not self._points: return {} if len(self._points) == 1: return self._points[0][1].evaluate(time) val = max(self._points[0][0], min(self._parameter, self._points[-1][0])) for i in range(len(self._points) - 1): pos_a, clip_a = self._points[i] pos_b, clip_b = self._points[i + 1] if pos_a <= val <= pos_b: span = pos_b - pos_a t = (val - pos_a) / span if span > 0 else 0.0 vals_a = clip_a.evaluate(time) vals_b = clip_b.evaluate(time) result: dict[str, Any] = {} for prop in set(vals_a.keys()) | set(vals_b.keys()): result[prop] = _blend_values(vals_a.get(prop), vals_b.get(prop), t) return result return self._points[-1][1].evaluate(time)
# ============================================================================ # BlendSpace2D # ============================================================================
[docs] class BlendSpace2D: """Blends between animation clips positioned in 2D parameter space. Uses inverse-distance weighted interpolation across all points for robustness (falls back to exact match when the parameter lands directly on a point). Example: bs = BlendSpace2D() bs.add_point(idle_clip, (0.0, 0.0)) bs.add_point(walk_fwd_clip, (0.0, 1.0)) bs.add_point(strafe_r_clip, (1.0, 0.0)) bs.set_parameter(0.5, 0.5) value = bs.sample("position", 0.5) """ def __init__(self): self._points: list[tuple[tuple[float, float], AnimationClip]] = [] self._px: float = 0.0 self._py: float = 0.0
[docs] def add_point(self, clip: AnimationClip, position: tuple[float, float]) -> None: """Register a clip at a 2D position.""" self._points.append((position, clip))
[docs] def set_parameter(self, x: float, y: float) -> None: """Set the current 2D blend parameter.""" self._px, self._py = x, y
[docs] def sample(self, property_path: str, time: float) -> Any: """Sample a single property with the current 2D blend parameter.""" weights = self._compute_weights() if not weights: return None values: list[tuple[float, Any]] = [] for idx, w in weights: v = self._points[idx][1].evaluate(time).get(property_path) if v is not None: values.append((w, v)) return self._weighted_blend(values)
[docs] def sample_all(self, time: float) -> dict[str, Any]: """Sample all properties with the current 2D blend parameter.""" weights = self._compute_weights() if not weights: return {} # Gather per-clip evaluated values with their weights clip_vals: list[tuple[float, dict[str, Any]]] = [] all_props: set[str] = set() for idx, w in weights: vals = self._points[idx][1].evaluate(time) clip_vals.append((w, vals)) all_props.update(vals.keys()) result: dict[str, Any] = {} for prop in all_props: entries: list[tuple[float, Any]] = [] for w, vals in clip_vals: v = vals.get(prop) if v is not None: entries.append((w, v)) result[prop] = self._weighted_blend(entries) return result
# ------------------------------------------------------------------ # Internal helpers # ------------------------------------------------------------------ def _compute_weights(self) -> list[tuple[int, float]]: """Compute inverse-distance weights for all points. Returns list of (point_index, weight) with weights summing to 1. """ if not self._points: return [] if len(self._points) == 1: return [(0, 1.0)] dists: list[float] = [] for (px, py), _ in self._points: d = math.sqrt((self._px - px) ** 2 + (self._py - py) ** 2) dists.append(d) # Exact match on a point for i, d in enumerate(dists): if d < 1e-8: return [(i, 1.0)] inv = [1.0 / d for d in dists] total = sum(inv) return [(i, w / total) for i, w in enumerate(inv)] @staticmethod def _weighted_blend(entries: list[tuple[float, Any]]) -> Any: """Blend a list of (weight, value) pairs.""" if not entries: return None if len(entries) == 1: return entries[0][1] # Accumulate with repeated _blend_values # For numeric / vector types this gives a proper weighted average. # Strategy: iterative blend -- acc = blend(acc, next, w_next / remaining_w) total_w = sum(w for w, _ in entries) if total_w < 1e-12: return entries[0][1] acc = entries[0][1] acc_w = entries[0][0] for w, v in entries[1:]: combined = acc_w + w t = w / combined if combined > 0 else 0.0 acc = _blend_values(acc, v, t) acc_w = combined return acc