Skip to content

Commit

Permalink
Add move_along_path and related example.
Browse files Browse the repository at this point in the history
  • Loading branch information
salt-die committed Mar 11, 2024
1 parent efde969 commit 07b8ae4
Show file tree
Hide file tree
Showing 2 changed files with 173 additions and 22 deletions.
64 changes: 64 additions & 0 deletions examples/basic/motion.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""An example showcasing movement along a path made up of Bezier curves."""
import asyncio
from itertools import cycle
from pathlib import Path

import numpy as np
from batgrl.app import App
from batgrl.colors import (
ABLUE,
AGREEN,
ARED,
AYELLOW,
DEFAULT_PRIMARY_BG,
DEFAULT_PRIMARY_FG,
gradient,
)
from batgrl.gadgets.graphics import Graphics
from batgrl.gadgets.image import Image
from batgrl.gadgets.text import Text, cell
from batgrl.geometry import BezierCurve, Easing, move_along_path

LOGO = Path(__file__).parent / ".." / "assets" / "python_discord_logo.png"
BG_SIZE = (30, 80)
GRADIENTS = [
gradient(ARED, AGREEN, 100),
gradient(AGREEN, ABLUE, 100),
gradient(ABLUE, AYELLOW, 100),
]


class PathApp(App):
async def on_start(self):
bg = Graphics(size=BG_SIZE, default_color=(*DEFAULT_PRIMARY_BG, 255))
image = Image(path=LOGO, size=(15, 30), alpha=0.85)
label = Text(
default_cell=cell(fg_color=DEFAULT_PRIMARY_FG, bg_color=DEFAULT_PRIMARY_BG),
is_transparent=True,
)

self.add_gadgets(bg, image, label)

for easing in cycle(Easing.__args__):
label.set_text(f"Easing: {easing}")
control_points = np.random.random((7, 2)) * BG_SIZE
path = [
BezierCurve(control_points[:3]),
BezierCurve(control_points[2:5]),
BezierCurve(control_points[4:]),
]

# Draw curve:
bg.clear()
for curve, gradient_ in zip(path, GRADIENTS):
points = curve.evaluate(np.linspace(0, 1, 100)).astype(int)
points[:, 0] *= 2
for (y, x), color in zip(points, gradient_):
bg.texture[y : y + 2, x] = color

await move_along_path(image, path=path, speed=20, easing=easing)
await asyncio.sleep(1)


if __name__ == "__main__":
PathApp(title="Motion Example", bg_color=DEFAULT_PRIMARY_BG).run()
131 changes: 109 additions & 22 deletions src/batgrl/geometry.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
"""Data structures and functions for :mod:`batgrl` geometry."""
import asyncio
from bisect import bisect
from dataclasses import dataclass, field
from itertools import accumulate
from math import comb
from numbers import Real
from typing import Callable, Iterator, NamedTuple
from time import monotonic
from typing import Callable, Iterator, NamedTuple, Protocol

import numpy as np
from numpy.typing import NDArray

from .easings import EASINGS, Easing

__all__ = [
"clamp",
"lerp",
Expand Down Expand Up @@ -566,7 +571,38 @@ def __contains__(self, point: Point) -> bool:

@dataclass
class BezierCurve:
"""A Bezier curve."""
"""
A Bezier curve.
Parameters
----------
control_points : NDArray[np.float32]
Array of control points of Bezier curve with shape `(N, 2)`.
arc_length_approximation : int, default: 50
Number of evaluations for arc length approximation.
Attributes
----------
arc_length : float
Approximate length of Bezier curve.
arc_length_approximation : int
Number of evaluations for arc length approximation.
arc_lengths : NDArray[np.float32]
Approximate arc lengths along Bezier curve.
coef : NDArray[np.float32]
Binomial coefficients of Bezier curve.
control_points : NDArray[np.float32]
Array of control points of Bezier curve with shape `(N, 2)`.
degree : int
Degree of Bezier curve.
Methods
-------
evaluate(t)
Evaluate the Bezier curve at `t` (0 <= t <= 1).
arc_length_proportion(p)
Evaluate the Bezier curve at a proportion of its total arc length.
"""

control_points: NDArray[np.float32]
"""Array of control points of Bezier curve with shape `(N, 2)`."""
Expand All @@ -577,12 +613,14 @@ def __post_init__(self):
if self.degree == -1:
raise ValueError("There must be at least one control point.")

self.coef = np.array([comb(self.degree, i) for i in range(self.degree + 1)])
self.coef: NDArray[np.float32] = np.array(
[comb(self.degree, i) for i in range(self.degree + 1)], dtype=float
)
"""Binomial coefficients of Bezier curve."""

evaluated = self.evaluate(np.linspace(0, 1, self.arc_length_approximation))
norms = np.linalg.norm(evaluated[1:] - evaluated[:-1], axis=-1)
self.arc_lengths = np.append(0, norms.cumsum())
self.arc_lengths: NDArray[np.float32] = np.append(0, norms.cumsum())
"""Approximate arc lengths along Bezier curve."""

@property
Expand All @@ -591,7 +629,7 @@ def degree(self) -> int:
return len(self.control_points) - 1

@property
def length(self) -> float:
def arc_length(self) -> float:
"""Approximate length of Bezier curve."""
return self.arc_lengths[-1]

Expand All @@ -605,23 +643,72 @@ def evaluate(self, t: float | NDArray[np.float32]) -> NDArray[np.float32]:

def arc_length_proportion(self, p: float) -> NDArray[np.float32]:
"""Evaluate the Bezier curve at a proportion of its total arc length."""
if p == 0.0:
return self.evaluate(0.0)
if p == 1.0:
return self.evaluate(1.0)

target_length = self.length * p
i = np.searchsorted(self.arc_lengths, target_length)

right = target_length - self.arc_lengths[i - 1]
left = target_length - self.arc_lengths[i]
if abs(right) < abs(left):
i -= 1
target_dif = right
else:
target_dif = left
target_length = self.arc_length * p
n = self.arc_length_approximation
i = clamp(bisect(self.arc_lengths, target_length) - 1, 0, n - 1)

previous_length = self.arc_lengths[i]
if previous_length == target_length:
return self.evaluate(i / n)

arc_dif = self.arc_lengths[i + 1] - self.arc_lengths[i]
target_dif = target_length - previous_length
if i < n - 1:
arc_dif = self.arc_lengths[i + 1] - previous_length
else:
arc_dif = previous_length - self.arc_lengths[i - 1]

t = (i + target_dif / arc_dif) / self.arc_length_approximation
t = (i + target_dif / arc_dif) / n
return self.evaluate(t)


class HasPos(Protocol):
"""An object with a position."""

pos: Point


async def move_along_path(
has_pos: HasPos,
path: list[BezierCurve],
speed: float = 1.0,
easing: Easing = "linear",
):
"""
Move `has_pos` along a path of Bezier curves at some speed (in cells per second).
Parameters
----------
has_pos : HasPos
Object to be moved along path.
path : list[BezierCurve]
A path made up of Bezier curves.
speed : float, default: 1.0
Speed of movement in approximately cells per second.
"""
cumulative_arc_lengths = [
*accumulate((curve.arc_length for curve in path), initial=0)
]
total_arc_length = cumulative_arc_lengths[-1]
easing_function = EASINGS[easing]
has_pos.pos = path[0].evaluate(0.0)
last_time = monotonic()
distance_traveled = 0.0

while True:
await asyncio.sleep(0)

current_time = monotonic()
elapsed = current_time - last_time
last_time = current_time
distance_traveled += speed * elapsed
if distance_traveled >= total_arc_length:
has_pos.pos = path[-1].evaluate(1.0)
return

p = distance_traveled / total_arc_length
eased_distance = easing_function(p) * total_arc_length

i = clamp(bisect(cumulative_arc_lengths, eased_distance) - 1, 0, len(path) - 1)
distance_on_curve = eased_distance - cumulative_arc_lengths[i]
curve_p = distance_on_curve / path[i].arc_length
has_pos.pos = path[i].arc_length_proportion(curve_p)

0 comments on commit 07b8ae4

Please sign in to comment.