Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ jobs:
run: sudo apt-get update && sudo apt-get install -y libcairo2-dev pkg-config

- name: Install project
run: uv sync --extra dev --python ${{ matrix.python-version }}
run: uv sync --extra dev --extra image --extra intersecting_cylinders --python ${{ matrix.python-version }}

- name: Run tests
run: uv run --no-sync pytest -q -m "not slow"
Expand Down
61 changes: 34 additions & 27 deletions eucare/alternating_flagstones.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,15 @@

from collections import defaultdict
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any

import numpy as np
from numpy.typing import NDArray

from .base import angle_to_axis, line_intersection, unit_vector
from .conway import alternating_flagstone_graph
from .cutting import cut_out_poly
from .half import Face, Vertex
from .half import Face, GeometricHEG, HalfEdge, Vertex
from .overlap import CREASE_ASSIGNMENT, MOUNTAIN, VALLEY, color_creases
from .rendering import inset_poly

Expand Down Expand Up @@ -202,7 +203,7 @@ def _assign_initial_creases(structure: AlternatingFlagstoneStructure) -> None:
# Metric


def _edge_length(h) -> float:
def _edge_length(h: HalfEdge) -> float:
return float(np.linalg.norm(h.orig["pos"] - h.dest["pos"]))


Expand Down Expand Up @@ -327,18 +328,18 @@ def optimize_alternating_flagstone(
# target_length: indices of the two flagstone-side endpoints whose
# current distance is the *target* length.
corner_index = {v: i for i, v in enumerate(corners)}
connections = []
target_length_indices = []
connections_list: list[list[int]] = []
target_length_indices_list: list[list[int]] = []
for h in structure.original.halfedges_representing_edges():
if h.on_border() or h.rev.on_border():
continue
va, _ = original_to_corner[(h.orig, h.face)]
vb, _ = original_to_corner[(h.dest, h.rev.face)]
vc, _ = original_to_corner[(h.dest, h.face)]
connections.append([corner_index[va], corner_index[vb]])
target_length_indices.append([corner_index[va], corner_index[vc]])
connections = torch.LongTensor(connections)
target_length_indices = torch.LongTensor(target_length_indices)
connections_list.append([corner_index[va], corner_index[vb]])
target_length_indices_list.append([corner_index[va], corner_index[vc]])
connections = torch.LongTensor(connections_list)
target_length_indices = torch.LongTensor(target_length_indices_list)

star_group_indices = [[corner_index[c] for c in star_groups[star]] for star in star_vertices]

Expand All @@ -349,18 +350,18 @@ def optimize_alternating_flagstone(
offsets = torch.nn.Parameter(torch.zeros(n_faces, 2).float())
star_points = torch.nn.Parameter(initial_star_points.clone())

def rot_mat(a):
def rot_mat(a: Any) -> Any:
s, c = torch.sin(a), torch.cos(a)
return torch.stack([c, -s, s, c], dim=-1).view(-1, 2, 2)

def forward():
def forward() -> tuple[Any, Any]:
rms = rot_mat(angles)
out = []
for coords, center, R, off in zip(face_coords0, rotation_centers, rms, offsets):
out.append(((coords - center) @ R * scale) + center + off)
return torch.cat(out), star_points

def loss_fn(face_coords, star_coords):
def loss_fn(face_coords: Any, star_coords: Any) -> Any:
# connection loss
a = face_coords[connections]
l = (a[:, 1] - a[:, 0]).pow(2).sum(-1).sqrt()
Expand Down Expand Up @@ -388,7 +389,7 @@ def loss_fn(face_coords, star_coords):
for v, p in zip(corners + star_vertices, position_view):
v["pos"] = p

iterator = range(n_steps)
iterator: Any = range(n_steps)
if progress:
try: # pragma: no cover
from tqdm.auto import tqdm
Expand Down Expand Up @@ -433,18 +434,18 @@ def loss_fn(face_coords, star_coords):
# Border processing


def _translation_mat(t):
def _translation_mat(t: NDArray) -> NDArray:
m = np.eye(3)
m[:2, 2] = t
return m


def _rotation_mat(a):
def _rotation_mat(a: float | NDArray) -> NDArray:
s, c = np.sin(a), np.cos(a)
return np.array([[c, -s, 0], [s, c, 0], [0, 0, 1]])


def _mirror_mat(line):
def _mirror_mat(line: NDArray | list[NDArray]) -> NDArray:
"""3x3 affine that mirrors the plane across the line ``[p0, p1]``."""
t1 = _translation_mat(-line[0])
t2 = _translation_mat(line[0])
Expand All @@ -455,7 +456,7 @@ def _mirror_mat(line):
return t2 @ r2 @ mx @ r1 @ t1


def _apply_affine(m, v):
def _apply_affine(m: NDArray, v: NDArray) -> NDArray:
return (m @ np.concatenate([v, [1]]))[:2]


Expand Down Expand Up @@ -490,7 +491,9 @@ def extend_border(CP: "EuclideanPositionHEG") -> "EuclideanPositionHEG":
try:
v = h.rev.nex.dest
pos = _apply_affine(_mirror_mat([h.orig["pos"], h.dest["pos"]]), v["pos"])
h_new, _ = out.subdivide_face(h.rev.face, h.orig, h.dest)
rev_face = h.rev.face
assert rev_face is not None
h_new, _ = out.subdivide_face(rev_face, h.orig, h.dest)
h_new[CREASE_ASSIGNMENT] = h_new.rev[CREASE_ASSIGNMENT] = MOUNTAIN
h_new_2, v_new = out.subdivide_edge(h, pos=pos)
out.subdivide_face(None, h_new_2.nex.dest, v_new)
Expand All @@ -507,7 +510,7 @@ def extend_border(CP: "EuclideanPositionHEG") -> "EuclideanPositionHEG":
# Cutting twist centres for a foldable CP


def _edge_midpoint(h):
def _edge_midpoint(h: HalfEdge) -> NDArray:
return np.mean([v["pos"] for v in (h.orig, h.dest)], axis=0)


Expand All @@ -533,7 +536,7 @@ def cut_twist_centres(
"""
CP_for_folding, (v_map, _, f_map) = structure.CP.copy(return_mappings=True)

polys = []
polys: list[NDArray] = []
for star, group in structure.star_groups.items():
star_c = v_map[star]
if star_c not in CP_for_folding.vertices:
Expand All @@ -542,15 +545,15 @@ def cut_twist_centres(

if star_c.on_border():
h = next(h1 for h1 in star_c.incoming_iter() if h1.on_border()).rev
poly = [h.orig["pos"], h.dest["pos"]]
poly_pts: list[NDArray] = [h.orig["pos"], h.dest["pos"]]
while True:
h = h.pre.rev
if h.on_border():
break
h = h.pre.rev
poly.append(h.dest["pos"])
poly.append(_edge_midpoint(h))
poly = np.stack(poly)
poly_pts.append(h.dest["pos"])
poly_pts.append(_edge_midpoint(h))
poly = np.stack(poly_pts)
else:
poly = np.stack([v["pos"] for v in star_c.vertex_iter() if v in group_c])
polys.append(poly)
Expand All @@ -570,7 +573,7 @@ def cut_twist_centres(
CP_for_folding.delete_subset(to_delete)

for poly in polys:
cut_out_poly(CP_for_folding, inset_poly(poly, inset), delete_outside=True)
cut_out_poly(CP_for_folding, np.asarray(inset_poly(list(poly), inset)), delete_outside=True)
CP_for_folding.recompute_lengths_and_angles()
return CP_for_folding

Expand All @@ -579,9 +582,10 @@ def cut_twist_centres(
# Curved-fold subdivision (for Origami Simulator)


def _opposite_triangle_vertex(h):
def _opposite_triangle_vertex(h: HalfEdge) -> Vertex | None:
if h.on_border():
return None
assert h.face is not None
if h.face.order() != 3:
return None
return h.nex.dest
Expand Down Expand Up @@ -638,10 +642,13 @@ def subdivide_ridges_for_curved_fold(
)
new_vs = [G.subdivide_edge(h, pos=p)[1] for p in pts]

assert h.face is not None
for vi in new_vs:
G.subdivide_face(h.face, v1, vi, color_key=(1, 1, 0))
if not h.rev.on_border():
rev_face = h.rev.face
assert rev_face is not None
for vi in new_vs:
G.subdivide_face(h.rev.face, vi, v2, color_key=(1, 1, 0))
G.subdivide_face(rev_face, vi, v2, color_key=(1, 1, 0))

return G
23 changes: 14 additions & 9 deletions eucare/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,15 @@

from __future__ import annotations

from typing import Callable
from typing import TYPE_CHECKING, Callable

import numpy as np
from numba import jit
from numpy.typing import ArrayLike, NDArray

if TYPE_CHECKING:
from .geometries.base import Geometry

pi = np.pi
tau = 2 * np.pi

Expand Down Expand Up @@ -54,7 +57,7 @@ def edge_lengths(points: NDArray) -> NDArray:
return np.linalg.norm(edge_vectors, axis=1)


def edge_lengths_and_in_angles(points: NDArray, geometry) -> tuple[list[float], list[float]]:
def edge_lengths_and_in_angles(points: NDArray, geometry: type[Geometry]) -> tuple[list[float], list[float]]:
"""Return ``(edge_lengths, interior_angles)`` for a polygon under *geometry*.

*geometry* must expose ``distance(p, q)`` and ``angle(p, q, r)`` (see
Expand Down Expand Up @@ -128,7 +131,7 @@ def find_affine(line0: NDArray, line1: NDArray) -> NDArray:
return np.concatenate([linear, offset[None]])


def nearest_neighbor(data: NDArray, query: NDArray, return_index: bool = True):
def nearest_neighbor(data: NDArray, query: NDArray, return_index: bool = True) -> NDArray | tuple[NDArray, int]:
"""Return the nearest point in *data* to *query* (brute-force).

Args:
Expand All @@ -138,12 +141,14 @@ def nearest_neighbor(data: NDArray, query: NDArray, return_index: bool = True):
"""
if len(data.shape) > len(query.shape):
query = query[None]
index = np.argmin(np.linalg.norm(data - query, axis=-1))
return data[index], index if return_index else data[index]
index = int(np.argmin(np.linalg.norm(data - query, axis=-1)))
if return_index:
return data[index], index
return data[index]


@jit(nopython=True)
def signed_area(pts):
def signed_area(pts: NDArray) -> float:
"""Signed area of a polygon (positive = CCW). Numba-accelerated.

*pts* must have shape ``(n, 2)``; the polygon is closed implicitly.
Expand All @@ -154,7 +159,7 @@ def signed_area(pts):


@jit(nopython=True)
def orientation(pts, eps=0):
def orientation(pts: NDArray, eps: float = 0) -> int:
"""Return ``+1`` (CCW), ``-1`` (CW), or ``0`` (degenerate) for a polygon.

Numba-accelerated. *eps* is the absolute-area threshold for degeneracy.
Expand All @@ -172,7 +177,7 @@ def euclidean_to_barycentric_map(tri: NDArray) -> Callable[[NDArray], NDArray]:
"""
tri = np.array(tri, dtype=np.float32)

def inner(point):
def inner(point: NDArray) -> NDArray:
mat = np.repeat(tri[None, :], 3, axis=0)
mat[np.eye(3, dtype=bool)] = point
coords = np.array([signed_area(pts) for pts in mat], dtype=np.float32)
Expand All @@ -184,7 +189,7 @@ def inner(point):
def barycentric_to_euclidean_map(tri: NDArray) -> Callable[[NDArray], NDArray]:
"""Return a function converting barycentric coords back to 2D points w.r.t. *tri*."""

def inner(barycentric_coords):
def inner(barycentric_coords: NDArray) -> NDArray:
return tri.T @ barycentric_coords

return inner
Expand Down
Loading
Loading