Skip to content

Commit 7393423

Browse files
committed
typing
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.
1 parent b627556 commit 7393423

File tree

8 files changed

+133
-48
lines changed

8 files changed

+133
-48
lines changed

manim/mobject/geometry/labeled.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,10 @@ class LabeledPolygram(Polygram):
247247
If the input is open, LabeledPolygram will attempt to close it.
248248
This may cause the polygon to intersect itself leading to unexpected results.
249249
250+
.. tip::
251+
Make sure the precision corresponds to the scale of your inputs!
252+
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.
253+
250254
Examples
251255
--------
252256
.. manim:: LabeledPolygramExample

manim/mobject/three_d/polyhedra.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,7 @@ class ConvexHull3D(Polyhedron):
388388
--------
389389
.. manim:: ConvexHull3DExample
390390
:save_last_frame:
391+
:quality: high
391392
392393
class ConvexHull3DExample(ThreeDScene):
393394
def construct(self):

manim/utils/polylabel.py

Lines changed: 50 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,24 @@
22
from __future__ import annotations
33

44
from queue import PriorityQueue
5+
from typing import TYPE_CHECKING
56

67
import numpy as np
78

9+
if TYPE_CHECKING:
10+
from manim.typing import Point3D
811

9-
class Polygon:
10-
"""Polygon class to compute and store associated data."""
1112

12-
def __init__(self, rings):
13+
class Polygon:
14+
def __init__(self, rings: list[np.ndarray]) -> None:
15+
"""
16+
Initializes the Polygon with the given rings.
17+
18+
Parameters
19+
----------
20+
rings : list[np.ndarray]
21+
List of arrays, each representing a polygonal ring
22+
"""
1323
# Flatten Array
1424
csum = np.cumsum([ring.shape[0] for ring in rings])
1525
self.array = np.concatenate(rings, axis=0)
@@ -32,14 +42,14 @@ def __init__(self, rings):
3242
cy = np.sum((y + yr) * factor) / (6.0 * self.area)
3343
self.centroid = np.array([cx, cy])
3444

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

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

5464

5565
class Cell:
56-
"""Cell class to represent a square in the grid covering the polygon."""
57-
58-
def __init__(self, c, h, polygon):
66+
def __init__(self, c: np.ndarray, h: float, polygon: Polygon) -> None:
67+
"""
68+
Initializes the Cell, a square in the mesh covering the polygon.
69+
70+
Parameters
71+
----------
72+
c : np.ndarray
73+
Center coordinates of the Cell.
74+
h : float
75+
Half-Size of the Cell.
76+
polygon : Polygon
77+
Polygon object for which the distance is computed.
78+
"""
5979
self.c = c
6080
self.h = h
6181
self.d = polygon.compute_distance(self.c)
6282
self.p = self.d + self.h * np.sqrt(2)
6383

64-
def __lt__(self, other):
84+
def __lt__(self, other: Cell) -> bool:
6585
return self.d < other.d
6686

67-
def __gt__(self, other):
87+
def __gt__(self, other: Cell) -> bool:
6888
return self.d > other.d
6989

70-
def __le__(self, other):
90+
def __le__(self, other: Cell) -> bool:
7191
return self.d <= other.d
7292

73-
def __ge__(self, other):
93+
def __ge__(self, other: Cell) -> bool:
7494
return self.d >= other.d
7595

7696

77-
def PolyLabel(rings, precision=1):
78-
"""Find the pole of inaccessibility using a grid approach."""
97+
def PolyLabel(rings: list[list[Point3D]], precision: float = 0.01) -> Cell:
98+
"""
99+
Finds the pole of inaccessibility (the point that is farthest from the edges of the polygon)
100+
using an iterative grid-based approach.
101+
102+
Parameters
103+
----------
104+
rings : list[list[Point3D]]
105+
A list of lists, where each list is a sequence of points representing the rings of the polygon.
106+
Typically, multiple rings indicate holes in the polygon.
107+
precision : float, optional
108+
The precision of the result (default is 0.01).
109+
110+
Returns
111+
-------
112+
Cell
113+
A Cell containing the pole of inaccessibility to a given precision.
114+
"""
79115
# Precompute Polygon Data
80116
array = [np.array(ring)[:, :2] for ring in rings]
81117
polygon = Polygon(array)

manim/utils/qhull.py

Lines changed: 59 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -5,30 +5,30 @@
55

66

77
class Point:
8-
def __init__(self, coordinates):
8+
def __init__(self, coordinates: np.ndarray) -> None:
99
self.coordinates = coordinates
1010

11-
def __hash__(self):
11+
def __hash__(self) -> int:
1212
return hash(self.coordinates.tobytes())
1313

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

1717

1818
class SubFacet:
19-
def __init__(self, coordinates):
19+
def __init__(self, coordinates: np.ndarray) -> None:
2020
self.coordinates = coordinates
2121
self.points = frozenset(Point(c) for c in coordinates)
2222

23-
def __hash__(self):
23+
def __hash__(self) -> int:
2424
return hash(self.points)
2525

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

2929

3030
class Facet:
31-
def __init__(self, coordinates, normal=None, internal=None):
31+
def __init__(self, coordinates: np.ndarray, internal: np.ndarray) -> None:
3232
self.coordinates = coordinates
3333
self.center = np.mean(coordinates, axis=0)
3434
self.normal = self.compute_normal(internal)
@@ -37,41 +37,68 @@ def __init__(self, coordinates, normal=None, internal=None):
3737
for i in range(self.coordinates.shape[0])
3838
)
3939

40-
def compute_normal(self, internal):
40+
def compute_normal(self, internal: np.ndarray) -> np.ndarray:
4141
centered = self.coordinates - self.center
4242
_, _, vh = np.linalg.svd(centered)
4343
normal = vh[-1, :]
4444
normal /= np.linalg.norm(normal)
4545

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

