Files
fluencyCAD/sdf/ease.py

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()