diff --git a/manim/mobject/geometry/labeled.py b/manim/mobject/geometry/labeled.py index 371870b8a7..3c6ef335e2 100644 --- a/manim/mobject/geometry/labeled.py +++ b/manim/mobject/geometry/labeled.py @@ -2,65 +2,155 @@ from __future__ import annotations -__all__ = ["LabeledLine", "LabeledArrow"] +__all__ = ["Label", "LabeledLine", "LabeledArrow", "LabeledPolygram"] from typing import TYPE_CHECKING +import numpy as np + from manim.constants import * from manim.mobject.geometry.line import Arrow, Line +from manim.mobject.geometry.polygram import Polygram from manim.mobject.geometry.shape_matchers import ( BackgroundRectangle, SurroundingRectangle, ) from manim.mobject.text.tex_mobject import MathTex, Tex from manim.mobject.text.text_mobject import Text -from manim.utils.color import WHITE, ManimColor, ParsableManimColor +from manim.mobject.types.vectorized_mobject import VGroup +from manim.utils.color import WHITE +from manim.utils.polylabel import polylabel if TYPE_CHECKING: from typing import Any + from manim.typing import Point3D_Array + + +class Label(VGroup): + """A Label consisting of text surrounded by a frame. + + Parameters + ---------- + label + Label that will be displayed. + label_config + A dictionary containing the configuration for the label. + This is only applied if ``label`` is of type ``str``. + box_config + A dictionary containing the configuration for the background box. + frame_config + A dictionary containing the configuration for the frame. + + Examples + -------- + .. manim:: LabelExample + :save_last_frame: + :quality: high + + class LabelExample(Scene): + def construct(self): + label = Label( + label=Text('Label Text', font='sans-serif'), + box_config = { + "color" : BLUE, + "fill_opacity" : 0.75 + } + ) + label.scale(3) + self.add(label) + """ + + def __init__( + self, + label: str | Tex | MathTex | Text, + label_config: dict[str, Any] | None = None, + box_config: dict[str, Any] | None = None, + frame_config: dict[str, Any] | None = None, + **kwargs: Any, + ) -> None: + super().__init__(**kwargs) + + # Setup Defaults + default_label_config: dict[str, Any] = { + "color": WHITE, + "font_size": DEFAULT_FONT_SIZE, + } + + default_box_config: dict[str, Any] = { + "color": None, + "buff": 0.05, + "fill_opacity": 1, + "stroke_width": 0.5, + } + + default_frame_config: dict[str, Any] = { + "color": WHITE, + "buff": 0.05, + "stroke_width": 0.5, + } + + # Merge Defaults + label_config = default_label_config | (label_config or {}) + box_config = default_box_config | (box_config or {}) + frame_config = default_frame_config | (frame_config or {}) + + # Determine the type of label and instantiate the appropriate object + self.rendered_label: MathTex | Tex | Text + if isinstance(label, str): + self.rendered_label = MathTex(label, **label_config) + elif isinstance(label, (MathTex, Tex, Text)): + self.rendered_label = label + else: + raise TypeError("Unsupported label type. Must be MathTex, Tex, or Text.") + + # Add a background box + self.background_rect = BackgroundRectangle(self.rendered_label, **box_config) + + # Add a frame around the label + self.frame = SurroundingRectangle(self.rendered_label, **frame_config) + + # Add components to the VGroup + self.add(self.background_rect, self.rendered_label, self.frame) + class LabeledLine(Line): """Constructs a line containing a label box somewhere along its length. Parameters ---------- - label : str | Tex | MathTex | Text + label Label that will be displayed on the line. - label_position : float | optional + label_position A ratio in the range [0-1] to indicate the position of the label with respect to the length of the line. Default value is 0.5. - font_size : float | optional - Control font size for the label. This parameter is only used when `label` is of type `str`. - label_color: ParsableManimColor | optional - The color of the label's text. This parameter is only used when `label` is of type `str`. - label_frame : Bool | optional - Add a `SurroundingRectangle` frame to the label box. - frame_fill_color : ParsableManimColor | optional - Background color to fill the label box. If no value is provided, the background color of the canvas will be used. - frame_fill_opacity : float | optional - Determine the opacity of the label box by passing a value in the range [0-1], where 0 indicates complete transparency and 1 means full opacity. - - .. seealso:: - :class:`LabeledArrow` + label_config + A dictionary containing the configuration for the label. + This is only applied if ``label`` is of type ``str``. + box_config + A dictionary containing the configuration for the background box. + frame_config + A dictionary containing the configuration for the frame. + + .. seealso:: + :class:`LabeledArrow` Examples -------- .. manim:: LabeledLineExample :save_last_frame: + :quality: high class LabeledLineExample(Scene): def construct(self): line = LabeledLine( label = '0.5', label_position = 0.8, - font_size = 20, - label_color = WHITE, - label_frame = True, - + label_config = { + "font_size" : 20 + }, start=LEFT+DOWN, end=RIGHT+UP) - line.set_length(line.get_length() * 2) self.add(line) """ @@ -69,52 +159,29 @@ def __init__( self, label: str | Tex | MathTex | Text, label_position: float = 0.5, - font_size: float = DEFAULT_FONT_SIZE, - label_color: ParsableManimColor = WHITE, - label_frame: bool = True, - frame_fill_color: ParsableManimColor | None = None, - frame_fill_opacity: float = 1, + label_config: dict[str, Any] | None = None, + box_config: dict[str, Any] | None = None, + frame_config: dict[str, Any] | None = None, *args: Any, **kwargs: Any, ) -> None: - label_color = ManimColor(label_color) - frame_fill_color = ManimColor(frame_fill_color) - if isinstance(label, str): - from manim import MathTex - - rendered_label: Tex | MathTex | Text = MathTex( - label, color=label_color, font_size=font_size - ) - else: - rendered_label = label - super().__init__(*args, **kwargs) - # calculating the vector for the label position + # Create Label + self.label = Label( + label=label, + label_config=label_config, + box_config=box_config, + frame_config=frame_config, + ) + + # Compute Label Position line_start, line_end = self.get_start_and_end() new_vec = (line_end - line_start) * label_position label_coords = line_start + new_vec - # rendered_label.move_to(self.get_vector() * label_position) - rendered_label.move_to(label_coords) - - box = BackgroundRectangle( - rendered_label, - buff=0.05, - color=frame_fill_color, - fill_opacity=frame_fill_opacity, - stroke_width=0.5, - ) - self.add(box) - - if label_frame: - box_frame = SurroundingRectangle( - rendered_label, buff=0.05, color=label_color, stroke_width=0.5 - ) - - self.add(box_frame) - - self.add(rendered_label) + self.label.move_to(label_coords) + self.add(self.label) class LabeledArrow(LabeledLine, Arrow): @@ -123,29 +190,26 @@ class LabeledArrow(LabeledLine, Arrow): Parameters ---------- - label : str | Tex | MathTex | Text - Label that will be displayed on the line. - label_position : float | optional + label + Label that will be displayed on the Arrow. + label_position A ratio in the range [0-1] to indicate the position of the label with respect to the length of the line. Default value is 0.5. - font_size : float | optional - Control font size for the label. This parameter is only used when `label` is of type `str`. - label_color: ParsableManimColor | optional - The color of the label's text. This parameter is only used when `label` is of type `str`. - label_frame : Bool | optional - Add a `SurroundingRectangle` frame to the label box. - frame_fill_color : ParsableManimColor | optional - Background color to fill the label box. If no value is provided, the background color of the canvas will be used. - frame_fill_opacity : float | optional - Determine the opacity of the label box by passing a value in the range [0-1], where 0 indicates complete transparency and 1 means full opacity. - + label_config + A dictionary containing the configuration for the label. + This is only applied if ``label`` is of type ``str``. + box_config + A dictionary containing the configuration for the background box. + frame_config + A dictionary containing the configuration for the frame. - .. seealso:: - :class:`LabeledLine` + .. seealso:: + :class:`LabeledLine` Examples -------- .. manim:: LabeledArrowExample :save_last_frame: + :quality: high class LabeledArrowExample(Scene): def construct(self): @@ -160,3 +224,155 @@ def __init__( **kwargs: Any, ) -> None: super().__init__(*args, **kwargs) + + +class LabeledPolygram(Polygram): + """Constructs a polygram containing a label box at its pole of inaccessibility. + + Parameters + ---------- + vertex_groups + Vertices passed to the :class:`~.Polygram` constructor. + label + Label that will be displayed on the Polygram. + precision + The precision used by the PolyLabel algorithm. + label_config + A dictionary containing the configuration for the label. + This is only applied if ``label`` is of type ``str``. + box_config + A dictionary containing the configuration for the background box. + frame_config + A dictionary containing the configuration for the frame. + + .. note:: + The PolyLabel Algorithm expects each vertex group to form a closed ring. + If the input is open, :class:`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 + :save_last_frame: + :quality: high + + class LabeledPolygramExample(Scene): + def construct(self): + # Define Rings + ring1 = [ + [-3.8, -2.4, 0], [-2.4, -2.5, 0], [-1.3, -1.6, 0], [-0.2, -1.7, 0], + [1.7, -2.5, 0], [2.9, -2.6, 0], [3.5, -1.5, 0], [4.9, -1.4, 0], + [4.5, 0.2, 0], [4.7, 1.6, 0], [3.5, 2.4, 0], [1.1, 2.5, 0], + [-0.1, 0.9, 0], [-1.2, 0.5, 0], [-1.6, 0.7, 0], [-1.4, 1.9, 0], + [-2.6, 2.6, 0], [-4.4, 1.2, 0], [-4.9, -0.8, 0], [-3.8, -2.4, 0] + ] + ring2 = [ + [0.2, -1.2, 0], [0.9, -1.2, 0], [1.4, -2.0, 0], [2.1, -1.6, 0], + [2.2, -0.5, 0], [1.4, 0.0, 0], [0.4, -0.2, 0], [0.2, -1.2, 0] + ] + ring3 = [[-2.7, 1.4, 0], [-2.3, 1.7, 0], [-2.8, 1.9, 0], [-2.7, 1.4, 0]] + + # Create Polygons (for reference) + p1 = Polygon(*ring1, fill_opacity=0.75) + p2 = Polygon(*ring2, fill_color=BLACK, fill_opacity=1) + p3 = Polygon(*ring3, fill_color=BLACK, fill_opacity=1) + + # Create Labeled Polygram + polygram = LabeledPolygram( + *[ring1, ring2, ring3], + label=Text('Pole', font='sans-serif'), + precision=0.01, + ) + + # Display Circle (for reference) + circle = Circle(radius=polygram.radius, color=WHITE).move_to(polygram.pole) + + self.add(p1, p2, p3) + self.add(polygram) + self.add(circle) + + .. manim:: LabeledCountryExample + :save_last_frame: + :quality: high + + import requests + import json + + class LabeledCountryExample(Scene): + def construct(self): + # Fetch JSON data and process arcs + data = requests.get('https://cdn.jsdelivr.net/npm/us-atlas@3/nation-10m.json').json() + arcs, transform = data['arcs'], data['transform'] + sarcs = [np.cumsum(arc, axis=0) * transform['scale'] + transform['translate'] for arc in arcs] + ssarcs = sorted(sarcs, key=len, reverse=True)[:1] + + # Compute Bounding Box + points = np.concatenate(ssarcs) + mins, maxs = np.min(points, axis=0), np.max(points, axis=0) + + # Build Axes + ax = Axes( + x_range=[mins[0], maxs[0], maxs[0] - mins[0]], x_length=10, + y_range=[mins[1], maxs[1], maxs[1] - mins[1]], y_length=7, + tips=False + ) + + # Adjust Coordinates + array = [[ax.c2p(*point) for point in sarc] for sarc in ssarcs] + + # Add Polygram + polygram = LabeledPolygram( + *array, + label=Text('USA', font='sans-serif'), + precision=0.01, + fill_color=BLUE, + stroke_width=0, + fill_opacity=0.75 + ) + + # Display Circle (for reference) + circle = Circle(radius=polygram.radius, color=WHITE).move_to(polygram.pole) + + self.add(ax) + self.add(polygram) + self.add(circle) + """ + + def __init__( + self, + *vertex_groups: Point3D_Array, + label: str | Tex | MathTex | Text, + precision: float = 0.01, + label_config: dict[str, Any] | None = None, + box_config: dict[str, Any] | None = None, + frame_config: dict[str, Any] | None = None, + **kwargs: Any, + ) -> None: + # Initialize the Polygram with the vertex groups + super().__init__(*vertex_groups, **kwargs) + + # Create Label + self.label = Label( + label=label, + label_config=label_config, + box_config=box_config, + frame_config=frame_config, + ) + + # Close Vertex Groups + rings = [ + group if np.array_equal(group[0], group[-1]) else list(group) + [group[0]] + for group in vertex_groups + ] + + # Compute the Pole of Inaccessibility + cell = polylabel(rings, precision=precision) + self.pole, self.radius = np.pad(cell.c, (0, 1), "constant"), cell.d + + # Position the label at the pole + self.label.move_to(self.pole) + self.add(self.label) diff --git a/manim/mobject/geometry/polygram.py b/manim/mobject/geometry/polygram.py index bfb9f00ab7..482581df12 100644 --- a/manim/mobject/geometry/polygram.py +++ b/manim/mobject/geometry/polygram.py @@ -13,6 +13,7 @@ "Square", "RoundedRectangle", "Cutout", + "ConvexHull", ] @@ -27,6 +28,7 @@ from manim.mobject.types.vectorized_mobject import VGroup, VMobject from manim.utils.color import BLUE, WHITE, ParsableManimColor from manim.utils.iterables import adjacent_n_tuples, adjacent_pairs +from manim.utils.qhull import QuickHull from manim.utils.space_ops import angle_between_vectors, normalize, regular_vertices if TYPE_CHECKING: @@ -39,6 +41,7 @@ InternalPoint3D, InternalPoint3D_Array, Point3D, + Point3D_Array, ) from manim.utils.color import ParsableManimColor @@ -80,7 +83,7 @@ def construct(self): def __init__( self, - *vertex_groups: Point3D, + *vertex_groups: Point3D_Array, color: ParsableManimColor = BLUE, **kwargs: Any, ): @@ -780,3 +783,67 @@ def __init__( ) for mobject in mobjects: self.append_points(mobject.force_direction(sub_direction).points) + + +class ConvexHull(Polygram): + """Constructs a convex hull for a set of points in no particular order. + + Parameters + ---------- + points + The points to consider. + tolerance + The tolerance used by quickhull. + kwargs + Forwarded to the parent constructor. + + Examples + -------- + .. manim:: ConvexHullExample + :save_last_frame: + :quality: high + + class ConvexHullExample(Scene): + def construct(self): + points = [ + [-2.35, -2.25, 0], + [1.65, -2.25, 0], + [2.65, -0.25, 0], + [1.65, 1.75, 0], + [-0.35, 2.75, 0], + [-2.35, 0.75, 0], + [-0.35, -1.25, 0], + [0.65, -0.25, 0], + [-1.35, 0.25, 0], + [0.15, 0.75, 0] + ] + hull = ConvexHull(*points, color=BLUE) + dots = VGroup(*[Dot(point) for point in points]) + self.add(hull) + self.add(dots) + """ + + def __init__( + self, *points: Point3D, tolerance: float = 1e-5, **kwargs: Any + ) -> None: + # Build Convex Hull + array = np.array(points)[:, :2] + hull = QuickHull(tolerance) + hull.build(array) + + # Extract Vertices + facets = set(hull.facets) - hull.removed + facet = facets.pop() + subfacets = list(facet.subfacets) + while len(subfacets) <= len(facets): + sf = subfacets[-1] + (facet,) = hull.neighbors[sf] - {facet} + (sf,) = facet.subfacets - {sf} + subfacets.append(sf) + + # Setup Vertices as Point3D + coordinates = np.vstack([sf.coordinates for sf in subfacets]) + vertices = np.hstack((coordinates, np.zeros((len(coordinates), 1)))) + + # Call Polygram + super().__init__(vertices, **kwargs) diff --git a/manim/mobject/three_d/polyhedra.py b/manim/mobject/three_d/polyhedra.py index 300cf660a8..8046f6066c 100644 --- a/manim/mobject/three_d/polyhedra.py +++ b/manim/mobject/three_d/polyhedra.py @@ -10,11 +10,20 @@ from manim.mobject.graph import Graph from manim.mobject.three_d.three_dimensions import Dot3D from manim.mobject.types.vectorized_mobject import VGroup +from manim.utils.qhull import QuickHull if TYPE_CHECKING: from manim.mobject.mobject import Mobject + from manim.typing import Point3D -__all__ = ["Polyhedron", "Tetrahedron", "Octahedron", "Icosahedron", "Dodecahedron"] +__all__ = [ + "Polyhedron", + "Tetrahedron", + "Octahedron", + "Icosahedron", + "Dodecahedron", + "ConvexHull3D", +] class Polyhedron(VGroup): @@ -361,3 +370,91 @@ def __init__(self, edge_length: float = 1, **kwargs): ], **kwargs, ) + + +class ConvexHull3D(Polyhedron): + """A convex hull for a set of points + + Parameters + ---------- + points + The points to consider. + tolerance + The tolerance used for quickhull. + kwargs + Forwarded to the parent constructor. + + Examples + -------- + .. manim:: ConvexHull3DExample + :save_last_frame: + :quality: high + + class ConvexHull3DExample(ThreeDScene): + def construct(self): + self.set_camera_orientation(phi=75 * DEGREES, theta=30 * DEGREES) + points = [ + [ 1.93192757, 0.44134585, -1.52407061], + [-0.93302521, 1.23206983, 0.64117067], + [-0.44350918, -0.61043677, 0.21723705], + [-0.42640268, -1.05260843, 1.61266094], + [-1.84449637, 0.91238739, -1.85172623], + [ 1.72068132, -0.11880457, 0.51881751], + [ 0.41904805, 0.44938012, -1.86440686], + [ 0.83864666, 1.66653337, 1.88960123], + [ 0.22240514, -0.80986286, 1.34249326], + [-1.29585759, 1.01516189, 0.46187522], + [ 1.7776499, -1.59550796, -1.70240747], + [ 0.80065226, -0.12530398, 1.70063977], + [ 1.28960948, -1.44158255, 1.39938582], + [-0.93538943, 1.33617705, -0.24852643], + [-1.54868271, 1.7444399, -0.46170734] + ] + hull = ConvexHull3D( + *points, + faces_config = {"stroke_opacity": 0}, + graph_config = { + "vertex_type": Dot3D, + "edge_config": { + "stroke_color": BLUE, + "stroke_width": 2, + "stroke_opacity": 0.05, + } + } + ) + dots = VGroup(*[Dot3D(point) for point in points]) + self.add(hull) + self.add(dots) + """ + + def __init__(self, *points: Point3D, tolerance: float = 1e-5, **kwargs): + # Build Convex Hull + array = np.array(points) + hull = QuickHull(tolerance) + hull.build(array) + + # Setup Lists + vertices = [] + faces = [] + + # Extract Faces + c = 0 + d = {} + facets = set(hull.facets) - hull.removed + for facet in facets: + tmp = set() + for subfacet in facet.subfacets: + for point in subfacet.points: + if point not in d: + vertices.append(point.coordinates) + d[point] = c + c += 1 + tmp.add(point) + faces.append([d[point] for point in tmp]) + + # Call Polyhedron + super().__init__( + vertex_coords=vertices, + faces_list=faces, + **kwargs, + ) diff --git a/manim/utils/polylabel.py b/manim/utils/polylabel.py new file mode 100644 index 0000000000..e7739b8dc1 --- /dev/null +++ b/manim/utils/polylabel.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python +from __future__ import annotations + +from queue import PriorityQueue +from typing import TYPE_CHECKING + +import numpy as np + +if TYPE_CHECKING: + from collections.abc import Sequence + + from manim.typing import Point3D, Point3D_Array + + +class Polygon: + """ + Initializes the Polygon with the given rings. + + Parameters + ---------- + rings + A collection of closed polygonal ring. + """ + + def __init__(self, rings: Sequence[Point3D_Array]) -> None: + # Flatten Array + csum = np.cumsum([ring.shape[0] for ring in rings]) + self.array = np.concatenate(rings, axis=0) + + # Compute Boundary + self.start = np.delete(self.array, csum - 1, axis=0) + self.stop = np.delete(self.array, csum % csum[-1], axis=0) + self.diff = np.delete(np.diff(self.array, axis=0), csum[:-1] - 1, axis=0) + self.norm = self.diff / np.einsum("ij,ij->i", self.diff, self.diff).reshape( + -1, 1 + ) + + # Compute Centroid + x, y = self.start[:, 0], self.start[:, 1] + xr, yr = self.stop[:, 0], self.stop[:, 1] + self.area = 0.5 * (np.dot(x, yr) - np.dot(xr, y)) + if self.area: + factor = x * yr - xr * y + cx = np.sum((x + xr) * factor) / (6.0 * self.area) + cy = np.sum((y + yr) * factor) / (6.0 * self.area) + self.centroid = np.array([cx, cy]) + + def compute_distance(self, point: Point3D) -> 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: Point3D) -> 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] + + # 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]) + return np.sum(c) % 2 == 1 + + +class Cell: + """ + A square in a mesh covering the :class:`~.Polygon` passed as an argument. + + Parameters + ---------- + c + Center coordinates of the Cell. + h + Half-Size of the Cell. + polygon + :class:`~.Polygon` object for which the distance is computed. + """ + + def __init__(self, c: Point3D, h: float, polygon: Polygon) -> None: + 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: Cell) -> bool: + return self.d < other.d + + def __gt__(self, other: Cell) -> bool: + return self.d > other.d + + def __le__(self, other: Cell) -> bool: + return self.d <= other.d + + def __ge__(self, other: Cell) -> bool: + return self.d >= other.d + + +def polylabel(rings: Sequence[Point3D_Array], 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 + 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 + 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) + + # Bounding Box + mins = np.min(polygon.array, axis=0) + maxs = np.max(polygon.array, axis=0) + dims = maxs - mins + s = np.min(dims) + h = s / 2.0 + + # Initial Grid + queue = PriorityQueue() + xv, yv = np.meshgrid(np.arange(mins[0], maxs[0], s), np.arange(mins[1], maxs[1], s)) + for corner in np.vstack([xv.ravel(), yv.ravel()]).T: + queue.put(Cell(corner + h, h, polygon)) + + # Initial Guess + best = Cell(polygon.centroid, 0, polygon) + bbox = Cell(mins + (dims / 2), 0, polygon) + if bbox.d > best.d: + best = bbox + + # While there are cells to consider... + directions = np.array([[-1, -1], [1, -1], [-1, 1], [1, 1]]) + while not queue.empty(): + cell = queue.get() + if cell > best: + best = cell + # If a cell is promising, subdivide! + if cell.p - best.d > precision: + h = cell.h / 2.0 + offsets = cell.c + directions * h + queue.put(Cell(offsets[0], h, polygon)) + queue.put(Cell(offsets[1], h, polygon)) + queue.put(Cell(offsets[2], h, polygon)) + queue.put(Cell(offsets[3], h, polygon)) + return best diff --git a/manim/utils/qhull.py b/manim/utils/qhull.py new file mode 100644 index 0000000000..215f114b59 --- /dev/null +++ b/manim/utils/qhull.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np + +if TYPE_CHECKING: + from manim.typing import PointND, PointND_Array + + +class QuickHullPoint: + def __init__(self, coordinates: PointND_Array) -> None: + self.coordinates = coordinates + + def __hash__(self) -> int: + return hash(self.coordinates.tobytes()) + + def __eq__(self, other: QuickHullPoint) -> bool: + return np.array_equal(self.coordinates, other.coordinates) + + +class SubFacet: + def __init__(self, coordinates: PointND_Array) -> None: + self.coordinates = coordinates + self.points = frozenset(QuickHullPoint(c) for c in coordinates) + + def __hash__(self) -> int: + return hash(self.points) + + def __eq__(self, other: SubFacet) -> bool: + return self.points == other.points + + +class Facet: + def __init__(self, coordinates: PointND_Array, internal: PointND) -> None: + self.coordinates = coordinates + self.center = np.mean(coordinates, axis=0) + self.normal = self.compute_normal(internal) + self.subfacets = frozenset( + SubFacet(np.delete(self.coordinates, i, axis=0)) + for i in range(self.coordinates.shape[0]) + ) + + def compute_normal(self, internal: PointND) -> PointND: + 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) -> int: + return hash(self.subfacets) + + def __eq__(self, other: Facet) -> bool: + return self.subfacets == other.subfacets + + +class Horizon: + def __init__(self) -> None: + self.facets: set[Facet] = set() + self.boundary: list[SubFacet] = [] + + +class QuickHull: + """ + QuickHull algorithm for constructing a convex hull from a set of points. + + Parameters + ---------- + tolerance + A tolerance threshold for determining when points lie on the convex hull (default is 1e-5). + + Attributes + ---------- + facets + List of facets considered. + removed + Set of internal facets that have been removed from the hull during the construction process. + outside + Dictionary mapping each facet to its outside points and eye point. + neighbors + Mapping of subfacets to their neighboring facets. Each subfacet links precisely two neighbors. + unclaimed + Points that have not yet been classified as inside or outside the current hull. + internal + An internal point (i.e., the center of the initial simplex) used as a reference during hull construction. + tolerance + 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[PointND_Array, PointND | None]] = {} + self.neighbors: dict[SubFacet, set[Facet]] = {} + self.unclaimed: PointND_Array | None = None + self.internal: PointND | None = None + self.tolerance = tolerance + + def initialize(self, points: PointND_Array) -> None: + # Sample Points + simplex = points[ + np.random.choice(points.shape[0], points.shape[1] + 1, replace=False) + ] + self.unclaimed = points + self.internal = np.mean(simplex, axis=0) + + # Build Simplex + for c in range(simplex.shape[0]): + facet = Facet(np.delete(simplex, c, axis=0), internal=self.internal) + self.classify(facet) + self.facets.append(facet) + + # Attach Neighbors + for f in self.facets: + for sf in f.subfacets: + self.neighbors.setdefault(sf, set()).add(f) + + def classify(self, facet: Facet) -> None: + if not self.unclaimed.size: + self.outside[facet] = (None, None) + return + + # Compute Projections + projections = (self.unclaimed - facet.center) @ facet.normal + arg = np.argmax(projections) + mask = projections > self.tolerance + + # Identify Eye and Outside Set + eye = self.unclaimed[arg] if projections[arg] > self.tolerance else None + outside = self.unclaimed[mask] + self.outside[facet] = (outside, eye) + self.unclaimed = self.unclaimed[~mask] + + def compute_horizon(self, eye: PointND, start_facet: Facet) -> Horizon: + horizon = Horizon() + self._recursive_horizon(eye, start_facet, horizon) + return horizon + + def _recursive_horizon(self, eye: PointND, 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... + else: + # Label the facet as visible and cross each edge + horizon.facets.add(facet) + for subfacet in facet.subfacets: + neighbor = (self.neighbors[subfacet] - {facet}).pop() + # If the neighbor is not visible, then the edge shared must be on the boundary + if neighbor not in horizon.facets and not self._recursive_horizon( + eye, neighbor, horizon + ): + horizon.boundary.append(subfacet) + return True + + def build(self, points: PointND_Array): + num, dim = points.shape + if (dim == 0) or (num < dim + 1): + raise ValueError("Not enough points supplied to build Convex Hull!") + if dim == 1: + raise ValueError("The Convex Hull of 1D data is its min-max!") + + self.initialize(points) + while True: + updated = False + for facet in self.facets: + if facet in self.removed: + continue + outside, eye = self.outside[facet] + if eye is not None: + updated = True + horizon = self.compute_horizon(eye, facet) + for f in horizon.facets: + self.unclaimed = np.vstack((self.unclaimed, self.outside[f][0])) + self.removed.add(f) + for sf in f.subfacets: + self.neighbors[sf].discard(f) + if self.neighbors[sf] == set(): + del self.neighbors[sf] + for sf in horizon.boundary: + nf = Facet( + np.vstack((sf.coordinates, eye)), internal=self.internal + ) + self.classify(nf) + self.facets.append(nf) + for nsf in nf.subfacets: + self.neighbors.setdefault(nsf, set()).add(nf) + if not updated: + break diff --git a/tests/test_graphical_units/control_data/geometry/ConvexHull.npz b/tests/test_graphical_units/control_data/geometry/ConvexHull.npz new file mode 100644 index 0000000000..6ccb415ee0 Binary files /dev/null and b/tests/test_graphical_units/control_data/geometry/ConvexHull.npz differ diff --git a/tests/test_graphical_units/control_data/geometry/LabeledPolygram.npz b/tests/test_graphical_units/control_data/geometry/LabeledPolygram.npz new file mode 100644 index 0000000000..2a14d5f2be Binary files /dev/null and b/tests/test_graphical_units/control_data/geometry/LabeledPolygram.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 10ea6094e9..fef2ca0951 100644 --- a/tests/test_graphical_units/test_geometry.py +++ b/tests/test_graphical_units/test_geometry.py @@ -138,6 +138,20 @@ def test_RoundedRectangle(scene): scene.add(a) +@frames_comparison +def test_ConvexHull(scene): + a = ConvexHull( + *[ + [-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) + + @frames_comparison def test_Arrange(scene): s1 = Square() @@ -254,9 +268,7 @@ def test_LabeledLine(scene): line = LabeledLine( label="0.5", label_position=0.8, - font_size=20, - label_color=WHITE, - label_frame=True, + label_config={"font_size": 20}, start=LEFT + DOWN, end=RIGHT + UP, ) @@ -266,6 +278,27 @@ def test_LabeledLine(scene): @frames_comparison def test_LabeledArrow(scene): l_arrow = LabeledArrow( - "0.5", start=LEFT * 3, end=RIGHT * 3 + UP * 2, label_position=0.5, font_size=15 + label="0.5", + label_position=0.5, + label_config={"font_size": 15}, + start=LEFT * 3, + end=RIGHT * 3 + UP * 2, ) scene.add(l_arrow) + + +@frames_comparison +def test_LabeledPolygram(scene): + polygram = LabeledPolygram( + [ + [-2.5, -2.5, 0], + [2.5, -2.5, 0], + [2.5, 2.5, 0], + [-2.5, 2.5, 0], + [-2.5, -2.5, 0], + ], + [[-1, -1, 0], [0.5, -1, 0], [0.5, 0.5, 0], [-1, 0.5, 0], [-1, -1, 0]], + [[1, 1, 0], [2, 1, 0], [2, 2, 0], [1, 2, 0], [1, 1, 0]], + label="C", + ) + scene.add(polygram) 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)