Skip to content

Add tests for polylabel utility #4269

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
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
14 changes: 8 additions & 6 deletions manim/mobject/opengl/opengl_mobject.py
Original file line number Diff line number Diff line change
Expand Up @@ -1908,15 +1908,16 @@ def stretch_to_fit_width(self, width: float, **kwargs) -> Self:
::

>>> from manim import *
>>> import numpy as np
>>> sq = Square()
>>> sq.height
2.0
np.float64(2.0)
>>> sq.stretch_to_fit_width(5)
Square
>>> sq.width
5.0
np.float64(5.0)
>>> sq.height
2.0
np.float64(2.0)
"""
return self.rescale_to_fit(width, 0, stretch=True, **kwargs)

Expand All @@ -1941,15 +1942,16 @@ def set_width(self, width: float, stretch: bool = False, **kwargs) -> Self:
::

>>> from manim import *
>>> import numpy as np
>>> sq = Square()
>>> sq.height
2.0
np.float64(2.0)
>>> sq.scale_to_fit_width(5)
Square
>>> sq.width
5.0
np.float64(5.0)
>>> sq.height
5.0
np.float64(5.0)
"""
return self.rescale_to_fit(width, 0, stretch=stretch, **kwargs)

Expand Down
91 changes: 79 additions & 12 deletions manim/utils/polylabel.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ class Polygon:
Parameters
----------
rings
A collection of closed polygonal ring.
A sequence of points, where each sequence represents the rings of the polygon.
Typically, multiple rings indicate holes in the polygon.
"""

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

def inside(self, point: Point2DLike) -> bool:
"""Check if a point is inside the polygon."""
# Views
px, py = point
x, y = self.start[:, 0], self.start[:, 1]
xr, yr = self.stop[:, 0], self.stop[:, 1]
def _is_point_on_segment(
self,
x_point: float,
y_point: float,
x0: float,
y0: float,
x1: float,
y1: float,
) -> bool:
"""
Check if a point is on the segment.

The segment is defined by (x0, y0) to (x1, y1).
"""
if min(x0, x1) <= x_point <= max(x0, x1) and min(y0, y1) <= y_point <= max(
y0, y1
):
dx = x1 - x0
dy = y1 - y0
cross = dx * (y_point - y0) - dy * (x_point - x0)
return bool(np.isclose(cross, 0.0))
return False

def _ray_crosses_segment(
self,
x_point: float,
y_point: float,
x0: float,
y0: float,
x1: float,
y1: float,
) -> bool:
"""
Check if a horizontal ray to the right from point (x_point, y_point) crosses the segment.

The segment is defined by (x0, y0) to (x1, y1).
"""
if (y0 > y_point) != (y1 > y_point):
slope = (x1 - x0) / (y1 - y0)
x_intersect = slope * (y_point - y0) + x0
return bool(x_point < x_intersect)
return False

# Count Crossings (enforce short-circuit)
c = (y > py) != (yr > py)
c = px < x[c] + (py - y[c]) * (xr[c] - x[c]) / (yr[c] - y[c])
c_sum: int = np.sum(c)
return c_sum % 2 == 1
def inside(self, point: Point2DLike) -> bool:
"""
Check if a point is inside the polygon.

