diff --git a/manim/mobject/geometry/labeled.py b/manim/mobject/geometry/labeled.py index 36120c08d8..00ceb77699 100644 --- a/manim/mobject/geometry/labeled.py +++ b/manim/mobject/geometry/labeled.py @@ -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 diff --git a/manim/mobject/three_d/polyhedra.py b/manim/mobject/three_d/polyhedra.py index 9d0ff387c5..8046f6066c 100644 --- a/manim/mobject/three_d/polyhedra.py +++ b/manim/mobject/three_d/polyhedra.py @@ -388,6 +388,7 @@ class ConvexHull3D(Polyhedron): -------- .. manim:: ConvexHull3DExample :save_last_frame: + :quality: high class ConvexHull3DExample(ThreeDScene): def construct(self): diff --git a/manim/utils/polylabel.py b/manim/utils/polylabel.py index 81e26b6f3f..8379b3ddd9 100644 --- a/manim/utils/polylabel.py +++ b/manim/utils/polylabel.py @@ -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) @@ -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 @@ -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) diff --git a/manim/utils/qhull.py b/manim/utils/qhull.py index d13c551630..200ede1376 100644 --- a/manim/utils/qhull.py +++ b/manim/utils/qhull.py @@ -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) @@ -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) @@ -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 @@ -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: @@ -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: num, dim = points.shape if (dim == 0) or (num < dim + 1): raise ValueError("Not enough points supplied to build Convex Hull!") diff --git a/tests/test_graphical_units/control_data/geometry/ConvexHull.npz b/tests/test_graphical_units/control_data/geometry/ConvexHull.npz index 8a907a449a..6ccb415ee0 100644 Binary files a/tests/test_graphical_units/control_data/geometry/ConvexHull.npz and b/tests/test_graphical_units/control_data/geometry/ConvexHull.npz differ diff --git a/tests/test_graphical_units/control_data/polyhedra/ConvexHull3D.npz b/tests/test_graphical_units/control_data/polyhedra/ConvexHull3D.npz new file mode 100644 index 0000000000..eaa726b0fb Binary files /dev/null and b/tests/test_graphical_units/control_data/polyhedra/ConvexHull3D.npz differ diff --git a/tests/test_graphical_units/test_geometry.py b/tests/test_graphical_units/test_geometry.py index 8306e52658..962aec301b 100644 --- a/tests/test_graphical_units/test_geometry.py +++ b/tests/test_graphical_units/test_geometry.py @@ -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) diff --git a/tests/test_graphical_units/test_polyhedra.py b/tests/test_graphical_units/test_polyhedra.py index bc13676fde..9679bed3a1 100644 --- a/tests/test_graphical_units/test_polyhedra.py +++ b/tests/test_graphical_units/test_polyhedra.py @@ -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)