638 lines
15 KiB
Python
638 lines
15 KiB
Python
# system modules
|
|
from dataclasses import dataclass
|
|
from typing import Callable
|
|
import itertools
|
|
import functools
|
|
import warnings
|
|
|
|
# external modules
|
|
import numpy as np
|
|
import scipy.optimize
|
|
|
|
|
|
@dataclass
|
|
@functools.total_ordering
|
|
class Extremum:
|
|
"""
|
|
Container for min and max in Easing
|
|
"""
|
|
|
|
pos: float
|
|
value: float
|
|
|
|
def __eq__(self, other):
|
|
return self.value == other.value
|
|
|
|
def __lt__(self, other):
|
|
return self.value < other.value
|
|
|
|
|
|
@dataclass
|
|
@functools.total_ordering
|
|
class Easing:
|
|
"""
|
|
A function defined on the interval [0;1]
|
|
"""
|
|
|
|
f: Callable[float, float]
|
|
name: str
|
|
|
|
def modifier(decorated_fun):
|
|
@functools.wraps(decorated_fun)
|
|
def wrapper(self, *args, **kwargs):
|
|
newfun = decorated_fun(self, *args, **kwargs)
|
|
arglist = ",".join(
|
|
itertools.chain(map(str, args), (f"{k}={v}" for k, v in kwargs.items()))
|
|
)
|
|
newfun.__name__ = f"{self.f.__name__}.{decorated_fun.__name__}({arglist})"
|
|
return type(self)(f=newfun, name=newfun.__name__)
|
|
|
|
return wrapper
|
|
|
|
def __repr__(self):
|
|
return self.name
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
@functools.cached_property
|
|
def is_ascending(self):
|
|
return np.all(np.diff(self.f(np.linspace(0, 1, 100))) >= 0)
|
|
|
|
@functools.cached_property
|
|
def is_symmetric(self):
|
|
t = np.linspace(0, 0.5, 100)
|
|
return np.allclose(self.f(t), self.f(1 - t))
|
|
|
|
@property
|
|
@modifier
|
|
def reverse(self):
|
|
"""
|
|
Revert the function so it goes the other way round (starts at the end)
|
|
"""
|
|
return lambda t: self.f(1 - t)
|
|
|
|
@property
|
|
@modifier
|
|
def symmetric(self):
|
|
"""
|
|
Mirror and squash function to make it symmetric
|
|
"""
|
|
return lambda t: self.f(-2 * (np.abs(t - 0.5) - 0.5))
|
|
|
|
@modifier
|
|
def mirror(self, x=None, y=None, copy=False):
|
|
"""
|
|
Mirror function around an x and/or y value.
|
|
|
|
Args:
|
|
x (float): x value to mirror around
|
|
y (float): y value to mirror around
|
|
copy (bool): when mirroring around x, do copy-mirror
|
|
"""
|
|
if (x, y) == (None, None):
|
|
x = 0.5
|
|
|
|
def mirrored(t):
|
|
if x is not None:
|
|
t = 2 * x - t
|
|
if copy:
|
|
t = np.abs(-t)
|
|
if y is None:
|
|
return self.f(t)
|
|
else:
|
|
return y - self.f(t)
|
|
|
|
return mirrored
|
|
|
|
@modifier
|
|
def clip(self, min=None, max=None):
|
|
"""
|
|
Clip function at low and/or high values
|
|
"""
|
|
if min is None and max is None:
|
|
min = 0
|
|
max = 1
|
|
return lambda t: np.clip(self.f(t), min, max)
|
|
|
|
@modifier
|
|
def clip_input(self, min=None, max=None):
|
|
"""
|
|
Clip input parameter, i.e. extrapolate constantly outside the interval.
|
|
"""
|
|
if min is None and max is None:
|
|
min = 0
|
|
max = 1
|
|
return lambda t: self.f(np.clip(t, min, max))
|
|
|
|
@property
|
|
@modifier
|
|
def clipped(self):
|
|
"""
|
|
Clipped parameter and result to [0;1]
|
|
"""
|
|
return lambda t: np.clip(self(np.clip(t, 0, 1)), 0, 1)
|
|
|
|
@modifier
|
|
def append(self, other, e=None):
|
|
"""
|
|
Append another easing function and squish both into the [0;1] interval
|
|
"""
|
|
if e is None:
|
|
e = in_out_square
|
|
|
|
def f(t):
|
|
mix = e(t)
|
|
return self.f(t * 2) * (1 - mix) + other((t - 0.5) * 2) * mix
|
|
|
|
return f
|
|
|
|
@modifier
|
|
def prepend(self, other, e=None):
|
|
"""
|
|
Prepend another easing function and squish both into the [0;1] interval
|
|
"""
|
|
if e is None:
|
|
e = in_out_square
|
|
|
|
def f(t):
|
|
mix = e(t)
|
|
return other(t * 2) * (1 - mix) + self.f((t - 0.5) * 2) * mix
|
|
|
|
return f
|
|
|
|
@modifier
|
|
def shift(self, offset):
|
|
"""
|
|
Shift function on x-axis into positive direction by ``offset``.
|
|
"""
|
|
return lambda t: self.f(t - offset)
|
|
|
|
@modifier
|
|
def repeat(self, n=2):
|
|
"""
|
|
Repeat the function a total of n times in the interval [0;1].
|
|
"""
|
|
return lambda t: self.f(t % (1 / n) * n)
|
|
|
|
@modifier
|
|
def multiply(self, factor):
|
|
"""
|
|
Scale function by ``factor``
|
|
"""
|
|
if isinstance(factor, Easing):
|
|
return lambda t: self(t) * factor(t)
|
|
else:
|
|
return lambda t: factor * self.f(t)
|
|
|
|
@modifier
|
|
def add(self, offset):
|
|
"""
|
|
Add ``offset`` to function
|
|
"""
|
|
if isinstance(offset, Easing):
|
|
return lambda t: self(t) + offset(t)
|
|
else:
|
|
return lambda t: self.f(t) + offset
|
|
|
|
def __add__(self, offset):
|
|
return self.add(offset)
|
|
|
|
def __radd__(self, offset):
|
|
return self.add(offset)
|
|
|
|
def __sub__(self, offset):
|
|
return self.add(-offset)
|
|
|
|
def __rsub__(self, offset):
|
|
return self.add(-offset)
|
|
|
|
def __mul__(self, factor):
|
|
return self.multiply(factor)
|
|
|
|
def __rmul__(self, factor):
|
|
return self.multiply(factor)
|
|
|
|
def __neg__(self):
|
|
return self.multiply(-1)
|
|
|
|
def __truediv__(self, factor):
|
|
return self.multiply(1 / factor)
|
|
|
|
def __or__(self, other):
|
|
return self.transition(other)
|
|
|
|
def __rshift__(self, offset):
|
|
return self.shift(offset)
|
|
|
|
def __lshift__(self, offset):
|
|
return self.shift(-offset)
|
|
|
|
def __getitem__(self, index):
|
|
if isinstance(index, Easing):
|
|
return self.chain(index)
|
|
if isinstance(index, slice):
|
|
return self.zoom(
|
|
0 if index.start is None else index.start,
|
|
1 if index.stop is None else index.stop,
|
|
)
|
|
else:
|
|
raise ValueError(
|
|
f"{index = } has to be slice of floats or an easing function"
|
|
)
|
|
|
|
@modifier
|
|
def chain(self, f=None):
|
|
"""
|
|
Feed parameter through the given function before evaluating this function.
|
|
"""
|
|
if f is None:
|
|
f = self.f
|
|
return lambda t: self.f(f(t))
|
|
|
|
@modifier
|
|
def zoom(self, left, right=None):
|
|
"""
|
|
Arrange so that the interval [left;right] is moved into [0;1]
|
|
If only one argument is given, zoom in/out by moving edges that far.
|
|
"""
|
|
if left is not None and right is None:
|
|
if left >= 0.5:
|
|
raise ValueError(
|
|
f"{left = } is > 0.5 which doesn't make sense (bounds would cross)"
|
|
)
|
|
left = left
|
|
right = 1 - left
|
|
if left >= right:
|
|
raise ValueError(f"{right = } bound must be greater than {left = }")
|
|
return self.chain(linear.between(left, right)).f
|
|
|
|
@modifier
|
|
def between(self, left=0, right=1, e=None):
|
|
"""
|
|
Arrange so ``f(0)==a`` and ``f(1)==b``.
|
|
"""
|
|
f0, f1 = self.f(np.array([0, 1]))
|
|
la = f0 - left
|
|
lb = f1 - right
|
|
if e is None: # linear is defined later
|
|
e = (
|
|
self # use ourself as transition when we're ascending within [0;1]
|
|
if (self.is_ascending and np.allclose(self.f(np.array([0, 1])), [0, 1]))
|
|
else linear
|
|
)
|
|
|
|
def f(t):
|
|
t_ = e(t)
|
|
return self.f(t_) - (la * (1 - t_)) - lb * t_
|
|
|
|
return f
|
|
|
|
@modifier
|
|
def transition(self, other, e=None):
|
|
"""
|
|
Transiton from one easing to another
|
|
"""
|
|
if e is None:
|
|
e = linear
|
|
|
|
def f(t):
|
|
t_ = e(t)
|
|
return self.f(t) * (1 - t_) + other(t) * t_
|
|
|
|
return f
|
|
|
|
@classmethod
|
|
def function(cls, decorated_fun):
|
|
return cls(f=decorated_fun, name=decorated_fun.__name__)
|
|
|
|
def plot(self, *others, xlim=(0, 1), ax=None):
|
|
import matplotlib.pyplot as plt # lazy import for speed
|
|
from cycler import cycler
|
|
|
|
if ax is None:
|
|
fig, ax_ = plt.subplots()
|
|
else:
|
|
ax_ = ax
|
|
|
|
try:
|
|
ax_.set_prop_cycle(
|
|
cycler(linestyle=["solid", "dashed", "dotted"], linewidth=[1, 1, 2])
|
|
* plt.rcParams["axes.prop_cycle"]
|
|
)
|
|
except ValueError as e:
|
|
pass
|
|
|
|
t = np.linspace(*xlim, 1000)
|
|
funs = list(others or [])
|
|
if isinstance(self, Easing):
|
|
funs.insert(0, self)
|
|
for f in funs:
|
|
ax_.plot(t, f(t), label=getattr(f, "name", getattr(f, "__name__", str(f))))
|
|
ax_.legend(ncol=int(np.ceil(len(ax_.get_lines()) / 10)))
|
|
if ax is None:
|
|
plt.show()
|
|
return ax_
|
|
|
|
@functools.cached_property
|
|
def min(self):
|
|
v = self.f(t := np.linspace(0, 1, 1000))
|
|
approxmin = Extremum(pos=t[i := np.argmin(v)], value=v[i])
|
|
opt = scipy.optimize.minimize(self, x0=[approxmin.pos], bounds=[(0, 1)])
|
|
optmin = Extremum(pos=opt.x[0], value=opt.fun)
|
|
return min(approxmin, optmin)
|
|
|
|
@functools.cached_property
|
|
def max(self):
|
|
"""
|
|
Determine the maximum value
|
|
"""
|
|
v = self.f(t := np.linspace(0, 1, 1000))
|
|
approxmax = Extremum(pos=t[i := np.argmax(v)], value=v[i])
|
|
opt = scipy.optimize.minimize(-self, x0=[approxmax.pos], bounds=[(0, 1)])
|
|
optmax = Extremum(pos=opt.x[0], value=-opt.fun)
|
|
return max(approxmax, optmax)
|
|
|
|
@functools.cached_property
|
|
def mean(self):
|
|
return np.mean(self.f(np.linspace(0, 1, 1000)))
|
|
|
|
def __lt__(self, e):
|
|
return np.all(self.f(t := np.linspace(0, 1, 50)) < e.f(t))
|
|
|
|
def __eq__(self, e):
|
|
return np.allclose(self.f(t := np.linspace(0, 1, 50)), e.f(t))
|
|
|
|
def __call__(self, t):
|
|
return self.f(t)
|
|
|
|
|
|
@Easing.function
|
|
def linear(t):
|
|
return t
|
|
|
|
|
|
@Easing.function
|
|
def in_quad(t):
|
|
return t * t
|
|
|
|
|
|
@Easing.function
|
|
def out_quad(t):
|
|
return -t * (t - 2)
|
|
|
|
|
|
@Easing.function
|
|
def in_out_quad(t):
|
|
u = 2 * t - 1
|
|
a = 2 * t * t
|
|
b = -0.5 * (u * (u - 2) - 1)
|
|
return np.where(t < 0.5, a, b)
|
|
|
|
|
|
@Easing.function
|
|
def in_cubic(t):
|
|
return t * t * t
|
|
|
|
|
|
@Easing.function
|
|
def out_cubic(t):
|
|
u = t - 1
|
|
return u * u * u + 1
|
|
|
|
|
|
@Easing.function
|
|
def in_out_cubic(t):
|
|
u = t * 2
|
|
v = u - 2
|
|
a = 0.5 * u * u * u
|
|
b = 0.5 * (v * v * v + 2)
|
|
return np.where(u < 1, a, b)
|
|
|
|
|
|
@Easing.function
|
|
def in_quart(t):
|
|
return t * t * t * t
|
|
|
|
|
|
@Easing.function
|
|
def out_quart(t):
|
|
u = t - 1
|
|
return -(u * u * u * u - 1)
|
|
|
|
|
|
@Easing.function
|
|
def in_out_quart(t):
|
|
u = t * 2
|
|
v = u - 2
|
|
a = 0.5 * u * u * u * u
|
|
b = -0.5 * (v * v * v * v - 2)
|
|
return np.where(u < 1, a, b)
|
|
|
|
|
|
@Easing.function
|
|
def in_quint(t):
|
|
return t * t * t * t * t
|
|
|
|
|
|
@Easing.function
|
|
def out_quint(t):
|
|
u = t - 1
|
|
return u * u * u * u * u + 1
|
|
|
|
|
|
@Easing.function
|
|
def in_out_quint(t):
|
|
u = t * 2
|
|
v = u - 2
|
|
a = 0.5 * u * u * u * u * u
|
|
b = 0.5 * (v * v * v * v * v + 2)
|
|
return np.where(u < 1, a, b)
|
|
|
|
|
|
@Easing.function
|
|
def in_sine(t):
|
|
return -np.cos(t * np.pi / 2) + 1
|
|
|
|
|
|
@Easing.function
|
|
def out_sine(t):
|
|
return np.sin(t * np.pi / 2)
|
|
|
|
|
|
@Easing.function
|
|
def in_out_sine(t):
|
|
return -0.5 * (np.cos(np.pi * t) - 1)
|
|
|
|
|
|
@Easing.function
|
|
def in_expo(t):
|
|
a = np.zeros(len(t))
|
|
b = 2 ** (10 * (t - 1))
|
|
return np.where(t == 0, a, b)
|
|
|
|
|
|
@Easing.function
|
|
def out_expo(t):
|
|
a = np.zeros(len(t)) + 1
|
|
b = 1 - 2 ** (-10 * t)
|
|
return np.where(t == 1, a, b)
|
|
|
|
|
|
@Easing.function
|
|
def in_out_expo(t):
|
|
zero = np.zeros(len(t))
|
|
one = zero + 1
|
|
a = 0.5 * 2 ** (20 * t - 10)
|
|
b = 1 - 0.5 * 2 ** (-20 * t + 10)
|
|
return np.where(t == 0, zero, np.where(t == 1, one, np.where(t < 0.5, a, b)))
|
|
|
|
|
|
@Easing.function
|
|
def in_circ(t):
|
|
return -1 * (np.sqrt(1 - t * t) - 1)
|
|
|
|
|
|
@Easing.function
|
|
def out_circ(t):
|
|
u = t - 1
|
|
return np.sqrt(1 - u * u)
|
|
|
|
|
|
@Easing.function
|
|
def in_out_circ(t):
|
|
u = t * 2
|
|
v = u - 2
|
|
a = -0.5 * (np.sqrt(1 - u * u) - 1)
|
|
b = 0.5 * (np.sqrt(1 - v * v) + 1)
|
|
return np.where(u < 1, a, b)
|
|
|
|
|
|
@Easing.function
|
|
def in_elastic(t, k=0.5):
|
|
u = t - 1
|
|
return -1 * (2 ** (10.0 * u) * np.sin((u - k / 4) * (2 * np.pi) / k))
|
|
|
|
|
|
@Easing.function
|
|
def out_elastic(t, k=0.5):
|
|
return 2 ** (-10.0 * t) * np.sin((t - k / 4) * (2 * np.pi / k)) + 1
|
|
|
|
|
|
@Easing.function
|
|
def in_out_elastic(t, k=0.5):
|
|
u = t * 2
|
|
v = u - 1
|
|
a = -0.5 * (2 ** (10 * v) * np.sin((v - k / 4) * 2 * np.pi / k))
|
|
b = 2 ** (-10 * v) * np.sin((v - k / 4) * 2 * np.pi / k) * 0.5 + 1
|
|
return np.where(u < 1, a, b)
|
|
|
|
|
|
@Easing.function
|
|
def in_back(t):
|
|
k = 1.70158
|
|
return t * t * ((k + 1) * t - k)
|
|
|
|
|
|
@Easing.function
|
|
def out_back(t):
|
|
k = 1.70158
|
|
u = t - 1
|
|
return u * u * ((k + 1) * u + k) + 1
|
|
|
|
|
|
@Easing.function
|
|
def in_out_back(t):
|
|
k = 1.70158 * 1.525
|
|
u = t * 2
|
|
v = u - 2
|
|
a = 0.5 * (u * u * ((k + 1) * u - k))
|
|
b = 0.5 * (v * v * ((k + 1) * v + k) + 2)
|
|
return np.where(u < 1, a, b)
|
|
|
|
|
|
@Easing.function
|
|
def in_bounce(t):
|
|
return 1 - out_bounce(1 - t)
|
|
|
|
|
|
@Easing.function
|
|
def out_bounce(t):
|
|
a = (121 * t * t) / 16
|
|
b = (363 / 40 * t * t) - (99 / 10 * t) + 17 / 5
|
|
c = (4356 / 361 * t * t) - (35442 / 1805 * t) + 16061 / 1805
|
|
d = (54 / 5 * t * t) - (513 / 25 * t) + 268 / 25
|
|
return np.where(t < 4 / 11, a, np.where(t < 8 / 11, b, np.where(t < 9 / 10, c, d)))
|
|
|
|
|
|
@Easing.function
|
|
def in_out_bounce(t):
|
|
a = in_bounce(2 * t) * 0.5
|
|
b = out_bounce(2 * t - 1) * 0.5 + 0.5
|
|
return np.where(t < 0.5, a, b)
|
|
|
|
|
|
@Easing.function
|
|
def in_square(t):
|
|
return np.heaviside(t - 1, 0)
|
|
|
|
|
|
@Easing.function
|
|
def out_square(t):
|
|
return np.heaviside(t + 1, 0)
|
|
|
|
|
|
@Easing.function
|
|
def in_out_square(t):
|
|
return np.heaviside(t - 0.5, 0)
|
|
|
|
|
|
def constant(x):
|
|
return Easing(f=lambda t: np.full_like(t, x), name=f"constant({x})")
|
|
|
|
|
|
zero = constant(0)
|
|
one = constant(1)
|
|
|
|
|
|
@Easing.function
|
|
def smoothstep(t):
|
|
t = np.clip(t, 0, 1)
|
|
return 3 * t * t - 2 * t * t * t
|
|
|
|
|
|
def _main():
|
|
import matplotlib.pyplot as plt
|
|
from cycler import cycler
|
|
|
|
plt.rcParams["axes.prop_cycle"] *= cycler(
|
|
linestyle=["solid", "dashed", "dotted"], linewidth=[1, 2, 3]
|
|
)
|
|
plt.rcParams["figure.autolayout"] = True
|
|
plt.rcParams["axes.grid"] = True
|
|
plt.rcParams["axes.axisbelow"] = True
|
|
plt.rcParams["legend.fontsize"] = "small"
|
|
LOCALS = globals()
|
|
print(f"{LOCALS = }")
|
|
fig, axes = plt.subplots(nrows=2)
|
|
Easing.plot(
|
|
*sorted((obj for n, obj in LOCALS.items() if isinstance(obj, Easing)), key=str),
|
|
ax=axes[0],
|
|
)
|
|
Easing.plot(
|
|
in_sine.symmetric,
|
|
in_out_sine.symmetric.multiply(-0.6),
|
|
linear.symmetric.multiply(-0.7),
|
|
in_out_sine.multiply(-0.6).symmetric,
|
|
out_sine.multiply(-0.6).reverse.symmetric.multiply(2),
|
|
out_bounce.add(-0.5),
|
|
ax=axes[1],
|
|
)
|
|
axes[0].set_title("Standard")
|
|
axes[1].set_title("Derived")
|
|
plt.show()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
_main()
|