Skip to content

Commit

Permalink
typing
Browse files Browse the repository at this point in the history
added typing to `qhull.py` and `polylabel.py` for debugging. simplified test cases for `ConvexHull` and `ConvexHull3D` and rewrote control data. added tip to LabeledPolygon documentation.
  • Loading branch information
JayGupta797 committed Sep 9, 2024
1 parent b627556 commit 7393423
Show file tree
Hide file tree
Showing 8 changed files with 133 additions and 48 deletions.
4 changes: 4 additions & 0 deletions manim/mobject/geometry/labeled.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,10 @@ class LabeledPolygram(Polygram):
If the input is open, LabeledPolygram will attempt to close it.
This may cause the polygon to intersect itself leading to unexpected results.
.. tip::
Make sure the precision corresponds to the scale of your inputs!
For instance, if the bounding box of your polygon stretches from 0 to 10,000, a precision of 1.0 or 10.0 should be sufficient.
Examples
--------
.. manim:: LabeledPolygramExample
Expand Down
1 change: 1 addition & 0 deletions manim/mobject/three_d/polyhedra.py
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,7 @@ class ConvexHull3D(Polyhedron):
--------
.. manim:: ConvexHull3DExample
:save_last_frame:
:quality: high
class ConvexHull3DExample(ThreeDScene):
def construct(self):
Expand Down
64 changes: 50 additions & 14 deletions manim/utils/polylabel.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,24 @@
from __future__ import annotations

from queue import PriorityQueue
from typing import TYPE_CHECKING

import numpy as np

if TYPE_CHECKING:
from manim.typing import Point3D

class Polygon:
"""Polygon class to compute and store associated data."""

def __init__(self, rings):
class Polygon:
def __init__(self, rings: list[np.ndarray]) -> None:
"""
Initializes the Polygon with the given rings.
Parameters
----------
rings : list[np.ndarray]
List of arrays, each representing a polygonal ring
"""
# Flatten Array
csum = np.cumsum([ring.shape[0] for ring in rings])
self.array = np.concatenate(rings, axis=0)
Expand All @@ -32,14 +42,14 @@ def __init__(self, rings):
cy = np.sum((y + yr) * factor) / (6.0 * self.area)
self.centroid = np.array([cx, cy])

def compute_distance(self, point):
def compute_distance(self, point: np.ndarray) -> float:
"""Compute the minimum distance from a point to the polygon."""
scalars = np.einsum("ij,ij->i", self.norm, point - self.start)
clips = np.clip(scalars, 0, 1).reshape(-1, 1)
d = np.min(np.linalg.norm(self.start + self.diff * clips - point, axis=1))
return d if self.inside(point) else -d

def inside(self, point):
def inside(self, point: np.ndarray) -> bool:
"""Check if a point is inside the polygon."""
# Views
px, py = point
Expand All @@ -53,29 +63,55 @@ def inside(self, point):


class Cell:
"""Cell class to represent a square in the grid covering the polygon."""

def __init__(self, c, h, polygon):
def __init__(self, c: np.ndarray, h: float, polygon: Polygon) -> None:
"""
Initializes the Cell, a square in the mesh covering the polygon.
Parameters
----------
c : np.ndarray
Center coordinates of the Cell.
h : float
Half-Size of the Cell.
polygon : Polygon
Polygon object for which the distance is computed.
"""
self.c = c
self.h = h
self.d = polygon.compute_distance(self.c)
self.p = self.d + self.h * np.sqrt(2)

def __lt__(self, other):
def __lt__(self, other: Cell) -> bool:
return self.d < other.d

def __gt__(self, other):
def __gt__(self, other: Cell) -> bool:
return self.d > other.d

def __le__(self, other):
def __le__(self, other: Cell) -> bool:
return self.d <= other.d

def __ge__(self, other):
def __ge__(self, other: Cell) -> bool:
return self.d >= other.d


