Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -134,3 +134,29 @@ dist/

# Ignore the built dependencies
third_party/*

# Virtual environments
.venv/
venv/
env/
testenv/

# Python cache
__pycache__/
*.pyc
*.pyo
*.pyd

# mypy
.mypy_cache/

# pytest
.pytest_cache/

# IDEs
.vscode/
.idea/

# OS
.DS_Store
Thumbs.db
57 changes: 36 additions & 21 deletions manim/mobject/geometry/arc.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,15 +118,16 @@ def add_tip(
tip_length: float | None = None,
tip_width: float | None = None,
at_start: bool = False,
is_loop: bool = False,
) -> Self:
"""Adds a tip to the TipableVMobject instance, recognising
that the endpoints might need to be switched if it's
a 'starting tip' or not.
"""
if tip is None:
tip = self.create_tip(tip_shape, tip_length, tip_width, at_start)
tip = self.create_tip(tip_shape, tip_length, tip_width, at_start, is_loop)
else:
self.position_tip(tip, at_start)
self.position_tip(tip, at_start, is_loop)
self.reset_endpoints_based_on_tip(tip, at_start)
self.assign_tip_attr(tip, at_start)
self.add(tip)
Expand All @@ -138,12 +139,13 @@ def create_tip(
tip_length: float | None = None,
tip_width: float | None = None,
at_start: bool = False,
is_loop: bool = False,
) -> tips.ArrowTip:
"""Stylises the tip, positions it spatially, and returns
the newly instantiated tip to the caller.
"""
tip = self.get_unpositioned_tip(tip_shape, tip_length, tip_width)
self.position_tip(tip, at_start)
self.position_tip(tip, at_start, is_loop)
return tip

def get_unpositioned_tip(
Expand Down Expand Up @@ -175,7 +177,9 @@ def get_unpositioned_tip(
tip = tip_shape(length=tip_length, **style)
return tip

def position_tip(self, tip: tips.ArrowTip, at_start: bool = False) -> tips.ArrowTip:
def position_tip(
self, tip: tips.ArrowTip, at_start: bool = False, is_loop: bool = False
) -> tips.ArrowTip:
# Last two control points, defining both
# the end, and the tangency direction
if at_start:
Expand All @@ -184,25 +188,36 @@ def position_tip(self, tip: tips.ArrowTip, at_start: bool = False) -> tips.Arrow
else:
handle = self.get_last_handle()
anchor = self.get_end()

angles = cartesian_to_spherical(handle - anchor)
tip.rotate(
angles[1] - PI - tip.tip_angle,
) # Rotates the tip along the azimuthal
if not hasattr(self, "_init_positioning_axis"):
axis = np.array(
[
np.sin(angles[1]),
-np.cos(angles[1]),
0,
]
) # Obtains the perpendicular of the tip
tip.rotate(
-angles[2] + PI / 2,
axis=axis,
) # Rotates the tip along the vertical wrt the axis
self._init_positioning_axis = axis

tip.shift(anchor - tip.tip_point)
if is_loop:
alpha = angles[1] - 10 * PI / 9 - tip.tip_angle
tip.rotate(
alpha,
)
tip.move_to(handle)
else:
tip.rotate(
angles[1] - PI - tip.tip_angle,
) # Rotates the tip along the azimuthal

if not hasattr(self, "_init_positioning_axis"):
axis = np.array(
[
np.sin(angles[1]),
-np.cos(angles[1]),
0,
]
) # Obtains the perpendicular of the tip

tip.rotate(
-angles[2] + PI / 2,
axis=axis,
) # Rotates the tip along the vertical wrt the axis
self._init_positioning_axis = axis

tip.shift(anchor - tip.tip_point)
return tip

def reset_endpoints_based_on_tip(self, tip: tips.ArrowTip, at_start: bool) -> Self:
Expand Down
2 changes: 1 addition & 1 deletion manim/mobject/geometry/labeled.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ def construct(self):

def __init__(
self,
label: str | Tex | MathTex | Text,
label: str | Tex | MathTex | Text = "",
label_position: float = 0.5,
label_config: dict[str, Any] | None = None,
box_config: dict[str, Any] | None = None,
Expand Down
115 changes: 114 additions & 1 deletion manim/mobject/geometry/line.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"DoubleArrow",
"Angle",
"RightAngle",
"LoopEdge",
]

from typing import TYPE_CHECKING, Any, Literal, cast
Expand Down Expand Up @@ -604,7 +605,7 @@ def __init__(
self._set_stroke_width_from_length()

def scale(self, factor: float, scale_tips: bool = False, **kwargs: Any) -> Self: # type: ignore[override]
r"""Scale an arrow, but keep stroke width and arrow tip size fixed.
"""Scale an arrow, but keep stroke width and arrow tip size fixed.


.. seealso::
Expand Down Expand Up @@ -1210,3 +1211,115 @@ def __init__(
**kwargs: Any,
) -> None:
super().__init__(line1, line2, radius=length, elbow=True, **kwargs)


class LoopEdge(TipableVMobject):
def __init__(
self,
vertex: Point3DLike,
graph_center: Point3DLike,
edge_type: type[Mobject] = Line,
**kwargs: Any,
) -> None:
self.label: Label | None = None

label = kwargs.pop("label", None)
label_config = kwargs.pop("label_config", None)
box_config = kwargs.pop("box_config", None)
frame_config = kwargs.pop("frame_config", None)

super().__init__(**kwargs)
self.anchor_vertex = self._pointify(vertex)
self.graph_center = self._pointify(graph_center)
self.edge_type = edge_type

edge = edge_type()

points = self._generate_arch_points()
edge.set_points(points)
self.set_points(edge.points)

if label is not None:
from manim.mobject.geometry.labeled import Label

self.label = Label(
label=label,
label_config=label_config,
box_config=box_config,
frame_config=frame_config,
)

self.label.move_to(self.get_arch_point(0.25))
self.add(self.label)
else:
self.label = None

def _generate_arch_points(self, vertex: Point3DLike | None = None) -> np.array:
radius = 0.5
anchor_vertex = self.anchor_vertex if vertex is None else self._pointify(vertex)

direction = anchor_vertex - self.graph_center
norm = np.linalg.norm(direction)
direction = UP if norm == 0 else direction / norm

self.arc_center = anchor_vertex + direction * radius

vector = anchor_vertex - self.arc_center
self.angle = np.arctan2(vector[1], vector[0])

arc = Arc(
arc_center=self.arc_center,
radius=radius,
start_angle=self.angle,
angle=2 * PI - 1e-3,
)
return arc.points

def set_points_by_vertex(self, vertex: Point3DLike | Mobject) -> None:
points = self._generate_arch_points(vertex)

edge = self.edge_type()

edge.set_points(points)
self.set_points(edge.points)

if self.label is not None:
self.label.move_to(self.get_arch_point(0.25))
self.add(self.label)

def get_center(self) -> Point3DLike:
return self.arc_center

def get_start(self) -> Point3DLike:
return self.points[0]

def get_end(self) -> Point3DLike:
return self.points[0] # technically should be [-1], but that breaks the loop

def get_arch_point(self, t: float) -> Point3DLike:
radius = 0.5
theta = self.angle + 2 * t * PI
return (
np.array([radius * np.cos(theta), radius * np.sin(theta), 0])
+ self.arc_center
)

def get_first_handle(self) -> Point3DLike:
return self.get_arch_point(0.18)

def get_last_handle(self) -> Point3DLike:
return self.get_arch_point(0.82)

# copied from Line
def _pointify(
self,
mob_or_point: Mobject | Point3DLike,
direction: Vector3DLike | None = None,
) -> Point3D:
if isinstance(mob_or_point, (Mobject, OpenGLMobject)):
mob = mob_or_point
if direction is None:
return mob.get_center()
else:
return mob.get_boundary_point(direction)
return np.array(mob_or_point)
Loading
Loading