"""Mobjects generated from an SVG pathstring."""
from __future__ import annotations
__all__ = ["SVGPathMobject", "string_to_numbers"]
import re
from math import *
import numpy as np
from manim.mobject.opengl.opengl_compatibility import ConvertToOpenGL
from ... import config
from ...constants import *
from ..types.vectorized_mobject import VMobject
[docs]def correct_out_of_range_radii(rx, ry, x1p, y1p):
"""Correction of out-of-range radii.
See: https://www.w3.org/TR/SVG11/implnote.html#ArcCorrectionOutOfRangeRadii
"""
# Step 1: Ensure radii are non-zero (taken care of in elliptical_arc_to_cubic_bezier).
# Step 2: Ensure radii are positive. If rx or ry have negative signs, these are dropped;
# the absolute value is used instead.
rx = abs(rx)
ry = abs(ry)
# Step 3: Ensure radii are large enough.
Lambda = (x1p * x1p) / (rx * rx) + (y1p * y1p) / (ry * ry)
if Lambda > 1:
rx = sqrt(Lambda) * rx
ry = sqrt(Lambda) * ry
# Step 4: Proceed with computations.
return rx, ry
[docs]def vector_angle(ux, uy, vx, vy):
"""Calculate the dot product angle between two vectors.
This clamps the argument to the arc cosine due to roundoff errors
from some SVG files.
"""
sign = -1 if ux * vy - uy * vx < 0 else 1
ua = sqrt(ux * ux + uy * uy)
va = sqrt(vx * vx + vy * vy)
dot = ux * vx + uy * vy
# Clamp argument between [-1,1].
return sign * acos(max(min(dot / (ua * va), 1), -1))
[docs]def get_elliptical_arc_center_parameters(x1, y1, rx, ry, phi, fA, fS, x2, y2):
"""Conversion from endpoint to center parameterization.
See: https://www.w3.org/TR/SVG11/implnote.html#ArcConversionEndpointToCenter
"""
cos_phi = cos(phi)
sin_phi = sin(phi)
# Step 1: Compute (x1p,y1p).
x = (x1 - x2) / 2
y = (y1 - y2) / 2
x1p = x * cos_phi + y * sin_phi
y1p = -x * sin_phi + y * cos_phi
# Correct out of range radii
rx, ry = correct_out_of_range_radii(rx, ry, x1p, y1p)
# Step 2: Compute (cxp,cyp).
rx2 = rx * rx
ry2 = ry * ry
x1p2 = x1p * x1p
y1p2 = y1p * y1p
k = sqrt(max((rx2 * ry2 - rx2 * y1p2 - ry2 * x1p2) / (rx2 * y1p2 + ry2 * x1p2), 0))
sign = -1 if fA == fS else 1
cxp = sign * k * (rx * y1p) / ry
cyp = sign * k * (-ry * x1p) / rx
# Step 3: Compute (cx,cy) from (cxp,cyp).
x = (x1 + x2) / 2
y = (y1 + y2) / 2
cx = cxp * cos_phi - cyp * sin_phi + x
cy = cxp * sin_phi + cyp * cos_phi + y
# Step 4: Compute theta1 and dtheta.
x = (x1p - cxp) / rx
y = (y1p - cyp) / ry
theta1 = vector_angle(1, 0, x, y)
x_ = (-x1p - cxp) / rx
y_ = (-y1p - cyp) / ry
dtheta = degrees(vector_angle(x, y, x_, y_)) % 360
if fS == 0 and dtheta > 0:
dtheta -= 360
elif fS == 1 and dtheta < 0:
dtheta += 360
return cx, cy, theta1, radians(dtheta)
[docs]def elliptical_arc_to_cubic_bezier(x1, y1, rx, ry, phi, fA, fS, x2, y2):
"""Generate cubic bezier points to approximate SVG elliptical arc.
See: http://www.w3.org/TR/SVG11/implnote.html#ArcImplementationNotes
"""
# Out of range parameters
# See: https://www.w3.org/TR/SVG11/implnote.html#ArcOutOfRangeParameters
# If rx or ry are 0 then this arc is treated as a
# straight line segment (a "lineto") joining the endpoints.
if not rx or not ry:
return [x1, y1, x2, y2, x2, y2]
# phi is taken mod 360 degrees and set to radians for subsequent calculations.
phi = radians(phi % 360)
# Any nonzero value for either of the flags fA or fS is taken to mean the value 1.
fA = 1 if fA else 0
fS = 1 if fS else 0
# Convert from endpoint to center parameterization.
cx, cy, theta1, dtheta = get_elliptical_arc_center_parameters(
x1,
y1,
rx,
ry,
phi,
fA,
fS,
x2,
y2,
)
# For a given arc we should "chop" it up into segments if it is too big
# to help miminze cubic bezier curve approximation errors.
# If dtheta is a multiple of 90 degrees, set the limit to 90 degrees,
# otherwise 360/10=36 degrees is a decent sweep limit.
if degrees(dtheta) % 90 == 0:
sweep_limit = 90
else:
sweep_limit = 36
segments = int(ceil(abs(degrees(dtheta)) / sweep_limit))
segment = dtheta / float(segments)
current_angle = theta1
start_x = x1
start_y = y1
cos_phi = cos(phi)
sin_phi = sin(phi)
alpha = sin(segment) * (sqrt(4 + 3 * pow(tan(segment / 2.0), 2)) - 1) / 3.0
bezier_points = []
# Calculate the cubic bezier points from elliptical arc parametric equations.
# See: (the box on page 18) http://www.spaceroots.org/documents/ellipse/elliptical-arc.pdf
for idx in range(segments):
next_angle = current_angle + segment
cos_start = cos(current_angle)
sin_start = sin(current_angle)
e1x = -rx * cos_phi * sin_start - ry * sin_phi * cos_start
e1y = -rx * sin_phi * sin_start + ry * cos_phi * cos_start
q1_x = start_x + alpha * e1x
q1_y = start_y + alpha * e1y
cos_end = cos(next_angle)
sin_end = sin(next_angle)
p2x = cx + rx * cos_phi * cos_end - ry * sin_phi * sin_end
p2y = cy + rx * sin_phi * cos_end + ry * cos_phi * sin_end
end_x = p2x
end_y = p2y
if idx == segments - 1:
end_x = x2
end_y = y2
e2x = -rx * cos_phi * sin_end - ry * sin_phi * cos_end
e2y = -rx * sin_phi * sin_end + ry * cos_phi * cos_end
q2_x = end_x - alpha * e2x
q2_y = end_y - alpha * e2y
bezier_points += [[q1_x, q1_y, 0], [q2_x, q2_y, 0], [end_x, end_y, 0]]
start_x = end_x
start_y = end_y
current_angle = next_angle
return bezier_points
[docs]def string_to_numbers(num_string: str) -> list[float]:
"""Parse the SVG string representing a sequence of numbers into an array of floats.
Parameters
----------
num_string : :class:`str`
String representing a sequence of numbers, separated by commas, spaces, etc.
Returns
-------
list(float)
List of float values parsed out of the string.
"""
num_string = num_string.replace("-", ",-")
num_string = num_string.replace("e,-", "e-")
float_results = []
for s in re.split("[ ,]", num_string):
if s != "":
try:
float_results.append(float(s))
except ValueError:
# in this case, it's something like "2.4.3.14 which should be parsed as "2.4 0.3 0.14"
undotted_parts = s.split(".")
float_results.append(float(undotted_parts[0] + "." + undotted_parts[1]))
float_results += [float("." + u) for u in undotted_parts[2:]]
return float_results
[docs]def grouped(iterable, n):
"""Group iterable into arrays of n items."""
return (np.array(v) for v in zip(*[iter(iterable)] * n))
[docs]class SVGPathMobject(VMobject, metaclass=ConvertToOpenGL):
def __init__(self, path_string, **kwargs):
self.path_string = path_string
if config.renderer == "opengl":
kwargs["long_lines"] = True
super().__init__(**kwargs)
self.current_path_start = np.zeros((1, self.dim))
[docs] def get_path_commands(self):
"""Returns a list of possible path commands used within an SVG ``d``
attribute.
See: https://svgwg.org/svg2-draft/paths.html#DProperty for further
details on what each path command does.
Returns
-------
List[:class:`str`]
The various upper and lower cased path commands.
"""
result = [
"M", # moveto
"L", # lineto
"H", # horizontal lineto
"V", # vertical lineto
"C", # curveto
"S", # smooth curveto
"Q", # quadratic Bezier curve
"T", # smooth quadratic Bezier curveto
"A", # elliptical Arc
"Z", # closepath
]
result += [s.lower() for s in result]
return result
[docs] def generate_points(self):
"""Generates points from a given an SVG ``d`` attribute."""
pattern = "[%s]" % ("".join(self.get_path_commands()))
pairs = list(
zip(
re.findall(pattern, self.path_string),
re.split(pattern, self.path_string)[1:],
),
)
# Which mobject should new points be added to
prev_command = None
for command, coord_string in pairs:
self.handle_command(command, coord_string, prev_command)
prev_command = command
if config["renderer"] == "opengl":
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)
init_points = generate_points
[docs] def handle_command(self, command, coord_string, prev_command):
"""Core logic for handling each of the various path commands."""
# Relative SVG commands are specified as lowercase letters
is_relative = command.islower()
command = command.upper()
# Keep track of the most recently completed point
if config["renderer"] == "opengl":
points = self.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(
command,
is_relative,
coord_string,
start_point,
)
if command == "M": # moveto
self.start_new_path(new_points[0])
for p in new_points[1:]:
self.add_line_to(p)
return
elif command in ["H", "V", "L"]: # lineto of any kind
for p in new_points:
self.add_line_to(p)
return
elif command == "C": # Cubic
# points must be added in groups of 3.
for i in range(0, len(new_points), 3):
self.add_cubic_bezier_curve_to(*new_points[i : i + 3])
return
elif command == "S": # Smooth cubic
if config["renderer"] == "opengl":
points = self.points
else:
points = self.points
prev_handle = start_point
if prev_command.upper() in ["C", "S"]:
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(
new_handle,
new_points[i],
new_points[i + 1],
)
start_point = new_points[i + 1]
prev_handle = new_points[i]
return
elif command == "Q": # quadratic Bezier curve
for i in range(0, len(new_points), 2):
self.add_quadratic_bezier_curve_to(new_points[i], new_points[i + 1])
return
elif command == "T": # smooth quadratic
prev_quad_handle = start_point
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 * 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)
start_point = p
prev_quad_handle = new_quad_handle
elif command == "A": # elliptical Arc
# points must be added in groups of 3. See `string_to_points` for
# case that new_points can be None.
if new_points is not None:
for i in range(0, len(new_points), 3):
self.add_cubic_bezier_curve_to(*new_points[i : i + 3])
return
elif command == "Z": # closepath
if config["renderer"] == "opengl":
self.close_path()
else:
self.add_line_to(self.current_path_start)
return
[docs] def string_to_points(self, command, is_relative, coord_string, start_point):
"""Convert an SVG command string into a sequence of absolute-positioned control points.
Parameters
-----
command : `str`
A string containing a single uppercase letter representing the SVG command.
is_relative : `bool`
Whether the command is relative to the end of the previous command
coord_string : `str`
A string that contains many comma- or space-separated numbers that defined the control points. Different
commands require different numbers of numbers as arguments.
start_point : `ndarray`
If the command is relative, the position to begin the relations from.
"""
# this call to "string to numbers" where problems like parsing 0.5.6 lie
numbers = string_to_numbers(coord_string)
# arcs are weirdest, handle them first.
if command == "A":
result = np.zeros((0, self.dim))
last_end_point = None
for elliptic_numbers in grouped(numbers, 7):
# The startpoint changes with each iteration.
if last_end_point is not None:
start_point = last_end_point
# We have to handle offsets here because ellipses are complicated.
if is_relative:
elliptic_numbers[5] += start_point[0]
elliptic_numbers[6] += start_point[1]
# If the endpoints (x1, y1) and (x2, y2) are identical, then this
# is equivalent to omitting the elliptical arc segment entirely.
# for more information of where this math came from visit:
# http://www.w3.org/TR/SVG11/implnote.html#ArcImplementationNotes
if (
start_point[0] == elliptic_numbers[5]
and start_point[1] == elliptic_numbers[6]
):
continue
result = np.append(
result,
elliptical_arc_to_cubic_bezier(*start_point[:2], *elliptic_numbers),
axis=0,
)
# We store the endpoint so that it can be the startpoint for the
# next iteration.
last_end_point = elliptic_numbers[5:]
return result
# H and V expect a sequence of single coords, not coord pairs like the rest of the commands.
elif command == "H":
result = np.zeros((len(numbers), self.dim))
result[:, 0] = numbers
if not is_relative:
result[:, 1] = start_point[1]
elif command == "V":
result = np.zeros((len(numbers), self.dim))
result[:, 1] = numbers
if not is_relative:
result[:, 0] = start_point[0]
else:
num_points = len(numbers) // 2
result = np.zeros((num_points, self.dim))
result[:, :2] = np.array(numbers).reshape((num_points, 2))
# If it's not relative, we don't have any more work!
if not is_relative:
return result
# Each control / target point is calculated relative to the ending position of the previous curve.
# Curves consist of multiple point listings depending on the command.
entries = 1
# Quadratic curves expect pairs, S expects 3 (cubic) but one is implied by smoothness
if command in ["Q", "S"]:
entries = 2
# Only cubic curves expect three points.
elif command == "C":
entries = 3
offset = start_point
for i in range(result.shape[0]):
result[i, :] = result[i, :] + offset
if (i + 1) % entries == 0:
offset = result[i, :]
return result
[docs] def get_original_path_string(self):
"""A simple getter for the path's ``d`` attribute."""
return self.path_string
def start_new_path(self, point):
self.current_path_start = point
super().start_new_path(point)
return self