def PolyLabel(rings, precision=1):
"""Find the pole of inaccessibility using a grid approach."""
def PolyLabel(rings: list[list[Point3D]], precision: float = 0.01) -> Cell:
"""
Finds the pole of inaccessibility (the point that is farthest from the edges of the polygon)
using an iterative grid-based approach.
Parameters
----------
rings : list[list[Point3D]]
A list of lists, where each list is a sequence of points representing the rings of the polygon.
Typically, multiple rings indicate holes in the polygon.
precision : float, optional
The precision of the result (default is 0.01).
Returns
-------
Cell
A Cell containing the pole of inaccessibility to a given precision.
"""
# Precompute Polygon Data
array = [np.array(ring)[:, :2] for ring in rings]
polygon = Polygon(array)
Expand Down
88 changes: 59 additions & 29 deletions manim/utils/qhull.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,30 +5,30 @@


class Point:
def __init__(self, coordinates):
def __init__(self, coordinates: np.ndarray) -> None:
self.coordinates = coordinates

def __hash__(self):
def __hash__(self) -> int:
return hash(self.coordinates.tobytes())

def __eq__(self, other):
def __eq__(self, other: Point) -> bool:
return np.array_equal(self.coordinates, other.coordinates)


class SubFacet:
def __init__(self, coordinates):
def __init__(self, coordinates: np.ndarray) -> None:
self.coordinates = coordinates
self.points = frozenset(Point(c) for c in coordinates)

def __hash__(self):
def __hash__(self) -> int:
return hash(self.points)

def __eq__(self, other):
def __eq__(self, other: SubFacet) -> bool:
return self.points == other.points


class Facet:
def __init__(self, coordinates, normal=None, internal=None):
def __init__(self, coordinates: np.ndarray, internal: np.ndarray) -> None:
self.coordinates = coordinates
self.center = np.mean(coordinates, axis=0)
self.normal = self.compute_normal(internal)
Expand All @@ -37,41 +37,68 @@ def __init__(self, coordinates, normal=None, internal=None):
for i in range(self.coordinates.shape[0])
)

def compute_normal(self, internal):
def compute_normal(self, internal: np.ndarray) -> np.ndarray:
centered = self.coordinates - self.center
_, _, vh = np.linalg.svd(centered)
normal = vh[-1, :]
normal /= np.linalg.norm(normal)

# If the normal points towards the internal point, flip it!
if np.dot(normal, self.center - internal) < 0:
normal *= -1

return normal

def __hash__(self):
def __hash__(self) -> int:
return hash(self.subfacets)

def __eq__(self, other):
def __eq__(self, other: Facet) -> bool:
return self.subfacets == other.subfacets


class Horizon:
def __init__(self):
self.facets = set()
self.boundary = []
def __init__(self) -> None:
self.facets: set[Facet] = set()
self.boundary: list[SubFacet] = []


class QuickHull:
def __init__(self, tolerance=1e-5):
self.facets = []
self.removed = set()
self.outside = {}
self.neighbors = {}
self.unclaimed = None
self.internal = None
"""
QuickHull algorithm for constructing a convex hull from a set of points.
Parameters
----------
tolerance: float, optional
A tolerance threshold for determining when points lie on the convex hull (default is 1e-5).
Attributes
----------
facets: list[Facet]
List of facets considered.
removed: set[Facet]
Set of internal facets that have been removed from the hull during the construction process.
outside: dict[Facet, tuple[np.ndarray, np.ndarray | None]]
Dictionary mapping each facet to its outside points and eye point.
neighbors: dict[SubFacet, set[Facet]]
Mapping of subfacets to their neighboring facets. Each subfacet links precisely two neighbors.
unclaimed: np.ndarray | None
Points that have not yet been classified as inside or outside the current hull.
internal: np.ndarray | None
An internal point (i.e., the center of the initial simplex) used as a reference during hull construction.
tolerance: float
The tolerance used to determine if points are considered outside the current hull.
"""

