manim 0.18.1__py3-none-any.whl → 0.19.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of manim might be problematic. Click here for more details.

Files changed (129) hide show
  1. manim/__main__.py +45 -12
  2. manim/_config/__init__.py +2 -2
  3. manim/_config/cli_colors.py +8 -4
  4. manim/_config/default.cfg +0 -2
  5. manim/_config/logger_utils.py +5 -0
  6. manim/_config/utils.py +29 -38
  7. manim/animation/animation.py +148 -8
  8. manim/animation/composition.py +16 -13
  9. manim/animation/creation.py +184 -8
  10. manim/animation/fading.py +5 -8
  11. manim/animation/indication.py +93 -26
  12. manim/animation/movement.py +21 -3
  13. manim/animation/rotation.py +2 -1
  14. manim/animation/specialized.py +3 -5
  15. manim/animation/speedmodifier.py +3 -3
  16. manim/animation/transform.py +4 -5
  17. manim/animation/updaters/mobject_update_utils.py +17 -14
  18. manim/camera/camera.py +2 -2
  19. manim/cli/__init__.py +17 -0
  20. manim/cli/cfg/group.py +52 -36
  21. manim/cli/checkhealth/checks.py +92 -76
  22. manim/cli/checkhealth/commands.py +12 -5
  23. manim/cli/default_group.py +148 -24
  24. manim/cli/init/commands.py +28 -23
  25. manim/cli/plugins/commands.py +13 -3
  26. manim/cli/render/commands.py +47 -42
  27. manim/cli/render/global_options.py +43 -9
  28. manim/cli/render/render_options.py +84 -19
  29. manim/constants.py +11 -4
  30. manim/mobject/frame.py +0 -1
  31. manim/mobject/geometry/arc.py +109 -75
  32. manim/mobject/geometry/boolean_ops.py +20 -17
  33. manim/mobject/geometry/labeled.py +300 -77
  34. manim/mobject/geometry/line.py +120 -60
  35. manim/mobject/geometry/polygram.py +109 -25
  36. manim/mobject/geometry/shape_matchers.py +35 -15
  37. manim/mobject/geometry/tips.py +36 -27
  38. manim/mobject/graph.py +48 -40
  39. manim/mobject/graphing/coordinate_systems.py +110 -45
  40. manim/mobject/graphing/functions.py +16 -10
  41. manim/mobject/graphing/number_line.py +23 -9
  42. manim/mobject/graphing/probability.py +2 -10
  43. manim/mobject/graphing/scale.py +6 -5
  44. manim/mobject/matrix.py +17 -19
  45. manim/mobject/mobject.py +149 -103
  46. manim/mobject/opengl/opengl_geometry.py +4 -8
  47. manim/mobject/opengl/opengl_mobject.py +506 -343
  48. manim/mobject/opengl/opengl_point_cloud_mobject.py +3 -7
  49. manim/mobject/opengl/opengl_surface.py +1 -2
  50. manim/mobject/opengl/opengl_vectorized_mobject.py +27 -65
  51. manim/mobject/svg/brace.py +61 -13
  52. manim/mobject/svg/svg_mobject.py +2 -1
  53. manim/mobject/table.py +11 -12
  54. manim/mobject/text/code_mobject.py +186 -550
  55. manim/mobject/text/numbers.py +7 -7
  56. manim/mobject/text/tex_mobject.py +22 -13
  57. manim/mobject/text/text_mobject.py +29 -20
  58. manim/mobject/three_d/polyhedra.py +98 -1
  59. manim/mobject/three_d/three_dimensions.py +59 -31
  60. manim/mobject/types/image_mobject.py +37 -23
  61. manim/mobject/types/point_cloud_mobject.py +103 -67
  62. manim/mobject/types/vectorized_mobject.py +387 -214
  63. manim/mobject/value_tracker.py +2 -1
  64. manim/mobject/vector_field.py +2 -4
  65. manim/opengl/__init__.py +3 -3
  66. manim/plugins/__init__.py +2 -3
  67. manim/plugins/plugins_flags.py +3 -3
  68. manim/renderer/cairo_renderer.py +11 -11
  69. manim/renderer/opengl_renderer.py +19 -20
  70. manim/renderer/shader.py +2 -3
  71. manim/renderer/shader_wrapper.py +3 -2
  72. manim/scene/moving_camera_scene.py +23 -0
  73. manim/scene/scene.py +72 -41
  74. manim/scene/scene_file_writer.py +313 -164
  75. manim/scene/section.py +15 -15
  76. manim/scene/three_d_scene.py +8 -15
  77. manim/scene/vector_space_scene.py +3 -6
  78. manim/typing.py +326 -66
  79. manim/utils/bezier.py +1658 -381
  80. manim/utils/caching.py +11 -5
  81. manim/utils/color/AS2700.py +2 -0
  82. manim/utils/color/BS381.py +2 -0
  83. manim/utils/color/DVIPSNAMES.py +96 -0
  84. manim/utils/color/SVGNAMES.py +179 -0
  85. manim/utils/color/X11.py +3 -0
  86. manim/utils/color/XKCD.py +2 -0
  87. manim/utils/color/__init__.py +8 -5
  88. manim/utils/color/core.py +818 -301
  89. manim/utils/color/manim_colors.py +7 -9
  90. manim/utils/commands.py +40 -19
  91. manim/utils/config_ops.py +18 -13
  92. manim/utils/debug.py +8 -6
  93. manim/utils/deprecation.py +92 -43
  94. manim/utils/docbuild/autoaliasattr_directive.py +45 -8
  95. manim/utils/docbuild/autocolor_directive.py +12 -13
  96. manim/utils/docbuild/manim_directive.py +35 -29
  97. manim/utils/docbuild/module_parsing.py +74 -27
  98. manim/utils/family.py +3 -3
  99. manim/utils/family_ops.py +12 -4
  100. manim/utils/file_ops.py +22 -16
  101. manim/utils/hashing.py +7 -7
  102. manim/utils/images.py +10 -4
  103. manim/utils/ipython_magic.py +12 -8
  104. manim/utils/iterables.py +161 -119
  105. manim/utils/module_ops.py +55 -19
  106. manim/utils/opengl.py +68 -23
  107. manim/utils/parameter_parsing.py +3 -2
  108. manim/utils/paths.py +11 -5
  109. manim/utils/polylabel.py +168 -0
  110. manim/utils/qhull.py +218 -0
  111. manim/utils/rate_functions.py +69 -32
  112. manim/utils/simple_functions.py +24 -15
  113. manim/utils/sounds.py +7 -1
  114. manim/utils/space_ops.py +48 -37
  115. manim/utils/testing/_frames_testers.py +13 -8
  116. manim/utils/testing/_show_diff.py +5 -3
  117. manim/utils/testing/_test_class_makers.py +33 -18
  118. manim/utils/testing/frames_comparison.py +20 -14
  119. manim/utils/tex.py +4 -2
  120. manim/utils/tex_file_writing.py +45 -45
  121. manim/utils/tex_templates.py +1 -1
  122. manim/utils/unit.py +6 -5
  123. {manim-0.18.1.dist-info → manim-0.19.0.dist-info}/METADATA +16 -9
  124. manim-0.19.0.dist-info/RECORD +221 -0
  125. {manim-0.18.1.dist-info → manim-0.19.0.dist-info}/WHEEL +1 -1
  126. manim-0.18.1.dist-info/RECORD +0 -217
  127. {manim-0.18.1.dist-info → manim-0.19.0.dist-info}/LICENSE +0 -0
  128. {manim-0.18.1.dist-info → manim-0.19.0.dist-info}/LICENSE.community +0 -0
  129. {manim-0.18.1.dist-info → manim-0.19.0.dist-info}/entry_points.txt +0 -0
