2022-02-14 22:55:41 +08:00
|
|
|
from __future__ import annotations
|
|
|
|
|
2020-02-18 22:31:29 -08:00
|
|
|
import os
|
|
|
|
import hashlib
|
2022-02-11 23:53:21 +08:00
|
|
|
import itertools as it
|
2022-02-14 22:55:41 +08:00
|
|
|
from typing import Callable
|
2022-02-15 20:16:15 +08:00
|
|
|
from xml.etree import ElementTree as ET
|
2018-03-31 15:11:35 -07:00
|
|
|
|
2022-02-11 23:53:21 +08:00
|
|
|
import svgelements as se
|
|
|
|
import numpy as np
|
2020-02-18 22:31:29 -08:00
|
|
|
|
2022-02-11 23:53:21 +08:00
|
|
|
from manimlib.constants import RIGHT
|
2022-01-27 17:23:58 +08:00
|
|
|
from manimlib.mobject.geometry import Line
|
2018-12-24 12:37:51 -08:00
|
|
|
from manimlib.mobject.geometry import Circle
|
2022-02-11 23:53:21 +08:00
|
|
|
from manimlib.mobject.geometry import Polygon
|
|
|
|
from manimlib.mobject.geometry import Polyline
|
2018-12-24 12:37:51 -08:00
|
|
|
from manimlib.mobject.geometry import Rectangle
|
|
|
|
from manimlib.mobject.geometry import RoundedRectangle
|
|
|
|
from manimlib.mobject.types.vectorized_mobject import VMobject
|
|
|
|
from manimlib.utils.config_ops import digest_config
|
2021-01-02 20:47:51 -08:00
|
|
|
from manimlib.utils.directories import get_mobject_data_dir
|
|
|
|
from manimlib.utils.images import get_full_vector_image_path
|
2022-02-15 21:38:22 +08:00
|
|
|
from manimlib.utils.iterables import hash_obj
|
2022-01-25 14:09:05 +08:00
|
|
|
from manimlib.logger import log
|
2016-04-17 00:31:38 -07:00
|
|
|
|
2018-04-06 13:58:59 -07:00
|
|
|
|
2022-02-16 11:46:55 +08:00
|
|
|
SVG_HASH_TO_MOB_MAP: dict[int, VMobject] = {}
|
2022-02-15 20:16:15 +08:00
|
|
|
|
|
|
|
|
2022-02-14 22:55:41 +08:00
|
|
|
def _convert_point_to_3d(x: float, y: float) -> np.ndarray:
|
2022-02-11 23:53:21 +08:00
|
|
|
return np.array([x, y, 0.0])
|
2022-01-26 13:53:53 +08:00
|
|
|
|
|
|
|
|
2016-04-17 00:31:38 -07:00
|
|
|
class SVGMobject(VMobject):
|
2016-04-23 23:36:05 -07:00
|
|
|
CONFIG = {
|
2018-04-06 13:58:59 -07:00
|
|
|
"should_center": True,
|
|
|
|
"height": 2,
|
|
|
|
"width": None,
|
|
|
|
"file_name": None,
|
2022-02-15 20:16:15 +08:00
|
|
|
# Style that overrides the original svg
|
2022-02-11 23:53:21 +08:00
|
|
|
"color": None,
|
|
|
|
"opacity": None,
|
|
|
|
"fill_color": None,
|
|
|
|
"fill_opacity": None,
|
|
|
|
"stroke_width": None,
|
|
|
|
"stroke_color": None,
|
|
|
|
"stroke_opacity": None,
|
2022-02-15 20:16:15 +08:00
|
|
|
# Style that fills only when not specified
|
|
|
|
# If None, regarded as default values from svg standard
|
|
|
|
"svg_default": {
|
|
|
|
"color": None,
|
|
|
|
"opacity": None,
|
|
|
|
"fill_color": None,
|
|
|
|
"fill_opacity": None,
|
|
|
|
"stroke_width": None,
|
|
|
|
"stroke_color": None,
|
|
|
|
"stroke_opacity": None,
|
|
|
|
},
|
|
|
|
"path_string_config": {},
|
2016-04-23 23:36:05 -07:00
|
|
|
}
|
2022-02-14 22:55:41 +08:00
|
|
|
def __init__(self, file_name: str | None = None, **kwargs):
|
2021-01-02 20:47:51 -08:00
|
|
|
super().__init__(**kwargs)
|
2022-02-15 20:16:15 +08:00
|
|
|
self.file_name = file_name or self.file_name
|
|
|
|
self.init_svg_mobject()
|
|
|
|
self.init_colors()
|
2021-01-02 20:47:51 -08:00
|
|
|
self.move_into_position()
|
2016-07-12 10:34:35 -07:00
|
|
|
|
2022-02-16 11:46:55 +08:00
|
|
|
def init_svg_mobject(self) -> None:
|
2022-02-15 21:38:22 +08:00
|
|
|
hash_val = hash_obj(self.hash_seed)
|
2022-02-15 20:16:15 +08:00
|
|
|
if hash_val in SVG_HASH_TO_MOB_MAP:
|
|
|
|
mob = SVG_HASH_TO_MOB_MAP[hash_val].copy()
|
|
|
|
self.add(*mob)
|
|
|
|
return
|
|
|
|
|
|
|
|
self.generate_mobject()
|
|
|
|
SVG_HASH_TO_MOB_MAP[hash_val] = self.copy()
|
|
|
|
|
|
|
|
@property
|
2022-02-16 11:46:55 +08:00
|
|
|
def hash_seed(self) -> tuple[str, dict[str], dict[str, bool], str]:
|
2022-02-15 20:16:15 +08:00
|
|
|
# Returns data which can uniquely represent the result of `init_points`.
|
|
|
|
# The hashed value of it is stored as a key in `SVG_HASH_TO_MOB_MAP`.
|
|
|
|
return (
|
|
|
|
self.__class__.__name__,
|
|
|
|
self.svg_default,
|
|
|
|
self.path_string_config,
|
|
|
|
self.file_name
|
|
|
|
)
|
2018-01-28 15:02:57 +01:00
|
|
|
|
2022-02-16 11:46:55 +08:00
|
|
|
def generate_mobject(self) -> None:
|
2022-02-15 20:16:15 +08:00
|
|
|
file_path = self.get_file_path()
|
|
|
|
element_tree = ET.parse(file_path)
|
|
|
|
new_tree = self.modify_xml_tree(element_tree)
|
2022-02-11 23:53:21 +08:00
|
|
|
# Create a temporary svg file to dump modified svg to be parsed
|
2022-02-15 20:16:15 +08:00
|
|
|
modified_file_path = file_path.replace(".svg", "_.svg")
|
|
|
|
new_tree.write(modified_file_path)
|
|
|
|
|
|
|
|
svg = se.SVG.parse(modified_file_path)
|
2022-02-11 23:53:21 +08:00
|
|
|
os.remove(modified_file_path)
|
|
|
|
|
2022-02-15 20:16:15 +08:00
|
|
|
mobjects = self.get_mobjects_from(svg)
|
2022-02-11 23:53:21 +08:00
|
|
|
self.add(*mobjects)
|
|
|
|
self.flip(RIGHT) # Flip y
|
|
|
|
|
2022-02-16 11:46:55 +08:00
|
|
|
def get_file_path(self) -> str:
|
2022-02-15 20:16:15 +08:00
|
|
|
if self.file_name is None:
|
|
|
|
raise Exception("Must specify file for SVGMobject")
|
|
|
|
return get_full_vector_image_path(self.file_name)
|
|
|
|
|
2022-02-16 11:46:55 +08:00
|
|
|
def modify_xml_tree(self, element_tree: ET.ElementTree) -> ET.ElementTree:
|
2022-02-15 20:16:15 +08:00
|
|
|
config_style_dict = self.generate_config_style_dict()
|
|
|
|
style_keys = (
|
|
|
|
"fill",
|
|
|
|
"fill-opacity",
|
|
|
|
"stroke",
|
|
|
|
"stroke-opacity",
|
|
|
|
"stroke-width",
|
|
|
|
"style"
|
|
|
|
)
|
|
|
|
root = element_tree.getroot()
|
|
|
|
root_style_dict = {
|
|
|
|
k: v for k, v in root.attrib.items()
|
|
|
|
if k in style_keys
|
|
|
|
}
|
2018-01-28 14:55:17 +01:00
|
|
|
|
2022-02-15 20:16:15 +08:00
|
|
|
new_root = ET.Element("svg", {})
|
|
|
|
config_style_node = ET.SubElement(new_root, "g", config_style_dict)
|
|
|
|
root_style_node = ET.SubElement(config_style_node, "g", root_style_dict)
|
|
|
|
root_style_node.extend(root)
|
|
|
|
return ET.ElementTree(new_root)
|
|
|
|
|
2022-02-16 11:46:55 +08:00
|
|
|
def generate_config_style_dict(self) -> dict[str, str]:
|
2022-02-15 20:16:15 +08:00
|
|
|
keys_converting_dict = {
|
|
|
|
"fill": ("color", "fill_color"),
|
|
|
|
"fill-opacity": ("opacity", "fill_opacity"),
|
|
|
|
"stroke": ("color", "stroke_color"),
|
|
|
|
"stroke-opacity": ("opacity", "stroke_opacity"),
|
|
|
|
"stroke-width": ("stroke_width",)
|
|
|
|
}
|
|
|
|
svg_default_dict = self.svg_default
|
2022-02-11 23:53:21 +08:00
|
|
|
result = {}
|
2022-02-15 20:16:15 +08:00
|
|
|
for svg_key, style_keys in keys_converting_dict.items():
|
|
|
|
for style_key in style_keys:
|
|
|
|
if svg_default_dict[style_key] is None:
|
|
|
|
continue
|
|
|
|
result[svg_key] = str(svg_default_dict[style_key])
|
2022-02-11 23:53:21 +08:00
|
|
|
return result
|
2018-04-12 18:33:16 +02:00
|
|
|
|
2022-02-16 11:46:55 +08:00
|
|
|
def get_mobjects_from(self, svg: se.SVG) -> list[VMobject]:
|
2022-02-15 20:16:15 +08:00
|
|
|
result = []
|
|
|
|
for shape in svg.elements():
|
|
|
|
if isinstance(shape, se.Group):
|
|
|
|
continue
|
|
|
|
mob = self.get_mobject_from(shape)
|
|
|
|
if mob is None:
|
|
|
|
continue
|
|
|
|
if isinstance(shape, se.Transformable) and shape.apply:
|
|
|
|
self.handle_transform(mob, shape.transform)
|
|
|
|
result.append(mob)
|
|
|
|
return result
|
2022-02-11 23:53:21 +08:00
|
|
|
|
|
|
|
@staticmethod
|
2022-02-14 22:55:41 +08:00
|
|
|
def handle_transform(mob: VMobject, matrix: se.Matrix) -> VMobject:
|
2022-02-11 23:53:21 +08:00
|
|
|
mat = np.array([
|
|
|
|
[matrix.a, matrix.c],
|
|
|
|
[matrix.b, matrix.d]
|
|
|
|
])
|
|
|
|
vec = np.array([matrix.e, matrix.f, 0.0])
|
|
|
|
mob.apply_matrix(mat)
|
|
|
|
mob.shift(vec)
|
|
|
|
return mob
|
2018-04-12 18:33:16 +02:00
|
|
|
|
2022-02-14 22:55:41 +08:00
|
|
|
def get_mobject_from(self, shape: se.Shape | se.Text) -> VMobject | None:
|
|
|
|
shape_class_to_func_map: dict[
|
|
|
|
type, Callable[[se.Shape | se.Text], VMobject]
|
|
|
|
] = {
|
2022-02-11 23:53:21 +08:00
|
|
|
se.Path: self.path_to_mobject,
|
|
|
|
se.SimpleLine: self.line_to_mobject,
|
|
|
|
se.Rect: self.rect_to_mobject,
|
|
|
|
se.Circle: self.circle_to_mobject,
|
|
|
|
se.Ellipse: self.ellipse_to_mobject,
|
|
|
|
se.Polygon: self.polygon_to_mobject,
|
|
|
|
se.Polyline: self.polyline_to_mobject,
|
|
|
|
# se.Text: self.text_to_mobject, # TODO
|
|
|
|
}
|
|
|
|
for shape_class, func in shape_class_to_func_map.items():
|
|
|
|
if isinstance(shape, shape_class):
|
|
|
|
mob = func(shape)
|
|
|
|
self.apply_style_to_mobject(mob, shape)
|
|
|
|
return mob
|
|
|
|
|
|
|
|
shape_class_name = shape.__class__.__name__
|
|
|
|
if shape_class_name != "SVGElement":
|
|
|
|
log.warning(f"Unsupported element type: {shape_class_name}")
|
|
|
|
return None
|
|
|
|
|
|
|
|
@staticmethod
|
2022-02-14 22:55:41 +08:00
|
|
|
def apply_style_to_mobject(
|
|
|
|
mob: VMobject,
|
|
|
|
shape: se.Shape | se.Text
|
|
|
|
) -> VMobject:
|
2022-02-11 23:53:21 +08:00
|
|
|
mob.set_style(
|
|
|
|
stroke_width=shape.stroke_width,
|
|
|
|
stroke_color=shape.stroke.hex,
|
|
|
|
stroke_opacity=shape.stroke.opacity,
|
|
|
|
fill_color=shape.fill.hex,
|
|
|
|
fill_opacity=shape.fill.opacity
|
|
|
|
)
|
|
|
|
return mob
|
2018-04-12 23:19:09 +02:00
|
|
|
|
2022-02-14 22:55:41 +08:00
|
|
|
def path_to_mobject(self, path: se.Path) -> VMobjectFromSVGPath:
|
2022-02-11 23:53:21 +08:00
|
|
|
return VMobjectFromSVGPath(path, **self.path_string_config)
|
2018-04-12 23:19:09 +02:00
|
|
|
|
2022-02-14 22:55:41 +08:00
|
|
|
def line_to_mobject(self, line: se.Line) -> Line:
|
2022-02-11 23:53:21 +08:00
|
|
|
return Line(
|
|
|
|
start=_convert_point_to_3d(line.x1, line.y1),
|
|
|
|
end=_convert_point_to_3d(line.x2, line.y2)
|
|
|
|
)
|
2022-01-26 13:53:53 +08:00
|
|
|
|
2022-02-14 22:55:41 +08:00
|
|
|
def rect_to_mobject(self, rect: se.Rect) -> Rectangle | RoundedRectangle:
|
2022-02-11 23:53:21 +08:00
|
|
|
if rect.rx == 0 or rect.ry == 0:
|
2018-04-12 23:19:09 +02:00
|
|
|
mob = Rectangle(
|
2022-02-11 23:53:21 +08:00
|
|
|
width=rect.width,
|
|
|
|
height=rect.height,
|
2018-04-12 23:19:09 +02:00
|
|
|
)
|
|
|
|
else:
|
|
|
|
mob = RoundedRectangle(
|
2022-02-11 23:53:21 +08:00
|
|
|
width=rect.width,
|
|
|
|
height=rect.height * rect.rx / rect.ry,
|
|
|
|
corner_radius=rect.rx
|
2018-04-12 23:19:09 +02:00
|
|
|
)
|
2022-02-11 23:53:21 +08:00
|
|
|
mob.stretch_to_fit_height(rect.height)
|
|
|
|
mob.shift(_convert_point_to_3d(
|
|
|
|
rect.x + rect.width / 2,
|
|
|
|
rect.y + rect.height / 2
|
|
|
|
))
|
|
|
|
return mob
|
2018-04-12 23:19:09 +02:00
|
|
|
|
2022-02-14 22:55:41 +08:00
|
|
|
def circle_to_mobject(self, circle: se.Circle) -> Circle:
|
2022-02-11 23:53:21 +08:00
|
|
|
# svgelements supports `rx` & `ry` but `r`
|
|
|
|
mob = Circle(radius=circle.rx)
|
|
|
|
mob.shift(_convert_point_to_3d(
|
|
|
|
circle.cx, circle.cy
|
|
|
|
))
|
2016-04-17 00:31:38 -07:00
|
|
|
return mob
|
|
|
|
|
2022-02-14 22:55:41 +08:00
|
|
|
def ellipse_to_mobject(self, ellipse: se.Ellipse) -> Circle:
|
2022-02-11 23:53:21 +08:00
|
|
|
mob = Circle(radius=ellipse.rx)
|
|
|
|
mob.stretch_to_fit_height(2 * ellipse.ry)
|
|
|
|
mob.shift(_convert_point_to_3d(
|
|
|
|
ellipse.cx, ellipse.cy
|
|
|
|
))
|
|
|
|
return mob
|
|
|
|
|
2022-02-14 22:55:41 +08:00
|
|
|
def polygon_to_mobject(self, polygon: se.Polygon) -> Polygon:
|
2022-02-11 23:53:21 +08:00
|
|
|
points = [
|
|
|
|
_convert_point_to_3d(*point)
|
|
|
|
for point in polygon
|
2022-01-25 14:04:35 +08:00
|
|
|
]
|
2022-02-11 23:53:21 +08:00
|
|
|
return Polygon(*points)
|
|
|
|
|
2022-02-14 22:55:41 +08:00
|
|
|
def polyline_to_mobject(self, polyline: se.Polyline) -> Polyline:
|
2022-02-11 23:53:21 +08:00
|
|
|
points = [
|
|
|
|
_convert_point_to_3d(*point)
|
|
|
|
for point in polyline
|
|
|
|
]
|
|
|
|
return Polyline(*points)
|
|
|
|
|
2022-02-14 22:55:41 +08:00
|
|
|
def text_to_mobject(self, text: se.Text):
|
2022-02-11 23:53:21 +08:00
|
|
|
pass
|
2016-04-17 00:31:38 -07:00
|
|
|
|
2022-02-16 11:46:55 +08:00
|
|
|
def move_into_position(self) -> None:
|
2022-02-15 20:16:15 +08:00
|
|
|
if self.should_center:
|
|
|
|
self.center()
|
|
|
|
if self.height is not None:
|
|
|
|
self.set_height(self.height)
|
|
|
|
if self.width is not None:
|
|
|
|
self.set_width(self.width)
|
|
|
|
|
2018-04-06 13:58:59 -07:00
|
|
|
|
2022-02-11 23:53:21 +08:00
|
|
|
class VMobjectFromSVGPath(VMobject):
|
2020-02-20 15:51:04 -08:00
|
|
|
CONFIG = {
|
2021-10-01 12:32:04 -07:00
|
|
|
"long_lines": False,
|
2020-02-20 16:49:56 -08:00
|
|
|
"should_subdivide_sharp_curves": False,
|
2020-06-23 14:26:02 -07:00
|
|
|
"should_remove_null_curves": False,
|
2020-02-20 15:51:04 -08:00
|
|
|
}
|
|
|
|
|
2022-02-14 22:55:41 +08:00
|
|
|
def __init__(self, path_obj: se.Path, **kwargs):
|
2022-02-11 23:53:21 +08:00
|
|
|
# Get rid of arcs
|
|
|
|
path_obj.approximate_arcs_with_quads()
|
|
|
|
self.path_obj = path_obj
|
2020-06-09 20:39:32 -07:00
|
|
|
super().__init__(**kwargs)
|
2016-04-17 00:31:38 -07:00
|
|
|
|
2022-02-14 22:55:41 +08:00
|
|
|
def init_points(self) -> None:
|
2021-01-30 17:51:14 -08:00
|
|
|
# After a given svg_path has been converted into points, the result
|
2021-01-30 22:51:15 -08:00
|
|
|
# will be saved to a file so that future calls for the same path
|
|
|
|
# don't need to retrace the same computation.
|
2022-02-11 23:53:21 +08:00
|
|
|
path_string = self.path_obj.d()
|
|
|
|
hasher = hashlib.sha256(path_string.encode())
|
2020-02-18 22:31:29 -08:00
|
|
|
path_hash = hasher.hexdigest()[:16]
|
2021-01-11 16:37:01 -10:00
|
|
|
points_filepath = os.path.join(get_mobject_data_dir(), f"{path_hash}_points.npy")
|
|
|
|
tris_filepath = os.path.join(get_mobject_data_dir(), f"{path_hash}_tris.npy")
|
2020-02-18 22:31:29 -08:00
|
|
|
|
2022-01-25 20:25:30 +08:00
|
|
|
if os.path.exists(points_filepath) and os.path.exists(tris_filepath):
|
2021-01-11 16:37:01 -10:00
|
|
|
self.set_points(np.load(points_filepath))
|
2021-08-19 09:19:02 -07:00
|
|
|
self.triangulation = np.load(tris_filepath)
|
|
|
|
self.needs_new_triangulation = False
|
2020-02-18 22:31:29 -08:00
|
|
|
else:
|
2021-10-24 22:30:18 +08:00
|
|
|
self.handle_commands()
|
2020-02-20 16:49:56 -08:00
|
|
|
if self.should_subdivide_sharp_curves:
|
2020-02-20 15:51:04 -08:00
|
|
|
# For a healthy triangulation later
|
|
|
|
self.subdivide_sharp_curves()
|
2020-06-23 14:26:02 -07:00
|
|
|
if self.should_remove_null_curves:
|
|
|
|
# Get rid of any null curves
|
2021-01-10 18:51:47 -08:00
|
|
|
self.set_points(self.get_points_without_null_curves())
|
2020-02-18 22:31:29 -08:00
|
|
|
# Save to a file for future use
|
2021-01-11 16:37:01 -10:00
|
|
|
np.save(points_filepath, self.get_points())
|
2021-08-19 09:19:02 -07:00
|
|
|
np.save(tris_filepath, self.get_triangulation())
|
2020-02-06 10:02:42 -08:00
|
|
|
|
2022-02-14 22:55:41 +08:00
|
|
|
def handle_commands(self) -> None:
|
2022-02-11 23:53:21 +08:00
|
|
|
segment_class_to_func_map = {
|
|
|
|
se.Move: (self.start_new_path, ("end",)),
|
|
|
|
se.Close: (self.close_path, ()),
|
|
|
|
se.Line: (self.add_line_to, ("end",)),
|
|
|
|
se.QuadraticBezier: (self.add_quadratic_bezier_curve_to, ("control", "end")),
|
|
|
|
se.CubicBezier: (self.add_cubic_bezier_curve_to, ("control1", "control2", "end"))
|
2020-02-06 10:02:42 -08:00
|
|
|
}
|
2022-02-11 23:53:21 +08:00
|
|
|
for segment in self.path_obj:
|
|
|
|
segment_class = segment.__class__
|
|
|
|
func, attr_names = segment_class_to_func_map[segment_class]
|
|
|
|
points = [
|
|
|
|
_convert_point_to_3d(*segment.__getattribute__(attr_name))
|
|
|
|
for attr_name in attr_names
|
|
|
|
]
|
2022-02-15 20:16:15 +08:00
|
|
|
func(*points)
|