- added sdf folder ( doesnt work via pip or git=)
This commit is contained in:
637
sdf/ease.py
Normal file
637
sdf/ease.py
Normal file
@@ -0,0 +1,637 @@
|
||||
# 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()
|
||||
Reference in New Issue
Block a user