manim/utils/opengl.py CHANGED
@@ -1,9 +1,25 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from typing import TYPE_CHECKING
4
+
3
5
  import numpy as np
4
6
  import numpy.linalg as linalg
5
7
 
6
- from .. import config
8
+ from manim._config import config
9
+ from manim.typing import ManimFloat
10
+
11
+ if TYPE_CHECKING:
12
+ import numpy.typing as npt
13
+ from typing_extensions import TypeAlias
14
+
15
+ from manim.typing import MatrixMN, Point3D
16
+
17
+
18
+ if TYPE_CHECKING:
19
+ from typing_extensions import TypeAlias
20
+
21
+ from manim.typing import MatrixMN
22
+
7
23
 
8
24
  depth = 20
9
25
 
@@ -21,18 +37,37 @@ __all__ = [
21
37
  "view_matrix",
22
38
  ]
23
39
 
40
+ FlattenedMatrix4x4: TypeAlias = tuple[
41
+ float,
42
+ float,
43
+ float,
44
+ float,
45
+ float,
46
+ float,
47
+ float,
48
+ float,
49
+ float,
50
+ float,
51
+ float,
52
+ float,
53
+ float,
54
+ float,
55
+ float,
56
+ float,
57
+ ]
58
+
24
59
 
25
- def matrix_to_shader_input(matrix):
60
+ def matrix_to_shader_input(matrix: MatrixMN) -> FlattenedMatrix4x4:
26
61
  return tuple(matrix.T.ravel())
