NaraMotion / blend2d /motion /easing.py
ArifiIslam's picture
Release NaraMotion v0.26.0: Structure of Arrays (SoA), Path Arena Allocator, Uniform Grid Indexing, Dual-Buffer Rendering & Centroid Spring Pop-in
b516e00 verified
Raw History Blame Contribute Delete
14.6 kB
"""
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)