def __init__(self, tolerance: float = 1e-5) -> None:
self.facets: list[Facet] = []
self.removed: set[Facet] = set()
self.outside: dict[Facet, tuple[np.ndarray, np.ndarray | None]] = {}
self.neighbors: dict[SubFacet, set[Facet]] = {}
self.unclaimed: np.ndarray | None = None
self.internal: np.ndarray | None = None
self.tolerance = tolerance

def initialize(self, points):
def initialize(self, points: np.ndarray) -> None:
# Sample Points
simplex = points[
np.random.choice(points.shape[0], points.shape[1] + 1, replace=False)
Expand All @@ -90,7 +117,7 @@ def initialize(self, points):
for sf in f.subfacets:
self.neighbors.setdefault(sf, set()).add(f)

def classify(self, facet):
def classify(self, facet: Facet) -> None:
if not self.unclaimed.size:
self.outside[facet] = (None, None)
return
Expand All @@ -106,14 +133,19 @@ def classify(self, facet):
self.outside[facet] = (outside, eye)
self.unclaimed = self.unclaimed[~mask]

def compute_horizon(self, eye, start_facet):
def compute_horizon(self, eye: np.ndarray, start_facet: Facet) -> Horizon:
horizon = Horizon()
self._recursive_horizon(eye, start_facet, horizon)
return horizon

def _recursive_horizon(self, eye, facet, horizon):
def _recursive_horizon(
self, eye: np.ndarray, facet: Facet, horizon: Horizon
) -> int:
visible = np.dot(facet.normal, eye - facet.center) > 0
if not visible:
return False
# If the eye is visible from the facet...
if np.dot(facet.normal, eye - facet.center) > 0:
else:
# Label the facet as visible and cross each edge
horizon.facets.add(facet)
for subfacet in facet.subfacets:
Expand All @@ -123,11 +155,9 @@ def _recursive_horizon(self, eye, facet, horizon):
eye, neighbor, horizon
):
horizon.boundary.append(subfacet)
return 1
else:
return 0
return True

def build(self, points):
def build(self, points: np.ndarray) -> np.ndarray | None:

Check notice

Code scanning / CodeQL

Explicit returns mixed with implicit (fall through) returns Note

Mixing implicit and explicit returns may indicate an error as implicit returns always return None.
num, dim = points.shape
if (dim == 0) or (num < dim + 1):
raise ValueError("Not enough points supplied to build Convex Hull!")
Expand Down
Binary file modified tests/test_graphical_units/control_data/geometry/ConvexHull.npz
Binary file not shown.
Binary file not shown.
10 changes: 5 additions & 5 deletions tests/test_graphical_units/test_geometry.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,11 +142,11 @@ def test_RoundedRectangle(scene):
def test_ConvexHull(scene):
a = ConvexHull(
*[
np.array([-2.753, -0.612, 0]),
np.array([0.226, -1.766, 0]),
np.array([1.950, 1.260, 0]),
np.array([-2.754, 0.949, 0]),
np.array([1.679, 2.220, 0]),
[-2.7, -0.6, 0],
[0.2, -1.7, 0],
[1.9, 1.2, 0],
[-2.7, 0.9, 0],
[1.6, 2.2, 0],
]
)
scene.add(a)
Expand Down
14 changes: 14 additions & 0 deletions tests/test_graphical_units/test_polyhedra.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,17 @@ def test_Icosahedron(scene):
@frames_comparison
def test_Dodecahedron(scene):
scene.add(Dodecahedron())


@frames_comparison
def test_ConvexHull3D(scene):
a = ConvexHull3D(
*[
[-2.7, -0.6, 3.5],
[0.2, -1.7, -2.8],
[1.9, 1.2, 0.7],
[-2.7, 0.9, 1.9],
[1.6, 2.2, -4.2],
]
)
scene.add(a)

0 comments on commit 7393423

Please sign in to comment.