27
62
 
28
63
 
29
64
  def orthographic_projection_matrix(
30
- width=None,
31
- height=None,
32
- near=1,
33
- far=depth + 1,
34
- format=True,
35
- ):
65
+ width: float | None = None,
66
+ height: float | None = None,
67
+ near: float = 1,
68
+ far: float = depth + 1,
69
+ format_: bool = True,
70
+ ) -> MatrixMN | FlattenedMatrix4x4:
36
71
  if width is None:
37
72
  width = config["frame_width"]
38
73
  if height is None:
@@ -45,13 +80,19 @@ def orthographic_projection_matrix(
45
80
  [0, 0, 0, 1],
46
81
  ],
47
82
  )
48
- if format:
83
+ if format_:
49
84
  return matrix_to_shader_input(projection_matrix)
50
85
  else:
51
86
  return projection_matrix
52
87
 
53
88
 
54
- def perspective_projection_matrix(width=None, height=None, near=2, far=50, format=True):
89
+ def perspective_projection_matrix(
90
+ width: float | None = None,
91
+ height: float | None = None,
92
+ near: float = 2,
93
+ far: float = 50,
94
+ format_: bool = True,
95
+ ) -> MatrixMN | FlattenedMatrix4x4:
55
96
  if width is None:
56
97
  width = config["frame_width"] / 6
57
98
  if height is None:
@@ -64,13 +105,13 @@ def perspective_projection_matrix(width=None, height=None, near=2, far=50, forma
64
105
  [0, 0, -1, 0],
65
106
  ],
66
107
  )
67
- if format:
108
+ if format_:
68
109
  return matrix_to_shader_input(projection_matrix)
69
110
  else:
70
111
  return projection_matrix
71
112
 
72
113
 
73
- def translation_matrix(x=0, y=0, z=0):
114
+ def translation_matrix(x: float = 0, y: float = 0, z: float = 0) -> MatrixMN:
74
115
  return np.array(
75
116
  [
76
117
  [1, 0, 0, x],
@@ -78,10 +119,11 @@ def translation_matrix(x=0, y=0, z=0):
78
119
  [0, 0, 1, z],
79
120
  [0, 0, 0, 1],
80
121
  ],
122
+ dtype=ManimFloat,
81
123
  )
82
124
 
83
125
 
