diff --git a/manimlib/mobject/types/vectorized_mobject.py b/manimlib/mobject/types/vectorized_mobject.py index 2000553354..f28c5c24f3 100644 --- a/manimlib/mobject/types/vectorized_mobject.py +++ b/manimlib/mobject/types/vectorized_mobject.py @@ -44,6 +44,7 @@ from manimlib.utils.space_ops import poly_line_length from manimlib.utils.space_ops import z_to_vector from manimlib.shader_wrapper import VShaderWrapper +from manimlib.utils.triangulation import triangulate_polygon_with_holes from typing import TYPE_CHECKING from typing import Generic, TypeVar, Iterable @@ -1071,54 +1072,68 @@ def get_outer_vert_indices(self) -> np.ndarray: # Data for shaders that may need refreshing - def get_triangulation(self) -> np.ndarray: - # Figure out how to triangulate the interior to know - # how to send the points as to the vertex shader. - # First triangles come directly from the points + def get_triangulation(self): + """ + Returns triangle indices for the closed path(s) of this VMobject. + If a gradient fill is in use, prefer disjoint triangulation to avoid overlap artifacts. + """ points = self.get_points() - - if len(points) <= 1: - return np.zeros(0, dtype='i4') - - normal_vector = self.get_unit_normal() - - # Rotate points such that unit normal vector is OUT - if not np.isclose(normal_vector, OUT).all(): - points = np.dot(points, z_to_vector(normal_vector)) - - v01s = points[1::2] - points[0:-1:2] - v12s = points[2::2] - points[1::2] - curve_orientations = np.sign(cross2d(v01s, v12s)) - - concave_parts = curve_orientations < 0 - - # These are the vertices to which we'll apply a polygon triangulation - indices = np.arange(len(points), dtype=int) - inner_vert_indices = np.hstack([ - indices[0::2], - indices[1::2][concave_parts], - ]) - inner_vert_indices.sort() - # Even indices correspond to anchors, and `end_indices // 2` - # shows which anchors are considered end points - end_indices = self.get_subpath_end_indices() - counts = np.arange(1, len(inner_vert_indices) + 1) - rings = counts[inner_vert_indices % 2 == 0][end_indices // 2] - - # Triangulate - inner_verts = points[inner_vert_indices] - inner_tri_indices = inner_vert_indices[ - earclip_triangulation(inner_verts, rings) - ] - # Remove null triangles, coming from adjascent points - iti = inner_tri_indices - null1 = (iti[0::3] + 1 == iti[1::3]) & (iti[0::3] + 2 == iti[2::3]) - null2 = (iti[0::3] - 1 == iti[1::3]) & (iti[0::3] - 2 == iti[2::3]) - inner_tri_indices = iti[~(null1 | null2).repeat(3)] - - ovi = self.get_outer_vert_indices() - tri_indices = np.hstack([ovi, inner_tri_indices]) - return tri_indices + # Group points into subpaths (outer + holes) using existing helpers: + # e.g., self.get_subpaths() or similar existing utility in ManimGL + subpaths = self.get_subpaths() # each is (K, 3) array of anchors along the boundary + if not subpaths: + return np.zeros((0, 3), dtype=int) + + # Heuristic: the largest-area closed path is the outer ring; others are holes, + # or use existing orientation (CCW vs CW) if Manim already provides it. + rings = [] + for sp in subpaths: + # Ensure ring is closed (first==last) and 2D + ring = sp[:, :3] + if not np.allclose(ring[0], ring[-1]): + ring = np.vstack([ring, ring[0]]) + rings.append(ring[:, :2]) # XY only for triangulation + + # Pick outer ring as the one with largest absolute signed area + def signed_area(r): + x, y = r[:,0], r[:,1] + return 0.5 * np.sum(x[:-1]*y[1:] - x[1:]*y[:-1]) + + areas = [abs(signed_area(r)) for r in rings] + outer_idx = int(np.argmax(areas)) + outer = rings[outer_idx] + holes = [r for i, r in enumerate(rings) if i != outer_idx] + + try: + tri = triangulate_polygon_with_holes(outer, holes) + except ImportError: + # Fallback to existing method if earcut not available + return super().get_triangulation() # or the old implementation + + # Earcut indices are into the concatenated ring vertices; we need to map them + # to this VMobject’s vertex indexing (Manim typically flattens subpaths in order). + # Build a mapping from earcut-local to VMobject-global indices: + concat = [outer[:-1]] + [h[:-1] for h in holes] # drop duplicate last point + counts = [c.shape[0] for c in concat] + starts = np.cumsum([0] + counts[:-1]) + + # Build a table of the corresponding VMobject indices for each concatenated vertex + vm_indices = [] + for i, ring in enumerate(concat): + # find the corresponding indices of these ring points inside `points` + # Existing code usually stores the same order; if needed, use a KDTree/lookup + # Here we assume subpaths are concatenated in the same order as get_subpaths() + path = subpaths[outer_idx if i == 0 else [j for j in range(len(rings)) if j != outer_idx][i - 1]] + vm_path_idx = np.arange(len(points))[np.isin( + points.view([('', points.dtype)] * points.shape[1]), + path.view([('', path.dtype)] * path.shape[1]) + )] + vm_indices.extend(vm_path_idx.tolist()) + + + vm_indices = np.array(vm_indices, dtype=int) + tri_vm = vm_indices[tri] # map earcut triangles to VMobject indices + return tri_vm.astype(int) def refresh_joint_angles(self) -> Self: for mob in self.get_family(): diff --git a/manimlib/utils/triangulation.py b/manimlib/utils/triangulation.py new file mode 100644 index 0000000000..af34834ed6 --- /dev/null +++ b/manimlib/utils/triangulation.py @@ -0,0 +1,45 @@ +# manimlib/utils/triangulation.py +from typing import List, Tuple +import numpy as np + +try: + import mapbox_earcut as earcut + _HAS_EARCUT = True +except Exception: + _HAS_EARCUT = False + +def triangulate_polygon_with_holes( + outer: np.ndarray, + holes: List[np.ndarray] +) -> np.ndarray: + """ + Returns an (N, 3) int array of triangle indices covering the polygon exactly once. + `outer` and each hole is an array of shape (M, 2) or (M, 3). + """ + if not _HAS_EARCUT: + raise ImportError( + "mapbox_earcut not available. Install via `pip install mapbox_earcut`." + ) + + def _flatten_xy(a): + a2 = a[:, :2] if a.shape[1] >= 2 else np.pad(a, ((0,0),(0,2-a.shape[1]))) + return a2.reshape(-1).astype(float) + + vertices = [] + hole_indices = [] + cursor = 0 + + outer_flat = _flatten_xy(outer) + vertices.extend(outer_flat.tolist()) + cursor += outer.shape[0] + + for h in holes: + hole_indices.append(cursor) + h_flat = _flatten_xy(h) + vertices.extend(h_flat.tolist()) + cursor += h.shape[0] + + + tri = earcut.triangulate_float64(vertices, hole_indices, 2) + tri = np.array(tri, dtype=np.int32).reshape(-1, 3) + return tri diff --git a/requirements.txt b/requirements.txt index 5629de3a10..f2901b6f70 100644 --- a/requirements.txt +++ b/requirements.txt @@ -28,3 +28,4 @@ sympy tqdm typing-extensions; python_version < "3.11" validators +mapbox_earcut>=1.0.1 \ No newline at end of file