Uses ray casting algorithm and checks boundary points consistently.
"""
point_x, point_y = point
start_x, start_y = self.start[:, 0], self.start[:, 1]
stop_x, stop_y = self.stop[:, 0], self.stop[:, 1]
segment_count = len(start_x)

for i in range(segment_count):
if self._is_point_on_segment(
point_x,
point_y,
start_x[i],
start_y[i],
stop_x[i],
stop_y[i],
):
return True

crossings = 0
for i in range(segment_count):
if self._ray_crosses_segment(
point_x,
point_y,
start_x[i],
start_y[i],
stop_x[i],
stop_y[i],
):
crossings += 1

return crossings % 2 == 1


class Cell:
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ profile = "black"
omit = ["*tests*"]

[tool.coverage.report]
exclude_lines = ["pragma: no cover"]
exclude_lines = ["pragma: no cover", "if TYPE_CHECKING:"]

[tool.ruff]
line-length = 88
Expand Down
157 changes: 157 additions & 0 deletions tests/utils/test_polylabels.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import numpy as np
import pytest

from manim.utils.polylabel import Cell, Polygon, polylabel


# Test simple square and square with a hole for inside/outside logic
@pytest.mark.parametrize(
("rings", "inside_points", "outside_points"),
[
(
# Simple square: basic convex polygon
[[[0, 0], [4, 0], [4, 4], [0, 4], [0, 0]]], # rings
[
[2, 2],
[1, 1],
[3.9, 3.9],
[0, 0],
[2, 0],
[0, 2],
[0, 4],
[4, 0],
[4, 2],
[2, 4],
[4, 4],
], # inside points
[[-1, -1], [5, 5], [4.1, 2]], # outside points
),
(
# Square with a square hole (donut shape): tests handling of interior voids
[
[[1, 1], [5, 1], [5, 5], [1, 5], [1, 1]],
[[2, 2], [2, 4], [4, 4], [4, 2], [2, 2]],
], # rings
[[1.5, 1.5], [3, 1.5], [1.5, 3]], # inside points
[[3, 3], [6, 6], [0, 0]], # outside points
),
(
# Non-convex polygon (same shape as flags used in Brazilian june festivals)
[[[0, 0], [2, 2], [4, 0], [4, 4], [0, 4], [0, 0]]], # rings
[[1, 3], [3.9, 3.9], [2, 3.5]], # inside points
[
[0.1, 0],
[1, 0],
[2, 0],
[2, 1],
[2, 1.9],
[3, 0],
[3.9, 0],
], # outside points
),
],
)
def test_polygon_inside_outside(rings, inside_points, outside_points):
polygon = Polygon(rings)
for point in inside_points:
assert polygon.inside(point)

for point in outside_points:
assert not polygon.inside(point)


# Test distance calculation with known expected distances
@pytest.mark.parametrize(
("rings", "points", "expected_distance"),
[
(
[[[0, 0], [4, 0], [4, 4], [0, 4], [0, 0]]], # rings
[[2, 2]], # points
2.0, # Distance from center to closest edge in square
),
(
[[[0, 0], [4, 0], [4, 4], [0, 4], [0, 0]]], # rings
[[0, 0], [2, 0], [4, 2], [2, 4], [0, 2]], # points
0.0, # On the edge
),
(
[[[0, 0], [4, 0], [4, 4], [0, 4], [0, 0]]], # rings
[[5, 5]], # points
-np.sqrt(2), # Outside and diagonally offset
),
],
)
def test_polygon_compute_distance(rings, points, expected_distance):
polygon = Polygon(rings)
for point in points:
result = polygon.compute_distance(np.array(point))
assert pytest.approx(result, rel=1e-3) == expected_distance


@pytest.mark.parametrize(
("center", "h", "rings"),
[
(
[2, 2], # center
1.0, # h
[[[0, 0], [4, 0], [4, 4], [0, 4], [0, 0]]], # rings
),
(
[3, 1.5], # center
0.5, # h
[
[[1, 1], [5, 1], [5, 5], [1, 5], [1, 1]],
[[2, 2], [2, 4], [4, 4], [4, 2], [2, 2]],
], # rings
),
],
)
def test_cell(center, h, rings):
polygon = Polygon(rings)
cell = Cell(center, h, polygon)
assert isinstance(cell.d, float)
assert isinstance(cell.p, float)
assert np.allclose(cell.c, center)
assert cell.h == h

other = Cell(np.add(center, [0.1, 0.1]), h, polygon)
assert (cell < other) == (cell.d < other.d)
assert (cell > other) == (cell.d > other.d)
assert (cell <= other) == (cell.d <= other.d)
assert (cell >= other) == (cell.d >= other.d)


@pytest.mark.parametrize(
("rings", "expected_centers"),
[
(
# Simple square: basic convex polygon
[[[0, 0], [4, 0], [4, 4], [0, 4], [0, 0]]],
[[2.0, 2.0]], # single correct pole of inaccessibility
),
(
# Square with a square hole (donut shape): tests handling of interior voids
[
[[1, 1], [5, 1], [5, 5], [1, 5], [1, 1]],
[[2, 2], [2, 4], [4, 4], [4, 2], [2, 2]],
],
[ # any of the four pole of inaccessibility options
[1.5, 1.5],
[1.5, 4.5],
[4.5, 1.5],
[4.5, 4.5],
],
),
],
)
def test_polylabel(rings, expected_centers):
# Add third dimension to conform to polylabel input format
rings_3d = [np.column_stack([ring, np.zeros(len(ring))]) for ring in rings]
result = polylabel(rings_3d, precision=0.01)

assert isinstance(result, Cell)
assert result.h <= 0.01
assert result.d >= 0.0

match_found = any(np.allclose(result.c, ec, atol=0.1) for ec in expected_centers)
assert match_found, f"Expected one of {expected_centers}, but got {result.c}"