Skip to content
359 changes: 284 additions & 75 deletions manim/mobject/geometry/labeled.py

Large diffs are not rendered by default.

66 changes: 66 additions & 0 deletions manim/mobject/geometry/polygram.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"Square",
"RoundedRectangle",
"Cutout",
"ConvexHull",
]


Expand All @@ -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:
Expand Down Expand Up @@ -780,3 +782,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)
1 change: 1 addition & 0 deletions manim/mobject/geometry/shape_matchers.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ def __init__(
buff=buff,
**kwargs,
)
self.color: ManimColor
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't worry about this shape_matchers issue: it was introduced in a different PR, and it would be solved in another one.

Suggested change
self.color: ManimColor

self.original_fill_opacity: float = self.fill_opacity

def pointwise_become_partial(self, mobject: Mobject, a: Any, b: float) -> Self:
Expand Down
99 changes: 98 additions & 1 deletion manim/mobject/three_d/polyhedra.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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,
)
156 changes: 156 additions & 0 deletions manim/utils/polylabel.py
Original file line number Diff line number Diff line change
@@ -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
Loading