From a41aba21ca80d6d134f2ac4a8d70192a2dbbfb2e Mon Sep 17 00:00:00 2001 From: Brighton Date: Sat, 21 Mar 2026 03:50:24 +0800 Subject: [PATCH] Fix polyline distortion when adding tips to Line paths --- manim/mobject/geometry/line.py | 44 +++++++++++++++++++ manim/mobject/opengl/opengl_geometry.py | 40 +++++++++++++++++ .../mobject/geometry/test_unit_geometry.py | 44 +++++++++++++++++++ 3 files changed, 128 insertions(+) diff --git a/manim/mobject/geometry/line.py b/manim/mobject/geometry/line.py index 2cd7aff807..a158ec05a2 100644 --- a/manim/mobject/geometry/line.py +++ b/manim/mobject/geometry/line.py @@ -26,6 +26,7 @@ from manim.mobject.opengl.opengl_compatibility import ConvertToOpenGL from manim.mobject.opengl.opengl_mobject import OpenGLMobject from manim.mobject.types.vectorized_mobject import DashedVMobject, VGroup, VMobject +from manim.utils.bezier import partial_bezier_points from manim.utils.color import WHITE from manim.utils.space_ops import angle_of_vector, line_intersection, normalize @@ -234,6 +235,49 @@ def construct(self): self.generate_points() return super().put_start_and_end_on(start, end) + def _trim_path_with_tip_base( + self, + point: Point3DLike, + at_start: bool, + ) -> bool: + if self.path_arc != 0 or self.get_num_curves() <= 1: + return False + + curve_index = 0 if at_start else self.get_num_curves() - 1 + if curve_index < 0: + return False + + curve_points = self.get_nth_curve_points(curve_index) + start_anchor = curve_points[0] + end_anchor = curve_points[-1] + segment = end_anchor - start_anchor + segment_length_sq = float(np.dot(segment, segment)) + if segment_length_sq == 0: + return False + + residue = float( + np.clip( + np.dot(np.asarray(point) - start_anchor, segment) / segment_length_sq, + 0, + 1, + ) + ) + nppc = self.n_points_per_curve + if at_start: + self.points[:nppc] = partial_bezier_points(curve_points, residue, 1) + else: + self.points[-nppc:] = partial_bezier_points(curve_points, 0, residue) + return True + + def reset_endpoints_based_on_tip(self, tip: ArrowTip, at_start: bool) -> Self: + if self.get_length() == 0: + return self + + if self._trim_path_with_tip_base(tip.base, at_start): + return self + + return super().reset_endpoints_based_on_tip(tip, at_start) + def get_vector(self) -> Vector3D: return self.get_end() - self.get_start() diff --git a/manim/mobject/opengl/opengl_geometry.py b/manim/mobject/opengl/opengl_geometry.py index 6b09a82e2d..a84f35f74f 100644 --- a/manim/mobject/opengl/opengl_geometry.py +++ b/manim/mobject/opengl/opengl_geometry.py @@ -21,6 +21,7 @@ Vector3D, Vector3DLike, ) +from manim.utils.bezier import partial_bezier_points from manim.utils.color import * from manim.utils.iterables import adjacent_n_tuples, adjacent_pairs from manim.utils.simple_functions import clip @@ -553,6 +554,45 @@ def put_start_and_end_on(self, start: Point3DLike, end: Point3DLike) -> Self: self.set_points_by_ends(start, end, self.path_arc) return super().put_start_and_end_on(start, end) + def _trim_path_with_tip_base(self, point: Point3DLike, at_start: bool) -> bool: + if self.path_arc != 0 or self.get_num_curves() <= 1: + return False + + curve_index = 0 if at_start else self.get_num_curves() - 1 + if curve_index < 0: + return False + + curve_points = self.get_nth_curve_points(curve_index) + start_anchor = curve_points[0] + end_anchor = curve_points[-1] + segment = end_anchor - start_anchor + segment_length_sq = float(np.dot(segment, segment)) + if segment_length_sq == 0: + return False + + residue = float( + np.clip( + np.dot(np.asarray(point) - start_anchor, segment) / segment_length_sq, + 0, + 1, + ) + ) + nppc = self.n_points_per_curve + if at_start: + self.points[:nppc] = partial_bezier_points(curve_points, residue, 1) + else: + self.points[-nppc:] = partial_bezier_points(curve_points, 0, residue) + return True + + def reset_endpoints_based_on_tip(self, tip: OpenGLArrowTip, at_start: bool) -> Self: + if self.get_length() == 0: + return self + + if self._trim_path_with_tip_base(tip.get_base(), at_start): + return self + + return super().reset_endpoints_based_on_tip(tip, at_start) + def get_vector(self) -> Vector3D: return self.get_end() - self.get_start() diff --git a/tests/module/mobject/geometry/test_unit_geometry.py b/tests/module/mobject/geometry/test_unit_geometry.py index e6da248356..8e1f6ca4cb 100644 --- a/tests/module/mobject/geometry/test_unit_geometry.py +++ b/tests/module/mobject/geometry/test_unit_geometry.py @@ -217,6 +217,50 @@ def test_line_with_buff_and_path_arc(): np.testing.assert_allclose(line.points, expected_points) +def test_add_tip_preserves_polyline_corner(): + line = Line() + line.set_points_as_corners( + [ + np.array([0.0, 0.0, 0.0]), + np.array([0.0, 2.0, 0.0]), + np.array([3.0, 2.0, 0.0]), + ] + ) + original_first_curve = line.points[:4].copy() + + line.add_tip(tip_length=0.5) + + np.testing.assert_allclose(line.points[:4], original_first_curve) + np.testing.assert_allclose(line.points[3], np.array([0.0, 2.0, 0.0])) + np.testing.assert_allclose(line.points[4], np.array([0.0, 2.0, 0.0])) + np.testing.assert_allclose(line.points[-1], line.tip.base) + np.testing.assert_allclose(line.tip.base, np.array([2.5, 2.0, 0.0])) + + +def test_add_start_tip_preserves_polyline_corner(): + line = Line() + line.set_points_as_corners( + [ + np.array([0.0, 0.0, 0.0]), + np.array([0.0, 2.0, 0.0]), + np.array([3.0, 2.0, 0.0]), + ] + ) + original_last_curve = line.points[-4:].copy() + + line.add_tip(at_start=True, tip_length=0.5) + + np.testing.assert_allclose(line.points[-4:], original_last_curve) + np.testing.assert_allclose(line.points[3], np.array([0.0, 2.0, 0.0])) + np.testing.assert_allclose(line.points[4], np.array([0.0, 2.0, 0.0])) + np.testing.assert_allclose(line.points[0], line.start_tip.base, atol=1e-12) + np.testing.assert_allclose( + line.start_tip.base, + np.array([0.0, 0.5, 0.0]), + atol=1e-12, + ) + + def test_Circle_point_at_angle(): from manim import TAU