""" High-Performance Easing Curves, Physics Springs, and Interpolators for Blend2D Motion. """ from __future__ import annotations import math from typing import Callable, Union, Tuple, Optional, Dict, Any from blend2d.color import parse_color, Rgba32 from blend2d.matrix import Matrix2D # ============================================================================ # Core Interpolators # ============================================================================ def lerp(a: float, b: float, t: float) -> float: """Standard linear interpolation between a and b at progress t.""" return a + (b - a) * t def clamp(val: float, min_val: float = 0.0, max_val: float = 1.0) -> float: """Clamp value between min_val and max_val.""" return max(min_val, min(max_val, val)) def lerp_color(c1: Union[str, Rgba32, int], c2: Union[str, Rgba32, int], t: float) -> Rgba32: """Smooth linear color interpolation between two colors.""" t = clamp(t, 0.0, 1.0) col1 = parse_color(c1) col2 = parse_color(c2) r = int(round(lerp(col1.r, col2.r, t))) g = int(round(lerp(col1.g, col2.g, t))) b = int(round(lerp(col1.b, col2.b, t))) a = int(round(lerp(col1.a, col2.a, t))) return Rgba32(r, g, b, a) def lerp_matrix(m1: Matrix2D, m2: Matrix2D, t: float) -> Matrix2D: """Interpolate all affine transformation matrix components.""" return Matrix2D( m00=lerp(m1.m00, m2.m00, t), m01=lerp(m1.m01, m2.m01, t), m10=lerp(m1.m10, m2.m10, t), m11=lerp(m1.m11, m2.m11, t), m20=lerp(m1.m20, m2.m20, t), m21=lerp(m1.m21, m2.m21, t) ) # ============================================================================ # Penner Easing Equations # ============================================================================ class Easing: """Collection of standard Penner animation curves and timing functions.""" @staticmethod def linear(t: float) -> float: return t # Quad @staticmethod def quad_in(t: float) -> float: return t * t @staticmethod def quad_out(t: float) -> float: return t * (2.0 - t) @staticmethod def quad_in_out(t: float) -> float: return 2.0 * t * t if t < 0.5 else -1.0 + (4.0 - 2.0 * t) * t # Cubic @staticmethod def cubic_in(t: float) -> float: return t * t * t @staticmethod def cubic_out(t: float) -> float: t -= 1.0 return t * t * t + 1.0 @staticmethod def cubic_in_out(t: float) -> float: return 4.0 * t * t * t if t < 0.5 else (t - 1.0) * (2.0 * t - 2.0) * (2.0 * t - 2.0) + 1.0 # Quart @staticmethod def quart_in(t: float) -> float: return t * t * t * t @staticmethod def quart_out(t: float) -> float: t -= 1.0 return 1.0 - t * t * t * t @staticmethod def quart_in_out(t: float) -> float: t2 = t * 2.0 if t2 < 1.0: return 0.5 * t2 * t2 * t2 * t2 t2 -= 2.0 return -0.5 * (t2 * t2 * t2 * t2 - 2.0) # Quint @staticmethod def quint_in(t: float) -> float: return t * t * t * t * t @staticmethod def quint_out(t: float) -> float: t -= 1.0 return t * t * t * t * t + 1.0 @staticmethod def quint_in_out(t: float) -> float: t2 = t * 2.0 if t2 < 1.0: return 0.5 * t2 * t2 * t2 * t2 * t2 t2 -= 2.0 return 0.5 * (t2 * t2 * t2 * t2 * t2 + 2.0) # Sine @staticmethod def sine_in(t: float) -> float: return 1.0 - math.cos(t * math.pi * 0.5) @staticmethod def sine_out(t: float) -> float: return math.sin(t * math.pi * 0.5) @staticmethod def sine_in_out(t: float) -> float: return 0.5 * (1.0 - math.cos(math.pi * t)) # Expo @staticmethod def expo_in(t: float) -> float: return 0.0 if t <= 0.0 else math.pow(2.0, 10.0 * (t - 1.0)) @staticmethod def expo_out(t: float) -> float: return 1.0 if t >= 1.0 else 1.0 - math.pow(2.0, -10.0 * t) @staticmethod def expo_in_out(t: float) -> float: if t <= 0.0: return 0.0 if t >= 1.0: return 1.0 t2 = t * 2.0 if t2 < 1.0: return 0.5 * math.pow(2.0, 10.0 * (t2 - 1.0)) return 0.5 * (2.0 - math.pow(2.0, -10.0 * (t2 - 1.0))) # Circ @staticmethod def circ_in(t: float) -> float: return 1.0 - math.sqrt(max(0.0, 1.0 - t * t)) @staticmethod def circ_out(t: float) -> float: t -= 1.0 return math.sqrt(max(0.0, 1.0 - t * t)) @staticmethod def circ_in_out(t: float) -> float: t2 = t * 2.0 if t2 < 1.0: return -0.5 * (math.sqrt(max(0.0, 1.0 - t2 * t2)) - 1.0) t2 -= 2.0 return 0.5 * (math.sqrt(max(0.0, 1.0 - t2 * t2)) + 1.0) # Back @staticmethod def back_in(t: float, s: float = 1.70158) -> float: return t * t * ((s + 1.0) * t - s) @staticmethod def back_out(t: float, s: float = 1.70158) -> float: t -= 1.0 return t * t * ((s + 1.0) * t + s) + 1.0 @staticmethod def back_in_out(t: float, s: float = 1.70158) -> float: s2 = s * 1.525 t2 = t * 2.0 if t2 < 1.0: return 0.5 * (t2 * t2 * ((s2 + 1.0) * t2 - s2)) t2 -= 2.0 return 0.5 * (t2 * t2 * ((s2 + 1.0) * t2 + s2) + 2.0) # Elastic @staticmethod def elastic_in(t: float) -> float: if t <= 0.0: return 0.0 if t >= 1.0: return 1.0 p = 0.3 s = p / 4.0 t -= 1.0 return -(math.pow(2.0, 10.0 * t) * math.sin((t - s) * (2.0 * math.pi) / p)) @staticmethod def elastic_out(t: float) -> float: if t <= 0.0: return 0.0 if t >= 1.0: return 1.0 p = 0.3 s = p / 4.0 return math.pow(2.0, -10.0 * t) * math.sin((t - s) * (2.0 * math.pi) / p) + 1.0 @staticmethod def elastic_in_out(t: float) -> float: if t <= 0.0: return 0.0 if t >= 1.0: return 1.0 t2 = t * 2.0 p = 0.3 * 1.5 s = p / 4.0 if t2 < 1.0: t2 -= 1.0 return -0.5 * (math.pow(2.0, 10.0 * t2) * math.sin((t2 - s) * (2.0 * math.pi) / p)) t2 -= 1.0 return math.pow(2.0, -10.0 * t2) * math.sin((t2 - s) * (2.0 * math.pi) / p) * 0.5 + 1.0 # Bounce @staticmethod def bounce_out(t: float) -> float: if t < (1.0 / 2.75): return 7.5625 * t * t elif t < (2.0 / 2.75): t -= 1.5 / 2.75 return 7.5625 * t * t + 0.75 elif t < (2.5 / 2.75): t -= 2.25 / 2.75 return 7.5625 * t * t + 0.9375 else: t -= 2.625 / 2.75 return 7.5625 * t * t + 0.984375 @staticmethod def bounce_in(t: float) -> float: return 1.0 - Easing.bounce_out(1.0 - t) @staticmethod def bounce_in_out(t: float) -> float: if t < 0.5: return Easing.bounce_in(t * 2.0) * 0.5 return Easing.bounce_out(t * 2.0 - 1.0) * 0.5 + 0.5 @classmethod def get(cls, name: Union[str, Callable[[float], float]]) -> Callable[[float], float]: """Lookup easing function by name (e.g. 'easeInOutCubic', 'bounce_out', 'sineIn').""" if callable(name): return name key = name.lower().replace("-", "_").replace(" ", "_") # Standard camelCase conversions mapping = { "easeinquad": cls.quad_in, "easeoutquad": cls.quad_out, "easeinoutquad": cls.quad_in_out, "easeincubic": cls.cubic_in, "easeoutcubic": cls.cubic_out, "easeinoutcubic": cls.cubic_in_out, "easeinquart": cls.quart_in, "easeoutquart": cls.quart_out, "easeinoutquart": cls.quart_in_out, "easeinquint": cls.quint_in, "easeoutquint": cls.quint_out, "easeinoutquint": cls.quint_in_out, "easeinsine": cls.sine_in, "easeoutsine": cls.sine_out, "easeinoutsine": cls.sine_in_out, "easeinexpo": cls.expo_in, "easeoutexpo": cls.expo_out, "easeinoutexpo": cls.expo_in_out, "easeincirc": cls.circ_in, "easeoutcirc": cls.circ_out, "easeinoutcirc": cls.circ_in_out, "easeinback": cls.back_in, "easeoutback": cls.back_out, "easeinoutback": cls.back_in_out, "easeinelastic": cls.elastic_in, "easeoutelastic": cls.elastic_out, "easeinoutelastic": cls.elastic_in_out, "easeinbounce": cls.bounce_in, "easeoutbounce": cls.bounce_out, "easeinoutbounce": cls.bounce_in_out, } clean_key = key.replace("_", "") if clean_key in mapping: return mapping[clean_key] # Check attributes if hasattr(cls, key): return getattr(cls, key) # Check reversed formats like in_out_cubic, in_cubic, out_cubic for fam in ("quad", "cubic", "quart", "quint", "sine", "expo", "circ", "back", "elastic", "bounce"): if fam in clean_key: if "inout" in clean_key or "in_out" in key: return getattr(cls, f"{fam}_in_out", cls.linear) elif "out" in clean_key: return getattr(cls, f"{fam}_out", cls.linear) elif "in" in clean_key: return getattr(cls, f"{fam}_in", cls.linear) return cls.linear # Direct aliases for common AI hallucinations: in_quad = quad_in out_quad = quad_out in_out_quad = quad_in_out easeInOutQuad = quad_in_out easeInQuad = quad_in easeOutQuad = quad_out in_cubic = cubic_in out_cubic = cubic_out in_out_cubic = cubic_in_out easeInOutCubic = cubic_in_out easeInCubic = cubic_in easeOutCubic = cubic_out in_quart = quart_in out_quart = quart_out in_out_quart = quart_in_out easeInOutQuart = quart_in_out in_quint = quint_in out_quint = quint_out in_out_quint = quint_in_out easeInOutQuint = quint_in_out in_sine = sine_in out_sine = sine_out in_out_sine = sine_in_out easeInOutSine = sine_in_out in_expo = expo_in out_expo = expo_out in_out_expo = expo_in_out easeInOutExpo = expo_in_out in_circ = circ_in out_circ = circ_out in_out_circ = circ_in_out easeInOutCirc = circ_in_out in_back = back_in out_back = back_out in_out_back = back_in_out easeInOutBack = back_in_out in_elastic = elastic_in out_elastic = elastic_out in_out_elastic = elastic_in_out easeInOutElastic = elastic_in_out in_bounce = bounce_in out_bounce = bounce_out in_out_bounce = bounce_in_out easeInOutBounce = bounce_in_out # ============================================================================ # Cubic Bezier Easing Evaluator # ============================================================================ class CubicBezier: """ CSS / After Effects style Cubic Bezier timing function `cubic-bezier(x1, y1, x2, y2)`. """ def __init__(self, x1: float, y1: float, x2: float, y2: float): self.x1 = float(x1) self.y1 = float(y1) self.x2 = float(x2) self.y2 = float(y2) def evaluate(self, t: float) -> float: if t <= 0.0: return 0.0 if t >= 1.0: return 1.0 # Solve for parameter u where B_x(u) = t using Newton-Raphson u = t for _ in range(8): # B_x(u) = 3(1-u)^2*u*x1 + 3(1-u)*u^2*x2 + u^3 u2 = u * u u3 = u2 * u om_u = 1.0 - u om_u2 = om_u * om_u current_x = 3.0 * om_u2 * u * self.x1 + 3.0 * om_u * u2 * self.x2 + u3 dx = 3.0 * om_u2 * self.x1 + 6.0 * om_u * u * (self.x2 - self.x1) + 3.0 * u2 * (1.0 - self.x2) diff = current_x - t if abs(diff) < 1e-6: break if abs(dx) < 1e-6: break u -= diff / dx u = max(0.0, min(1.0, u)) # Evaluate B_y(u) om_u = 1.0 - u return 3.0 * om_u * om_u * u * self.y1 + 3.0 * om_u * u * u * self.y2 + u * u * u def __call__(self, t: float) -> float: return self.evaluate(t) # ============================================================================ # Physics-Based Spring Dynamics # ============================================================================ class Spring: """ Analytical Damped Harmonic Oscillator for natural physics animations. """ def __init__( self, stiffness: float = 100.0, damping: float = 10.0, mass: float = 1.0, initial_velocity: float = 0.0 ): self.stiffness = float(stiffness) self.damping = float(damping) self.mass = float(mass) self.v0 = float(initial_velocity) # Natural frequency and damping ratio self.omega0 = math.sqrt(self.stiffness / self.mass) self.zeta = self.damping / (2.0 * math.sqrt(self.stiffness * self.mass)) if self.zeta < 1.0: # Underdamped self.omega_d = self.omega0 * math.sqrt(1.0 - self.zeta * self.zeta) else: self.omega_d = 0.0 def evaluate(self, t: float) -> float: """Evaluate normalized spring value at time t seconds (approaching 1.0).""" if t <= 0.0: return 0.0 if self.zeta < 1.0: # Underdamped (oscillates and settles) alpha = self.zeta * self.omega0 c1 = 1.0 c2 = (self.v0 + alpha * c1) / self.omega_d if self.omega_d > 0.0 else 0.0 decay = math.exp(-alpha * t) osc = c1 * math.cos(self.omega_d * t) + c2 * math.sin(self.omega_d * t) return 1.0 - decay * osc elif abs(self.zeta - 1.0) < 1e-5: # Critically damped alpha = self.omega0 c1 = 1.0 c2 = self.v0 + alpha * c1 return 1.0 - (c1 + c2 * t) * math.exp(-alpha * t) else: # Overdamped alpha = self.zeta * self.omega0 beta = self.omega0 * math.sqrt(self.zeta * self.zeta - 1.0) r1 = -alpha + beta r2 = -alpha - beta c2 = (self.v0 - r1) / (r2 - r1) c1 = 1.0 - c2 return 1.0 - (c1 * math.exp(r1 * t) + c2 * math.exp(r2 * t)) def __call__(self, t: float) -> float: return self.evaluate(t)