84
- def x_rotation_matrix(x=0):
126
+ def x_rotation_matrix(x: float = 0) -> MatrixMN:
85
127
  return np.array(
86
128
  [
87
129
  [1, 0, 0, 0],
@@ -92,7 +134,7 @@ def x_rotation_matrix(x=0):
92
134
  )
93
135
 
94
136
 
95
- def y_rotation_matrix(y=0):
137
+ def y_rotation_matrix(y: float = 0) -> MatrixMN:
96
138
  return np.array(
97
139
  [
98
140
  [np.cos(y), 0, np.sin(y), 0],
@@ -103,7 +145,7 @@ def y_rotation_matrix(y=0):
103
145
  )
104
146
 
105
147
 
106
- def z_rotation_matrix(z=0):
148
+ def z_rotation_matrix(z: float = 0) -> MatrixMN:
107
149
  return np.array(
108
150
  [
109
151
  [np.cos(z), -np.sin(z), 0, 0],
@@ -115,7 +157,9 @@ def z_rotation_matrix(z=0):
115
157
 
116
158
 
117
159
  # TODO: When rotating around the x axis, rotation eventually stops.
118
- def rotate_in_place_matrix(initial_position, x=0, y=0, z=0):
160
+ def rotate_in_place_matrix(
161
+ initial_position: Point3D, x: float = 0, y: float = 0, z: float = 0
162
+ ) -> MatrixMN:
119
163
  return np.matmul(
120
164
  translation_matrix(*-initial_position),
121
165
  np.matmul(
@@ -125,14 +169,14 @@ def rotate_in_place_matrix(initial_position, x=0, y=0, z=0):
125
169
  )
126
170
 
127
171
 
128
- def rotation_matrix(x=0, y=0, z=0):
172
+ def rotation_matrix(x: float = 0, y: float = 0, z: float = 0) -> MatrixMN:
129
173
  return np.matmul(
130
174
  np.matmul(x_rotation_matrix(x), y_rotation_matrix(y)),
131
175
  z_rotation_matrix(z),
132
176
  )
133
177
 
134
178
 
135
- def scale_matrix(scale_factor=1):
179
+ def scale_matrix(scale_factor: float = 1) -> npt.NDArray:
136
180
  return np.array(
137
181
  [
138
182
  [scale_factor, 0, 0, 0],
@@ -140,15 +184,16 @@ def scale_matrix(scale_factor=1):
140
184
  [0, 0, scale_factor, 0],
141
185
  [0, 0, 0, 1],
142
186
  ],
187
+ dtype=ManimFloat,
143
188
  )
144
189
 
145
190
 
146
191
  def view_matrix(
147
- translation=None,
148
- x_rotation=0,
149
- y_rotation=0,
150
- z_rotation=0,
151
- ):
192
+ translation: Point3D | None = None,
193
+ x_rotation: float = 0,
194
+ y_rotation: float = 0,
195
+ z_rotation: float = 0,
196
+ ) -> MatrixMN:
152
197
  if translation is None:
153
198
  translation = np.array([0, 0, depth / 2 + 1])
154
199
  model_matrix = np.matmul(
@@ -1,7 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from collections.abc import Iterable
3
4
  from types import GeneratorType
4
- from typing import Iterable, TypeVar
5
+ from typing import TypeVar
5
6
 
6
7
  T = TypeVar("T")
7
8
 
@@ -22,7 +23,7 @@ def flatten_iterable_parameters(
22
23
  :class:`list`
23
24
  The flattened list of parameters.
24
25
  """
25
- flattened_parameters = []
26
+ flattened_parameters: list[T] = []
26
27
  for arg in args:
27
28
  if isinstance(arg, (Iterable, GeneratorType)):
28
29
  flattened_parameters.extend(arg)
manim/utils/paths.py CHANGED
@@ -19,13 +19,13 @@ from ..utils.bezier import interpolate
19
19
  from ..utils.space_ops import rotation_matrix
20
20
 
21
21
  if TYPE_CHECKING:
22
- from manim.typing import PathFuncType, Vector3D
22
+ from manim.typing import PathFuncType, Point3D_Array, Vector3D
23
23
 
24
24
 
25
25
  STRAIGHT_PATH_THRESHOLD = 0.01
26
26
 
27
27
 
28
- def straight_path():
28
+ def straight_path() -> PathFuncType:
29
29
  """Simplest path function. Each point in a set goes in a straight path toward its destination.
30
30
 
31
31
  Examples
@@ -136,7 +136,9 @@ def path_along_circles(
136
136
  axis = OUT
137
137
  unit_axis = axis / np.linalg.norm(axis)
138
138
 
139
- def path(start_points: np.ndarray, end_points: np.ndarray, alpha: float):
139
+ def path(
140
+ start_points: Point3D_Array, end_points: Point3D_Array, alpha: float
141
+ ) -> Point3D_Array:
140
142
  detransformed_end_points = circles_centers + np.dot(
141
143
  end_points - circles_centers, rotation_matrix(-arc_angle, unit_axis).T
142
144
  )
@@ -206,7 +208,9 @@ def path_along_arc(arc_angle: float, axis: Vector3D = OUT) -> PathFuncType:
206
208
  axis = OUT
207
209
  unit_axis = axis / np.linalg.norm(axis)
208
210
 
209
- def path(start_points: np.ndarray, end_points: np.ndarray, alpha: float):
211
+ def path(
212
+ start_points: Point3D_Array, end_points: Point3D_Array, alpha: float
213
+ ) -> Point3D_Array:
210
214
  vects = end_points - start_points
211
215
  centers = start_points + 0.5 * vects
212
216
  if arc_angle != np.pi:
@@ -365,7 +369,9 @@ def spiral_path(angle: float, axis: Vector3D = OUT) -> PathFuncType:
365
369
  axis = OUT
366
370
  unit_axis = axis / np.linalg.norm(axis)
367
371
 
368
- def path(start_points: np.ndarray, end_points: np.ndarray, alpha: float):
372
+ def path(
373
+ start_points: Point3D_Array, end_points: Point3D_Array, alpha: float
374
+ ) -> Point3D_Array:
369
375
  rot_matrix = rotation_matrix((alpha - 1) * angle, unit_axis)
370
376
  return start_points + alpha * np.dot(end_points - start_points, rot_matrix.T)
371
377
 
@@ -0,0 +1,168 @@
1
+ #!/usr/bin/env python
2
+ from __future__ import annotations
3
+
4
+ from queue import PriorityQueue
5
+ from typing import TYPE_CHECKING
6
+
7
+ import numpy as np
8
+
9
+ if TYPE_CHECKING:
10
+ from collections.abc import Sequence
11
+
12
+ from manim.typing import (
13
+ Point2D,
14
+ Point2D_Array,
15
+ Point2DLike,
16
+ Point2DLike_Array,
17
+ Point3DLike_Array,
18
+ )
19
+
20
+
21
+ class Polygon:
22
+ """
23
+ Initializes the Polygon with the given rings.
24
+
25
+ Parameters
26
+ ----------
27
+ rings
28
+ A collection of closed polygonal ring.
29
+ """
30
+
31
+ def __init__(self, rings: Sequence[Point2DLike_Array]) -> None:
32
+ np_rings: list[Point2D_Array] = [np.asarray(ring) for ring in rings]
33
+ # Flatten Array
34
+ csum = np.cumsum([ring.shape[0] for ring in np_rings])
35
+ self.array: Point2D_Array = np.concatenate(np_rings, axis=0)
36
+
37
+ # Compute Boundary
38
+ self.start: Point2D_Array = np.delete(self.array, csum - 1, axis=0)
39
+ self.stop: Point2D_Array = np.delete(self.array, csum % csum[-1], axis=0)
40
+ self.diff: Point2D_Array = np.delete(
41
+ np.diff(self.array, axis=0), csum[:-1] - 1, axis=0
42
+ )
43
+ self.norm: Point2D_Array = self.diff / np.einsum(
44
+ "ij,ij->i", self.diff, self.diff
45
+ ).reshape(-1, 1)
46
+
47
+ # Compute Centroid
48
+ x, y = self.start[:, 0], self.start[:, 1]
49
+ xr, yr = self.stop[:, 0], self.stop[:, 1]
50
+ self.area: float = 0.5 * (np.dot(x, yr) - np.dot(xr, y))
51
+ if self.area:
52
+ factor = x * yr - xr * y
53
+ cx = np.sum((x + xr) * factor) / (6.0 * self.area)
54
+ cy = np.sum((y + yr) * factor) / (6.0 * self.area)
55
+ self.centroid = np.array([cx, cy])
56
+
57
+ def compute_distance(self, point: Point2DLike) -> float:
58
+ """Compute the minimum distance from a point to the polygon."""
59
+ scalars = np.einsum("ij,ij->i", self.norm, point - self.start)
60
+ clips = np.clip(scalars, 0, 1).reshape(-1, 1)
61
+ d: float = np.min(
62
+ np.linalg.norm(self.start + self.diff * clips - point, axis=1)
63
+ )
64
+ return d if self.inside(point) else -d
65
+
66
+ def inside(self, point: Point2DLike) -> bool:
67
+ """Check if a point is inside the polygon."""
68
+ # Views
69
+ px, py = point
70
+ x, y = self.start[:, 0], self.start[:, 1]
71
+ xr, yr = self.stop[:, 0], self.stop[:, 1]
72
+
73
+ # Count Crossings (enforce short-circuit)
74
+ c = (y > py) != (yr > py)
75
+ c = px < x[c] + (py - y[c]) * (xr[c] - x[c]) / (yr[c] - y[c])
76
+ c_sum: int = np.sum(c)
77
+ return c_sum % 2 == 1
78
+
79
+
80
+ class Cell:
81
+ """
82
+ A square in a mesh covering the :class:`~.Polygon` passed as an argument.
83
+
84
+ Parameters
85
+ ----------
86
+ c
87
+ Center coordinates of the Cell.
88
+ h
89
+ Half-Size of the Cell.
90
+ polygon
91
+ :class:`~.Polygon` object for which the distance is computed.
92
+ """
93
+
94
+ def __init__(self, c: Point2DLike, h: float, polygon: Polygon) -> None:
95
+ self.c: Point2D = np.asarray(c)
96
+ self.h = h
97
+ self.d = polygon.compute_distance(self.c)
98
+ self.p = self.d + self.h * np.sqrt(2)
99
+
100
+ def __lt__(self, other: Cell) -> bool:
101
+ return self.d < other.d
102
+
103
+ def __gt__(self, other: Cell) -> bool:
104
+ return self.d > other.d
105
+
106
+ def __le__(self, other: Cell) -> bool:
107
+ return self.d <= other.d
108
+
109
+ def __ge__(self, other: Cell) -> bool:
110
+ return self.d >= other.d
111
+
112
+
113
+ def polylabel(rings: Sequence[Point3DLike_Array], precision: float = 0.01) -> Cell:
114
+ """
115
+ Finds the pole of inaccessibility (the point that is farthest from the edges of the polygon)
116
+ using an iterative grid-based approach.
117
+
118
+ Parameters
119
+ ----------
120
+ rings
121
+ A list of lists, where each list is a sequence of points representing the rings of the polygon.
122
+ Typically, multiple rings indicate holes in the polygon.
123
+ precision
124
+ The precision of the result (default is 0.01).
125
+
126
+ Returns
127
+ -------
128
+ Cell
129
+ A Cell containing the pole of inaccessibility to a given precision.
130
+ """
131
+ # Precompute Polygon Data
132
+ np_rings: list[Point2D_Array] = [np.asarray(ring)[:, :2] for ring in rings]
133
+ polygon = Polygon(np_rings)
134
+
135
+ # Bounding Box
136
+ mins = np.min(polygon.array, axis=0)
137
+ maxs = np.max(polygon.array, axis=0)
138
+ dims = maxs - mins
139
+ s = np.min(dims)
140
+ h = s / 2.0
141
+
142
+ # Initial Grid
143
+ queue: PriorityQueue[Cell] = PriorityQueue()
144
+ xv, yv = np.meshgrid(np.arange(mins[0], maxs[0], s), np.arange(mins[1], maxs[1], s))
145
+ for corner in np.vstack([xv.ravel(), yv.ravel()]).T:
146
+ queue.put(Cell(corner + h, h, polygon))
147
+
148
+ # Initial Guess
149
+ best = Cell(polygon.centroid, 0, polygon)
150
+ bbox = Cell(mins + (dims / 2), 0, polygon)
151
+ if bbox.d > best.d:
152
+ best = bbox
153
+
154
+ # While there are cells to consider...
155
+ directions = np.array([[-1, -1], [1, -1], [-1, 1], [1, 1]])
156
+ while not queue.empty():
157
+ cell = queue.get()
158
+ if cell > best:
159
+ best = cell
160
+ # If a cell is promising, subdivide!
161
+ if cell.p - best.d > precision:
162
+ h = cell.h / 2.0
163
+ offsets = cell.c + directions * h
164
+ queue.put(Cell(offsets[0], h, polygon))
165
+ queue.put(Cell(offsets[1], h, polygon))
166
+ queue.put(Cell(offsets[2], h, polygon))
167
+ queue.put(Cell(offsets[3], h, polygon))
168
+ return best
manim/utils/qhull.py ADDED
@@ -0,0 +1,218 @@
1
+ #!/usr/bin/env python
2
+ from __future__ import annotations
3
+
4
+ from typing import TYPE_CHECKING
5
+
6
+ import numpy as np
7
+
8
+ if TYPE_CHECKING:
9
+ from manim.typing import PointND, PointND_Array
10
+
11
+
12
+ class QuickHullPoint:
13
+ def __init__(self, coordinates: PointND_Array) -> None:
14
+ self.coordinates = coordinates
15
+
16
+ def __hash__(self) -> int:
17
+ return hash(self.coordinates.tobytes())
18
+
19
+ def __eq__(self, other: object) -> bool:
20
+ if not isinstance(other, QuickHullPoint):
21
+ raise ValueError
22
+ are_coordinates_equal: bool = np.array_equal(
23
+ self.coordinates, other.coordinates
24
+ )
25
+ return are_coordinates_equal
26
+
27
+
28
+ class SubFacet:
29
+ def __init__(self, coordinates: PointND_Array) -> None:
30
+ self.coordinates = coordinates
31
+ self.points = frozenset(QuickHullPoint(c) for c in coordinates)
32
+
33
+ def __hash__(self) -> int:
34
+ return hash(self.points)
35
+
36
+ def __eq__(self, other: object) -> bool:
37
+ if not isinstance(other, SubFacet):
38
+ raise ValueError
39
+ return self.points == other.points
40
+
41
+
42
+ class Facet:
43
+ def __init__(self, coordinates: PointND_Array, internal: PointND) -> None:
44
+ self.coordinates = coordinates
45
+ self.center: PointND = np.mean(coordinates, axis=0)
46
+ self.normal = self.compute_normal(internal)
47
+ self.subfacets = frozenset(
48
+ SubFacet(np.delete(self.coordinates, i, axis=0))
49
+ for i in range(self.coordinates.shape[0])
50
+ )
51
+
52
+ def compute_normal(self, internal: PointND) -> PointND:
53
+ centered = self.coordinates - self.center
54
+ _, _, vh = np.linalg.svd(centered)
55
+ normal: PointND = vh[-1, :]
56
+ normal /= np.linalg.norm(normal)
57
+
58
+ # If the normal points towards the internal point, flip it!
59
+ if np.dot(normal, self.center - internal) < 0:
60
+ normal *= -1
61
+
62
+ return normal
63
+
64
+ def __hash__(self) -> int:
65
+ return hash(self.subfacets)
66
+
67
+ def __eq__(self, other: object) -> bool:
68
+ if not isinstance(other, Facet):
69
+ raise ValueError
70
+ return self.subfacets == other.subfacets
71
+
72
+
73
+ class Horizon:
74
+ def __init__(self) -> None:
75
+ self.facets: set[Facet] = set()
76
+ self.boundary: list[SubFacet] = []
77
+
78
+
79
+ class QuickHull:
80
+ """
81
+ QuickHull algorithm for constructing a convex hull from a set of points.
82
+
83
+ Parameters
84
+ ----------
85
+ tolerance
86
+ A tolerance threshold for determining when points lie on the convex hull (default is 1e-5).
87
+
88
+ Attributes
89
+ ----------
90
+ facets
91
+ List of facets considered.
92
+ removed
93
+ Set of internal facets that have been removed from the hull during the construction process.
94
+ outside
95
+ Dictionary mapping each facet to its outside points and eye point.
96
+ neighbors
97
+ Mapping of subfacets to their neighboring facets. Each subfacet links precisely two neighbors.
98
+ unclaimed
99
+ Points that have not yet been classified as inside or outside the current hull.
100
+ internal
101
+ An internal point (i.e., the center of the initial simplex) used as a reference during hull construction.
102
+ tolerance
103
+ The tolerance used to determine if points are considered outside the current hull.
104
+ """
105
+
106
+ def __init__(self, tolerance: float = 1e-5) -> None:
107
+ self.facets: list[Facet] = []
108
+ self.removed: set[Facet] = set()
109
+ self.outside: dict[Facet, tuple[PointND_Array | None, PointND | None]] = {}
110
+ self.neighbors: dict[SubFacet, set[Facet]] = {}
111
+ self.unclaimed: PointND_Array | None = None
112
+ self.internal: PointND | None = None
113
+ self.tolerance = tolerance
114
+
115
+ def initialize(self, points: PointND_Array) -> None:
116
+ # Sample Points
117
+ simplex = points[
118
+ np.random.choice(points.shape[0], points.shape[1] + 1, replace=False)
119
+ ]
120
+ self.unclaimed = points
121
+ new_internal: PointND = np.mean(simplex, axis=0)
122
+ self.internal = new_internal
123
+
124
+ # Build Simplex
125
+ for c in range(simplex.shape[0]):
126
+ facet = Facet(np.delete(simplex, c, axis=0), internal=new_internal)
127
+ self.classify(facet)
128
+ self.facets.append(facet)
129
+
130
+ # Attach Neighbors
131
+ for f in self.facets:
132
+ for sf in f.subfacets:
133
+ self.neighbors.setdefault(sf, set()).add(f)
134
+
135
+ def classify(self, facet: Facet) -> None:
136
+ assert self.unclaimed is not None, (
137
+ "Call .initialize() before using .classify()."
138
+ )
139
+
140
+ if not self.unclaimed.size:
141
+ self.outside[facet] = (None, None)
142
+ return
143
+
144
+ # Compute Projections
145
+ projections = (self.unclaimed - facet.center) @ facet.normal
146
+ arg = np.argmax(projections)
147
+ mask = projections > self.tolerance
148
+
149
+ # Identify Eye and Outside Set
150
+ eye = self.unclaimed[arg] if projections[arg] > self.tolerance else None
151
+ outside = self.unclaimed[mask]
152
+ self.outside[facet] = (outside, eye)
153
+ self.unclaimed = self.unclaimed[~mask]
154
+
155
+ def compute_horizon(self, eye: PointND, start_facet: Facet) -> Horizon:
156
+ horizon = Horizon()
157
+ self._recursive_horizon(eye, start_facet, horizon)
158
+ return horizon
159
+
160
+ def _recursive_horizon(self, eye: PointND, facet: Facet, horizon: Horizon) -> bool:
161
+ visible = np.dot(facet.normal, eye - facet.center) > 0
162
+ if not visible:
163
+ return False
164
+
165
+ # If the eye is visible from the facet:
166
+ # Label the facet as visible and cross each edge
167
+ horizon.facets.add(facet)
168
+ for subfacet in facet.subfacets:
169
+ neighbor = (self.neighbors[subfacet] - {facet}).pop()
170
+ # If the neighbor is not visible, then the edge shared must be on the boundary
171
+ if neighbor not in horizon.facets and not self._recursive_horizon(
172
+ eye, neighbor, horizon
173
+ ):
174
+ horizon.boundary.append(subfacet)
175
+ return True
176
+
177
+ def build(self, points: PointND_Array) -> None:
178
+ num, dim = points.shape
179
+ if (dim == 0) or (num < dim + 1):
180
+ raise ValueError("Not enough points supplied to build Convex Hull!")
181
+ if dim == 1:
182
+ raise ValueError("The Convex Hull of 1D data is its min-max!")
183
+
184
+ self.initialize(points)
185
+
186
+ # This helps the type checker.
187
+ assert self.unclaimed is not None
188
+ assert self.internal is not None
189
+
190
+ while True:
191
+ updated = False
192
+ for facet in self.facets:
193
+ if facet in self.removed:
194
+ continue
195
+ outside, eye = self.outside[facet]
196
+ if eye is not None:
197
+ updated = True
198
+ horizon = self.compute_horizon(eye, facet)
199
+ for f in horizon.facets:
200
+ points_to_append = self.outside[f][0]
201
+ # TODO: is this always true?
202
+ assert points_to_append is not None
203
+ self.unclaimed = np.vstack((self.unclaimed, points_to_append))
204
+ self.removed.add(f)
205
+ for sf in f.subfacets:
206
+ self.neighbors[sf].discard(f)
207
+ if self.neighbors[sf] == set():
208
+ del self.neighbors[sf]
209
+ for sf in horizon.boundary:
210
+ nf = Facet(
211
+ np.vstack((sf.coordinates, eye)), internal=self.internal
212
+ )
213
+ self.classify(nf)
214
+ self.facets.append(nf)
215
+ for nsf in nf.subfacets:
216
+ self.neighbors.setdefault(nsf, set()).add(nf)
217
+ if not updated:
218
+ break