From ab8b92d122ac62e8421d23c3f8d2bc4b72c02498 Mon Sep 17 00:00:00 2001 From: Devin Neal Date: Sat, 27 Mar 2021 01:14:16 -0700 Subject: [PATCH 1/8] Tex working with artifacts --- example_scenes/basic.py | 9 + example_scenes/opengl.py | 9 + manim/mobject/mobject.py | 5 +- manim/mobject/svg/opengl_svg_mobject.py | 34 ++ manim/mobject/svg/opengl_svg_path.py | 16 + manim/mobject/svg/opengl_tex_mobject.py | 607 ++++++++++++++++++++++++ manim/mobject/svg/svg_mobject.py | 30 +- manim/mobject/svg/svg_path.py | 17 +- manim/opengl/__init__.py | 3 + 9 files changed, 717 insertions(+), 13 deletions(-) create mode 100644 manim/mobject/svg/opengl_svg_mobject.py create mode 100644 manim/mobject/svg/opengl_svg_path.py create mode 100644 manim/mobject/svg/opengl_tex_mobject.py diff --git a/example_scenes/basic.py b/example_scenes/basic.py index 67444be348..bc34233c6e 100644 --- a/example_scenes/basic.py +++ b/example_scenes/basic.py @@ -14,6 +14,15 @@ # for a 1920x1080 video) +class TestLatex(Scene): + def construct(self): + # mob = SVGMobject("../manim/media/Tex/1e2155e0e4bdc7ad.svg").scale(3) + # mob.set_color(WHITE) + # self.play(FadeIn(mob)) + mob = MathTex("a") + self.play(FadeIn(mob)) + + class OpeningManim(Scene): def construct(self): title = Tex(r"This is some \LaTeX") diff --git a/example_scenes/opengl.py b/example_scenes/opengl.py index d634e96806..48fcc90d4c 100644 --- a/example_scenes/opengl.py +++ b/example_scenes/opengl.py @@ -8,6 +8,15 @@ # Lines that do not yet work with the Community Version are commented. +class TestLatex(Scene): + def construct(self): + # mob = OpenGLSVGMobject("../manim/media/Tex/184715fb937073ce.svg").scale(3) + # mob.set_color(WHITE) + mob = OpenGLTex("Manim Community").set_width(13) + self.play(Write(mob)) + self.wait(10) + + class InteractiveDevelopment(Scene): def construct(self): circle = OpenGLCircle() diff --git a/manim/mobject/mobject.py b/manim/mobject/mobject.py index 13a6544038..0835a707e5 100644 --- a/manim/mobject/mobject.py +++ b/manim/mobject/mobject.py @@ -206,7 +206,10 @@ def __deepcopy__(self, clone_from_id): return result def __repr__(self): - return str(self.name) + if config["use_opengl_renderer"]: + return super().__repr__() + else: + return str(self.name) def reset_points(self): """Sets :attr:`points` to be an empty array.""" diff --git a/manim/mobject/svg/opengl_svg_mobject.py b/manim/mobject/svg/opengl_svg_mobject.py new file mode 100644 index 0000000000..38830a1d91 --- /dev/null +++ b/manim/mobject/svg/opengl_svg_mobject.py @@ -0,0 +1,34 @@ +from ..types.opengl_vectorized_mobject import OpenGLVMobject +from ..svg.svg_mobject import SVGMobject +from .style_utils import cascade_element_style, parse_style +from .opengl_svg_path import OpenGLSVGPathMobject +from ...constants import * + + +class OpenGLSVGMobject(OpenGLVMobject, SVGMobject): + def __init__( + self, + file_name=None, + should_center=True, + height=2, + width=None, + unpack_groups=True, # if False, creates a hierarchy of VGroups + stroke_width=DEFAULT_STROKE_WIDTH, + fill_opacity=1.0, + **kwargs, + ): + self.def_map = {} + self.file_name = file_name or self.file_name + self.ensure_valid_file() + self.should_center = should_center + self.unpack_groups = unpack_groups + OpenGLVMobject.__init__( + self, stroke_width=stroke_width, fill_opacity=fill_opacity, **kwargs + ) + self.move_into_position(width, height) + + def init_points(self): + self.generate_points() + + def path_string_to_mobject(self, path_string: str, style: dict): + return OpenGLSVGPathMobject(path_string, **parse_style(style)) diff --git a/manim/mobject/svg/opengl_svg_path.py b/manim/mobject/svg/opengl_svg_path.py new file mode 100644 index 0000000000..0fc872430c --- /dev/null +++ b/manim/mobject/svg/opengl_svg_path.py @@ -0,0 +1,16 @@ +import numpy as np +from ..types.opengl_vectorized_mobject import OpenGLVMobject +from .svg_path import SVGPathMobject + + +class OpenGLSVGPathMobject(OpenGLVMobject, SVGPathMobject): + def __init__(self, path_string, **kwargs): + self.path_string = path_string + OpenGLVMobject.__init__(self, **kwargs) + self.current_path_start = np.zeros((1, self.dim)) + + def init_points(self): + self.generate_points() + + def start_new_path(self, point): + SVGPathMobject.start_new_path(self, point) diff --git a/manim/mobject/svg/opengl_tex_mobject.py b/manim/mobject/svg/opengl_tex_mobject.py new file mode 100644 index 0000000000..bc006d4415 --- /dev/null +++ b/manim/mobject/svg/opengl_tex_mobject.py @@ -0,0 +1,607 @@ +r"""Mobjects representing text rendered using LaTeX. + + +The Tex mobject ++++++++++++++++ +Just as you can use :class:`~.Text` to add text to your videos, you can use :class:`~.Tex` to insert LaTeX. + +.. manim:: HelloLaTeX + :save_last_frame: + + class HelloLaTeX(Scene): + def construct(self): + tex = Tex(r'\LaTeX').scale(3) + self.add(tex) + +Note that we are using a raw string (``r'---'``) instead of a regular string (``'---'``). +This is because TeX code uses a lot of special characters - like ``\`` for example - +that have special meaning within a regular python string. An alternative would have +been to write ``\\`` as in ``Tex('\\LaTeX')``. + +The MathTex mobject ++++++++++++++++++++ +Anything enclosed in ``$`` signs is interpreted as maths-mode: + +.. manim:: HelloTex + :save_last_frame: + + class HelloTex(Scene): + def construct(self): + tex = Tex(r'$\xrightarrow{x^2y^3}$ \LaTeX').scale(3) + self.add(tex) + +Whereas in a :class:`~.MathTex` mobject everything is math-mode by default. + +.. manim:: MovingBraces + + class MovingBraces(Scene): + def construct(self): + text=MathTex( + "\\frac{d}{dx}f(x)g(x)=", #0 + "f(x)\\frac{d}{dx}g(x)", #1 + "+", #2 + "g(x)\\frac{d}{dx}f(x)" #3 + ) + self.play(Write(text)) + brace1 = Brace(text[1], UP, buff=SMALL_BUFF) + brace2 = Brace(text[3], UP, buff=SMALL_BUFF) + t1 = brace1.get_text("$g'f$") + t2 = brace2.get_text("$f'g$") + self.play( + GrowFromCenter(brace1), + FadeIn(t1), + ) + self.wait() + self.play( + ReplacementTransform(brace1,brace2), + ReplacementTransform(t1,t2) + ) + self.wait() + + +LaTeX commands and keyword arguments +++++++++++++++++++++++++++++++++++++ +We can use any standard LaTeX commands in the AMS maths packages. For example the ``mathtt`` math-text type, or the ``looparrowright`` arrow. + +.. manim:: AMSLaTeX + :save_last_frame: + + class AMSLaTeX(Scene): + def construct(self): + tex = Tex(r'$\mathtt{H} \looparrowright$ \LaTeX').scale(3) + self.add(tex) + +On the manim side, the :class:`~.Tex` class also accepts attributes to change the appearance of the output. +This is very similar to the :class:`~.Text` class. For example, the ``color`` keyword changes the color of the TeX mobject: + +.. manim:: LaTeXAttributes + :save_last_frame: + + class LaTeXAttributes(Scene): + def construct(self): + tex = Tex(r'Hello \LaTeX', color=BLUE).scale(3) + self.add(tex) + +Extra LaTeX Packages +++++++++++++++++++++ +Some commands require special packages to be loaded into the TeX template. For example, +to use the ``mathscr`` script, we need to add the ``mathrsfs`` package. Since this package isn't loaded +into manim's tex template by default, we add it manually: + +.. manim:: AddPackageLatex + :save_last_frame: + + class AddPackageLatex(Scene): + def construct(self): + myTemplate = TexTemplate() + myTemplate.add_to_preamble(r"\usepackage{mathrsfs}") + tex = Tex(r'$\mathscr{H} \rightarrow \mathbb{H}$}', tex_template=myTemplate).scale(3) + self.add(tex) + +Substrings and parts +++++++++++++++++++++ +The TeX mobject can accept multiple strings as arguments. Afterwards you can refer to the individual +parts either by their index (like ``tex[1]``), or you can look them up by (parts of) the tex code like +in this example where we set the color of the ``\bigstar`` using :func:`~.set_color_by_tex`: + +.. manim:: LaTeXSubstrings + :save_last_frame: + + class LaTeXSubstrings(Scene): + def construct(self): + tex = Tex('Hello', r'$\bigstar$', r'\LaTeX').scale(3) + tex.set_color_by_tex('igsta', RED) + self.add(tex) + +LaTeX Maths Fonts - The Template Library +++++++++++++++++++++++++++++++++++++++++ +Changing fonts in LaTeX when typesetting mathematical formulae is a little bit more tricky than +with regular text. It requires changing the template that is used to compile the tex code. +Manim comes with a collection of :class:`~.TexFontTemplates` ready for you to use. These templates will all work +in maths mode: + +.. manim:: LaTeXMathFonts + :save_last_frame: + + class LaTeXMathFonts(Scene): + def construct(self): + tex = Tex(r'$x^2 + y^2 = z^2$', tex_template=TexFontTemplates.french_cursive).scale(3) + self.add(tex) + +Manim also has a :class:`~.TexTemplateLibrary` containing the TeX templates used by 3Blue1Brown. One example +is the ctex template, used for typesetting Chinese. For this to work, the ctex LaTeX package +must be installed on your system. Furthermore, if you are only typesetting Text, you probably do not +need :class:`~.Tex` at all, and should use :class:`~.Text` or :class:`~.PangoText` instead. + +.. manim:: LaTeXTemplateLibrary + :save_last_frame: + + class LaTeXTemplateLibrary(Scene): + def construct(self): + tex = Tex('Hello 你好 \\LaTeX', tex_template=TexTemplateLibrary.ctex).scale(3) + self.add(tex) + + +Aligning formulae ++++++++++++++++++ +A :class:`~.MathTex` mobject is typeset in the LaTeX ``align*`` environment. This means you can use the ``&`` alignment +character when typesetting multiline formulae: + +.. manim:: LaTeXAlignEnvironment + :save_last_frame: + + class LaTeXAlignEnvironment(Scene): + def construct(self): + tex = MathTex(r'f(x) &= 3 + 2 + 1\\ &= 5 + 1 \\ &= 6').scale(2) + self.add(tex) +""" + +__all__ = [ + "OpenGLTexSymbol", + "OpenGLSingleStringMathTex", + "OpenGLMathTex", + "OpenGLTex", + "OpenGLBulletedList", + "OpenGLTitle", + "OpenGLTexMobject", + "OpenGLTextMobject", +] + + +from functools import reduce +import operator as op + +from .style_utils import parse_style +from ... import config, logger +from ...constants import * +from ...mobject.opengl_geometry import OpenGLLine +from ...mobject.svg.opengl_svg_mobject import OpenGLSVGMobject +from ...mobject.svg.opengl_svg_path import OpenGLSVGPathMobject +from ...mobject.types.opengl_vectorized_mobject import OpenGLVGroup +from ...mobject.types.opengl_vectorized_mobject import OpenGLVectorizedPoint +from ...utils.strings import split_string_list_to_isolate_substrings +from ...utils.tex_file_writing import tex_to_svg_file +from ...utils.color import BLACK + +# from ...utils.tex import TexTemplate + +TEX_MOB_SCALE_FACTOR = 0.05 + + +class OpenGLTexSymbol(OpenGLSVGPathMobject): + """Purely a renaming of SVGPathMobject.""" + + pass + + +class OpenGLSingleStringMathTex(OpenGLSVGMobject): + """Elementary building block for rendering text with LaTeX. + + Tests + ----- + Check that creating a :class:`~.SingleStringMathTex` object works:: + + >>> SingleStringMathTex('Test') + SingleStringMathTex('Test') + """ + + def __init__( + self, + tex_string, + stroke_width=0, + fill_opacity=1.0, + background_stroke_width=0, + background_stroke_color=BLACK, + should_center=True, + height=None, + organize_left_to_right=False, + tex_environment="align*", + tex_template=None, + **kwargs, + ): + self.organize_left_to_right = organize_left_to_right + self.tex_environment = tex_environment + if tex_template is None: + tex_template = config["tex_template"] + self.tex_template = tex_template + + assert isinstance(tex_string, str) + self.tex_string = tex_string + file_name = tex_to_svg_file( + self.get_modified_expression(tex_string), + environment=self.tex_environment, + tex_template=self.tex_template, + ) + OpenGLSVGMobject.__init__( + self, + file_name=file_name, + should_center=should_center, + stroke_width=stroke_width, + height=height, + fill_opacity=fill_opacity, + background_stroke_width=background_stroke_width, + background_stroke_color=background_stroke_color, + **kwargs, + ) + if height is None: + self.scale(TEX_MOB_SCALE_FACTOR) + if self.organize_left_to_right: + self.organize_submobjects_left_to_right() + + def __repr__(self): + return f"{type(self).__name__}({repr(self.tex_string)})" + + def get_modified_expression(self, tex_string): + result = tex_string + result = result.strip() + result = self.modify_special_strings(result) + return result + + def modify_special_strings(self, tex): + tex = self.remove_stray_braces(tex) + should_add_filler = reduce( + op.or_, + [ + # Fraction line needs something to be over + tex == "\\over", + tex == "\\overline", + # Make sure sqrt has overbar + tex == "\\sqrt", + # Need to add blank subscript or superscript + tex.endswith("_"), + tex.endswith("^"), + tex.endswith("dot"), + ], + ) + if should_add_filler: + filler = "{\\quad}" + tex += filler + + if tex == "\\substack": + tex = "\\quad" + + if tex == "": + tex = "\\quad" + + # To keep files from starting with a line break + if tex.startswith("\\\\"): + tex = tex.replace("\\\\", "\\quad\\\\") + + # Handle imbalanced \left and \right + num_lefts, num_rights = [ + len([s for s in tex.split(substr)[1:] if s and s[0] in "(){}[]|.\\"]) + for substr in ("\\left", "\\right") + ] + if num_lefts != num_rights: + tex = tex.replace("\\left", "\\big") + tex = tex.replace("\\right", "\\big") + + for context in ["array"]: + begin_in = ("\\begin{%s}" % context) in tex + end_in = ("\\end{%s}" % context) in tex + if begin_in ^ end_in: + # Just turn this into a blank string, + # which means caller should leave a + # stray \\begin{...} with other symbols + tex = "" + return tex + + def remove_stray_braces(self, tex): + r""" + Makes :class:`~.MathTex` resilient to unmatched braces. + + This is important when the braces in the TeX code are spread over + multiple arguments as in, e.g., ``MathTex(r"e^{i", r"\tau} = 1")``. + """ + + # "\{" does not count (it's a brace literal), but "\\{" counts (it's a new line and then brace) + num_lefts = tex.count("{") - tex.count("\\{") + tex.count("\\\\{") + num_rights = tex.count("}") - tex.count("\\}") + tex.count("\\\\}") + while num_rights > num_lefts: + tex = "{" + tex + num_lefts += 1 + while num_lefts > num_rights: + tex = tex + "}" + num_rights += 1 + return tex + + def get_tex_string(self): + return self.tex_string + + def path_string_to_mobject(self, path_string, style): + # Overwrite superclass default to use + # specialized path_string mobject + return OpenGLTexSymbol(path_string, **parse_style(style)) + + def organize_submobjects_left_to_right(self): + self.sort(lambda p: p[0]) + return self + + def init_colors(self, propagate_colors=True): + # OpenGLSVGMobject.init_colors(self, propagate_colors=propagate_colors) + OpenGLSVGMobject.set_style( + self, + fill_color=self.fill_color or self.color, + fill_opacity=self.fill_opacity, + stroke_color=self.stroke_color or self.color, + stroke_width=self.stroke_width, + stroke_opacity=self.stroke_opacity, + recurse=propagate_colors, + ) + + +class OpenGLMathTex(OpenGLSingleStringMathTex): + r"""A string compiled with LaTeX in math mode. + + Examples + -------- + .. manim:: Formula + :save_last_frame: + + class Formula(Scene): + def construct(self): + t = MathTex(r"\int_a^b f'(x) dx = f(b)- f(a)") + self.add(t) + + Tests + ----- + Check that creating a :class:`~.MathTex` works:: + + >>> MathTex('a^2 + b^2 = c^2') + MathTex('a^2 + b^2 = c^2') + + """ + + def __init__( + self, + *tex_strings, + arg_separator=" ", + substrings_to_isolate=None, + tex_to_color_map=None, + tex_environment="align*", + **kwargs, + ): + self.tex_template = kwargs.pop("tex_template", config["tex_template"]) + self.arg_separator = arg_separator + self.substrings_to_isolate = ( + [] if substrings_to_isolate is None else substrings_to_isolate + ) + self.tex_to_color_map = tex_to_color_map + if self.tex_to_color_map is None: + self.tex_to_color_map = {} + self.tex_environment = tex_environment + tex_strings = self.break_up_tex_strings(tex_strings) + self.tex_strings = tex_strings + OpenGLSingleStringMathTex.__init__( + self, + self.arg_separator.join(tex_strings), + tex_environment=self.tex_environment, + tex_template=self.tex_template, + **kwargs, + ) + self.break_up_by_substrings() + self.set_color_by_tex_to_color_map(self.tex_to_color_map) + + if self.organize_left_to_right: + self.organize_submobjects_left_to_right() + + def break_up_tex_strings(self, tex_strings): + substrings_to_isolate = op.add( + self.substrings_to_isolate, list(self.tex_to_color_map.keys()) + ) + split_list = split_string_list_to_isolate_substrings( + tex_strings, *substrings_to_isolate + ) + if self.arg_separator == " ": + split_list = [str(x).strip() for x in split_list] + # split_list = list(map(str.strip, split_list)) + split_list = [s for s in split_list if s != ""] + return split_list + + def break_up_by_substrings(self): + """ + Reorganize existing submobjects one layer + deeper based on the structure of tex_strings (as a list + of tex_strings) + """ + new_submobjects = [] + curr_index = 0 + for tex_string in self.tex_strings: + sub_tex_mob = OpenGLSingleStringMathTex( + tex_string, + tex_environment=self.tex_environment, + tex_template=self.tex_template, + ) + num_submobs = len(sub_tex_mob.submobjects) + new_index = curr_index + num_submobs + if num_submobs == 0: + # For cases like empty tex_strings, we want the corresponding + # part of the whole MathTex to be a VectorizedPoint + # positioned in the right part of the MathTex + sub_tex_mob.submobjects = [OpenGLVectorizedPoint()] + last_submob_index = min(curr_index, len(self.submobjects) - 1) + sub_tex_mob.move_to(self.submobjects[last_submob_index], RIGHT) + else: + sub_tex_mob.submobjects = self.submobjects[curr_index:new_index] + new_submobjects.append(sub_tex_mob) + curr_index = new_index + self.submobjects = new_submobjects + return self + + def get_parts_by_tex(self, tex, substring=True, case_sensitive=True): + def test(tex1, tex2): + if not case_sensitive: + tex1 = tex1.lower() + tex2 = tex2.lower() + if substring: + return tex1 in tex2 + else: + return tex1 == tex2 + + return OpenGLVGroup( + *[m for m in self.submobjects if test(tex, m.get_tex_string())] + ) + + def get_part_by_tex(self, tex, **kwargs): + all_parts = self.get_parts_by_tex(tex, **kwargs) + return all_parts[0] if all_parts else None + + def set_color_by_tex(self, tex, color, **kwargs): + parts_to_color = self.get_parts_by_tex(tex, **kwargs) + for part in parts_to_color: + part.set_color(color) + return self + + def set_color_by_tex_to_color_map(self, texs_to_color_map, **kwargs): + for texs, color in list(texs_to_color_map.items()): + try: + # If the given key behaves like tex_strings + texs + "" + self.set_color_by_tex(texs, color, **kwargs) + except TypeError: + # If the given key is a tuple + for tex in texs: + self.set_color_by_tex(tex, color, **kwargs) + return self + + def index_of_part(self, part): + split_self = self.split() + if part not in split_self: + raise ValueError("Trying to get index of part not in MathTex") + return split_self.index(part) + + def index_of_part_by_tex(self, tex, **kwargs): + part = self.get_part_by_tex(tex, **kwargs) + return self.index_of_part(part) + + def sort_alphabetically(self): + self.submobjects.sort(key=lambda m: m.get_tex_string()) + + +class OpenGLTex(OpenGLMathTex): + r"""A string compiled with LaTeX in normal mode. + + Tests + ----- + + Check whether writing a LaTeX string works:: + + >>> Tex('The horse does not eat cucumber salad.') + Tex('The horse does not eat cucumber salad.') + + """ + + def __init__( + self, *tex_strings, arg_separator="", tex_environment="center", **kwargs + ): + OpenGLMathTex.__init__( + self, + *tex_strings, + arg_separator=arg_separator, + tex_environment=tex_environment, + **kwargs, + ) + + +class OpenGLBulletedList(OpenGLTex): + def __init__( + self, + *items, + buff=MED_LARGE_BUFF, + dot_scale_factor=2, + tex_environment=None, + **kwargs, + ): + self.buff = buff + self.dot_scale_factor = dot_scale_factor + self.tex_environment = tex_environment + line_separated_items = [s + "\\\\" for s in items] + Tex.__init__( + self, *line_separated_items, tex_environment=tex_environment, **kwargs + ) + for part in self: + dot = MathTex("\\cdot").scale(self.dot_scale_factor) + dot.next_to(part[0], LEFT, SMALL_BUFF) + part.add_to_back(dot) + self.arrange(DOWN, aligned_edge=LEFT, buff=self.buff) + + def fade_all_but(self, index_or_string, opacity=0.5): + arg = index_or_string + if isinstance(arg, str): + part = self.get_part_by_tex(arg) + elif isinstance(arg, int): + part = self.submobjects[arg] + else: + raise TypeError(f"Expected int or string, got {arg}") + for other_part in self.submobjects: + if other_part is part: + other_part.set_fill(opacity=1) + else: + other_part.set_fill(opacity=opacity) + + +class OpenGLTitle(OpenGLTex): + def __init__( + self, + *text_parts, + scale_factor=1, + include_underline=True, + match_underline_width_to_text=False, + underline_buff=MED_SMALL_BUFF, + **kwargs, + ): + self.scale_factor = scale_factor + self.include_underline = include_underline + self.match_underline_width_to_text = match_underline_width_to_text + self.underline_buff = underline_buff + Tex.__init__(self, *text_parts, **kwargs) + self.scale(self.scale_factor) + self.to_edge(UP) + if self.include_underline: + underline_width = config["frame_width"] - 2 + underline = OpenGLLine(LEFT, RIGHT) + underline.next_to(self, DOWN, buff=self.underline_buff) + if self.match_underline_width_to_text: + underline.match_width(self) + else: + underline.width = underline_width + self.add(underline) + self.underline = underline + + +class OpenGLTexMobject(OpenGLMathTex): + def __init__(self, *tex_strings, **kwargs): + logger.warning( + "TexMobject has been deprecated (due to its confusing name) " + "in favour of MathTex. Please use MathTex instead!" + ) + MathTex.__init__(self, *tex_strings, **kwargs) + + +class OpenGLTextMobject(OpenGLTex): + def __init__(self, *text_parts, **kwargs): + logger.warning( + "TextMobject has been deprecated (due to its confusing name) " + "in favour of Tex. Please use Tex instead!" + ) + Tex.__init__(self, *text_parts, **kwargs) diff --git a/manim/mobject/svg/svg_mobject.py b/manim/mobject/svg/svg_mobject.py index 2d431019bb..6145b982a9 100644 --- a/manim/mobject/svg/svg_mobject.py +++ b/manim/mobject/svg/svg_mobject.py @@ -22,9 +22,11 @@ from ...constants import * from ...mobject.geometry import Circle from ...mobject.geometry import Rectangle +from ...mobject.opengl_geometry import OpenGLRectangle from ...mobject.geometry import RoundedRectangle from ...mobject.types.vectorized_mobject import VGroup from ...mobject.types.vectorized_mobject import VMobject +from ...mobject.types.opengl_vectorized_mobject import OpenGLVGroup class SVGMobject(VMobject): @@ -199,9 +201,15 @@ def get_mobjects_from( pass # TODO result = [m for m in result if m is not None] - self.handle_transforms(element, VGroup(*result)) + if config["use_opengl_renderer"]: + self.handle_transforms(element, OpenGLVGroup(*result)) + else: + self.handle_transforms(element, VGroup(*result)) if len(result) > 1 and not self.unpack_groups: - result = [VGroup(*result)] + if config["use_opengl_renderer"]: + result = [OpenGLVGroup(*result)] + else: + result = [VGroup(*result)] if within_defs and element.hasAttribute("id"): # it seems wasteful to throw away the actual element, @@ -399,11 +407,19 @@ def rect_to_mobject(self, rect_element: MinidomElement, style: dict): parsed_style["stroke_width"] = stroke_width if corner_radius == 0: - mob = Rectangle( - width=self.attribute_to_float(rect_element.getAttribute("width")), - height=self.attribute_to_float(rect_element.getAttribute("height")), - **parsed_style, - ) + if config["use_opengl_renderer"]: + mob = OpenGLRectangle( + width=self.attribute_to_float(rect_element.getAttribute("width")), + height=self.attribute_to_float(rect_element.getAttribute("height")), + **parsed_style, + ) + else: + mob = Rectangle( + width=self.attribute_to_float(rect_element.getAttribute("width")), + height=self.attribute_to_float(rect_element.getAttribute("height")), + **parsed_style, + ) + else: mob = RoundedRectangle( width=self.attribute_to_float(rect_element.getAttribute("width")), diff --git a/manim/mobject/svg/svg_path.py b/manim/mobject/svg/svg_path.py index 836932d2dc..4ccb34422e 100644 --- a/manim/mobject/svg/svg_path.py +++ b/manim/mobject/svg/svg_path.py @@ -12,6 +12,7 @@ from ...constants import * from ...mobject.types.vectorized_mobject import VMobject +from ... import config def string_to_numbers(num_string: str) -> List[float]: @@ -99,9 +100,11 @@ def handle_command(self, command, coord_string, prev_command): command = command.upper() # Keep track of the most recently completed point - start_point = ( - self.points[-1] if self.points.shape[0] else np.zeros((1, self.dim)) - ) + if config["use_opengl_renderer"]: + points = self.data["points"] + else: + points = self.points + start_point = points[-1] if points.shape[0] else np.zeros((1, self.dim)) # Produce the (absolute) coordinates of the controls and handles new_points = self.string_to_points( @@ -126,9 +129,13 @@ def handle_command(self, command, coord_string, prev_command): return elif command == "S": # Smooth cubic + if config["use_opengl_renderer"]: + points = self.data["points"] + else: + points = self.points prev_handle = start_point if prev_command.upper() in ["C", "S"]: - prev_handle = self.points[-2] + prev_handle = points[-2] for i in range(0, len(new_points), 2): new_handle = 2 * start_point - prev_handle self.add_cubic_bezier_curve_to( @@ -148,7 +155,7 @@ def handle_command(self, command, coord_string, prev_command): if prev_command.upper() in ["Q", "T"]: # because of the conversion from quadratic to cubic, # our actual previous handle was 3/2 in the direction of p[-2] from p[-1] - prev_quad_handle = 1.5 * self.points[-2] - 0.5 * self.points[-1] + prev_quad_handle = 1.5 * points[-2] - 0.5 * points[-1] for p in new_points: new_quad_handle = 2 * start_point - prev_quad_handle self.add_quadratic_bezier_curve_to(new_quad_handle, p) diff --git a/manim/opengl/__init__.py b/manim/opengl/__init__.py index 09f2aadd84..e275c6a414 100644 --- a/manim/opengl/__init__.py +++ b/manim/opengl/__init__.py @@ -3,3 +3,6 @@ from ..mobject.opengl_geometry import * from ..mobject.types.opengl_surface import * from ..mobject.opengl_three_dimensions import * +from ..mobject.opengl_three_dimensions import * +from ..mobject.svg.opengl_svg_mobject import * +from ..mobject.svg.opengl_tex_mobject import * From 752dce32290c1fb52d4128cb974b6b2dcec37ef1 Mon Sep 17 00:00:00 2001 From: Devin Neal Date: Sun, 28 Mar 2021 16:02:30 -0700 Subject: [PATCH 2/8] Add OpenGLTex --- example_scenes/basic.py | 5 +++-- example_scenes/opengl.py | 7 +++++-- manim/animation/transform.py | 6 +++++- manim/mobject/svg/opengl_svg_mobject.py | 10 +++++++++- manim/mobject/svg/opengl_svg_path.py | 16 ++++++++++++++-- manim/mobject/svg/opengl_tex_mobject.py | 6 +++++- manim/mobject/svg/svg_path.py | 11 ++++++++++- manim/mobject/types/opengl_vectorized_mobject.py | 6 +++++- manim/utils/space_ops.py | 6 +++++- 9 files changed, 61 insertions(+), 12 deletions(-) diff --git a/example_scenes/basic.py b/example_scenes/basic.py index bc34233c6e..ddec8fa1dc 100644 --- a/example_scenes/basic.py +++ b/example_scenes/basic.py @@ -19,8 +19,9 @@ def construct(self): # mob = SVGMobject("../manim/media/Tex/1e2155e0e4bdc7ad.svg").scale(3) # mob.set_color(WHITE) # self.play(FadeIn(mob)) - mob = MathTex("a") - self.play(FadeIn(mob)) + mob = Tex("C").set_height(7) + self.play(Write(mob)) + self.wait(2) class OpeningManim(Scene): diff --git a/example_scenes/opengl.py b/example_scenes/opengl.py index 48fcc90d4c..c43e33ed88 100644 --- a/example_scenes/opengl.py +++ b/example_scenes/opengl.py @@ -10,11 +10,14 @@ class TestLatex(Scene): def construct(self): + config["background_color"] = "#333333" # mob = OpenGLSVGMobject("../manim/media/Tex/184715fb937073ce.svg").scale(3) # mob.set_color(WHITE) - mob = OpenGLTex("Manim Community").set_width(13) + mob = OpenGLTex("Manim Community").set_width(12) + mob2 = OpenGLTex("v4.0").set_width(12) self.play(Write(mob)) - self.wait(10) + self.play(Transform(mob, mob2)) + self.interact() class InteractiveDevelopment(Scene): diff --git a/manim/animation/transform.py b/manim/animation/transform.py index 064e71193b..c081d8b48c 100644 --- a/manim/animation/transform.py +++ b/manim/animation/transform.py @@ -34,6 +34,7 @@ from ..mobject.opengl_mobject import OpenGLMobject from ..utils.paths import path_along_arc, straight_path from ..utils.rate_functions import smooth, squish_rate_func +from .. import config if typing.TYPE_CHECKING: from ..scene.scene import Scene @@ -78,7 +79,10 @@ def begin(self) -> None: self.target_copy = self.target_mobject.copy() # Note, this potentially changes the structure # of both mobject and target_mobject - self.mobject.align_data(self.target_copy) + if config["use_opengl_renderer"]: + self.mobject.align_data_and_family(self.target_copy) + else: + self.mobject.align_data(self.target_copy) super().begin() def create_target(self) -> typing.Union[Mobject, None]: diff --git a/manim/mobject/svg/opengl_svg_mobject.py b/manim/mobject/svg/opengl_svg_mobject.py index 38830a1d91..e7d8c659ec 100644 --- a/manim/mobject/svg/opengl_svg_mobject.py +++ b/manim/mobject/svg/opengl_svg_mobject.py @@ -15,6 +15,8 @@ def __init__( unpack_groups=True, # if False, creates a hierarchy of VGroups stroke_width=DEFAULT_STROKE_WIDTH, fill_opacity=1.0, + should_subdivide_sharp_curves=False, + should_remove_null_curves=False, **kwargs, ): self.def_map = {} @@ -22,6 +24,10 @@ def __init__( self.ensure_valid_file() self.should_center = should_center self.unpack_groups = unpack_groups + self.path_string_config = { + "should_subdivide_sharp_curves": should_subdivide_sharp_curves, + "should_remove_null_curves": should_remove_null_curves, + } OpenGLVMobject.__init__( self, stroke_width=stroke_width, fill_opacity=fill_opacity, **kwargs ) @@ -31,4 +37,6 @@ def init_points(self): self.generate_points() def path_string_to_mobject(self, path_string: str, style: dict): - return OpenGLSVGPathMobject(path_string, **parse_style(style)) + return OpenGLSVGPathMobject( + path_string, **self.path_string_config, **parse_style(style) + ) diff --git a/manim/mobject/svg/opengl_svg_path.py b/manim/mobject/svg/opengl_svg_path.py index 0fc872430c..6cc46e8ca4 100644 --- a/manim/mobject/svg/opengl_svg_path.py +++ b/manim/mobject/svg/opengl_svg_path.py @@ -4,9 +4,21 @@ class OpenGLSVGPathMobject(OpenGLVMobject, SVGPathMobject): - def __init__(self, path_string, **kwargs): + def __init__( + self, + path_string, + should_subdivide_sharp_curves=False, + should_remove_null_curves=False, + **kwargs + ): self.path_string = path_string - OpenGLVMobject.__init__(self, **kwargs) + OpenGLVMobject.__init__( + self, + long_lines=True, + should_subdivide_sharp_curves=should_subdivide_sharp_curves, + should_remove_null_curves=should_remove_null_curves, + **kwargs + ) self.current_path_start = np.zeros((1, self.dim)) def init_points(self): diff --git a/manim/mobject/svg/opengl_tex_mobject.py b/manim/mobject/svg/opengl_tex_mobject.py index bc006d4415..58bd711aa4 100644 --- a/manim/mobject/svg/opengl_tex_mobject.py +++ b/manim/mobject/svg/opengl_tex_mobject.py @@ -241,6 +241,8 @@ def __init__( fill_opacity=fill_opacity, background_stroke_width=background_stroke_width, background_stroke_color=background_stroke_color, + should_subdivide_sharp_curves=True, + should_remove_null_curves=True, **kwargs, ) if height is None: @@ -331,7 +333,9 @@ def get_tex_string(self): def path_string_to_mobject(self, path_string, style): # Overwrite superclass default to use # specialized path_string mobject - return OpenGLTexSymbol(path_string, **parse_style(style)) + return OpenGLTexSymbol( + path_string, **self.path_string_config, **parse_style(style) + ) def organize_submobjects_left_to_right(self): self.sort(lambda p: p[0]) diff --git a/manim/mobject/svg/svg_path.py b/manim/mobject/svg/svg_path.py index 4ccb34422e..21b7090de1 100644 --- a/manim/mobject/svg/svg_path.py +++ b/manim/mobject/svg/svg_path.py @@ -90,6 +90,12 @@ def generate_points(self): for command, coord_string in pairs: self.handle_command(command, coord_string, prev_command) prev_command = command + if self.should_subdivide_sharp_curves: + # For a healthy triangulation later + self.subdivide_sharp_curves() + if self.should_remove_null_curves: + # Get rid of any null curves + self.set_points(self.get_points_without_null_curves()) # people treat y-coordinate differently self.rotate(np.pi, RIGHT, about_point=ORIGIN) @@ -166,7 +172,10 @@ def handle_command(self, command, coord_string, prev_command): raise NotImplementedError() elif command == "Z": # closepath - self.add_line_to(self.current_path_start) + if config["use_opengl_renderer"]: + self.close_path() + else: + self.add_line_to(self.current_path_start) return def string_to_points(self, command, is_relative, coord_string, start_point): diff --git a/manim/mobject/types/opengl_vectorized_mobject.py b/manim/mobject/types/opengl_vectorized_mobject.py index e73c896cad..57e9894f87 100644 --- a/manim/mobject/types/opengl_vectorized_mobject.py +++ b/manim/mobject/types/opengl_vectorized_mobject.py @@ -23,7 +23,7 @@ # from manimlib.utils.color import rgb_to_hex from ...utils.iterables import listify, make_even, resize_with_interpolation -# from manimlib.utils.space_ops import angle_between_vectors +from ...utils.space_ops import angle_between_vectors from ...utils.space_ops import ( cross2d, earclip_triangulation, @@ -77,6 +77,8 @@ def __init__( tolerance_for_point_equality=1e-8, n_points_per_curve=3, long_lines=False, + should_subdivide_sharp_curves=False, + should_remove_null_curves=False, # Could also be "bevel", "miter", "round" joint_type="auto", flat_stroke=True, @@ -103,6 +105,8 @@ def __init__( self.tolerance_for_point_equality = tolerance_for_point_equality self.n_points_per_curve = n_points_per_curve self.long_lines = long_lines + self.should_subdivide_sharp_curves = should_subdivide_sharp_curves + self.should_remove_null_curves = should_remove_null_curves # Could also be "bevel", "miter", "round" self.joint_type = joint_type self.flat_stroke = flat_stroke diff --git a/manim/utils/space_ops.py b/manim/utils/space_ops.py index 443a6a6a8e..5e29e41bb2 100644 --- a/manim/utils/space_ops.py +++ b/manim/utils/space_ops.py @@ -232,7 +232,11 @@ def angle_between_vectors(v1, v2): Returns the angle between two 3D vectors. This angle will always be btw 0 and pi """ - return np.arccos(fdiv(np.dot(v1, v2), get_norm(v1) * get_norm(v2))) + if config["use_opengl_renderer"]: + diff = (angle_of_vector(v2) - angle_of_vector(v1)) % TAU + return min(diff, TAU - diff) + else: + return np.arccos(fdiv(np.dot(v1, v2), get_norm(v1) * get_norm(v2))) def project_along_vector(point, vector): From e3153f7d45d7df8c8df191e4df848e171d96c221 Mon Sep 17 00:00:00 2001 From: Devin Neal Date: Sun, 28 Mar 2021 18:44:14 -0700 Subject: [PATCH 3/8] Add OpenGLText --- example_scenes/basic.py | 2 +- example_scenes/opengl.py | 8 ++++---- manim/mobject/svg/svg_path.py | 13 +++++++------ manim/opengl/__init__.py | 1 + 4 files changed, 13 insertions(+), 11 deletions(-) diff --git a/example_scenes/basic.py b/example_scenes/basic.py index ddec8fa1dc..e4b182f254 100644 --- a/example_scenes/basic.py +++ b/example_scenes/basic.py @@ -19,7 +19,7 @@ def construct(self): # mob = SVGMobject("../manim/media/Tex/1e2155e0e4bdc7ad.svg").scale(3) # mob.set_color(WHITE) # self.play(FadeIn(mob)) - mob = Tex("C").set_height(7) + mob = Text("This is some text").set_width(13) self.play(Write(mob)) self.wait(2) diff --git a/example_scenes/opengl.py b/example_scenes/opengl.py index c43e33ed88..4f4dc2a359 100644 --- a/example_scenes/opengl.py +++ b/example_scenes/opengl.py @@ -13,11 +13,11 @@ def construct(self): config["background_color"] = "#333333" # mob = OpenGLSVGMobject("../manim/media/Tex/184715fb937073ce.svg").scale(3) # mob.set_color(WHITE) - mob = OpenGLTex("Manim Community").set_width(12) - mob2 = OpenGLTex("v4.0").set_width(12) + mob = OpenGLCairoText("Manim Community").set_width(12) self.play(Write(mob)) - self.play(Transform(mob, mob2)) - self.interact() + print(len(mob.submobjects)) + self.wait() + # self.interact() class InteractiveDevelopment(Scene): diff --git a/manim/mobject/svg/svg_path.py b/manim/mobject/svg/svg_path.py index 21b7090de1..07d89583d9 100644 --- a/manim/mobject/svg/svg_path.py +++ b/manim/mobject/svg/svg_path.py @@ -90,12 +90,13 @@ def generate_points(self): for command, coord_string in pairs: self.handle_command(command, coord_string, prev_command) prev_command = command - if self.should_subdivide_sharp_curves: - # For a healthy triangulation later - self.subdivide_sharp_curves() - if self.should_remove_null_curves: - # Get rid of any null curves - self.set_points(self.get_points_without_null_curves()) + if config["use_opengl_renderer"]: + if self.should_subdivide_sharp_curves: + # For a healthy triangulation later + self.subdivide_sharp_curves() + if self.should_remove_null_curves: + # Get rid of any null curves + self.set_points(self.get_points_without_null_curves()) # people treat y-coordinate differently self.rotate(np.pi, RIGHT, about_point=ORIGIN) diff --git a/manim/opengl/__init__.py b/manim/opengl/__init__.py index e275c6a414..42562c2433 100644 --- a/manim/opengl/__init__.py +++ b/manim/opengl/__init__.py @@ -6,3 +6,4 @@ from ..mobject.opengl_three_dimensions import * from ..mobject.svg.opengl_svg_mobject import * from ..mobject.svg.opengl_tex_mobject import * +from ..mobject.svg.opengl_text_mobject import * From 0c7e98add5357633a04229a44f35aa3fd433b075 Mon Sep 17 00:00:00 2001 From: Devin Neal Date: Sun, 28 Mar 2021 19:31:41 -0700 Subject: [PATCH 4/8] Add OpenGLText and related classes --- manim/mobject/svg/opengl_text_mobject.py | 1526 ++++++++++++++++++++++ 1 file changed, 1526 insertions(+) create mode 100644 manim/mobject/svg/opengl_text_mobject.py diff --git a/manim/mobject/svg/opengl_text_mobject.py b/manim/mobject/svg/opengl_text_mobject.py new file mode 100644 index 0000000000..9565d7b48d --- /dev/null +++ b/manim/mobject/svg/opengl_text_mobject.py @@ -0,0 +1,1526 @@ +"""Mobjects used for displaying (non-LaTeX) text. + +The simplest way to add text to your animations is to use the :class:`~.Text` class. It uses the Pango library to render text. +With Pango, you are also able to render non-English alphabets like `你好` or `こんにちは` or `안녕하세요` or `مرحبا بالعالم`. + +Examples +-------- + +.. manim:: HelloWorld + :save_last_frame: + + class HelloWorld(Scene): + def construct(self): + text = Text('Hello world').scale(3) + self.add(text) + +.. manim:: TextAlignment + :save_last_frame: + + class TextAlignment(Scene): + def construct(self): + title = Text("K-means clustering and Logistic Regression", color=WHITE) + title.scale_in_place(0.75) + self.add(title.to_edge(UP)) + + t1 = Text("1. Measuring").set_color(WHITE) + t1.next_to(ORIGIN, direction=RIGHT, aligned_edge=UP) + + t2 = Text("2. Clustering").set_color(WHITE) + t2.next_to(t1, direction=DOWN, aligned_edge=LEFT) + + t3 = Text("3. Regression").set_color(WHITE) + t3.next_to(t2, direction=DOWN, aligned_edge=LEFT) + + t4 = Text("4. Prediction").set_color(WHITE) + t4.next_to(t3, direction=DOWN, aligned_edge=LEFT) + + x = VGroup(t1, t2, t3, t4).scale_in_place(0.7) + x.set_opacity(0.5) + x.submobjects[1].set_opacity(1) + self.add(x) + +""" + +# __all__ = ["Text", "Paragraph", "CairoText", "MarkupText", "register_font"] + + +import copy +import hashlib +import os +import re +import sys +import typing +from contextlib import contextmanager +from pathlib import Path +from typing import Dict +from xml.sax.saxutils import escape + +import cairo +import manimpango +from manimpango import MarkupUtils, PangoUtils, TextSetting + +from ... import config, logger +from ...constants import * +from ...mobject.opengl_geometry import OpenGLDot +from ...mobject.svg.opengl_svg_mobject import OpenGLSVGMobject +from ...mobject.types.opengl_vectorized_mobject import OpenGLVGroup +from ...utils.color import WHITE, Colors + +TEXT_MOB_SCALE_FACTOR = 0.05 + + +def remove_invisible_chars(mobject): + """Function to remove unwanted invisible characters from some mobject + + Parameters + ---------- + mobject : :class:`~.SVGMobject` + Any SVGMobject from which we want to remove unwanted invisible characters. + + Returns + ------- + :class:`~.SVGMobject` + The SVGMobject without unwanted invisible characters. + """ + + iscode = False + if mobject.__class__.__name__ == "Text": + mobject = mobject[:] + elif mobject.__class__.__name__ == "Code": + iscode = True + code = mobject + mobject = mobject.code + mobject_without_dots = VGroup() + if mobject[0].__class__ == VGroup: + for i in range(mobject.__len__()): + mobject_without_dots.add(VGroup()) + mobject_without_dots[i].add(*[k for k in mobject[i] if k.__class__ != Dot]) + else: + mobject_without_dots.add(*[k for k in mobject if k.__class__ != Dot]) + if iscode: + code.code = mobject_without_dots + return code + return mobject_without_dots + + +class OpenGLCairoText(OpenGLSVGMobject): + """Display (non-LaTeX) text. + + Text objects behave like a :class:`.VGroup`-like iterable of all characters + in the given text. In particular, slicing is possible. + + + Tests + ----- + + Check whether writing text works:: + + >>> Text('The horse does not eat cucumber salad.') + Text('The horse does not eat cucumber salad.') + + """ + + def __init__( + self, + text, + # Mobject + color=WHITE, + height=None, + width=None, + fill_opacity=1, + stroke_width=0, + should_center=True, + unpack_groups=True, + # Text + font="", + gradient=None, + line_spacing=-1, + size=1, + slant: str = NORMAL, + weight: str = NORMAL, + t2c=None, + t2f=None, + t2g=None, + t2s=None, + t2w=None, + tab_width=4, + **kwargs, + ): + # self.full2short(config) + if t2c is None: + t2c = {} + if t2f is None: + t2f = {} + if t2g is None: + t2g = {} + if t2s is None: + t2s = {} + if t2w is None: + t2w = {} + # If long form arguments are present, they take precedence + t2c = kwargs.pop("text2color", t2c) + t2f = kwargs.pop("text2font", t2f) + t2g = kwargs.pop("text2gradient", t2g) + t2s = kwargs.pop("text2slant", t2s) + t2w = kwargs.pop("text2weight", t2w) + self.t2c = t2c + self.t2f = t2f + self.t2g = t2g + self.t2s = t2s + self.t2w = t2w + self.font = font + self.gradient = gradient + self.line_spacing = line_spacing + self.size = size + self.slant = slant + self.weight = weight + self.tab_width = tab_width + self.original_text = text + text_without_tabs = text + if text.find("\t") != -1: + text_without_tabs = text.replace("\t", " " * self.tab_width) + self.text = text_without_tabs + if self.line_spacing == -1: + self.line_spacing = self.size + self.size * 0.3 + else: + self.line_spacing = self.size + self.size * self.line_spacing + file_name = self.text2svg() + PangoUtils.remove_last_M(file_name) + OpenGLSVGMobject.__init__( + self, + file_name, + height=height, + width=width, + unpack_groups=unpack_groups, + color=color, + fill_opacity=fill_opacity, + stroke_width=stroke_width, + should_center=should_center, + **kwargs, + ) + self.text = text + self.submobjects = [*self.gen_chars()] + self.chars = OpenGLVGroup(*self.submobjects) + self.text = text_without_tabs.replace(" ", "").replace("\n", "") + if config["use_opengl_renderer"]: + nppc = self.n_points_per_curve + else: + nppc = self.n_points_per_cubic_curve + for each in self: + if config["use_opengl_renderer"]: + points = each.data["points"] + else: + points = each.points + if len(points) == 0: + continue + last = points[0] + each.clear_points() + for index, point in enumerate(points): + each.append_points([point]) + if ( + index != len(points) - 1 + and (index + 1) % nppc == 0 + and any(point != points[index + 1]) + ): + each.add_line_to(last) + last = points[index + 1] + each.add_line_to(last) + if self.t2c: + self.set_color_by_t2c() + if self.gradient: + self.set_color_by_gradient(*self.gradient) + if self.t2g: + self.set_color_by_t2g() + # anti-aliasing + if height is None and width is None: + self.scale(TEXT_MOB_SCALE_FACTOR) + + def __repr__(self): + return f"Text({repr(self.original_text)})" + + def gen_chars(self): + chars = OpenGLVGroup() + submobjects_char_index = 0 + for char_index in range(self.text.__len__()): + if ( + self.text[char_index] == " " + or self.text[char_index] == "\t" + or self.text[char_index] == "\n" + ): + space = OpenGLDot(radius=0, fill_opacity=0, stroke_opacity=0) + if char_index == 0: + space.move_to(self.submobjects[submobjects_char_index].get_center()) + else: + space.move_to( + self.submobjects[submobjects_char_index - 1].get_center() + ) + chars.add(space) + else: + chars.add(self.submobjects[submobjects_char_index]) + submobjects_char_index += 1 + return chars + + def find_indexes(self, word, text): + m = re.match(r"\[([0-9\-]{0,}):([0-9\-]{0,})\]", word) + if m: + start = int(m.group(1)) if m.group(1) != "" else 0 + end = int(m.group(2)) if m.group(2) != "" else len(text) + start = len(text) + start if start < 0 else start + end = len(text) + end if end < 0 else end + return [(start, end)] + indexes = [] + index = text.find(word) + while index != -1: + indexes.append((index, index + len(word))) + index = text.find(word, index + len(word)) + return indexes + + # def full2short(self, config_args): + # for kwargs in [config_args]: + # if kwargs.__contains__("text2color"): + # kwargs["t2c"] = kwargs.pop("text2color") + # if kwargs.__contains__("text2font"): + # kwargs["t2f"] = kwargs.pop("text2font") + # if kwargs.__contains__("text2gradient"): + # kwargs["t2g"] = kwargs.pop("text2gradient") + # if kwargs.__contains__("text2slant"): + # kwargs["t2s"] = kwargs.pop("text2slant") + # if kwargs.__contains__("text2weight"): + # kwargs["t2w"] = kwargs.pop("text2weight") + + def set_color_by_t2c(self, t2c=None): + t2c = t2c if t2c else self.t2c + for word, color in list(t2c.items()): + for start, end in self.find_indexes(word, self.original_text): + self.chars[start:end].set_color(color) + + def set_color_by_t2g(self, t2g=None): + t2g = t2g if t2g else self.t2g + for word, gradient in list(t2g.items()): + for start, end in self.find_indexes(word, self.original_text): + self.chars[start:end].set_color_by_gradient(*gradient) + + def str2slant(self, string): + if string == NORMAL: + return cairo.FontSlant.NORMAL + if string == ITALIC: + return cairo.FontSlant.ITALIC + if string == OBLIQUE: + return cairo.FontSlant.OBLIQUE + + def str2weight(self, string): + if string == NORMAL: + return cairo.FontWeight.NORMAL + if string == BOLD: + return cairo.FontWeight.BOLD + + def text2hash(self): + settings = self.font + self.slant + self.weight + settings += str(self.t2f) + str(self.t2s) + str(self.t2w) + settings += str(self.line_spacing) + str(self.size) + id_str = self.text + settings + hasher = hashlib.sha256() + hasher.update(id_str.encode()) + return hasher.hexdigest()[:16] + + def text2settings(self): + settings = [] + t2x = [self.t2f, self.t2s, self.t2w] + for i in range(len(t2x)): + fsw = [self.font, self.slant, self.weight] + if t2x[i]: + for word, x in list(t2x[i].items()): + for start, end in self.find_indexes(word, self.text): + fsw[i] = x + settings.append(TextSetting(start, end, *fsw)) + # Set All text settings(default font slant weight) + fsw = [self.font, self.slant, self.weight] + settings.sort(key=lambda setting: setting.start) + temp_settings = settings.copy() + start = 0 + for setting in settings: + if setting.start != start: + temp_settings.append(TextSetting(start, setting.start, *fsw)) + start = setting.end + if start != len(self.text): + temp_settings.append(TextSetting(start, len(self.text), *fsw)) + settings = sorted(temp_settings, key=lambda setting: setting.start) + + if re.search(r"\n", self.text): + line_num = 0 + for start, end in self.find_indexes("\n", self.text): + for setting in settings: + if setting.line_num == -1: + setting.line_num = line_num + if start < setting.end: + line_num += 1 + new_setting = copy.copy(setting) + setting.end = end + new_setting.start = end + new_setting.line_num = line_num + settings.append(new_setting) + settings.sort(key=lambda setting: setting.start) + break + for setting in settings: + if setting.line_num == -1: + setting.line_num = 0 + return settings + + def text2svg(self): + # anti-aliasing + size = self.size * 10 + line_spacing = self.line_spacing * 10 + + if self.font == "": + if NOT_SETTING_FONT_MSG: + logger.warning(NOT_SETTING_FONT_MSG) + + dir_name = config.get_dir("text_dir") + if not os.path.exists(dir_name): + os.makedirs(dir_name) + + hash_name = self.text2hash() + file_name = os.path.join(dir_name, hash_name) + ".svg" + if os.path.exists(file_name): + return file_name + surface = cairo.SVGSurface(file_name, 600, 400) + context = cairo.Context(surface) + context.set_font_size(size) + context.move_to(START_X, START_Y) + + settings = self.text2settings() + offset_x = 0 + last_line_num = 0 + for setting in settings: + font = setting.font.decode("utf-8") + slant = self.str2slant(setting.slant) + weight = self.str2weight(setting.weight) + text = self.text[setting.start : setting.end].replace("\n", " ") + + context.select_font_face(font, slant, weight) + if setting.line_num != last_line_num: + offset_x = 0 + last_line_num = setting.line_num + context.move_to( + START_X + offset_x, START_Y + line_spacing * setting.line_num + ) + context.show_text(text) + offset_x += context.text_extents(text)[4] + surface.finish() + return file_name + + +class OpenGLParagraph(OpenGLVGroup): + r"""Display a paragraph of text. + + For a given :class:`.Paragraph` ``par``, the attribute ``par.chars`` is a + :class:`.VGroup` containing all the lines. In this context, every line is + constructed as a :class:`.VGroup` of characters contained in the line. + + + Parameters + ---------- + line_spacing : :class:`int`, optional + Represents the spacing between lines. Default to -1, which means auto. + alignment : :class:`str`, optional + Defines the alignment of paragraph. Default to "left". Possible values are "left", "right", "center" + + Examples + -------- + Normal usage:: + + paragraph = Paragraph('this is a awesome', 'paragraph', + 'With \nNewlines', '\tWith Tabs', + ' With Spaces', 'With Alignments', + 'center', 'left', 'right') + + Remove unwanted invisible characters:: + + self.play(Transform(remove_invisible_chars(paragraph.chars[0:2]), + remove_invisible_chars(paragraph.chars[3][0:3])) + + """ + + def __init__(self, *text, line_spacing=-1, alignment=None, **config): + self.line_spacing = line_spacing + self.alignment = alignment + OpenGLVGroup.__init__(self, **config) + + lines_str = "\n".join(list(text)) + self.lines_text = OpenGLText(lines_str, line_spacing=line_spacing, **config) + lines_str_list = lines_str.split("\n") + self.chars = self.gen_chars(lines_str_list) + + chars_lines_text_list = OpenGLVGroup() + char_index_counter = 0 + for line_index in range(lines_str_list.__len__()): + chars_lines_text_list.add( + self.lines_text[ + char_index_counter : char_index_counter + + lines_str_list[line_index].__len__() + + 1 + ] + ) + char_index_counter += lines_str_list[line_index].__len__() + 1 + self.lines = [] + self.lines.append([]) + for line_no in range(chars_lines_text_list.__len__()): + self.lines[0].append(chars_lines_text_list[line_no]) + self.lines_initial_positions = [] + for line_no in range(self.lines[0].__len__()): + self.lines_initial_positions.append(self.lines[0][line_no].get_center()) + self.lines.append([]) + self.lines[1].extend( + [self.alignment for _ in range(chars_lines_text_list.__len__())] + ) + OpenGLVGroup.__init__( + self, *[self.lines[0][i] for i in range(self.lines[0].__len__())], **config + ) + self.move_to(np.array([0, 0, 0])) + if self.alignment: + self.set_all_lines_alignments(self.alignment) + + def gen_chars(self, lines_str_list): + """Function to convert plain string to 2d-VGroup of chars. 2d-VGroup mean "VGroup of VGroup". + + Parameters + ---------- + lines_str_list : :class:`str` + Plain text string. + + Returns + ------- + :class:`~.VGroup` + The generated 2d-VGroup of chars. + """ + char_index_counter = 0 + chars = OpenGLVGroup() + for line_no in range(lines_str_list.__len__()): + chars.add(OpenGLVGroup()) + chars[line_no].add( + *self.lines_text.chars[ + char_index_counter : char_index_counter + + lines_str_list[line_no].__len__() + + 1 + ] + ) + char_index_counter += lines_str_list[line_no].__len__() + 1 + return chars + + def set_all_lines_alignments(self, alignment): + """Function to set all line's alignment to a specific value. + + Parameters + ---------- + alignment : :class:`str` + Defines the alignment of paragraph. Possible values are "left", "right", "center". + """ + for line_no in range(0, self.lines[0].__len__()): + self.change_alignment_for_a_line(alignment, line_no) + return self + + def set_line_alignment(self, alignment, line_no): + """Function to set one line's alignment to a specific value. + + Parameters + ---------- + alignment : :class:`str` + Defines the alignment of paragraph. Possible values are "left", "right", "center". + line_no : :class:`int` + Defines the line number for which we want to set given alignment. + """ + self.change_alignment_for_a_line(alignment, line_no) + return self + + def set_all_lines_to_initial_positions(self): + """Set all lines to their initial positions.""" + self.lines[1] = [None for _ in range(self.lines[0].__len__())] + for line_no in range(0, self.lines[0].__len__()): + self[line_no].move_to( + self.get_center() + self.lines_initial_positions[line_no] + ) + return self + + def set_line_to_initial_position(self, line_no): + """Function to set one line to initial positions. + + Parameters + ---------- + line_no : :class:`int` + Defines the line number for which we want to set given alignment. + """ + self.lines[1][line_no] = None + self[line_no].move_to(self.get_center() + self.lines_initial_positions[line_no]) + return self + + def change_alignment_for_a_line(self, alignment, line_no): + """Function to change one line's alignment to a specific value. + + Parameters + ---------- + alignment : :class:`str` + Defines the alignment of paragraph. Possible values are "left", "right", "center". + line_no : :class:`int` + Defines the line number for which we want to set given alignment. + """ + self.lines[1][line_no] = alignment + if self.lines[1][line_no] == "center": + self[line_no].move_to( + np.array([self.get_center()[0], self[line_no].get_center()[1], 0]) + ) + elif self.lines[1][line_no] == "right": + self[line_no].move_to( + np.array( + [ + self.get_right()[0] - self[line_no].width / 2, + self[line_no].get_center()[1], + 0, + ] + ) + ) + elif self.lines[1][line_no] == "left": + self[line_no].move_to( + np.array( + [ + self.get_left()[0] + self[line_no].width / 2, + self[line_no].get_center()[1], + 0, + ] + ) + ) + + +class OpenGLText(OpenGLSVGMobject): + r"""Display (non-LaTeX) text rendered using `Pango `_. + + Text objects behave like a :class:`.VGroup`-like iterable of all characters + in the given text. In particular, slicing is possible. + + Parameters + ---------- + text : :class:`str` + The text that need to created as mobject. + + Returns + ------- + :class:`Text` + The mobject like :class:`.VGroup`. + + Examples + --------- + + .. manim:: Example1Text + :save_last_frame: + + class Example1Text(Scene): + def construct(self): + text = Text('Hello world').scale(3) + self.add(text) + + .. manim:: TextColorExample + :save_last_frame: + + class TextColorExample(Scene): + def construct(self): + text1 = Text('Hello world', color=BLUE).scale(3) + text2 = Text('Hello world', gradient=(BLUE, GREEN)).scale(3).next_to(text1, DOWN) + self.add(text1, text2) + + .. manim:: TextItalicAndBoldExample + :save_last_frame: + + class TextItalicAndBoldExample(Scene): + def construct(self): + text0 = Text('Hello world', slant=ITALIC) + text1 = Text('Hello world', t2s={'world':ITALIC}) + text2 = Text('Hello world', weight=BOLD) + text3 = Text('Hello world', t2w={'world':BOLD}) + self.add(text0,text1, text2,text3) + for i,mobj in enumerate(self.mobjects): + mobj.shift(DOWN*(i-1)) + + + .. manim:: TextMoreCustomization + :save_last_frame: + + class TextMoreCustomization(Scene): + def construct(self): + text1 = Text( + 'Google', + t2c={'[:1]': '#3174f0', '[1:2]': '#e53125', + '[2:3]': '#fbb003', '[3:4]': '#3174f0', + '[4:5]': '#269a43', '[5:]': '#e53125'}, size=1.2).scale(3) + self.add(text1) + + As :class:`Text` uses Pango to render text, rendering non-English + characters is easily possible: + + .. manim:: MultipleFonts + :save_last_frame: + + class MultipleFonts(Scene): + def construct(self): + morning = Text("வணக்கம்", font="sans-serif") + chin = Text( + "見 角 言 谷 辛 辰 辵 邑 酉 釆 里!", t2c={"見 角 言": BLUE} + ) # works same as ``Text``. + mess = Text("Multi-Language", style=BOLD) + russ = Text("Здравствуйте मस नम म ", font="sans-serif") + hin = Text("नमस्ते", font="sans-serif") + arb = Text( + "صباح الخير \n تشرفت بمقابلتك", font="sans-serif" + ) # don't mix RTL and LTR languages nothing shows up then ;-) + japanese = Text("臂猿「黛比」帶著孩子", font="sans-serif") + self.add(morning,chin,mess,russ,hin,arb,japanese) + for i,mobj in enumerate(self.mobjects): + mobj.shift(DOWN*(i-3)) + + + .. manim:: PangoRender + :quality: low + + class PangoRender(Scene): + def construct(self): + morning = Text("வணக்கம்", font="sans-serif") + self.play(Write(morning)) + self.wait(2) + + Tests + ----- + + Check that the creation of :class:`~.Text` works:: + + >>> Text('The horse does not eat cucumber salad.') + Text('The horse does not eat cucumber salad.') + + """ + + def __init__( + self, + text: str, + fill_opacity: float = 1.0, + stroke_width: int = 0, + color: str = WHITE, + size: int = 1, + line_spacing: int = -1, + font: str = "", + slant: str = NORMAL, + weight: str = NORMAL, + t2c: Dict[str, str] = None, + t2f: Dict[str, str] = None, + t2g: Dict[str, tuple] = None, + t2s: Dict[str, str] = None, + t2w: Dict[str, str] = None, + gradient: tuple = None, + tab_width: int = 4, + # Mobject + height: int = None, + width: int = None, + should_center: bool = True, + unpack_groups: bool = True, + disable_ligatures: bool = False, + **kwargs, + ): + self.size = size + self.line_spacing = line_spacing + self.font = font + self.slant = slant + self.weight = weight + self.gradient = gradient + self.tab_width = tab_width + if t2c is None: + t2c = {} + if t2f is None: + t2f = {} + if t2g is None: + t2g = {} + if t2s is None: + t2s = {} + if t2w is None: + t2w = {} + # If long form arguments are present, they take precedence + t2c = kwargs.pop("text2color", t2c) + t2f = kwargs.pop("text2font", t2f) + t2g = kwargs.pop("text2gradient", t2g) + t2s = kwargs.pop("text2slant", t2s) + t2w = kwargs.pop("text2weight", t2w) + self.t2c = t2c + self.t2f = t2f + self.t2g = t2g + self.t2s = t2s + self.t2w = t2w + + self.original_text = text + self.disable_ligatures = disable_ligatures + text_without_tabs = text + if text.find("\t") != -1: + text_without_tabs = text.replace("\t", " " * self.tab_width) + self.text = text_without_tabs + if self.line_spacing == -1: + self.line_spacing = self.size + self.size * 0.3 + else: + self.line_spacing = self.size + self.size * self.line_spacing + file_name = self.text2svg() + PangoUtils.remove_last_M(file_name) + OpenGLSVGMobject.__init__( + self, + file_name, + color=color, + fill_opacity=fill_opacity, + stroke_width=stroke_width, + height=height, + width=width, + should_center=should_center, + unpack_groups=unpack_groups, + **kwargs, + ) + self.text = text + if self.disable_ligatures: + self.submobjects = [*self.gen_chars()] + self.chars = OpenGLVGroup(*self.submobjects) + self.text = text_without_tabs.replace(" ", "").replace("\n", "") + if config["use_opengl_renderer"]: + nppc = self.n_points_per_curve + else: + nppc = self.n_points_per_cubic_curve + for each in self: + if config["use_opengl_renderer"]: + points = each.data["points"] + else: + nppc = each.points + if len(points) == 0: + continue + last = points[0] + each.clear_points() + for index, point in enumerate(points): + each.append_points([point]) + if ( + index != len(points) - 1 + and (index + 1) % nppc == 0 + and any(point != points[index + 1]) + ): + each.add_line_to(last) + last = points[index + 1] + each.add_line_to(last) + if self.t2c: + self.set_color_by_t2c() + if self.gradient: + self.set_color_by_gradient(*self.gradient) + if self.t2g: + self.set_color_by_t2g() + # anti-aliasing + if height is None and width is None: + self.scale(TEXT_MOB_SCALE_FACTOR) + + def __repr__(self): + return f"Text({repr(self.original_text)})" + + def gen_chars(self): + chars = OpenGLVGroup() + submobjects_char_index = 0 + for char_index in range(self.text.__len__()): + if self.text[char_index] in (" ", "\t", "\n"): + space = OpenGLDot(radius=0, fill_opacity=0, stroke_opacity=0) + if char_index == 0: + space.move_to(self.submobjects[submobjects_char_index].get_center()) + else: + space.move_to( + self.submobjects[submobjects_char_index - 1].get_center() + ) + chars.add(space) + else: + chars.add(self.submobjects[submobjects_char_index]) + submobjects_char_index += 1 + return chars + + def find_indexes(self, word: str, text: str): + """Internally used function. Finds the indexes of ``text`` in ``word``.""" + temp = re.match(r"\[([0-9\-]{0,}):([0-9\-]{0,})\]", word) + if temp: + start = int(temp.group(1)) if temp.group(1) != "" else 0 + end = int(temp.group(2)) if temp.group(2) != "" else len(text) + start = len(text) + start if start < 0 else start + end = len(text) + end if end < 0 else end + return [(start, end)] + indexes = [] + index = text.find(word) + while index != -1: + indexes.append((index, index + len(word))) + index = text.find(word, index + len(word)) + return indexes + + # def full2short(self, kwargs): + # """Internally used function. Formats some expansion to short forms. + # text2color -> t2c + # text2font -> t2f + # text2gradient -> t2g + # text2slant -> t2s + # text2weight -> t2w + # """ + # if "text2color" in kwargs: + # self.t2c = kwargs.pop("text2color") + # if "text2font" in kwargs: + # self.t2f = kwargs.pop("text2font") + # if "text2gradient" in kwargs: + # self.t2g = kwargs.pop("text2gradient") + # if "text2slant" in kwargs: + # self.t2s = kwargs.pop("text2slant") + # if "text2weight" in kwargs: + # self.t2w = kwargs.pop("text2weight") + + def set_color_by_t2c(self, t2c=None): + """Internally used function. Sets colour for specified strings.""" + t2c = t2c if t2c else self.t2c + for word, color in list(t2c.items()): + for start, end in self.find_indexes(word, self.original_text): + self.chars[start:end].set_color(color) + + def set_color_by_t2g(self, t2g=None): + """Internally used. Sets gradient colors for specified + strings. Behaves similarly to ``set_color_by_t2c``.""" + t2g = t2g if t2g else self.t2g + for word, gradient in list(t2g.items()): + for start, end in self.find_indexes(word, self.original_text): + self.chars[start:end].set_color_by_gradient(*gradient) + + def text2hash(self): + """Internally used function. + Generates ``sha256`` hash for file name. + """ + settings = ( + "PANGO" + self.font + self.slant + self.weight + ) # to differentiate Text and CairoText + settings += str(self.t2f) + str(self.t2s) + str(self.t2w) + settings += str(self.line_spacing) + str(self.size) + settings += str(self.disable_ligatures) + id_str = self.text + settings + hasher = hashlib.sha256() + hasher.update(id_str.encode()) + return hasher.hexdigest()[:16] + + def text2settings(self): + """Internally used function. Converts the texts and styles + to a setting for parsing.""" + settings = [] + t2x = [self.t2f, self.t2s, self.t2w] + for i in range(len(t2x)): + fsw = [self.font, self.slant, self.weight] + if t2x[i]: + for word, x in list(t2x[i].items()): + for start, end in self.find_indexes(word, self.text): + fsw[i] = x + settings.append(TextSetting(start, end, *fsw)) + # Set all text settings (default font, slant, weight) + fsw = [self.font, self.slant, self.weight] + settings.sort(key=lambda setting: setting.start) + temp_settings = settings.copy() + start = 0 + for setting in settings: + if setting.start != start: + temp_settings.append(TextSetting(start, setting.start, *fsw)) + start = setting.end + if start != len(self.text): + temp_settings.append(TextSetting(start, len(self.text), *fsw)) + settings = sorted(temp_settings, key=lambda setting: setting.start) + + if re.search(r"\n", self.text): + line_num = 0 + for start, end in self.find_indexes("\n", self.text): + for setting in settings: + if setting.line_num == -1: + setting.line_num = line_num + if start < setting.end: + line_num += 1 + new_setting = copy.copy(setting) + setting.end = end + new_setting.start = end + new_setting.line_num = line_num + settings.append(new_setting) + settings.sort(key=lambda setting: setting.start) + break + for setting in settings: + if setting.line_num == -1: + setting.line_num = 0 + return settings + + def text2svg(self): + """Internally used function. + Convert the text to SVG using Pango + """ + size = self.size * 10 + line_spacing = self.line_spacing * 10 + dir_name = config.get_dir("text_dir") + disable_liga = self.disable_ligatures + if not os.path.exists(dir_name): + os.makedirs(dir_name) + hash_name = self.text2hash() + file_name = os.path.join(dir_name, hash_name) + ".svg" + if os.path.exists(file_name): + return file_name + settings = self.text2settings() + width = 600 + height = 400 + + return manimpango.text2svg( + settings, + size, + line_spacing, + disable_liga, + file_name, + START_X, + START_Y, + width, + height, + self.text, + ) + + def init_colors(self, propagate_colors=True): + OpenGLSVGMobject.set_style( + self, + fill_color=self.fill_color or self.color, + fill_opacity=self.fill_opacity, + stroke_color=self.stroke_color or self.color, + stroke_width=self.stroke_width, + stroke_opacity=self.stroke_opacity, + recurse=propagate_colors, + ) + + +class OpenGLMarkupText(OpenGLSVGMobject): + r"""Display (non-LaTeX) text rendered using `Pango `_. + + Text objects behave like a :class:`.VGroup`-like iterable of all characters + in the given text. In particular, slicing is possible. Text can be formatted + using different tags: + + - ``bold``, ``italic`` and ``bold+italic`` + - ``
    underline
