diff --git a/examples/advanced/cloth.py b/examples/advanced/cloth.py new file mode 100644 index 00000000..ca759ab7 --- /dev/null +++ b/examples/advanced/cloth.py @@ -0,0 +1,153 @@ +import asyncio +from dataclasses import dataclass + +import cv2 +import numpy as np +from batgrl.app import App +from batgrl.colors import AWHITE, AColor +from batgrl.gadgets.graphics import Graphics, Size +from batgrl.gadgets.slider import Slider +from batgrl.gadgets.text import Text +from batgrl.io import MouseButton +from numpy.typing import NDArray + +MESH_SIZE = 11, 21 +DAMPING = 0.97 +GRAVITY = np.array([0.015, 0]) + + +@dataclass +class Node: + position: NDArray[np.float64] + + def __post_init__(self): + self.velocity = np.zeros(2) + self.acceleration = np.zeros(2) + self.is_anchored = False + + def step(self): + if not self.is_anchored: + self.velocity += self.acceleration + self.position += self.velocity + self.velocity *= DAMPING + self.acceleration[:] = GRAVITY + + +@dataclass +class Link: + a: Node + b: Node + + def __post_init__(self): + self.rest_length = np.linalg.norm(self.a.position - self.b.position) + + def step(self): + direction = self.b.position - self.a.position + length = np.linalg.norm(direction) + stretch = (length - self.rest_length) / length + if stretch > 0: + momentum = direction * stretch * 0.5 + self.a.acceleration += momentum + self.b.acceleration -= momentum + + +def make_mesh(size: Size, nanchors: int) -> tuple[list[Node], list[Link]]: + height, width = size + + nodes = [ + Node(position=np.array([y, x], dtype=float)) + for y in range(height) + for x in range(width) + ] + + links = [] + for y in range(height): + for x in range(width): + a = nodes[y * width + x] + + if y != height - 1: # attach down + b = nodes[(y + 1) * width + x] + links.append(Link(a, b)) + + if x != width - 1: # attach right + b = nodes[y * width + x + 1] + links.append(Link(a, b)) + + return nodes, links + + +class Cloth(Graphics): + def __init__(self, mesh_size: Size, scale=5, mesh_color: AColor = AWHITE, **kwargs): + super().__init__(**kwargs) + self.nodes, self.links = make_mesh(mesh_size, nanchors=21) + self.scale = scale + self.mesh_color = mesh_color + self.on_size() + + def on_size(self): + super().on_size() + self.h_offset = (self.width - self.nodes[-1].position[1] * self.scale) / 2 + + def scale_pos(self, pos: NDArray[np.float64]) -> NDArray[np.float64]: + scaled = self.scale * pos + scaled[1] += self.h_offset + return scaled + + def step(self): + self.clear() + for link in self.links: + link.step() + for node in self.nodes: + node.step() + for link in self.links: + cv2.line( + self.texture, + self.scale_pos(link.a.position).astype(int)[::-1], + self.scale_pos(link.b.position).astype(int)[::-1], + self.mesh_color, + ) + + def on_mouse(self, mouse_event): + if mouse_event.button != MouseButton.LEFT: + return False + mouse_pos = np.array(self.to_local(mouse_event.position)) + for node in self.nodes: + force_direction = self.scale_pos(node.position) - mouse_pos + magnitude = np.linalg.norm(force_direction) + if magnitude != 0: + force_normal = force_direction / magnitude + node.acceleration -= 0.01 * force_normal + return True + + +class ClothApp(App): + async def on_start(self): + cloth = Cloth( + mesh_size=MESH_SIZE, size_hint={"height_hint": 1.0, "width_hint": 1.0} + ) + slider_label = Text(size=(1, 11)) + + def update_anchors(nanchors): + nanchors = round(nanchors) + slider_label.add_str(f"Anchors: {nanchors:02d}") + height, width = MESH_SIZE + for y in range(height): + for x in range(width): + node = cloth.nodes[y * width + x] + node.position[:] = y, x + node.is_anchored = False + for i in np.linspace(0, width - 1, nanchors).astype(int): + cloth.nodes[i].is_anchored = True + + slider = Slider( + size=(1, 11), min=2, max=13, start_value=5, callback=update_anchors + ) + slider.top = slider_label.bottom + self.add_gadgets(cloth, slider_label, slider) + while True: + cloth.step() + await asyncio.sleep(0) + + +if __name__ == "__main__": + ClothApp(title="Cloth Simulation").run() diff --git a/examples/advanced/cloth/README.md b/examples/advanced/cloth/README.md deleted file mode 100644 index a8706e4d..00000000 --- a/examples/advanced/cloth/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Cloth Simulation - -A cloth simulation with batgrl. `python -m cloth` to run. diff --git a/examples/advanced/cloth/cloth/__init__.py b/examples/advanced/cloth/cloth/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/examples/advanced/cloth/cloth/__main__.py b/examples/advanced/cloth/cloth/__main__.py deleted file mode 100644 index 1732df3e..00000000 --- a/examples/advanced/cloth/cloth/__main__.py +++ /dev/null @@ -1,20 +0,0 @@ -from batgrl.app import App - -from .cloth import Cloth - -MESH_SIZE = 11, 21 - - -class ClothApp(App): - async def on_start(self): - cloth = Cloth( - mesh_size=MESH_SIZE, size_hint={"height_hint": 1.0, "width_hint": 1.0} - ) - - self.add_gadget(cloth) - - await cloth.step_forever() - - -if __name__ == "__main__": - ClothApp(title="Cloth Simulation").run() diff --git a/examples/advanced/cloth/cloth/cloth.py b/examples/advanced/cloth/cloth/cloth.py deleted file mode 100644 index 71171c02..00000000 --- a/examples/advanced/cloth/cloth/cloth.py +++ /dev/null @@ -1,73 +0,0 @@ -import asyncio - -import cv2 -import numpy as np -from batgrl.colors import AWHITE, AColor -from batgrl.gadgets.graphics import Graphics, Size -from batgrl.io import MouseButton - -from .mesh import Mesh - - -class Cloth(Graphics): - def __init__(self, mesh_size: Size, scale=5, mesh_color: AColor = AWHITE, **kwargs): - super().__init__(**kwargs) - - self.mesh = Mesh(mesh_size, nanchors=5) - self.scale = scale - self.mesh_color = mesh_color - - self.on_size() - - def on_size(self): - h, w = self._size - - self.texture = np.full((h * 2, w, 4), self.default_color, dtype=np.uint8) - - # Center the nodes horizontally in the gadget with following offset: - self.h_offset = ( - (self.width - self.mesh.nodes[-1].position.imag * self.scale) / 2 * 1j - ) - - def step(self): - """Step the mesh and draw a line for each link.""" - texture = self.texture - texture[:] = self.default_color - - color = self.mesh_color - mesh = self.mesh - scale = self.scale - h_offset = self.h_offset - - mesh.step() - - for link in mesh.links: - a_pos = scale * link.a.position + h_offset - ay, ax = int(a_pos.real), int(a_pos.imag) - - b_pos = scale * link.b.position + h_offset - by, bx = int(b_pos.real), int(b_pos.imag) - - cv2.line(texture, (ax, ay), (bx, by), color) - - async def step_forever(self): - while True: - self.step() - await asyncio.sleep(0) - - def on_mouse(self, mouse_event): - if mouse_event.button != MouseButton.LEFT: - return False - - mouse_pos = complex(*self.to_local(mouse_event.position)) - scale = self.scale - h_offset = self.h_offset - - for node in self.mesh.nodes: - force_direction = scale * node.position + h_offset - mouse_pos - magnitude = abs(force_direction) - if magnitude != 0: - force_normal = force_direction / magnitude - node.acceleration -= 0.01 * force_normal - - return True diff --git a/examples/advanced/cloth/cloth/link.py b/examples/advanced/cloth/cloth/link.py deleted file mode 100644 index 83c1f8ad..00000000 --- a/examples/advanced/cloth/cloth/link.py +++ /dev/null @@ -1,25 +0,0 @@ -from .node import Node - - -class Link: - def __init__(self, a: Node, b: Node): - self.a = a - self.b = b - - self.rest_length = abs(a.position - b.position) - - def step(self): - a = self.a - b = self.b - - direction = b.position - a.position - length = abs(direction) - stretch = (length - self.rest_length) / length - - if stretch > 0: - # Typical calculation is `direction * stretch / (a.mass + b.mass)`, - # but nodes have implicit mass of 1. - momentum = direction * stretch * 0.5 - - a.acceleration += momentum - b.acceleration -= momentum diff --git a/examples/advanced/cloth/cloth/mesh.py b/examples/advanced/cloth/cloth/mesh.py deleted file mode 100644 index 78728bed..00000000 --- a/examples/advanced/cloth/cloth/mesh.py +++ /dev/null @@ -1,47 +0,0 @@ -import numpy as np - -from .link import Link -from .node import Node - - -class Mesh: - def __init__(self, size, *, nanchors=None): - height, width = size - - # Create a grid of nodes. - nodes = [ - [Node(position=complex(y, x)) for x in range(width)] for y in range(height) - ] - - # Link adjacent nodes. - links = [] - for y in range(height): - for x in range(width): - a = nodes[y][x] - - if y != height - 1: # attach down - b = nodes[y + 1][x] - links.append(Link(a, b)) - - if x != width - 1: # attach right - b = nodes[y][x + 1] - links.append(Link(a, b)) - - if nanchors is None: # Anchor entire top row. - for node in nodes[0]: - node.is_anchored = True - elif nanchors == 1: # Anchor midpoint of top row. - nodes[0][width // 2].is_anchored = True - else: # Evenly spaced nanchors anchors on top row. - for i in np.linspace(0, width - 1, nanchors).astype(int): - nodes[0][i].is_anchored = True - - self.nodes = [node for row in nodes for node in row] # flatten - self.links = links - - def step(self): - for link in self.links: - link.step() - - for node in self.nodes: - node.step() diff --git a/examples/advanced/cloth/cloth/node.py b/examples/advanced/cloth/cloth/node.py deleted file mode 100644 index b44dcb57..00000000 --- a/examples/advanced/cloth/cloth/node.py +++ /dev/null @@ -1,18 +0,0 @@ -FRICTION = 0.97 # Decrease this value for *more* friction. -GRAVITY = 0.015 + 0j - - -class Node: - def __init__(self, position: complex, *, is_anchored=False): - self.position = position - self.velocity = self.acceleration = 0j - self.is_anchored = is_anchored - - def step(self): - if not self.is_anchored: - self.velocity += self.acceleration - self.position += self.velocity - - self.velocity *= FRICTION - - self.acceleration = GRAVITY