"""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