Skip to content

Commit 5301ab8

Browse files
authored
Add tests for polylabel utility (#4269)
* Fix polygon inside/outside logic for edge cases * Stop using hardcoded values and improve variable naming * Decompose inside/outside logic into smaller functions Improves readability and maintanance. * Improve test readability * Fix polylabel test logic * Fix polylabel test by adding multiple options of pole * Improve docstring description of argument * Stop checking type checking blocks on coverage * Fix OpenGLMobject methods doctests
1 parent be6a9df commit 5301ab8

File tree

4 files changed

+245
-19
lines changed

4 files changed

+245
-19
lines changed

manim/mobject/opengl/opengl_mobject.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1908,15 +1908,16 @@ def stretch_to_fit_width(self, width: float, **kwargs) -> Self:
19081908
::
19091909
19101910
>>> from manim import *
1911+
>>> import numpy as np
19111912
>>> sq = Square()
19121913
>>> sq.height
1913-
2.0
1914+
np.float64(2.0)
19141915
>>> sq.stretch_to_fit_width(5)
19151916
Square
19161917
>>> sq.width
1917-
5.0
1918+
np.float64(5.0)
19181919
>>> sq.height
1919-
2.0
1920+
np.float64(2.0)
19201921
"""
19211922
return self.rescale_to_fit(width, 0, stretch=True, **kwargs)
19221923

@@ -1941,15 +1942,16 @@ def set_width(self, width: float, stretch: bool = False, **kwargs) -> Self:
19411942
::
19421943
19431944
>>> from manim import *
1945+
>>> import numpy as np
19441946
>>> sq = Square()
19451947
>>> sq.height
1946-
2.0
1948+
np.float64(2.0)
19471949
>>> sq.scale_to_fit_width(5)
19481950
Square
19491951
>>> sq.width
1950-
5.0
1952+
np.float64(5.0)
19511953
>>> sq.height
1952-
5.0
1954+
np.float64(5.0)
19531955
"""
19541956
return self.rescale_to_fit(width, 0, stretch=stretch, **kwargs)
19551957

manim/utils/polylabel.py

Lines changed: 79 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ class Polygon:
2525
Parameters
2626
----------
2727
rings
28-
A collection of closed polygonal ring.
28+
A sequence of points, where each sequence represents the rings of the polygon.
29+
Typically, multiple rings indicate holes in the polygon.
2930
"""
3031

3132
def __init__(self, rings: Sequence[Point2DLike_Array]) -> None:
@@ -63,18 +64,84 @@ def compute_distance(self, point: Point2DLike) -> float:
6364
)
6465
return d if self.inside(point) else -d
6566

66-
def inside(self, point: Point2DLike) -> bool:
67-
"""Check if a point is inside the polygon."""
68-
# Views
69-
px, py = point
70-
x, y = self.start[:, 0], self.start[:, 1]
71-
xr, yr = self.stop[:, 0], self.stop[:, 1]
67+
def _is_point_on_segment(
68+
self,
69+
x_point: float,
70+
y_point: float,
71+
x0: float,
72+
y0: float,
73+
x1: float,
74+
y1: float,
75+
) -> bool:
76+
"""
77+
Check if a point is on the segment.
78+
79+
The segment is defined by (x0, y0) to (x1, y1).
80+
"""
81+
if min(x0, x1) <= x_point <= max(x0, x1) and min(y0, y1) <= y_point <= max(
82+
y0, y1
83+
):
84+
dx = x1 - x0
85+
dy = y1 - y0
86+
cross = dx * (y_point - y0) - dy * (x_point - x0)
87+
return bool(np.isclose(cross, 0.0))
88+
return False
89+
90+
def _ray_crosses_segment(
91+
self,
92+
x_point: float,
93+
y_point: float,
94+
x0: float,
95+
y0: float,
96+
x1: float,
97+
y1: float,
98+
) -> bool:
99+
"""
100+
Check if a horizontal ray to the right from point (x_point, y_point) crosses the segment.
101+
102+
The segment is defined by (x0, y0) to (x1, y1).
103+
"""
104+
if (y0 > y_point) != (y1 > y_point):
105+
slope = (x1 - x0) / (y1 - y0)
106+
x_intersect = slope * (y_point - y0) + x0
107+
return bool(x_point < x_intersect)
108+
return False
72109

73-
# Count Crossings (enforce short-circuit)
74-
c = (y > py) != (yr > py)
75-
c = px < x[c] + (py - y[c]) * (xr[c] - x[c]) / (yr[c] - y[c])
76-
c_sum: int = np.sum(c)
77-
return c_sum % 2 == 1
110+
def inside(self, point: Point2DLike) -> bool:
111+
"""
112+
Check if a point is inside the polygon.
113+
114+
Uses ray casting algorithm and checks boundary points consistently.
115+
"""
116+
point_x, point_y = point
117+
start_x, start_y = self.start[:, 0], self.start[:, 1]
118+
stop_x, stop_y = self.stop[:, 0], self.stop[:, 1]
119+
segment_count = len(start_x)
120+
121+
for i in range(segment_count):
122+
if self._is_point_on_segment(
123+
point_x,
124+
point_y,
125+
start_x[i],
126+
start_y[i],
127+
stop_x[i],
128+
stop_y[i],
129+
):
130+
return True
131+
132+
crossings = 0
133+
for i in range(segment_count):
134+
if self._ray_crosses_segment(
135+
point_x,
136+
point_y,
137+
start_x[i],
138+
start_y[i],
139+
stop_x[i],
140+
stop_y[i],
141+
):
142+
crossings += 1
143+
144+
return crossings % 2 == 1
78145

79146

80147
class Cell:

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ profile = "black"
128128
omit = ["*tests*"]
129129

130130
[tool.coverage.report]
131-
exclude_lines = ["pragma: no cover"]
131+
exclude_lines = ["pragma: no cover", "if TYPE_CHECKING:"]
132132

133133
[tool.ruff]
134134
line-length = 88

tests/utils/test_polylabels.py

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import numpy as np
2+
import pytest
3+
4+
from manim.utils.polylabel import Cell, Polygon, polylabel
5+
6+
7+
# Test simple square and square with a hole for inside/outside logic
8+
@pytest.mark.parametrize(
9+
("rings", "inside_points", "outside_points"),
10+
[
11+
(
12+
# Simple square: basic convex polygon
13+
[[[0, 0], [4, 0], [4, 4], [0, 4], [0, 0]]], # rings
14+
[
15+
[2, 2],
16+
[1, 1],
17+
[3.9, 3.9],
18+
[0, 0],
19+
[2, 0],
20+
[0, 2],
21+
[0, 4],
22+
[4, 0],
23+
[4, 2],
24+
[2, 4],
25+
[4, 4],
26+
], # inside points
27+
[[-1, -1], [5, 5], [4.1, 2]], # outside points
28+
),
29+
(
30+
# Square with a square hole (donut shape): tests handling of interior voids
31+
[
32+
[[1, 1], [5, 1], [5, 5], [1, 5], [1, 1]],
33+
[[2, 2], [2, 4], [4, 4], [4, 2], [2, 2]],
34+
], # rings
35+
[[1.5, 1.5], [3, 1.5], [1.5, 3]], # inside points
36+
[[3, 3], [6, 6], [0, 0]], # outside points
37+
),
38+
(
39+
# Non-convex polygon (same shape as flags used in Brazilian june festivals)
40+
[[[0, 0], [2, 2], [4, 0], [4, 4], [0, 4], [0, 0]]], # rings
41+
[[1, 3], [3.9, 3.9], [2, 3.5]], # inside points
42+
[
43+
[0.1, 0],
44+
[1, 0],
45+
[2, 0],
46+
[2, 1],
47+
[2, 1.9],
48+
[3, 0],
49+
[3.9, 0],
50+
], # outside points
51+
),
52+
],
53+
)
54+
def test_polygon_inside_outside(rings, inside_points, outside_points):
55+
polygon = Polygon(rings)
56+
for point in inside_points:
57+
assert polygon.inside(point)
58+
59+
for point in outside_points:
60+
assert not polygon.inside(point)
61+
62+
63+
# Test distance calculation with known expected distances
64+
@pytest.mark.parametrize(
65+
("rings", "points", "expected_distance"),
66+
[
67+
(
68+
[[[0, 0], [4, 0], [4, 4], [0, 4], [0, 0]]], # rings
69+
[[2, 2]], # points
70+
2.0, # Distance from center to closest edge in square
71+
),
72+
(
73+
[[[0, 0], [4, 0], [4, 4], [0, 4], [0, 0]]], # rings
74+
[[0, 0], [2, 0], [4, 2], [2, 4], [0, 2]], # points
75+
0.0, # On the edge
76+
),
77+
(
78+
[[[0, 0], [4, 0], [4, 4], [0, 4], [0, 0]]], # rings
79+
[[5, 5]], # points
80+
-np.sqrt(2), # Outside and diagonally offset
81+
),
82+
],
83+
)
84+
def test_polygon_compute_distance(rings, points, expected_distance):
85+
polygon = Polygon(rings)
86+
for point in points:
87+
result = polygon.compute_distance(np.array(point))
88+
assert pytest.approx(result, rel=1e-3) == expected_distance
89+
90+
91+
@pytest.mark.parametrize(
92+
("center", "h", "rings"),
93+
[
94+
(
95+
[2, 2], # center
96+
1.0, # h
97+
[[[0, 0], [4, 0], [4, 4], [0, 4], [0, 0]]], # rings
98+
),
99+
(
100+
[3, 1.5], # center
101+
0.5, # h
102+
[
103+
[[1, 1], [5, 1], [5, 5], [1, 5], [1, 1]],
104+
[[2, 2], [2, 4], [4, 4], [4, 2], [2, 2]],
105+
], # rings
106+
),
107+
],
108+
)
109+
def test_cell(center, h, rings):
110+
polygon = Polygon(rings)
111+
cell = Cell(center, h, polygon)
112+
assert isinstance(cell.d, float)
113+
assert isinstance(cell.p, float)
114+
assert np.allclose(cell.c, center)
115+
assert cell.h == h
116+
117+
other = Cell(np.add(center, [0.1, 0.1]), h, polygon)
118+
assert (cell < other) == (cell.d < other.d)
119+
assert (cell > other) == (cell.d > other.d)
120+
assert (cell <= other) == (cell.d <= other.d)
121+
assert (cell >= other) == (cell.d >= other.d)
122+
123+
124+
@pytest.mark.parametrize(
125+
("rings", "expected_centers"),
126+
[
127+
(
128+
# Simple square: basic convex polygon
129+
[[[0, 0], [4, 0], [4, 4], [0, 4], [0, 0]]],
130+
[[2.0, 2.0]], # single correct pole of inaccessibility
131+
),
132+
(
133+
# Square with a square hole (donut shape): tests handling of interior voids
134+
[
135+
[[1, 1], [5, 1], [5, 5], [1, 5], [1, 1]],
136+
[[2, 2], [2, 4], [4, 4], [4, 2], [2, 2]],
137+
],
138+
[ # any of the four pole of inaccessibility options
139+
[1.5, 1.5],
140+
[1.5, 4.5],
141+
[4.5, 1.5],
142+
[4.5, 4.5],
143+
],
144+
),
145+
],
146+
)
147+
def test_polylabel(rings, expected_centers):
148+
# Add third dimension to conform to polylabel input format
149+
rings_3d = [np.column_stack([ring, np.zeros(len(ring))]) for ring in rings]
150+
result = polylabel(rings_3d, precision=0.01)
151+
152+
assert isinstance(result, Cell)
153+
assert result.h <= 0.01
154+
assert result.d >= 0.0
155+
156+
match_found = any(np.allclose(result.c, ec, atol=0.1) for ec in expected_centers)
157+
assert match_found, f"Expected one of {expected_centers}, but got {result.c}"

0 commit comments

Comments
 (0)