`` and ``strike through`` + - ``typewriter font`` + - ``bigger font`` and ``smaller font`` + - ``superscript`` and ``subscript`` + - ``double underline`` + - ``error underline`` + - ``overline`` + - ``strikethrough`` + - ``temporary change of font`` + - ``temporary change of color`` + - ``temporary change of color`` + - ``temporary gradient`` + + For ```` markup, colors can be specified either as hex triples like ``#aabbcc`` or as named CSS colors like ``AliceBlue``. + The ```` tag being handled by Manim rather than Pango, supports hex triplets or Manim constants like ``RED`` or ``RED_A``. + If you want to use Manim constants like ``RED_A`` together with ````, + you will need to use Python's f-String syntax as follows: + ``f'here you go'`` + + If your text contains ligatures, the :class:`MarkupText` class may incorrectly determine + the first and last letter when creating the gradient. This is due to the fact that e.g. ``fl`` + are two characters, but might be set as one single glyph, a ligature. If your language does + not depend on ligatures, consider setting ``disable_ligatures=True``. If you cannot or do + not want to do without ligatures, the ``gradient`` tag supports an optional attribute ``offset`` + which can be used to compensate for that error. Usage is as follows: + + - ``example`` to *start* the gradient one letter earlier + - ``example`` to *end* the gradient one letter earlier + - ``example`` to *start* the gradient two letters earlier and *end* it one letter earlier + + Specifying a second offset may be necessary if the text to be colored does + itself contain ligatures. The same can happen when using HTML entities for + special chars. + + When using ``underline``, ``overline`` or ``strikethrough`` together with ```` tags, you will also need to use the offset, because + underlines are additional paths in the final :class:`SVGMobject`, check out the corresponding example. + + Escaping of special characters: ``>`` *should* be written as ``>`` whereas ``<`` and + ``&`` *must* be written as ``<`` and ``&``. + + You can find more information about Pango markup formatting at the + corresponding documentation page: + `Pango Markup `_. + Please be aware that not all features are supported by this class and that + the ```` tag mentioned above is not supported by Pango. + + Parameters + ---------- + text : :class:`str` + The text that need to created as mobject. + fill_opacity : :class:`int` + The fill opacity with 1 meaning opaque and 0 meaning transparent. + stroke_width : :class:`int` + Stroke width. + color : :class:`str` + Global color setting for the entire text. Local overrides are possible. + size : :class:`int` + Font size. + line_spacing : :class:`int` + Line spacing. + font : :class:`str` + Global font setting for the entire text. Local overrides are possible. + slant : :class:`str` + Global slant setting, e.g. `NORMAL` or `ITALIC`. Local overrides are possible. + weight : :class:`str` + Global weight setting, e.g. `NORMAL` or `BOLD`. Local overrides are possible. + gradient: :class:`tuple` + Global gradient setting. Local overrides are possible. + + + Returns + ------- + :class:`MarkupText` + The text displayed in form of a :class:`.VGroup`-like mobject. + + Examples + --------- + + .. manim:: BasicMarkupExample + :save_last_frame: + + class BasicMarkupExample(Scene): + def construct(self): + text1 = MarkupText("foo bar foobar") + text2 = MarkupText("foo bar big small") + text3 = MarkupText("H2O and H3O+") + text4 = MarkupText("type help for help") + text5 = MarkupText( + 'foo bar' + ) + group = VGroup(text1, text2, text3, text4, text5).arrange(DOWN) + self.add(group) + + .. manim:: ColorExample + :save_last_frame: + + class ColorExample(Scene): + def construct(self): + text1 = MarkupText( + f'all in red except this', color=RED + ) + text2 = MarkupText("nice gradient", gradient=(BLUE, GREEN)) + text3 = MarkupText( + 'nice intermediate gradient', + gradient=(BLUE, GREEN), + ) + text4 = MarkupText( + 'fl ligature causing trouble here' + ) + text5 = MarkupText( + 'fl ligature defeated with offset' + ) + text6 = MarkupText( + 'fl ligature floating inside' + ) + text7 = MarkupText( + 'fl ligature floating inside' + ) + group = VGroup(text1, text2, text3, text4, text5, text6, text7).arrange(DOWN) + self.add(group) + + .. manim:: UnderlineExample + :save_last_frame: + + class UnderlineExample(Scene): + def construct(self): + text1 = MarkupText( + 'bla' + ) + text2 = MarkupText( + 'xxxaabby' + ) + text3 = MarkupText( + 'xxxaabby' + ) + text4 = MarkupText( + 'xxxaabby' + ) + text5 = MarkupText( + 'xxxaabby' + ) + group = VGroup(text1, text2, text3, text4, text5).arrange(DOWN) + self.add(group) + + .. manim:: FontExample + :save_last_frame: + + class FontExample(Scene): + def construct(self): + text1 = MarkupText( + 'all in sans except this', font="sans" + ) + text2 = MarkupText( + 'mixing fonts is ugly' + ) + text3 = MarkupText("special char > or >") + text4 = MarkupText("special char < and &") + group = VGroup(text1, text2, text3, text4).arrange(DOWN) + self.add(group) + + .. manim:: NewlineExample + :save_last_frame: + + class NewlineExample(Scene): + def construct(self): + text = MarkupText('foooooo\nbaaaar') + self.add(text) + + .. manim:: NoLigaturesExample + :save_last_frame: + + class NoLigaturesExample(Scene): + def construct(self): + text1 = MarkupText('floating') + text2 = MarkupText('floating', disable_ligatures=True) + group = VGroup(text1, text2).arrange(DOWN) + self.add(group) + + + As :class:`MarkupText` uses Pango to render text, rendering non-English + characters is easily possible: + + .. manim:: MultiLanguage + :save_last_frame: + + class MultiLanguage(Scene): + def construct(self): + morning = MarkupText("வணக்கம்", font="sans-serif") + chin = MarkupText( + '見 角 言 谷 辛 辰 辵 邑 酉 釆 里!' + ) # works as in ``Text``. + mess = MarkupText("Multi-Language", style=BOLD) + russ = MarkupText("Здравствуйте मस नम म ", font="sans-serif") + hin = MarkupText("नमस्ते", font="sans-serif") + japanese = MarkupText("臂猿「黛比」帶著孩子", font="sans-serif") + group = VGroup(morning, chin, mess, russ, hin, japanese).arrange(DOWN) + self.add(group) + + + Tests + ----- + + Check that the creation of :class:`~.MarkupText` works:: + + >>> MarkupText('The horse does not eat cucumber salad.') + MarkupText('The horse does not eat cucumber salad.') + + """ + + def __init__( + self, + text: str, + fill_opacity: int = 1, + stroke_width: int = 0, + color: str = WHITE, + size: int = 1, + line_spacing: int = -1, + font: str = "", + slant: str = NORMAL, + weight: str = NORMAL, + gradient: tuple = None, + tab_width: int = 4, + height: int = None, + width: int = None, + should_center: bool = True, + unpack_groups: bool = True, + disable_ligatures: bool = False, + **kwargs, + ): + self.text = text + self.color = color + self.size = size + self.line_spacing = line_spacing + self.font = font + self.slant = slant + self.weight = weight + self.gradient = gradient + self.tab_width = tab_width + + self.original_text = text + self.disable_ligatures = disable_ligatures + text_without_tabs = text + if "\t" in text: + text_without_tabs = text.replace("\t", " " * self.tab_width) + + colormap = self.extract_color_tags() + if len(colormap) > 0: + logger.warning( + 'Using tags in MarkupText is deprecated. Please use instead.' + ) + gradientmap = self.extract_gradient_tags() + + if not MarkupUtils.validate(self.text): + raise ValueError( + f"Pango cannot parse your markup in {self.text}. " + "Please check for typos, unmatched tags or unescaped " + "special chars like < and &." + ) + + if self.line_spacing == -1: + self.line_spacing = self.size + self.size * 0.3 + else: + self.line_spacing = self.size + self.size * self.line_spacing + + file_name = self.text2svg() + PangoUtils.remove_last_M(file_name) + OpenGLSVGMobject.__init__( + self, + file_name, + fill_opacity=fill_opacity, + stroke_width=stroke_width, + height=height, + width=width, + should_center=should_center, + unpack_groups=unpack_groups, + **kwargs, + ) + self.chars = OpenGLVGroup(*self.submobjects) + self.text = text_without_tabs.replace(" ", "").replace("\n", "") + + if config["use_opengl_renderer"]: + nppc = self.n_points_per_curve + else: + nppc = self.n_points_per_cubic_curve + for each in self: + if config["use_opengl_renderer"]: + points = each.data["points"] + else: + points = each.points + if len(points) == 0: + continue + last = points[0] + each.clear_points() + for index, point in enumerate(points): + each.append_points([point]) + if ( + index != len(points) - 1 + and (index + 1) % nppc == 0 + and any(point != points[index + 1]) + ): + each.add_line_to(last) + last = points[index + 1] + each.add_line_to(last) + + if self.gradient: + self.set_color_by_gradient(*self.gradient) + for col in colormap: + self.chars[ + col["start"] + - col["start_offset"] : col["end"] + - col["start_offset"] + - col["end_offset"] + ].set_color(self._parse_color(col["color"])) + for grad in gradientmap: + self.chars[ + grad["start"] + - grad["start_offset"] : grad["end"] + - grad["start_offset"] + - grad["end_offset"] + ].set_color_by_gradient( + *(self._parse_color(grad["from"]), self._parse_color(grad["to"])) + ) + # anti-aliasing + if height is None and width is None: + self.scale(TEXT_MOB_SCALE_FACTOR) + + def text2hash(self): + """Generates ``sha256`` hash for file name.""" + settings = ( + "MARKUPPANGO" + self.font + self.slant + self.weight + self.color + ) # to differentiate from classical Pango Text + settings += str(self.line_spacing) + str(self.size) + settings += str(self.disable_ligatures) + id_str = self.text + settings + hasher = hashlib.sha256() + hasher.update(id_str.encode()) + return hasher.hexdigest()[:16] + + def text2svg(self): + """Convert the text to SVG using Pango.""" + size = self.size * 10 + line_spacing = self.line_spacing * 10 + dir_name = config.get_dir("text_dir") + disable_liga = self.disable_ligatures + if not os.path.exists(dir_name): + os.makedirs(dir_name) + hash_name = self.text2hash() + file_name = os.path.join(dir_name, hash_name) + ".svg" + if os.path.exists(file_name): + return file_name + + logger.debug(f"Setting Text {self.text}") + return MarkupUtils.text2svg( + f'{self.text}', + self.font, + self.slant, + self.weight, + size, + line_spacing, + disable_liga, + file_name, + START_X, + START_Y, + 600, # width + 400, # height + ) + + def _count_real_chars(self, s): + """Counts characters that will be displayed. + + This is needed for partial coloring or gradients, because space + counts to the text's `len`, but has no corresponding character.""" + count = 0 + level = 0 + # temporarily replace HTML entities by single char + s = re.sub("&[^;]+;", "x", s) + for c in s: + if c == "<": + level += 1 + if c == ">" and level > 0: + level -= 1 + elif c != " " and c != "\t" and level == 0: + count += 1 + return count + + def extract_gradient_tags(self): + """Used to determine which parts (if any) of the string should be formatted + with a gradient. + + Removes the ```` tag, as it is not part of Pango's markup and would cause an error. + """ + tags = re.finditer( + r'(.+?)', + self.original_text, + re.S, + ) + gradientmap = [] + for tag in tags: + start = self._count_real_chars(self.original_text[: tag.start(0)]) + end = start + self._count_real_chars(tag.group(5)) + offsets = tag.group(4).split(",") if tag.group(4) else [0] + start_offset = int(offsets[0]) if offsets[0] else 0 + end_offset = int(offsets[1]) if len(offsets) == 2 and offsets[1] else 0 + + gradientmap.append( + { + "start": start, + "end": end, + "from": tag.group(1), + "to": tag.group(2), + "start_offset": start_offset, + "end_offset": end_offset, + } + ) + self.text = re.sub("]+>(.+?)", r"\1", self.text, 0, re.S) + return gradientmap + + def _parse_color(self, col): + """Parse color given in ```` or ```` tags.""" + if re.match("#[0-9a-f]{6}", col): + return col + else: + return Colors[col.lower()].value + + def extract_color_tags(self): + """Used to determine which parts (if any) of the string should be formatted + with a custom color. + + Removes the ```` tag, as it is not part of Pango's markup and would cause an error. + + Note: Using the ```` tags is deprecated. As soon as the legacy syntax is gone, this function + will be removed. + """ + tags = re.finditer( + r'(.+?)', + self.original_text, + re.S, + ) + + colormap = [] + for tag in tags: + start = self._count_real_chars(self.original_text[: tag.start(0)]) + end = start + self._count_real_chars(tag.group(4)) + offsets = tag.group(3).split(",") if tag.group(3) else [0] + start_offset = int(offsets[0]) if offsets[0] else 0 + end_offset = int(offsets[1]) if len(offsets) == 2 and offsets[1] else 0 + + colormap.append( + { + "start": start, + "end": end, + "color": tag.group(1), + "start_offset": start_offset, + "end_offset": end_offset, + } + ) + self.text = re.sub("]+>(.+?)", r"\1", self.text, 0, re.S) + return colormap + + def __repr__(self): + return f"MarkupText({repr(self.original_text)})" + + +@contextmanager +def register_font(font_file: typing.Union[str, Path]): + """Temporarily add a font file to Pango's search path. + + This searches for the font_file at various places. The order it searches it described below. + + 1. Absolute path. + 2. In ``assets/fonts`` folder. + 3. In ``font/`` folder. + 4. In the same directory. + + Parameters + ---------- + font_file : + The font file to add. + + Examples + -------- + Use ``with register_font(...)`` to add a font file to search + path. + + .. code-block:: python + + with register_font("path/to/font_file.ttf"): + a = Text("Hello", font="Custom Font Name") + + Raises + ------ + FileNotFoundError: + If the font doesn't exists. + + AttributeError: + If this method is used on macOS. + + Notes + ----- + This method of adding font files also works with :class:`CairoText`. + + .. important :: + + This method is available for macOS for ``ManimPango>=v0.2.3``. Using this + method with previous releases will raise an :class:`AttributeError` on macOS. + """ + + input_folder = Path(config.input_file).parent.resolve() + possible_paths = [ + Path(font_file), + input_folder / "assets/fonts" / font_file, + input_folder / "fonts" / font_file, + input_folder / font_file, + ] + for path in possible_paths: + path = path.resolve() + if path.exists(): + file_path = path + logger.debug("Found file at %s", file_path.absolute()) + break + else: + error = f"Can't find {font_file}." f"Tried these : {possible_paths}" + raise FileNotFoundError(error) + + try: + assert manimpango.register_font(str(file_path)) + yield + finally: + manimpango.unregister_font(str(file_path)) From c61bf02879b2fb818b62e1ab2b691ffac148e375 Mon Sep 17 00:00:00 2001 From: Devin Neal Date: Sun, 28 Mar 2021 19:32:09 -0700 Subject: [PATCH 5/8] Add OpenGLText and related classes --- example_scenes/basic.py | 20 +++++++++++++------ example_scenes/opengl.py | 10 ++++++---- manim/mobject/svg/opengl_tex_mobject.py | 1 - .../types/opengl_vectorized_mobject.py | 6 ++---- 4 files changed, 22 insertions(+), 15 deletions(-) diff --git a/example_scenes/basic.py b/example_scenes/basic.py index e4b182f254..42ec629505 100644 --- a/example_scenes/basic.py +++ b/example_scenes/basic.py @@ -16,12 +16,20 @@ class TestLatex(Scene): def construct(self): - # mob = SVGMobject("../manim/media/Tex/1e2155e0e4bdc7ad.svg").scale(3) - # mob.set_color(WHITE) - # self.play(FadeIn(mob)) - mob = Text("This is some text").set_width(13) - self.play(Write(mob)) - self.wait(2) + mob = Paragraph( + "this is a awesome", + "paragraph", + "With \nNewlines", + "\tWith Tabs", + " With Spaces", + "With Alignments", + "center", + "left", + "right", + ) + anim = Write(mob) + self.play(anim) + self.wait() class OpeningManim(Scene): diff --git a/example_scenes/opengl.py b/example_scenes/opengl.py index 4f4dc2a359..fc2979fef5 100644 --- a/example_scenes/opengl.py +++ b/example_scenes/opengl.py @@ -13,11 +13,13 @@ def construct(self): config["background_color"] = "#333333" # mob = OpenGLSVGMobject("../manim/media/Tex/184715fb937073ce.svg").scale(3) # mob.set_color(WHITE) - mob = OpenGLCairoText("Manim Community").set_width(12) - self.play(Write(mob)) - print(len(mob.submobjects)) + + mob = OpenGLTex("This is some text") + + anim = Write(mob) + self.play(anim) self.wait() - # self.interact() + self.interact() class InteractiveDevelopment(Scene): diff --git a/manim/mobject/svg/opengl_tex_mobject.py b/manim/mobject/svg/opengl_tex_mobject.py index 58bd711aa4..9987a1e862 100644 --- a/manim/mobject/svg/opengl_tex_mobject.py +++ b/manim/mobject/svg/opengl_tex_mobject.py @@ -342,7 +342,6 @@ def organize_submobjects_left_to_right(self): return self def init_colors(self, propagate_colors=True): - # OpenGLSVGMobject.init_colors(self, propagate_colors=propagate_colors) OpenGLSVGMobject.set_style( self, fill_color=self.fill_color or self.color, diff --git a/manim/mobject/types/opengl_vectorized_mobject.py b/manim/mobject/types/opengl_vectorized_mobject.py index 57e9894f87..954d5d0eb6 100644 --- a/manim/mobject/types/opengl_vectorized_mobject.py +++ b/manim/mobject/types/opengl_vectorized_mobject.py @@ -119,10 +119,8 @@ def __init__( super().__init__(**kwargs) self.refresh_unit_normal() - # - # def get_group_class(self): - # return VGroup - # + def get_group_class(self): + return OpenGLVGroup def init_data(self): super().init_data() From bf3dba2827ba7f5d6c1ee910b1c3222abdc3c6de Mon Sep 17 00:00:00 2001 From: Devin Neal Date: Tue, 30 Mar 2021 18:26:33 -0700 Subject: [PATCH 6/8] Undo changes to example code --- example_scenes/basic.py | 18 ------------------ example_scenes/opengl.py | 14 -------------- 2 files changed, 32 deletions(-) diff --git a/example_scenes/basic.py b/example_scenes/basic.py index 1c1c097843..aa8d7120f4 100644 --- a/example_scenes/basic.py +++ b/example_scenes/basic.py @@ -14,24 +14,6 @@ # for a 1920x1080 video) -class TestLatex(Scene): - def construct(self): - mob = Paragraph( - "this is a awesome", - "paragraph", - "With \nNewlines", - "\tWith Tabs", - " With Spaces", - "With Alignments", - "center", - "left", - "right", - ) - anim = Write(mob) - self.play(anim) - self.wait() - - class OpeningManim(Scene): def construct(self): title = Tex(r"This is some \LaTeX") diff --git a/example_scenes/opengl.py b/example_scenes/opengl.py index fc2979fef5..d634e96806 100644 --- a/example_scenes/opengl.py +++ b/example_scenes/opengl.py @@ -8,20 +8,6 @@ # Lines that do not yet work with the Community Version are commented. -class TestLatex(Scene): - def construct(self): - config["background_color"] = "#333333" - # mob = OpenGLSVGMobject("../manim/media/Tex/184715fb937073ce.svg").scale(3) - # mob.set_color(WHITE) - - mob = OpenGLTex("This is some text") - - anim = Write(mob) - self.play(anim) - self.wait() - self.interact() - - class InteractiveDevelopment(Scene): def construct(self): circle = OpenGLCircle() From 2a861d02b0892deea5f0e1666a8b16002311a26c Mon Sep 17 00:00:00 2001 From: Devin Neal Date: Wed, 31 Mar 2021 00:11:03 -0700 Subject: [PATCH 7/8] Checks --- manim/mobject/geometry.py | 16 ++++++++-------- manim/opengl/__init__.py | 1 - 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/manim/mobject/geometry.py b/manim/mobject/geometry.py index 2ad905b306..36e09b09b1 100644 --- a/manim/mobject/geometry.py +++ b/manim/mobject/geometry.py @@ -655,14 +655,14 @@ def __init__(self, label, radius=None, **kwargs) -> None: class Ellipse(Circle): """A circular shape; oval, circle. - Parameters - ---------- - width : :class:`float`, optional - The horizontal width of the ellipse. - height : :class:`float`, optional - The vertical height of the ellipse. - kwargs : Any - Additional arguments to be passed to :class:`Circle` + Parameters + ---------- + width : :class:`float`, optional + The horizontal width of the ellipse. + height : :class:`float`, optional + The vertical height of the ellipse. + kwargs : Any + Additional arguments to be passed to :class:`Circle` Examples -------- diff --git a/manim/opengl/__init__.py b/manim/opengl/__init__.py index 42562c2433..2f53ed3fe3 100644 --- a/manim/opengl/__init__.py +++ b/manim/opengl/__init__.py @@ -3,7 +3,6 @@ from ..mobject.opengl_geometry import * from ..mobject.types.opengl_surface import * from ..mobject.opengl_three_dimensions import * -from ..mobject.opengl_three_dimensions import * from ..mobject.svg.opengl_svg_mobject import * from ..mobject.svg.opengl_tex_mobject import * from ..mobject.svg.opengl_text_mobject import * From 7393b368d151b4d041c1eb760846b5f9d0fbe084 Mon Sep 17 00:00:00 2001 From: Devin Neal Date: Wed, 31 Mar 2021 00:37:11 -0700 Subject: [PATCH 8/8] Add OpenGLGraph, OpenGLLabelledDot --- manim/mobject/opengl_geometry.py | 51 +++ manim/mobject/opengl_graph.py | 532 +++++++++++++++++++++++++++++++ manim/opengl/__init__.py | 1 + 3 files changed, 584 insertions(+) create mode 100644 manim/mobject/opengl_graph.py diff --git a/manim/mobject/opengl_geometry.py b/manim/mobject/opengl_geometry.py index ae34c8fae5..1a3b7970e1 100644 --- a/manim/mobject/opengl_geometry.py +++ b/manim/mobject/opengl_geometry.py @@ -352,6 +352,57 @@ def __init__(self, radius=DEFAULT_SMALL_DOT_RADIUS, **kwargs): super().__init__(radius=radius, **kwargs) +class OpenGLLabeledDot(OpenGLDot): + """A :class:`Dot` containing a label in its center. + + Parameters + ---------- + label : Union[:class:`str`, :class:`~.SingleStringMathTex`, :class:`~.Text`, :class:`~.Tex`] + The label of the :class:`Dot`. This is rendered as :class:`~.MathTex` + by default (i.e., when passing a :class:`str`), but other classes + representing rendered strings like :class:`~.Text` or :class:`~.Tex` + can be passed as well. + + radius : :class:`float` + The radius of the :class:`Dot`. If ``None`` (the default), the radius + is calculated based on the size of the ``label``. + + Examples + -------- + + .. manim:: SeveralLabeledDots + :save_last_frame: + + class SeveralLabeledDots(Scene): + def construct(self): + sq = Square(fill_color=RED, fill_opacity=1) + self.add(sq) + dot1 = LabeledDot(Tex("42", color=RED)) + dot2 = LabeledDot(MathTex("a", color=GREEN)) + dot3 = LabeledDot(Text("ii", color=BLUE)) + dot4 = LabeledDot("3") + dot1.next_to(sq, UL) + dot2.next_to(sq, UR) + dot3.next_to(sq, DL) + dot4.next_to(sq, DR) + self.add(dot1, dot2, dot3, dot4) + """ + + def __init__(self, label, radius=None, **kwargs) -> None: + if isinstance(label, str): + from ..mobject.svg.opengl_tex_mobject import OpenGLMathTex + + rendered_label = OpenGLMathTex(label, color=BLACK) + else: + rendered_label = label + + if radius is None: + radius = 0.1 + max(rendered_label.width, rendered_label.height) / 2 + OpenGLDot.__init__(self, radius=radius, **kwargs) + rendered_label.move_to(self.get_center()) + self.add(rendered_label) + + class OpenGLEllipse(OpenGLCircle): def __init__(self, width=2, height=1, **kwargs): super().__init__(**kwargs) diff --git a/manim/mobject/opengl_graph.py b/manim/mobject/opengl_graph.py new file mode 100644 index 0000000000..e569773001 --- /dev/null +++ b/manim/mobject/opengl_graph.py @@ -0,0 +1,532 @@ +"""Mobjects used to represent mathematical graphs (think graph theory, not plotting).""" + +__all__ = [ + "OpenGLGraph", +] + +from ..utils.color import BLACK +from .types.opengl_vectorized_mobject import OpenGLVMobject +from .opengl_geometry import OpenGLDot, OpenGLLine, OpenGLLabeledDot +from .svg.opengl_tex_mobject import OpenGLMathTex +from ..utils.space_ops import normalize + +from typing import Hashable, Union, List, Tuple + +from copy import copy +import networkx as nx +import numpy as np + + +def _determine_graph_layout( + nx_graph: nx.classes.graph.Graph, + layout: Union[str, dict] = "spring", + layout_scale: float = 2, + layout_config: Union[dict, None] = None, + partitions: Union[List[List[Hashable]], None] = None, + root_vertex: Union[Hashable, None] = None, +) -> dict: + automatic_layouts = { + "circular": nx.layout.circular_layout, + "kamada_kawai": nx.layout.kamada_kawai_layout, + "planar": nx.layout.planar_layout, + "random": nx.layout.random_layout, + "shell": nx.layout.shell_layout, + "spectral": nx.layout.spectral_layout, + "partite": nx.layout.multipartite_layout, + "tree": _tree_layout, + "spiral": nx.layout.spiral_layout, + "spring": nx.layout.spring_layout, + } + + custom_layouts = ["random", "partite", "tree"] + + if layout_config is None: + layout_config = {} + + if isinstance(layout, dict): + return layout + elif layout in automatic_layouts and layout not in custom_layouts: + auto_layout = automatic_layouts[layout]( + nx_graph, scale=layout_scale, **layout_config + ) + return {k: np.append(v, [0]) for k, v in auto_layout.items()} + elif layout == "tree": + return _tree_layout( + nx_graph, + root_vertex=root_vertex, + scale=layout_scale, + ) + elif layout == "partite": + if partitions is None or len(partitions) == 0: + raise ValueError( + "The partite layout requires the 'partitions' parameter to contain the partition of the vertices" + ) + partition_count = len(partitions) + for i in range(partition_count): + for v in partitions[i]: + if nx_graph.nodes[v] is None: + raise ValueError( + "The partition must contain arrays of vertices in the graph" + ) + nx_graph.nodes[v]["subset"] = i + # Add missing vertices to their own side + for v in nx_graph.nodes: + if "subset" not in nx_graph.nodes[v]: + nx_graph.nodes[v]["subset"] = partition_count + + auto_layout = automatic_layouts["partite"]( + nx_graph, scale=layout_scale, **layout_config + ) + return {k: np.append(v, [0]) for k, v in auto_layout.items()} + elif layout == "random": + # the random layout places coordinates in [0, 1) + # we need to rescale manually afterwards... + auto_layout = automatic_layouts["random"](nx_graph, **layout_config) + for k, v in auto_layout.items(): + auto_layout[k] = 2 * layout_scale * (v - np.array([0.5, 0.5])) + return {k: np.append(v, [0]) for k, v in auto_layout.items()} + else: + raise ValueError( + f"The layout '{layout}' is neither a recognized automatic layout, " + "nor a vertex placement dictionary." + ) + + +def _tree_layout( + G: nx.classes.graph.Graph, + root_vertex: Union[Hashable, None], + scale: float, +) -> dict: + result = {root_vertex: np.array([0, 0, 0])} + + if not nx.is_tree(G): + raise ValueError("The tree layout must be used with trees") + if root_vertex is None: + raise ValueError("The tree layout requires the root_vertex parameter") + + def _recursive_position_for_row( + G: nx.classes.graph.Graph, + result: dict, + two_rows_before: List[Hashable], + last_row: List[Hashable], + current_height: float, + ): + new_row = [] + for v in last_row: + for x in G.neighbors(v): + if x not in two_rows_before: + new_row.append(x) + + new_row_length = len(new_row) + + if new_row_length == 0: + return + + if new_row_length == 1: + result[new_row[0]] = np.array([0, current_height, 0]) + else: + for i in range(new_row_length): + result[new_row[i]] = np.array( + [-1 + 2 * i / (new_row_length - 1), current_height, 0] + ) + + _recursive_position_for_row( + G, + result, + two_rows_before=last_row, + last_row=new_row, + current_height=current_height + 1, + ) + + _recursive_position_for_row( + G, result, two_rows_before=[], last_row=[root_vertex], current_height=1 + ) + + height = max(map(lambda v: result[v][1], result)) + + return { + v: np.array([pos[0], 1 - 2 * pos[1] / height, pos[2]]) * scale / 2 + for v, pos in result.items() + } + + +class OpenGLGraph(OpenGLVMobject): + """An undirected graph (that is, a collection of vertices connected with edges). + + Graphs can be instantiated by passing both a list of (distinct, hashable) + vertex names, together with list of edges (as tuples of vertex names). See + the examples below for details. + + .. note:: + + This implementation uses updaters to make the edges move with + the vertices. + + Parameters + ---------- + + vertices + A list of vertices. Must be hashable elements. + edges + A list of edges, specified as tuples ``(u, v)`` where both ``u`` + and ``v`` are vertices. + labels + Controls whether or not vertices are labeled. If ``False`` (the default), + the vertices are not labeled; if ``True`` they are labeled using their + names (as specified in ``vertices``) via :class:`~.MathTex`. Alternatively, + custom labels can be specified by passing a dictionary whose keys are + the vertices, and whose values are the corresponding vertex labels + (rendered via, e.g., :class:`~.Text` or :class:`~.Tex`). + label_fill_color + Sets the fill color of the default labels generated when ``labels`` + is set to ``True``. Has no effect for other values of ``labels``. + layout + Either one of ``"spring"`` (the default), ``"circular"``, ``"kamada_kawai"``, + ``"planar"``, ``"random"``, ``"shell"``, ``"spectral"``, ``"spiral"``, ``"tree"``, and ``"partite"`` + for automatic vertex positioning using ``networkx`` + (see `their documentation `_ + for more details), or a dictionary specifying a coordinate (value) + for each vertex (key) for manual positioning. + layout_scale + The scale of automatically generated layouts: the vertices will + be arranged such that the coordinates are located within the + interval ``[-scale, scale]``. Default: 2. + layout_config + Only for automatically generated layouts. A dictionary whose entries + are passed as keyword arguments to the automatic layout algorithm + specified via ``layout`` of``networkx``. + vertex_type + The mobject class used for displaying vertices in the scene. + vertex_config + Either a dictionary containing keyword arguments to be passed to + the class specified via ``vertex_type``, or a dictionary whose keys + are the vertices, and whose values are dictionaries containing keyword + arguments for the mobject related to the corresponding vertex. + edge_type + The mobject class used for displaying edges in the scene. + edge_config + Either a dictionary containing keyword arguments to be passed + to the class specified via ``edge_type``, or a dictionary whose + keys are the edges, and whose values are dictionaries containing + keyword arguments for the mobject related to the corresponding edge. + + Examples + -------- + + First, we create a small graph and demonstrate that the edges move + together with the vertices. + + .. manim:: MovingVertices + + class MovingVertices(Scene): + def construct(self): + vertices = [1, 2, 3, 4] + edges = [(1, 2), (2, 3), (3, 4), (1, 3), (1, 4)] + g = Graph(vertices, edges) + self.play(Create(g)) + self.wait() + self.play(g[1].animate.move_to([1, 1, 0]), + g[2].animate.move_to([-1, 1, 0]), + g[3].animate.move_to([1, -1, 0]), + g[4].animate.move_to([-1, -1, 0])) + self.wait() + + There are several automatic positioning algorithms to choose from: + + .. manim:: GraphAutoPosition + :save_last_frame: + + class GraphAutoPosition(Scene): + def construct(self): + vertices = [1, 2, 3, 4, 5, 6, 7, 8] + edges = [(1, 7), (1, 8), (2, 3), (2, 4), (2, 5), + (2, 8), (3, 4), (6, 1), (6, 2), + (6, 3), (7, 2), (7, 4)] + autolayouts = ["spring", "circular", "kamada_kawai", + "planar", "random", "shell", + "spectral", "spiral"] + graphs = [Graph(vertices, edges, layout=lt).scale(0.5) + for lt in autolayouts] + r1 = VGroup(*graphs[:3]).arrange() + r2 = VGroup(*graphs[3:6]).arrange() + r3 = VGroup(*graphs[6:]).arrange() + self.add(VGroup(r1, r2, r3).arrange(direction=DOWN)) + + Vertices can also be positioned manually: + + .. manim:: GraphManualPosition + :save_last_frame: + + class GraphManualPosition(Scene): + def construct(self): + vertices = [1, 2, 3, 4] + edges = [(1, 2), (2, 3), (3, 4), (4, 1)] + lt = {1: [0, 0, 0], 2: [1, 1, 0], 3: [1, -1, 0], 4: [-1, 0, 0]} + G = Graph(vertices, edges, layout=lt) + self.add(G) + + The vertices in graphs can be labeled, and configurations for vertices + and edges can be modified both by default and for specific vertices and + edges. + + .. note:: + + In ``edge_config``, edges can be passed in both directions: if + ``(u, v)`` is an edge in the graph, both ``(u, v)`` as well + as ``(v, u)`` can be used as keys in the dictionary. + + .. manim:: LabeledModifiedGraph + :save_last_frame: + + class LabeledModifiedGraph(Scene): + def construct(self): + vertices = [1, 2, 3, 4, 5, 6, 7, 8] + edges = [(1, 7), (1, 8), (2, 3), (2, 4), (2, 5), + (2, 8), (3, 4), (6, 1), (6, 2), + (6, 3), (7, 2), (7, 4)] + g = Graph(vertices, edges, layout="circular", layout_scale=3, + labels=True, vertex_config={7: {"fill_color": RED}}, + edge_config={(1, 7): {"stroke_color": RED}, + (2, 7): {"stroke_color": RED}, + (4, 7): {"stroke_color": RED}}) + self.add(g) + + You can also lay out a partite graph on columns by specifying + a list of the vertices on each side and choosing the partite layout. + + .. note:: + + All vertices in your graph which are not listed in any of the partitions + are collected in their own partition and rendered in the rightmost column. + + .. manim:: PartiteGraph + :save_last_frame: + + import networkx as nx + + class PartiteGraph(Scene): + def construct(self): + G = nx.Graph() + G.add_nodes_from([0, 1, 2, 3]) + G.add_edges_from([(0, 2), (0,3), (1, 2)]) + graph = Graph(list(G.nodes), list(G.edges), layout="partite", partitions=[[0, 1]]) + self.play(Create(graph)) + + The custom tree layout can be used to show the graph + by distance from the root vertex. You must pass the root vertex + of the tree. + + .. manim:: Tree + + from manim import * + import networkx as nx + + class Tree(Scene): + def construct(self): + G = nx.Graph() + + G.add_node("ROOT") + + for i in range(5): + G.add_node("Child_%i" % i) + G.add_node("Grandchild_%i" % i) + G.add_node("Greatgrandchild_%i" % i) + + G.add_edge("ROOT", "Child_%i" % i) + G.add_edge("Child_%i" % i, "Grandchild_%i" % i) + G.add_edge("Grandchild_%i" % i, "Greatgrandchild_%i" % i) + + self.play(Create( + Graph(list(G.nodes), list(G.edges), layout="tree", root_vertex="ROOT"))) + """ + + def __init__( + self, + vertices: List[Hashable], + edges: List[Tuple[Hashable, Hashable]], + labels: bool = False, + label_fill_color: str = BLACK, + layout: Union[str, dict] = "spring", + layout_scale: float = 2, + layout_config: Union[dict, None] = None, + vertex_type: "Mobject" = OpenGLDot, + vertex_config: Union[dict, None] = None, + edge_type: "Mobject" = OpenGLLine, + partitions: Union[List[List[Hashable]], None] = None, + root_vertex: Union[Hashable, None] = None, + edge_config: Union[dict, None] = None, + offset_edges_by_radius: bool = False, + ) -> None: + OpenGLVMobject.__init__(self) + + nx_graph = nx.Graph() + nx_graph.add_nodes_from(vertices) + nx_graph.add_edges_from(edges) + self._graph = nx_graph + + self._layout = _determine_graph_layout( + nx_graph, + layout=layout, + layout_scale=layout_scale, + layout_config=layout_config, + partitions=partitions, + root_vertex=root_vertex, + ) + + if isinstance(labels, dict): + self._labels = labels + elif isinstance(labels, bool): + if labels: + self._labels = { + v: OpenGLMathTex(v, fill_color=label_fill_color) for v in vertices + } + else: + self._labels = {} + + if self._labels and vertex_type is OpenGLDot: + vertex_type = OpenGLLabeledDot + + # build vertex_config + if vertex_config is None: + vertex_config = {} + default_vertex_config = {} + if vertex_config: + default_vertex_config = { + k: v for k, v in vertex_config.items() if k not in vertices + } + self._vertex_config = { + v: vertex_config.get(v, copy(default_vertex_config)) for v in vertices + } + for v, label in self._labels.items(): + self._vertex_config[v]["label"] = label + + self.vertices = {v: vertex_type(**self._vertex_config[v]) for v in vertices} + for v in self.vertices: + self[v].move_to(self._layout[v]) + + # build edge_config + if edge_config is None: + edge_config = {} + default_edge_config = {} + if edge_config: + default_edge_config = { + k: v + for k, v in edge_config.items() + if k not in edges and k[::-1] not in edges + } + self._edge_config = {} + for e in edges: + if e in edge_config: + self._edge_config[e] = edge_config[e] + elif e[::-1] in edge_config: + self._edge_config[e] = edge_config[e[::-1]] + else: + self._edge_config[e] = copy(default_edge_config) + + if offset_edges_by_radius: + for vertex in self.vertices.values(): + assert hasattr( + vertex, "radius" + ), f"Passed offset_edges_by_radius, but {str(vertex)} has no radius attribute." + + self.edges = {} + for (u, v) in edges: + u_v_vector = normalize(self[v].get_center() - self[u].get_center()) + self.edges[(u, v)] = edge_type( + self[u].get_center() + u_v_vector * self[u].radius, + self[v].get_center() - u_v_vector * self[v].radius, + z_index=-1, + **self._edge_config[(u, v)], + ) + + self.add(*self.vertices.values()) + self.add(*self.edges.values()) + + def update_edges(graph): + for (u, v), edge in graph.edges.items(): + u_v_vector = normalize(self[v].get_center() - self[u].get_center()) + edge.put_start_and_end_on( + graph[u].get_center() + u_v_vector * self[u].radius, + graph[v].get_center() - u_v_vector * self[v].radius, + ) + + self.add_updater(update_edges) + + def __getitem__(self: "Graph", v: Hashable) -> "Mobject": + return self.vertices[v] + + def __repr__(self: "Graph") -> str: + return f"Graph on {len(self.vertices)} vertices and {len(self.edges)} edges" + + @staticmethod + def from_networkx(nxgraph: nx.classes.graph.Graph, **kwargs) -> "Graph": + """Build a :class:`~.Graph` from a given ``networkx`` graph. + + Parameters + ---------- + + nxgraph + A ``networkx`` graph. + **kwargs + Keywords to be passed to the constructor of :class:`~.Graph`. + + Examples + -------- + + .. manim:: ImportNetworkxGraph + + import networkx as nx + + nxgraph = nx.erdos_renyi_graph(14, 0.5) + + class ImportNetworkxGraph(Scene): + def construct(self): + G = Graph.from_networkx(nxgraph, layout="spring", layout_scale=3.5) + self.play(Create(G)) + self.play(*[G[v].animate.move_to(5*RIGHT*np.cos(ind/7 * PI) + + 3*UP*np.sin(ind/7 * PI)) + for ind, v in enumerate(G.vertices)]) + self.play(Uncreate(G)) + + """ + return OpenGLGraph(list(nxgraph.nodes), list(nxgraph.edges), **kwargs) + + def change_layout( + self, + layout: Union[str, dict] = "spring", + layout_scale: float = 2, + layout_config: Union[dict, None] = None, + partitions: Union[List[List[Hashable]], None] = None, + root_vertex: Union[Hashable, None] = None, + ) -> "Graph": + """Change the layout of this graph. + + See the documentation of :class:`~.Graph` for details about the + keyword arguments. + + Examples + -------- + + .. manim:: ChangeGraphLayout + + class ChangeGraphLayout(Scene): + def construct(self): + G = Graph([1, 2, 3, 4, 5], [(1, 2), (2, 3), (3, 4), (4, 5)], + layout={1: [-2, 0, 0], 2: [-1, 0, 0], 3: [0, 0, 0], + 4: [1, 0, 0], 5: [2, 0, 0]} + ) + self.play(Create(G)) + self.play(G.animate.change_layout("circular")) + self.wait() + """ + self._layout = _determine_graph_layout( + self._graph, + layout=layout, + layout_scale=layout_scale, + layout_config=layout_config, + partitions=partitions, + root_vertex=root_vertex, + ) + for v in self.vertices: + self[v].move_to(self._layout[v]) + return self diff --git a/manim/opengl/__init__.py b/manim/opengl/__init__.py index 2f53ed3fe3..778d52a3e3 100644 --- a/manim/opengl/__init__.py +++ b/manim/opengl/__init__.py @@ -6,3 +6,4 @@ from ..mobject.svg.opengl_svg_mobject import * from ..mobject.svg.opengl_tex_mobject import * from ..mobject.svg.opengl_text_mobject import * +from ..mobject.opengl_graph import *