4950
return normal
5051

51-
def __hash__(self):
52+
def __hash__(self) -> int:
5253
return hash(self.subfacets)
5354

54-
def __eq__(self, other):
55+
def __eq__(self, other: Facet) -> bool:
5556
return self.subfacets == other.subfacets
5657

5758

5859
class Horizon:
59-
def __init__(self):
60-
self.facets = set()
61-
self.boundary = []
60+
def __init__(self) -> None:
61+
self.facets: set[Facet] = set()
62+
self.boundary: list[SubFacet] = []
6263

6364

6465
class QuickHull:
65-
def __init__(self, tolerance=1e-5):
66-
self.facets = []
67-
self.removed = set()
68-
self.outside = {}
69-
self.neighbors = {}
70-
self.unclaimed = None
71-
self.internal = None
66+
"""
67+
QuickHull algorithm for constructing a convex hull from a set of points.
68+
69+
Parameters
70+
----------
71+
tolerance: float, optional
72+
A tolerance threshold for determining when points lie on the convex hull (default is 1e-5).
73+
74+
Attributes
75+
----------
76+
facets: list[Facet]
77+
List of facets considered.
78+
removed: set[Facet]
79+
Set of internal facets that have been removed from the hull during the construction process.
80+
outside: dict[Facet, tuple[np.ndarray, np.ndarray | None]]
81+
Dictionary mapping each facet to its outside points and eye point.
82+
neighbors: dict[SubFacet, set[Facet]]
83+
Mapping of subfacets to their neighboring facets. Each subfacet links precisely two neighbors.
84+
unclaimed: np.ndarray | None
85+
Points that have not yet been classified as inside or outside the current hull.
86+
internal: np.ndarray | None
87+
An internal point (i.e., the center of the initial simplex) used as a reference during hull construction.
88+
tolerance: float
89+
The tolerance used to determine if points are considered outside the current hull.
90+
"""
91+
92+
def __init__(self, tolerance: float = 1e-5) -> None:
93+
self.facets: list[Facet] = []
94+
self.removed: set[Facet] = set()
95+
self.outside: dict[Facet, tuple[np.ndarray, np.ndarray | None]] = {}
96+
self.neighbors: dict[SubFacet, set[Facet]] = {}
97+
self.unclaimed: np.ndarray | None = None
98+
self.internal: np.ndarray | None = None
7299
self.tolerance = tolerance
73100

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

93-
def classify(self, facet):
120+
def classify(self, facet: Facet) -> None:
94121
if not self.unclaimed.size:
95122
self.outside[facet] = (None, None)
96123
return
@@ -106,14 +133,19 @@ def classify(self, facet):
106133
self.outside[facet] = (outside, eye)
107134
self.unclaimed = self.unclaimed[~mask]
108135

109-
def compute_horizon(self, eye, start_facet):
136+
def compute_horizon(self, eye: np.ndarray, start_facet: Facet) -> Horizon:
110137
horizon = Horizon()
111138
self._recursive_horizon(eye, start_facet, horizon)
112139
return horizon
113140

114-
def _recursive_horizon(self, eye, facet, horizon):
141+
def _recursive_horizon(
142+
self, eye: np.ndarray, facet: Facet, horizon: Horizon
143+
) -> int:
144+
visible = np.dot(facet.normal, eye - facet.center) > 0
145+
if not visible:
146+
return False
115147
# If the eye is visible from the facet...
116-
if np.dot(facet.normal, eye - facet.center) > 0:
148+
else:
117149
# Label the facet as visible and cross each edge
118150
horizon.facets.add(facet)
119151
for subfacet in facet.subfacets:
@@ -123,11 +155,9 @@ def _recursive_horizon(self, eye, facet, horizon):
123155
eye, neighbor, horizon
124156
):
125157
horizon.boundary.append(subfacet)
126-
return 1
127-
else:
128-
return 0
158+
return True
129159

130-
def build(self, points):
160+
def build(self, points: np.ndarray) -> np.ndarray | None:
131161
num, dim = points.shape
132162
if (dim == 0) or (num < dim + 1):
133163
raise ValueError("Not enough points supplied to build Convex Hull!")
610 Bytes
Binary file not shown.
Binary file not shown.

tests/test_graphical_units/test_geometry.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -142,11 +142,11 @@ def test_RoundedRectangle(scene):
142142
def test_ConvexHull(scene):
143143
a = ConvexHull(
144144
*[
145-
np.array([-2.753, -0.612, 0]),
146-
np.array([0.226, -1.766, 0]),
147-
np.array([1.950, 1.260, 0]),
148-
np.array([-2.754, 0.949, 0]),
149-
np.array([1.679, 2.220, 0]),
145+
[-2.7, -0.6, 0],
146+
[0.2, -1.7, 0],
147+
[1.9, 1.2, 0],
148+
[-2.7, 0.9, 0],
149+
[1.6, 2.2, 0],
150150
]
151151
)
152152
scene.add(a)

tests/test_graphical_units/test_polyhedra.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,17 @@ def test_Icosahedron(scene):
2424
@frames_comparison
2525
def test_Dodecahedron(scene):
2626
scene.add(Dodecahedron())
27+
28+
29+
@frames_comparison
30+
def test_ConvexHull3D(scene):
31+
a = ConvexHull3D(
32+
*[
33+
[-2.7, -0.6, 3.5],
34+
[0.2, -1.7, -2.8],
35+
[1.9, 1.2, 0.7],
36+
[-2.7, 0.9, 1.9],
37+
[1.6, 2.2, -4.2],
38+
]
39+
)
40+
scene.add(a)

0 commit comments

Comments
 (0)