summaryrefslogtreecommitdiffstats
path: root/share/extensions/inkex/paths.py
diff options
context:
space:
mode:
Diffstat (limited to 'share/extensions/inkex/paths.py')
-rw-r--r--share/extensions/inkex/paths.py2018
1 files changed, 2018 insertions, 0 deletions
diff --git a/share/extensions/inkex/paths.py b/share/extensions/inkex/paths.py
new file mode 100644
index 0000000..a0624d0
--- /dev/null
+++ b/share/extensions/inkex/paths.py
@@ -0,0 +1,2018 @@
+# coding=utf-8
+#
+# Copyright (C) 2018 Martin Owens <doctormo@gmail.com>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+"""
+functions for digesting paths
+"""
+from __future__ import annotations
+
+import re
+import copy
+import abc
+
+from math import atan2, cos, pi, sin, sqrt, acos, tan
+from typing import (
+ Any,
+ Type,
+ Dict,
+ Optional,
+ Union,
+ Tuple,
+ List,
+ Generator,
+ TypeVar,
+)
+from .transforms import (
+ Transform,
+ BoundingBox,
+ Vector2d,
+ cubic_extrema,
+ quadratic_extrema,
+)
+from .utils import classproperty, strargs
+
+
+Pathlike = TypeVar("Pathlike", bound="PathCommand")
+AbsolutePathlike = TypeVar("AbsolutePathlike", bound="AbsolutePathCommand")
+
+# All the names that get added to the inkex API itself.
+__all__ = (
+ "Path",
+ "CubicSuperPath",
+ "PathCommand",
+ "AbsolutePathCommand",
+ "RelativePathCommand",
+ # Path commands:
+ "Line",
+ "line",
+ "Move",
+ "move",
+ "ZoneClose",
+ "zoneClose",
+ "Horz",
+ "horz",
+ "Vert",
+ "vert",
+ "Curve",
+ "curve",
+ "Smooth",
+ "smooth",
+ "Quadratic",
+ "quadratic",
+ "TepidQuadratic",
+ "tepidQuadratic",
+ "Arc",
+ "arc",
+ # errors
+ "InvalidPath",
+)
+
+LEX_REX = re.compile(r"([MLHVCSQTAZmlhvcsqtaz])([^MLHVCSQTAZmlhvcsqtaz]*)")
+NONE = lambda obj: obj is not None
+
+
+class InvalidPath(ValueError):
+ """Raised when given an invalid path string"""
+
+
+class PathCommand(abc.ABC):
+ """
+ Base class of all path commands
+ """
+
+ # Number of arguments that follow this path commands letter
+ nargs = -1
+
+ @classproperty # From python 3.9 on, just combine @classmethod and @property
+ def name(cls): # pylint: disable=no-self-argument
+ """The full name of the segment (i.e. Line, Arc, etc)"""
+ return cls.__name__ # pylint: disable=no-member
+
+ @classproperty
+ def letter(cls): # pylint: disable=no-self-argument
+ """The single letter representation of this command (i.e. L, A, etc)"""
+ return cls.name[0]
+
+ @classproperty
+ def next_command(self):
+ """The implicit next command. This is for automatic chains where the next
+ command isn't given, just a bunch on numbers which we automatically parse."""
+ return self
+
+ @property
+ def is_relative(self): # type: () -> bool
+ """Whether the command is defined in relative coordinates, i.e. relative to
+ the previous endpoint (lower case path command letter)"""
+ raise NotImplementedError
+
+ @property
+ def is_absolute(self): # type: () -> bool
+ """Whether the command is defined in absolute coordinates (upper case path
+ command letter)"""
+ raise NotImplementedError
+
+ def to_relative(self, prev): # type: (Vector2d) -> RelativePathCommand
+ """Return absolute counterpart for absolute commands or copy for relative"""
+ raise NotImplementedError
+
+ def to_absolute(self, prev): # type: (Vector2d) -> AbsolutePathCommand
+ """Return relative counterpart for relative commands or copy for absolute"""
+ raise NotImplementedError
+
+ def reverse(self, first, prev):
+ """Reverse path command
+
+ .. versionadded:: 1.1"""
+
+ def to_non_shorthand(self, prev, prev_control): # pylint: disable=unused-argument
+ # type: (Vector2d, Vector2d) -> AbsolutePathCommand
+ """Return an absolute non-shorthand command
+
+ .. versionadded:: 1.1"""
+ return self.to_absolute(prev)
+
+ # The precision of the numbers when converting to string
+ number_template = "{:.6g}"
+
+ # Maps single letter path command to corresponding class
+ # (filled at the bottom of file, when all classes already defined)
+ _letter_to_class = {} # type: Dict[str, Type[Any]]
+
+ @staticmethod
+ def letter_to_class(letter):
+ """Returns class for given path command letter"""
+ return PathCommand._letter_to_class[letter]
+
+ @property
+ def args(self): # type: () -> List[float]
+ """Returns path command arguments as tuple of floats"""
+ raise NotImplementedError()
+
+ def control_points(
+ self, first: Vector2d, prev: Vector2d, prev_prev: Vector2d
+ ) -> Union[List[Vector2d], Generator[Vector2d, None, None]]:
+ """Returns list of path command control points"""
+ raise NotImplementedError
+
+ @classmethod
+ def _argt(cls, sep):
+ return sep.join([cls.number_template] * cls.nargs)
+
+ def __str__(self):
+ return f"{self.letter} {self._argt(' ').format(*self.args)}".strip()
+
+ def __repr__(self):
+ # pylint: disable=consider-using-f-string
+ return "{{}}({})".format(self._argt(", ")).format(self.name, *self.args)
+
+ def __eq__(self, other):
+ previous = Vector2d()
+ if type(self) == type(other): # pylint: disable=unidiomatic-typecheck
+ return self.args == other.args
+ if isinstance(other, tuple):
+ return self.args == other
+ if not isinstance(other, PathCommand):
+ raise ValueError("Can't compare types")
+ try:
+ if self.is_relative == other.is_relative:
+ return self.to_curve(previous) == other.to_curve(previous)
+ except ValueError:
+ pass
+ return False
+
+ def end_point(self, first, prev): # type: (Vector2d, Vector2d) -> Vector2d
+ """Returns last control point of path command"""
+ raise NotImplementedError()
+
+ def update_bounding_box(
+ self, first: Vector2d, last_two_points: List[Vector2d], bbox: BoundingBox
+ ):
+ # pylint: disable=unused-argument
+ """Enlarges given bbox to contain path element.
+
+ Args:
+ first (Vector2d): first point of path. Required to calculate Z segment
+ last_two_points (List[Vector2d]): list with last two control points in abs
+ coords.
+ bbox (BoundingBox): bounding box to update
+ """
+
+ raise NotImplementedError(f"Bounding box is not implemented for {self.name}")
+
+ def to_curve(self, prev, prev_prev=Vector2d()):
+ # type: (Vector2d, Vector2d) -> Curve
+ """Convert command to :py:class:`Curve`
+
+ Curve().to_curve() returns a copy
+ """
+ raise NotImplementedError(f"To curve not supported for {self.name}")
+
+ def to_curves(self, prev, prev_prev=Vector2d()):
+ # type: (Vector2d, Vector2d) -> List[Curve]
+ """Convert command to list of :py:class:`Curve` commands"""
+ return [self.to_curve(prev, prev_prev)]
+
+ def to_line(self, prev):
+ # type: (Vector2d) -> Line
+ """Converts this segment to a line (copies if already a line)"""
+ return Line(*self.end_point(Vector2d(), prev))
+
+
+class RelativePathCommand(PathCommand):
+ """
+ Abstract base class for relative path commands.
+
+ Implements most of methods of :py:class:`PathCommand` through
+ conversion to :py:class:`AbsolutePathCommand`
+ """
+
+ @property
+ def is_relative(self):
+ return True
+
+ @property
+ def is_absolute(self):
+ return False
+
+ def control_points(
+ self, first: Vector2d, prev: Vector2d, prev_prev: Vector2d
+ ) -> Union[List[Vector2d], Generator[Vector2d, None, None]]:
+ return self.to_absolute(prev).control_points(first, prev, prev_prev)
+
+ def to_relative(self, prev):
+ # type: (Pathlike, Vector2d) -> Pathlike
+ return self.__class__(*self.args)
+
+ def update_bounding_box(self, first, last_two_points, bbox):
+ self.to_absolute(last_two_points[-1]).update_bounding_box(
+ first, last_two_points, bbox
+ )
+
+ def end_point(self, first, prev):
+ # type: (Vector2d, Vector2d) -> Vector2d
+ return self.to_absolute(prev).end_point(first, prev)
+
+ def to_curve(self, prev, prev_prev=Vector2d()):
+ # type: (Vector2d, Vector2d) -> Curve
+ return self.to_absolute(prev).to_curve(prev, prev_prev)
+
+ def to_curves(self, prev, prev_prev=Vector2d()):
+ # type: (Vector2d, Vector2d) -> List[Curve]
+ return self.to_absolute(prev).to_curves(prev, prev_prev)
+
+
+class AbsolutePathCommand(PathCommand):
+ """Absolute path command. Unlike :py:class:`RelativePathCommand` can be transformed
+ directly."""
+
+ @property
+ def is_relative(self):
+ return False
+
+ @property
+ def is_absolute(self):
+ return True
+
+ def to_absolute(
+ self, prev
+ ): # type: (AbsolutePathlike, Vector2d) -> AbsolutePathlike
+ return self.__class__(*self.args)
+
+ def transform(
+ self, transform
+ ): # type: (AbsolutePathlike, Transform) -> AbsolutePathlike
+ """Returns new transformed segment
+
+ :param transform: a transformation to apply
+ """
+ raise NotImplementedError()
+
+ def rotate(
+ self, degrees, center
+ ): # type: (AbsolutePathlike, float, Vector2d) -> AbsolutePathlike
+ """
+ Returns new transformed segment
+
+ :param degrees: rotation angle in degrees
+ :param center: invariant point of rotation
+ """
+ return self.transform(Transform(rotate=(degrees, center[0], center[1])))
+
+ def translate(self, dr): # type: (AbsolutePathlike, Vector2d) -> AbsolutePathlike
+ """Translate or scale this path command by dr"""
+ return self.transform(Transform(translate=dr))
+
+ def scale(
+ self, factor
+ ): # type: (AbsolutePathlike, Union[float, Tuple[float,float]]) -> AbsolutePathlike
+ """Returns new transformed segment
+
+ :param factor: scale or (scale_x, scale_y)
+ """
+ return self.transform(Transform(scale=factor))
+
+
+class Line(AbsolutePathCommand):
+ """Line segment"""
+
+ nargs = 2
+
+ @property
+ def args(self):
+ return self.x, self.y
+
+ def __init__(self, x, y):
+ self.x = x
+ self.y = y
+
+ def update_bounding_box(self, first, last_two_points, bbox):
+ bbox += BoundingBox(
+ (last_two_points[-1].x, self.x), (last_two_points[-1].y, self.y)
+ )
+
+ def control_points(self, first, prev, prev_prev):
+ # type: (Vector2d, Vector2d, Vector2d) -> Generator[Vector2d, None, None]
+ yield Vector2d(self.x, self.y)
+
+ def to_relative(self, prev):
+ # type: (Vector2d) -> line
+ return line(self.x - prev.x, self.y - prev.y)
+
+ def transform(self, transform):
+ # type: (Line, Transform) -> Line
+ return Line(*transform.apply_to_point((self.x, self.y)))
+
+ def end_point(self, first, prev):
+ # type: (Vector2d, Vector2d) -> Vector2d
+ return Vector2d(self.x, self.y)
+
+ def to_curve(self, prev, prev_prev=Vector2d()):
+ # type: (Vector2d, Optional[Vector2d]) -> Curve
+ return Curve(prev.x, prev.y, self.x, self.y, self.x, self.y)
+
+ def reverse(self, first, prev):
+ return Line(prev.x, prev.y)
+
+
+class line(RelativePathCommand): # pylint: disable=invalid-name
+ """Relative line segment"""
+
+ nargs = 2
+
+ @property
+ def args(self):
+ return self.dx, self.dy
+
+ def __init__(self, dx, dy):
+ self.dx = dx
+ self.dy = dy
+
+ def to_absolute(self, prev): # type: (Vector2d) -> Line
+ return Line(prev.x + self.dx, prev.y + self.dy)
+
+ def reverse(self, first, prev):
+ return line(-self.dx, -self.dy)
+
+
+class Move(AbsolutePathCommand):
+ """Move pen segment without a line"""
+
+ nargs = 2
+ next_command = Line
+
+ @property
+ def args(self):
+ return self.x, self.y
+
+ def __init__(self, x, y):
+ self.x = x
+ self.y = y
+
+ def update_bounding_box(self, first, last_two_points, bbox):
+ bbox += BoundingBox(self.x, self.y)
+
+ def control_points(self, first, prev, prev_prev):
+ # type: (Vector2d, Vector2d, Vector2d) -> Generator[Vector2d, None, None]
+ yield Vector2d(self.x, self.y)
+
+ def to_relative(self, prev):
+ # type: (Vector2d) -> move
+ return move(self.x - prev.x, self.y - prev.y)
+
+ def transform(self, transform):
+ # type: (Transform) -> Move
+ return Move(*transform.apply_to_point((self.x, self.y)))
+
+ def end_point(self, first, prev):
+ # type: (Vector2d, Vector2d) -> Vector2d
+ return Vector2d(self.x, self.y)
+
+ def to_curve(self, prev, prev_prev=Vector2d()):
+ # type: (Vector2d, Optional[Vector2d]) -> Curve
+ raise ValueError("Move segments can not be changed into curves.")
+
+ def reverse(self, first, prev):
+ return Move(prev.x, prev.y)
+
+
+class move(RelativePathCommand): # pylint: disable=invalid-name
+ """Relative move segment"""
+
+ nargs = 2
+ next_command = line
+
+ @property
+ def args(self):
+ return self.dx, self.dy
+
+ def __init__(self, dx, dy):
+ self.dx = dx
+ self.dy = dy
+
+ def to_absolute(self, prev): # type: (Vector2d) -> Move
+ return Move(prev.x + self.dx, prev.y + self.dy)
+
+ def reverse(self, first, prev):
+ return move(prev.x - first.x, prev.y - first.y)
+
+
+class ZoneClose(AbsolutePathCommand):
+ """Close segment to finish a path"""
+
+ nargs = 0
+ next_command = Move
+
+ @property
+ def args(self):
+ return ()
+
+ def update_bounding_box(self, first, last_two_points, bbox):
+ pass
+
+ def transform(self, transform):
+ # type: (Transform) -> ZoneClose
+ return ZoneClose()
+
+ def control_points(self, first, prev, prev_prev):
+ # type: (Vector2d, Vector2d, Vector2d) -> Generator[Vector2d, None, None]
+ yield first
+
+ def to_relative(self, prev):
+ # type: (Vector2d) -> zoneClose
+ return zoneClose()
+
+ def end_point(self, first, prev):
+ # type: (Vector2d, Vector2d) -> Vector2d
+ return first
+
+ def to_curve(self, prev, prev_prev=Vector2d()):
+ # type: (Vector2d, Optional[Vector2d]) -> Curve
+ raise ValueError("ZoneClose segments can not be changed into curves.")
+
+ def reverse(self, first, prev):
+ return Line(prev.x, prev.y)
+
+
+class zoneClose(RelativePathCommand): # pylint: disable=invalid-name
+ """Same as above (svg says no difference)"""
+
+ nargs = 0
+ next_command = Move
+
+ @property
+ def args(self):
+ return ()
+
+ def to_absolute(self, prev):
+ return ZoneClose()
+
+ def reverse(self, first, prev):
+ return line(prev.x - first.x, prev.y - first.y)
+
+
+class Horz(AbsolutePathCommand):
+ """Horizontal Line segment"""
+
+ nargs = 1
+
+ @property
+ def args(self):
+ return (self.x,)
+
+ def __init__(self, x):
+ self.x = x
+
+ def update_bounding_box(self, first, last_two_points, bbox):
+ bbox += BoundingBox((last_two_points[-1].x, self.x), last_two_points[-1].y)
+
+ def control_points(self, first, prev, prev_prev):
+ # type: (Vector2d, Vector2d, Vector2d) -> Generator[Vector2d, None, None]
+ yield Vector2d(self.x, prev.y)
+
+ def to_relative(self, prev):
+ # type: (Vector2d) -> horz
+ return horz(self.x - prev.x)
+
+ def to_non_shorthand(self, prev, prev_control):
+ # type: (Vector2d, Vector2d) -> Line
+ return self.to_line(prev)
+
+ def transform(self, transform):
+ # type: (Pathlike, Transform) -> Pathlike
+ raise ValueError("Horizontal lines can't be transformed directly.")
+
+ def end_point(self, first, prev):
+ # type: (Vector2d, Vector2d) -> Vector2d
+ return Vector2d(self.x, prev.y)
+
+ def to_curve(self, prev, prev_prev=Vector2d()):
+ # type: (Vector2d, Optional[Vector2d]) -> Curve
+ """Convert a horizontal line into a curve"""
+ return self.to_line(prev).to_curve(prev)
+
+ def to_line(self, prev):
+ # type: (Vector2d) -> Line
+ """Return this path command as a Line instead"""
+ return Line(self.x, prev.y)
+
+ def reverse(self, first, prev):
+ return Horz(prev.x)
+
+
+class horz(RelativePathCommand): # pylint: disable=invalid-name
+ """Relative horz line segment"""
+
+ nargs = 1
+
+ @property
+ def args(self):
+ return (self.dx,)
+
+ def __init__(self, dx):
+ self.dx = dx
+
+ def to_absolute(self, prev): # type: (Vector2d) -> Horz
+ return Horz(prev.x + self.dx)
+
+ def to_non_shorthand(self, prev, prev_control):
+ # type: (Vector2d, Vector2d) -> Line
+ return self.to_line(prev)
+
+ def to_line(self, prev): # type: (Vector2d) -> Line
+ """Return this path command as a Line instead"""
+ return Line(prev.x + self.dx, prev.y)
+
+ def reverse(self, first, prev):
+ return horz(-self.dx)
+
+
+class Vert(AbsolutePathCommand):
+ """Vertical Line segment"""
+
+ nargs = 1
+
+ @property
+ def args(self):
+ return (self.y,)
+
+ def __init__(self, y):
+ self.y = y
+
+ def update_bounding_box(self, first, last_two_points, bbox):
+ bbox += BoundingBox(last_two_points[-1].x, (last_two_points[-1].y, self.y))
+
+ def transform(self, transform): # type: (Pathlike, Transform) -> Pathlike
+ raise ValueError("Vertical lines can't be transformed directly.")
+
+ def control_points(self, first, prev, prev_prev):
+ # type: (Vector2d, Vector2d, Vector2d) -> Generator[Vector2d, None, None]
+ yield Vector2d(prev.x, self.y)
+
+ def to_non_shorthand(self, prev, prev_control):
+ # type: (Vector2d, Vector2d) -> Line
+ return self.to_line(prev)
+
+ def to_relative(self, prev):
+ # type: (Vector2d) -> vert
+ return vert(self.y - prev.y)
+
+ def end_point(self, first, prev):
+ # type: (Vector2d, Vector2d) -> Vector2d
+ return Vector2d(prev.x, self.y)
+
+ def to_line(self, prev):
+ # type: (Vector2d) -> Line
+ """Return this path command as a line instead"""
+ return Line(prev.x, self.y)
+
+ def to_curve(
+ self, prev, prev_prev=Vector2d()
+ ): # type: (Vector2d, Optional[Vector2d]) -> Curve
+ """Convert a horizontal line into a curve"""
+ return self.to_line(prev).to_curve(prev)
+
+ def reverse(self, first, prev):
+ return Vert(prev.y)
+
+
+class vert(RelativePathCommand): # pylint: disable=invalid-name
+ """Relative vertical line segment"""
+
+ nargs = 1
+
+ @property
+ def args(self):
+ return (self.dy,)
+
+ def __init__(self, dy):
+ self.dy = dy
+
+ def to_absolute(self, prev): # type: (Vector2d) -> Vert
+ return Vert(prev.y + self.dy)
+
+ def to_non_shorthand(self, prev, prev_control):
+ # type: (Vector2d, Vector2d) -> Line
+ return self.to_line(prev)
+
+ def to_line(self, prev): # type: (Vector2d) -> Line
+ """Return this path command as a line instead"""
+ return Line(prev.x, prev.y + self.dy)
+
+ def reverse(self, first, prev):
+ return vert(-self.dy)
+
+
+class Curve(AbsolutePathCommand):
+ """Absolute Curved Line segment"""
+
+ nargs = 6
+
+ @property
+ def args(self):
+ return self.x2, self.y2, self.x3, self.y3, self.x4, self.y4
+
+ def __init__(self, x2, y2, x3, y3, x4, y4): # pylint: disable=too-many-arguments
+ self.x2 = x2
+ self.y2 = y2
+
+ self.x3 = x3
+ self.y3 = y3
+
+ self.x4 = x4
+ self.y4 = y4
+
+ def update_bounding_box(self, first, last_two_points, bbox):
+
+ x1, x2, x3, x4 = last_two_points[-1].x, self.x2, self.x3, self.x4
+ y1, y2, y3, y4 = last_two_points[-1].y, self.y2, self.y3, self.y4
+
+ if not (x1 in bbox.x and x2 in bbox.x and x3 in bbox.x and x4 in bbox.x):
+ bbox.x += cubic_extrema(x1, x2, x3, x4)
+
+ if not (y1 in bbox.y and y2 in bbox.y and y3 in bbox.y and y4 in bbox.y):
+ bbox.y += cubic_extrema(y1, y2, y3, y4)
+
+ def transform(self, transform):
+ # type: (Transform) -> Curve
+ x2, y2 = transform.apply_to_point((self.x2, self.y2))
+ x3, y3 = transform.apply_to_point((self.x3, self.y3))
+ x4, y4 = transform.apply_to_point((self.x4, self.y4))
+ return Curve(x2, y2, x3, y3, x4, y4)
+
+ def control_points(self, first, prev, prev_prev):
+ # type: (Vector2d, Vector2d, Vector2d) -> Generator[Vector2d, None, None]
+ yield Vector2d(self.x2, self.y2)
+ yield Vector2d(self.x3, self.y3)
+ yield Vector2d(self.x4, self.y4)
+
+ def to_relative(self, prev): # type: (Vector2d) -> curve
+ return curve(
+ self.x2 - prev.x,
+ self.y2 - prev.y,
+ self.x3 - prev.x,
+ self.y3 - prev.y,
+ self.x4 - prev.x,
+ self.y4 - prev.y,
+ )
+
+ def end_point(self, first, prev):
+ return Vector2d(self.x4, self.y4)
+
+ def to_curve(
+ self, prev, prev_prev=Vector2d()
+ ): # type: (Vector2d, Optional[Vector2d]) -> Curve
+ """No conversion needed, pass-through, returns self"""
+ return Curve(*self.args)
+
+ def to_bez(self):
+ """Returns the list of coords for SuperPath"""
+ return [list(self.args[:2]), list(self.args[2:4]), list(self.args[4:6])]
+
+ def reverse(self, first, prev):
+ return Curve(self.x3, self.y3, self.x2, self.y2, prev.x, prev.y)
+
+
+class curve(RelativePathCommand): # pylint: disable=invalid-name
+ """Relative curved line segment"""
+
+ nargs = 6
+
+ @property
+ def args(self):
+ return self.dx2, self.dy2, self.dx3, self.dy3, self.dx4, self.dy4
+
+ def __init__(
+ self, dx2, dy2, dx3, dy3, dx4, dy4
+ ): # pylint: disable=too-many-arguments
+ self.dx2 = dx2
+ self.dy2 = dy2
+
+ self.dx3 = dx3
+ self.dy3 = dy3
+
+ self.dx4 = dx4
+ self.dy4 = dy4
+
+ def to_absolute(self, prev): # type: (Vector2d) -> Curve
+ return Curve(
+ self.dx2 + prev.x,
+ self.dy2 + prev.y,
+ self.dx3 + prev.x,
+ self.dy3 + prev.y,
+ self.dx4 + prev.x,
+ self.dy4 + prev.y,
+ )
+
+ def reverse(self, first, prev):
+ return curve(
+ -self.dx4 + self.dx3,
+ -self.dy4 + self.dy3,
+ -self.dx4 + self.dx2,
+ -self.dy4 + self.dy2,
+ -self.dx4,
+ -self.dy4,
+ )
+
+
+class Smooth(AbsolutePathCommand):
+ """Absolute Smoothed Curved Line segment"""
+
+ nargs = 4
+
+ @property
+ def args(self):
+ return self.x3, self.y3, self.x4, self.y4
+
+ def __init__(self, x3, y3, x4, y4):
+
+ self.x3 = x3
+ self.y3 = y3
+
+ self.x4 = x4
+ self.y4 = y4
+
+ def update_bounding_box(self, first, last_two_points, bbox):
+ self.to_curve(last_two_points[-1], last_two_points[-2]).update_bounding_box(
+ first, last_two_points, bbox
+ )
+
+ def control_points(self, first, prev, prev_prev):
+ # type: (Vector2d, Vector2d, Vector2d) -> Generator[Vector2d, None, None]
+
+ x1, x2, x3, x4 = prev_prev.x, prev.x, self.x3, self.x4
+ y1, y2, y3, y4 = prev_prev.y, prev.y, self.y3, self.y4
+
+ # infer reflected point
+ x2 = 2 * x2 - x1
+ y2 = 2 * y2 - y1
+
+ yield Vector2d(x2, y2)
+ yield Vector2d(x3, y3)
+ yield Vector2d(x4, y4)
+
+ def to_non_shorthand(self, prev, prev_control):
+ # type: (Vector2d, Vector2d) -> Curve
+ return self.to_curve(prev, prev_control)
+
+ def to_relative(self, prev): # type: (Vector2d) -> smooth
+ return smooth(
+ self.x3 - prev.x, self.y3 - prev.y, self.x4 - prev.x, self.y4 - prev.y
+ )
+
+ def transform(self, transform):
+ # type: (Transform) -> Smooth
+ x3, y3 = transform.apply_to_point((self.x3, self.y3))
+ x4, y4 = transform.apply_to_point((self.x4, self.y4))
+ return Smooth(x3, y3, x4, y4)
+
+ def end_point(self, first, prev):
+ return Vector2d(self.x4, self.y4)
+
+ def to_curve(self, prev, prev_prev=Vector2d()):
+ # type: (Vector2d, Vector2d) -> Curve
+ """
+ Convert this Smooth curve to a regular curve by creating a mirror
+ set of nodes based on the previous node. Previous should be a curve.
+ """
+ (x2, y2), (x3, y3), (x4, y4) = self.control_points(prev, prev, prev_prev)
+ return Curve(x2, y2, x3, y3, x4, y4)
+
+ def reverse(self, first, prev):
+ return Smooth(self.x3, self.y3, prev.x, prev.y)
+
+
+class smooth(RelativePathCommand): # pylint: disable=invalid-name
+ """Relative smoothed curved line segment"""
+
+ nargs = 4
+
+ @property
+ def args(self):
+ return self.dx3, self.dy3, self.dx4, self.dy4
+
+ def __init__(self, dx3, dy3, dx4, dy4):
+ self.dx3 = dx3
+ self.dy3 = dy3
+
+ self.dx4 = dx4
+ self.dy4 = dy4
+
+ def to_absolute(self, prev): # type: (Vector2d) -> Smooth
+ return Smooth(
+ self.dx3 + prev.x, self.dy3 + prev.y, self.dx4 + prev.x, self.dy4 + prev.y
+ )
+
+ def to_non_shorthand(self, prev, prev_control):
+ # type: (Vector2d, Vector2d) -> Curve
+ return self.to_absolute(prev).to_non_shorthand(prev, prev_control)
+
+ def reverse(self, first, prev):
+ return smooth(-self.dx4 + self.dx3, -self.dy4 + self.dy3, -self.dx4, -self.dy4)
+
+
+class Quadratic(AbsolutePathCommand):
+ """Absolute Quadratic Curved Line segment"""
+
+ nargs = 4
+
+ @property
+ def args(self):
+ return self.x2, self.y2, self.x3, self.y3
+
+ def __init__(self, x2, y2, x3, y3):
+
+ self.x2 = x2
+ self.y2 = y2
+
+ self.x3 = x3
+ self.y3 = y3
+
+ def update_bounding_box(self, first, last_two_points, bbox):
+
+ x1, x2, x3 = last_two_points[-1].x, self.x2, self.x3
+ y1, y2, y3 = last_two_points[-1].y, self.y2, self.y3
+
+ if not (x1 in bbox.x and x2 in bbox.x and x3 in bbox.x):
+ bbox.x += quadratic_extrema(x1, x2, x3)
+
+ if not (y1 in bbox.y and y2 in bbox.y and y3 in bbox.y):
+ bbox.y += quadratic_extrema(y1, y2, y3)
+
+ def control_points(self, first, prev, prev_prev):
+ # type: (Vector2d, Vector2d, Vector2d) -> Generator[Vector2d, None, None]
+ yield Vector2d(self.x2, self.y2)
+ yield Vector2d(self.x3, self.y3)
+
+ def to_relative(self, prev):
+ # type: (Vector2d) -> quadratic
+ return quadratic(
+ self.x2 - prev.x, self.y2 - prev.y, self.x3 - prev.x, self.y3 - prev.y
+ )
+
+ def transform(self, transform):
+ # type: (Transform) -> Quadratic
+ x2, y2 = transform.apply_to_point((self.x2, self.y2))
+ x3, y3 = transform.apply_to_point((self.x3, self.y3))
+ return Quadratic(x2, y2, x3, y3)
+
+ def end_point(self, first, prev):
+ # type: (Vector2d, Vector2d) -> Vector2d
+ return Vector2d(self.x3, self.y3)
+
+ def to_curve(self, prev, prev_prev=Vector2d()):
+ # type: (Vector2d, Vector2d) -> Curve
+ """Attempt to convert a quadratic to a curve"""
+ prev = Vector2d(prev)
+ x1 = 1.0 / 3 * prev.x + 2.0 / 3 * self.x2
+ x2 = 2.0 / 3 * self.x2 + 1.0 / 3 * self.x3
+ y1 = 1.0 / 3 * prev.y + 2.0 / 3 * self.y2
+ y2 = 2.0 / 3 * self.y2 + 1.0 / 3 * self.y3
+ return Curve(x1, y1, x2, y2, self.x3, self.y3)
+
+ def reverse(self, first, prev):
+ return Quadratic(self.x2, self.y2, prev.x, prev.y)
+
+
+class quadratic(RelativePathCommand): # pylint: disable=invalid-name
+ """Relative quadratic line segment"""
+
+ nargs = 4
+
+ @property
+ def args(self):
+ return self.dx2, self.dy2, self.dx3, self.dy3
+
+ def __init__(self, dx2, dy2, dx3, dy3):
+ self.dx2 = dx2
+ self.dx3 = dx3
+ self.dy2 = dy2
+ self.dy3 = dy3
+
+ def to_absolute(self, prev): # type: (Vector2d) -> Quadratic
+ return Quadratic(
+ self.dx2 + prev.x, self.dy2 + prev.y, self.dx3 + prev.x, self.dy3 + prev.y
+ )
+
+ def reverse(self, first, prev):
+ return quadratic(
+ -self.dx3 + self.dx2, -self.dy3 + self.dy2, -self.dx3, -self.dy3
+ )
+
+
+class TepidQuadratic(AbsolutePathCommand):
+ """Continued Quadratic Line segment"""
+
+ nargs = 2
+
+ @property
+ def args(self):
+ return self.x3, self.y3
+
+ def __init__(self, x3, y3):
+ self.x3 = x3
+ self.y3 = y3
+
+ def update_bounding_box(self, first, last_two_points, bbox):
+ self.to_quadratic(last_two_points[-1], last_two_points[-2]).update_bounding_box(
+ first, last_two_points, bbox
+ )
+
+ def control_points(self, first, prev, prev_prev):
+ # type: (Vector2d, Vector2d, Vector2d) -> Generator[Vector2d, None, None]
+
+ x1, x2, x3 = prev_prev.x, prev.x, self.x3
+ y1, y2, y3 = prev_prev.y, prev.y, self.y3
+
+ # infer reflected point
+ x2 = 2 * x2 - x1
+ y2 = 2 * y2 - y1
+
+ yield Vector2d(x2, y2)
+ yield Vector2d(x3, y3)
+
+ def to_non_shorthand(self, prev, prev_control):
+ # type: (Vector2d, Vector2d) -> AbsolutePathCommand
+ return self.to_quadratic(prev, prev_control)
+
+ def to_relative(self, prev): # type: (Vector2d) -> tepidQuadratic
+ return tepidQuadratic(self.x3 - prev.x, self.y3 - prev.y)
+
+ def transform(self, transform):
+ # type: (Transform) -> TepidQuadratic
+ x3, y3 = transform.apply_to_point((self.x3, self.y3))
+ return TepidQuadratic(x3, y3)
+
+ def end_point(self, first, prev):
+ # type: (Vector2d, Vector2d) -> Vector2d
+ return Vector2d(self.x3, self.y3)
+
+ def to_curve(self, prev, prev_prev=Vector2d()):
+ # type: (Vector2d, Vector2d) -> Curve
+ return self.to_quadratic(prev, prev_prev).to_curve(prev)
+
+ def to_quadratic(self, prev, prev_prev):
+ # type: (Vector2d, Vector2d) -> Quadratic
+ """
+ Convert this continued quadratic into a full quadratic
+ """
+ (x2, y2), (x3, y3) = self.control_points(prev, prev, prev_prev)
+ return Quadratic(x2, y2, x3, y3)
+
+ def reverse(self, first, prev):
+ return TepidQuadratic(prev.x, prev.y)
+
+
+class tepidQuadratic(RelativePathCommand): # pylint: disable=invalid-name
+ """Relative continued quadratic line segment"""
+
+ nargs = 2
+
+ @property
+ def args(self):
+ return self.dx3, self.dy3
+
+ def __init__(self, dx3, dy3):
+ self.dx3 = dx3
+ self.dy3 = dy3
+
+ def to_absolute(self, prev):
+ # type: (Vector2d) -> TepidQuadratic
+ return TepidQuadratic(self.dx3 + prev.x, self.dy3 + prev.y)
+
+ def to_non_shorthand(self, prev, prev_control):
+ # type: (Vector2d, Vector2d) -> AbsolutePathCommand
+ return self.to_absolute(prev).to_non_shorthand(prev, prev_control)
+
+ def reverse(self, first, prev):
+ return tepidQuadratic(-self.dx3, -self.dy3)
+
+
+class Arc(AbsolutePathCommand):
+ """Special Arc segment"""
+
+ nargs = 7
+
+ @property
+ def args(self):
+ return (
+ self.rx,
+ self.ry,
+ self.x_axis_rotation,
+ self.large_arc,
+ self.sweep,
+ self.x,
+ self.y,
+ )
+
+ def __init__(
+ self, rx, ry, x_axis_rotation, large_arc, sweep, x, y
+ ): # pylint: disable=too-many-arguments
+ self.rx = rx
+ self.ry = ry
+ self.x_axis_rotation = x_axis_rotation
+ self.large_arc = large_arc
+ self.sweep = sweep
+ self.x = x
+ self.y = y
+
+ def update_bounding_box(self, first, last_two_points, bbox):
+ prev = last_two_points[-1]
+ for seg in self.to_curves(prev=prev):
+ seg.update_bounding_box(first, [None, prev], bbox)
+ prev = seg.end_point(first, prev)
+
+ def control_points(self, first, prev, prev_prev):
+ # type: (Vector2d, Vector2d, Vector2d) -> Generator[Vector2d, None, None]
+ yield Vector2d(self.x, self.y)
+
+ def to_curves(self, prev, prev_prev=Vector2d()):
+ # type: (Vector2d, Vector2d) -> List[Curve]
+ """Convert this arc into bezier curves"""
+ path = CubicSuperPath([arc_to_path(list(prev), self.args)]).to_path(
+ curves_only=True
+ )
+ # Ignore the first move command from to_path()
+ return list(path)[1:]
+
+ def transform(self, transform):
+ # type: (Transform) -> Arc
+ # pylint: disable=invalid-name, too-many-locals
+ x_, y_ = transform.apply_to_point((self.x, self.y))
+
+ T = transform # type: Transform
+ if self.x_axis_rotation != 0:
+ T = T @ Transform(rotate=self.x_axis_rotation)
+ a, c, b, d, _, _ = list(T.to_hexad())
+ # T = | a b |
+ # | c d |
+
+ detT = a * d - b * c
+ detT2 = detT**2
+
+ rx = float(self.rx)
+ ry = float(self.ry)
+
+ if rx == 0.0 or ry == 0.0 or detT2 == 0.0:
+ # invalid Arc parameters
+ # transform only last point
+ return Arc(
+ self.rx,
+ self.ry,
+ self.x_axis_rotation,
+ self.large_arc,
+ self.sweep,
+ x_,
+ y_,
+ )
+
+ A = (d**2 / rx**2 + c**2 / ry**2) / detT2
+ B = -(d * b / rx**2 + c * a / ry**2) / detT2
+ D = (b**2 / rx**2 + a**2 / ry**2) / detT2
+
+ theta = atan2(-2 * B, D - A) / 2
+ theta_deg = theta * 180.0 / pi
+ DA = D - A
+ l2 = 4 * B**2 + DA**2
+
+ if l2 == 0:
+ delta = 0.0
+ else:
+ delta = 0.5 * (-(DA**2) - 4 * B**2) / sqrt(l2)
+
+ half = (A + D) / 2
+
+ rx_ = 1.0 / sqrt(half + delta)
+ ry_ = 1.0 / sqrt(half - delta)
+
+ x_, y_ = transform.apply_to_point((self.x, self.y))
+
+ if detT > 0:
+ sweep = self.sweep
+ else:
+ sweep = 0 if self.sweep > 0 else 1
+
+ return Arc(rx_, ry_, theta_deg, self.large_arc, sweep, x_, y_)
+
+ def to_relative(self, prev):
+ # type: (Vector2d) -> arc
+ return arc(
+ self.rx,
+ self.ry,
+ self.x_axis_rotation,
+ self.large_arc,
+ self.sweep,
+ self.x - prev.x,
+ self.y - prev.y,
+ )
+
+ def end_point(self, first, prev):
+ # type: (Vector2d, Vector2d) -> Vector2d
+ return Vector2d(self.x, self.y)
+
+ def reverse(self, first, prev):
+ return Arc(
+ self.rx,
+ self.ry,
+ self.x_axis_rotation,
+ self.large_arc,
+ 1 - self.sweep,
+ prev.x,
+ prev.y,
+ )
+
+
+class arc(RelativePathCommand): # pylint: disable=invalid-name
+ """Relative Arc line segment"""
+
+ nargs = 7
+
+ @property
+ def args(self):
+ return (
+ self.rx,
+ self.ry,
+ self.x_axis_rotation,
+ self.large_arc,
+ self.sweep,
+ self.dx,
+ self.dy,
+ )
+
+ def __init__(
+ self, rx, ry, x_axis_rotation, large_arc, sweep, dx, dy
+ ): # pylint: disable=too-many-arguments
+ self.rx = rx
+ self.ry = ry
+ self.x_axis_rotation = x_axis_rotation
+ self.large_arc = large_arc
+ self.sweep = sweep
+ self.dx = dx
+ self.dy = dy
+
+ def to_absolute(self, prev): # type: (Vector2d) -> "Arc"
+ x1, y1 = prev
+ return Arc(
+ self.rx,
+ self.ry,
+ self.x_axis_rotation,
+ self.large_arc,
+ self.sweep,
+ self.dx + x1,
+ self.dy + y1,
+ )
+
+ def reverse(self, first, prev):
+ return arc(
+ self.rx,
+ self.ry,
+ self.x_axis_rotation,
+ self.large_arc,
+ 1 - self.sweep,
+ -self.dx,
+ -self.dy,
+ )
+
+
+PathCommand._letter_to_class = { # pylint: disable=protected-access
+ "M": Move,
+ "L": Line,
+ "V": Vert,
+ "H": Horz,
+ "A": Arc,
+ "C": Curve,
+ "S": Smooth,
+ "Z": ZoneClose,
+ "Q": Quadratic,
+ "T": TepidQuadratic,
+ "m": move,
+ "l": line,
+ "v": vert,
+ "h": horz,
+ "a": arc,
+ "c": curve,
+ "s": smooth,
+ "z": zoneClose,
+ "q": quadratic,
+ "t": tepidQuadratic,
+}
+
+
+class Path(list):
+ """A list of segment commands which combine to draw a shape"""
+
+ class PathCommandProxy:
+ """
+ A handy class for Path traverse and coordinate access
+
+ Reduces number of arguments in user code compared to bare
+ :class:`PathCommand` methods
+ """
+
+ def __init__(
+ self, command, first_point, previous_end_point, prev2_control_point
+ ):
+ self.command = command # type: PathCommand
+ self.first_point = first_point # type: Vector2d
+ self.previous_end_point = previous_end_point # type: Vector2d
+ self.prev2_control_point = prev2_control_point # type: Vector2d
+
+ @property
+ def name(self):
+ """The full name of the segment (i.e. Line, Arc, etc)"""
+ return self.command.name
+
+ @property
+ def letter(self):
+ """The single letter representation of this command (i.e. L, A, etc)"""
+ return self.command.letter
+
+ @property
+ def next_command(self):
+ """The implicit next command."""
+ return self.command.next_command
+
+ @property
+ def is_relative(self):
+ """Whether the command is defined in relative coordinates, i.e. relative to
+ the previous endpoint (lower case path command letter)"""
+ return self.command.is_relative
+
+ @property
+ def is_absolute(self):
+ """Whether the command is defined in absolute coordinates (upper case path
+ command letter)"""
+ return self.command.is_absolute
+
+ @property
+ def args(self):
+ """Returns path command arguments as tuple of floats"""
+ return self.command.args
+
+ @property
+ def control_points(self):
+ """Returns list of path command control points"""
+ return self.command.control_points(
+ self.first_point, self.previous_end_point, self.prev2_control_point
+ )
+
+ @property
+ def end_point(self):
+ """Returns last control point of path command"""
+ return self.command.end_point(self.first_point, self.previous_end_point)
+
+ def reverse(self):
+ """Reverse path command"""
+ return self.command.reverse(self.end_point, self.previous_end_point)
+
+ def to_curve(self):
+ """Convert command to :py:class:`Curve`
+ Curve().to_curve() returns a copy
+ """
+ return self.command.to_curve(
+ self.previous_end_point, self.prev2_control_point
+ )
+
+ def to_curves(self):
+ """Convert command to list of :py:class:`Curve` commands"""
+ return self.command.to_curves(
+ self.previous_end_point, self.prev2_control_point
+ )
+
+ def to_absolute(self):
+ """Return relative counterpart for relative commands or copy for absolute"""
+ return self.command.to_absolute(self.previous_end_point)
+
+ def __str__(self):
+ return str(self.command)
+
+ def __repr__(self):
+ return "<" + self.__class__.__name__ + ">" + repr(self.command)
+
+ def __init__(self, path_d=None):
+ super().__init__()
+ if isinstance(path_d, str):
+ # Returns a generator returning PathCommand objects
+ path_d = self.parse_string(path_d)
+ elif isinstance(path_d, CubicSuperPath):
+ path_d = path_d.to_path()
+
+ for item in path_d or ():
+ if isinstance(item, PathCommand):
+ self.append(item)
+ elif isinstance(item, (list, tuple)) and len(item) == 2:
+ if isinstance(item[1], (list, tuple)):
+ self.append(PathCommand.letter_to_class(item[0])(*item[1]))
+ else:
+ self.append(Line(*item))
+ else:
+ raise TypeError(
+ f"Bad path type: {type(path_d).__name__}"
+ f"({type(item).__name__}, ...): {item}"
+ )
+
+ @classmethod
+ def parse_string(cls, path_d):
+ """Parse a path string and generate segment objects"""
+ for cmd, numbers in LEX_REX.findall(path_d):
+ args = list(strargs(numbers))
+ cmd = PathCommand.letter_to_class(cmd)
+ i = 0
+ while i < len(args) or cmd.nargs == 0:
+ if len(args[i : i + cmd.nargs]) != cmd.nargs:
+ return
+ seg = cmd(*args[i : i + cmd.nargs])
+ i += cmd.nargs
+ cmd = seg.next_command
+ yield seg
+
+ def bounding_box(self):
+ # type: () -> Optional[BoundingBox]
+ """Return bounding box of the Path"""
+ if not self:
+ return None
+ iterator = self.proxy_iterator()
+ proxy = next(iterator)
+ bbox = BoundingBox(proxy.first_point.x, proxy.first_point.y)
+ try:
+ while True:
+ proxy = next(iterator)
+ proxy.command.update_bounding_box(
+ proxy.first_point,
+ [
+ proxy.prev2_control_point,
+ proxy.previous_end_point,
+ ],
+ bbox,
+ )
+ except StopIteration:
+ return bbox
+
+ def append(self, cmd):
+ """Append a command to this path including any chained commands"""
+ if isinstance(cmd, list):
+ self.extend(cmd)
+ elif isinstance(cmd, PathCommand):
+ super().append(cmd)
+
+ def translate(self, x, y, inplace=False): # pylint: disable=invalid-name
+ """Move all coords in this path by the given amount"""
+ return self.transform(Transform(translate=(x, y)), inplace=inplace)
+
+ def scale(self, x, y, inplace=False): # pylint: disable=invalid-name
+ """Scale all coords in this path by the given amounts"""
+ return self.transform(Transform(scale=(x, y)), inplace=inplace)
+
+ def rotate(self, deg, center=None, inplace=False):
+ """Rotate the path around the given point"""
+ if center is None:
+ # Default center is center of bbox
+ bbox = self.bounding_box()
+ if bbox:
+ center = bbox.center
+ else:
+ center = Vector2d()
+ center = Vector2d(center)
+ return self.transform(
+ Transform(rotate=(deg, center.x, center.y)), inplace=inplace
+ )
+
+ @property
+ def control_points(self):
+ """Returns all control points of the Path"""
+ prev = Vector2d()
+ prev_prev = Vector2d()
+ first = Vector2d()
+
+ for seg in self: # type: PathCommand
+ cpts = list(seg.control_points(first, prev, prev_prev))
+ if isinstance(seg, (zoneClose, ZoneClose, move, Move)):
+ first = cpts[-1]
+ for cpt in cpts:
+ prev_prev = prev
+ prev = cpt
+ yield cpt
+
+ @property
+ def end_points(self):
+ """Returns all endpoints of all path commands (i.e. the nodes)"""
+ prev = Vector2d()
+ first = Vector2d()
+
+ for seg in self: # type: PathCommand
+ end_point = seg.end_point(first, prev)
+ if isinstance(seg, (zoneClose, ZoneClose, move, Move)):
+ first = end_point
+ prev = end_point
+ yield end_point
+
+ def transform(self, transform, inplace=False):
+ """Convert to new path"""
+ result = Path()
+ previous = Vector2d()
+ previous_new = Vector2d()
+ start_zone = True
+ first = Vector2d()
+ first_new = Vector2d()
+
+ for i, seg in enumerate(self): # type: PathCommand
+ if start_zone:
+ first = seg.end_point(first, previous)
+
+ if isinstance(seg, (horz, Horz, Vert, vert)):
+ seg = seg.to_line(previous)
+
+ if seg.is_relative:
+ new_seg = (
+ seg.to_absolute(previous)
+ .transform(transform)
+ .to_relative(previous_new)
+ )
+ else:
+ new_seg = seg.transform(transform)
+
+ if start_zone:
+ first_new = new_seg.end_point(first_new, previous_new)
+
+ if inplace:
+ self[i] = new_seg
+ else:
+ result.append(new_seg)
+ previous = seg.end_point(first, previous)
+ previous_new = new_seg.end_point(first_new, previous_new)
+ start_zone = isinstance(seg, (zoneClose, ZoneClose))
+ if inplace:
+ return self
+ return result
+
+ def reverse(self):
+ """Returns a reversed path"""
+ result = Path()
+ *_, first = self.end_points
+ closer = None
+
+ # Go through the path in reverse order
+ for index, prcom in reversed(list(enumerate(self.proxy_iterator()))):
+ if isinstance(prcom.command, (Move, move, ZoneClose, zoneClose)):
+ if closer is not None:
+ if len(result) > 0 and isinstance(
+ result[-1], (Line, line, Vert, vert, Horz, horz)
+ ):
+ result.pop() # We can replace simple lines with Z
+ result.append(closer) # replace with same type (rel or abs)
+ if isinstance(prcom.command, (ZoneClose, zoneClose)):
+ closer = prcom.command
+ else:
+ closer = None
+
+ if index == 0:
+ if prcom.letter == "M":
+ result.insert(0, Move(first.x, first.y))
+ elif prcom.letter == "m":
+ result.insert(0, move(first.x, first.y))
+ else:
+ result.append(prcom.reverse())
+
+ return result
+
+ def close(self):
+ """Attempt to close the last path segment"""
+ if self and not isinstance(self[-1], (zoneClose, ZoneClose)):
+ self.append(ZoneClose())
+
+ def proxy_iterator(self):
+ """
+ Yields :py:class:`AugmentedPathIterator`
+
+ :rtype: Iterator[ Path.PathCommandProxy ]
+ """
+
+ previous = Vector2d()
+ prev_prev = Vector2d()
+ first = Vector2d()
+
+ for seg in self: # type: PathCommand#
+ if isinstance(seg, (zoneClose, ZoneClose, move, Move)):
+ first = seg.end_point(first, previous)
+ yield Path.PathCommandProxy(seg, first, previous, prev_prev)
+ if isinstance(
+ seg,
+ (
+ curve,
+ tepidQuadratic,
+ quadratic,
+ smooth,
+ Curve,
+ TepidQuadratic,
+ Quadratic,
+ Smooth,
+ ),
+ ):
+ prev_prev = list(seg.control_points(first, previous, prev_prev))[-2]
+ previous = seg.end_point(first, previous)
+
+ def to_absolute(self):
+ """Convert this path to use only absolute coordinates"""
+ return self._to_absolute(True)
+
+ def to_non_shorthand(self):
+ # type: () -> Path
+ """Convert this path to use only absolute non-shorthand coordinates
+
+ .. versionadded:: 1.1"""
+ return self._to_absolute(False)
+
+ def _to_absolute(self, shorthand: bool) -> Path:
+ """Make entire Path absolute.
+
+ Args:
+ shorthand (bool): If false, then convert all shorthand commands to
+ non-shorthand.
+
+ Returns:
+ Path: the input path, converted to absolute coordinates.
+ """
+
+ abspath = Path()
+
+ previous = Vector2d()
+ first = Vector2d()
+
+ for seg in self: # type: PathCommand
+ if isinstance(seg, (move, Move)):
+ first = seg.end_point(first, previous)
+
+ if shorthand:
+ abspath.append(seg.to_absolute(previous))
+ else:
+ if abspath and isinstance(abspath[-1], (Curve, Quadratic)):
+ prev_control = list(
+ abspath[-1].control_points(Vector2d(), Vector2d(), Vector2d())
+ )[-2]
+ else:
+ prev_control = previous
+
+ abspath.append(seg.to_non_shorthand(previous, prev_control))
+
+ previous = seg.end_point(first, previous)
+
+ return abspath
+
+ def to_relative(self):
+ """Convert this path to use only relative coordinates"""
+ abspath = Path()
+
+ previous = Vector2d()
+ first = Vector2d()
+
+ for seg in self: # type: PathCommand
+ if isinstance(seg, (move, Move)):
+ first = seg.end_point(first, previous)
+
+ abspath.append(seg.to_relative(previous))
+ previous = seg.end_point(first, previous)
+
+ return abspath
+
+ def __str__(self):
+ return " ".join([str(seg) for seg in self])
+
+ def __add__(self, other):
+ acopy = copy.deepcopy(self)
+ if isinstance(other, str):
+ other = Path(other)
+ if isinstance(other, list):
+ acopy.extend(other)
+ return acopy
+
+ def to_arrays(self):
+ """Returns path in format of parsePath output, returning arrays of absolute
+ command data
+
+ .. deprecated:: 1.0
+ This is compatibility function for older API. Should not be used in new code
+
+ """
+ return [[seg.letter, list(seg.args)] for seg in self.to_non_shorthand()]
+
+ def to_superpath(self):
+ """Convert this path into a cubic super path"""
+ return CubicSuperPath(self)
+
+ def copy(self):
+ """Make a copy"""
+ return copy.deepcopy(self)
+
+
+class CubicSuperPath(list):
+ """
+ A conversion of a path into a predictable list of cubic curves which
+ can be operated on as a list of simplified instructions.
+
+ When converting back into a path, all lines, arcs etc will be converted
+ to curve instructions.
+
+ Structure is held as [SubPath[(point_a, bezier, point_b), ...]], ...]
+ """
+
+ def __init__(self, items):
+ super().__init__()
+ self._closed = True
+ self._prev = Vector2d()
+ self._prev_prev = Vector2d()
+
+ if isinstance(items, str):
+ items = Path(items)
+
+ if isinstance(items, Path):
+ items = items.to_absolute()
+
+ for item in items:
+ self.append(item)
+
+ def __str__(self):
+ return str(self.to_path())
+
+ def append(self, item, force_shift=False):
+ """Accept multiple different formats for the data
+
+ .. versionchanged:: 1.2
+ ``force_shift`` parameter has been added
+ """
+ if isinstance(item, list) and len(item) == 2 and isinstance(item[0], str):
+ item = PathCommand.letter_to_class(item[0])(*item[1])
+ coordinate_shift = True
+ if isinstance(item, list) and len(item) == 3 and not force_shift:
+ coordinate_shift = False
+ is_quadratic = False
+ if isinstance(item, PathCommand):
+ if isinstance(item, Move):
+ if self._closed is False:
+ super().append([])
+ item = [list(item.args), list(item.args), list(item.args)]
+ elif isinstance(item, ZoneClose) and self and self[-1]:
+ # This duplicates the first segment to 'close' the path, it's appended
+ # directly because we don't want to last coord to change for the final
+ # segment.
+ self[-1].append(
+ [self[-1][0][0][:], self[-1][0][1][:], self[-1][0][2][:]]
+ )
+ # Then adds a new subpath for the next shape (if any)
+ self._closed = True
+ self._prev.assign(self._first)
+ return
+ elif isinstance(item, Arc):
+ # Arcs are made up of three curves (approximated)
+ for arc_curve in item.to_curves(self._prev, self._prev_prev):
+ x2, y2, x3, y3, x4, y4 = arc_curve.args
+ self.append([[x2, y2], [x3, y3], [x4, y4]], force_shift=True)
+ self._prev_prev.assign(x3, y3)
+ return
+ else:
+ is_quadratic = isinstance(
+ item, (Quadratic, TepidQuadratic, quadratic, tepidQuadratic)
+ )
+ if isinstance(item, (Horz, Vert)):
+ item = item.to_line(self._prev)
+ prp = self._prev_prev
+ if is_quadratic:
+ self._prev_prev = list(
+ item.control_points(self._first, self._prev, prp)
+ )[-2:-1][0]
+ item = item.to_curve(self._prev, prp)
+
+ if isinstance(item, Curve):
+ # Curves are cut into three tuples for the super path.
+ item = item.to_bez()
+
+ if not isinstance(item, list):
+ raise ValueError(f"Unknown super curve item type: {item}")
+
+ if len(item) != 3 or not all(len(bit) == 2 for bit in item):
+ # The item is already a subpath (usually from some other process)
+ if len(item[0]) == 3 and all(len(bit) == 2 for bit in item[0]):
+ super().append(self._clean(item))
+ self._prev_prev = Vector2d(self[-1][-1][0])
+ self._prev = Vector2d(self[-1][-1][1])
+ return
+ raise ValueError(f"Unknown super curve list format: {item}")
+
+ if self._closed:
+ # Closed means that the previous segment is closed so we need a new one
+ # We always append to the last open segment. CSP starts out closed.
+ self._closed = False
+ super().append([])
+
+ if coordinate_shift:
+ if self[-1]:
+ # The last tuple is replaced, it's the coords of where the next segment
+ # will land.
+ self[-1][-1][-1] = item[0][:]
+ # The last coord is duplicated, but is expected to be replaced
+ self[-1].append(item[1:] + copy.deepcopy(item)[-1:])
+ else:
+ # Item is already a csp segment and has already been shifted.
+ self[-1].append(copy.deepcopy(item))
+
+ self._prev = Vector2d(self[-1][-1][1])
+ if not is_quadratic:
+ self._prev_prev = Vector2d(self[-1][-1][0])
+
+ def _clean(self, lst):
+ """Recursively clean lists so they have the same type"""
+ if isinstance(lst, (tuple, list)):
+ return [self._clean(child) for child in lst]
+ return lst
+
+ @property
+ def _first(self):
+ try:
+ return Vector2d(self[-1][0][0])
+ except IndexError:
+ return Vector2d()
+
+ def to_path(self, curves_only=False, rtol=1e-5, atol=1e-8):
+ """Convert the super path back to an svg path
+
+ Arguments: see :func:`to_segments` for parameters"""
+ return Path(list(self.to_segments(curves_only, rtol, atol)))
+
+ def to_segments(self, curves_only=False, rtol=1e-5, atol=1e-8):
+ """Generate a set of segments for this cubic super path
+
+ Arguments:
+ curves_only (bool, optional): If False, curves that can be represented
+ by Lineto / ZoneClose commands, will be. Defaults to False.
+ rtol (float, optional): relative tolerance, passed to :func:`is_line` and
+ :func:`inkex.transforms.ImmutableVector2d.is_close` for checking if a
+ line can be replaced by a ZoneClose command. Defaults to 1e-5.
+
+ .. versionadded:: 1.2
+ atol: absolute tolerance, passed to :func:`is_line` and
+ :func:`inkex.transforms.ImmutableVector2d.is_close`. Defaults to 1e-8.
+
+ .. versionadded:: 1.2"""
+ for subpath in self:
+ previous = []
+ for segment in subpath:
+ if not previous:
+ yield Move(*segment[1][:])
+ elif self.is_line(previous, segment, rtol, atol) and not curves_only:
+ if segment is subpath[-1] and Vector2d(segment[1]).is_close(
+ subpath[0][1], rtol, atol
+ ):
+ yield ZoneClose()
+ else:
+ yield Line(*segment[1][:])
+ else:
+ yield Curve(*(previous[2][:] + segment[0][:] + segment[1][:]))
+ previous = segment
+
+ def transform(self, transform):
+ """Apply a transformation matrix to this super path"""
+ return self.to_path().transform(transform).to_superpath()
+
+ @staticmethod
+ def is_on(pt_a, pt_b, pt_c, tol=1e-8):
+ """Checks if point pt_a is on the line between points pt_b and pt_c
+
+ .. versionadded:: 1.2"""
+ return CubicSuperPath.collinear(pt_a, pt_b, pt_c, tol) and (
+ CubicSuperPath.within(pt_a[0], pt_b[0], pt_c[0])
+ if pt_a[0] != pt_b[0]
+ else CubicSuperPath.within(pt_a[1], pt_b[1], pt_c[1])
+ )
+
+ @staticmethod
+ def collinear(pt_a, pt_b, pt_c, tol=1e-8):
+ """Checks if points pt_a, pt_b, pt_c lie on the same line,
+ i.e. that the cross product (b-a) x (c-a) < tol
+
+ .. versionadded:: 1.2"""
+ return (
+ abs(
+ (pt_b[0] - pt_a[0]) * (pt_c[1] - pt_a[1])
+ - (pt_c[0] - pt_a[0]) * (pt_b[1] - pt_a[1])
+ )
+ < tol
+ )
+
+ @staticmethod
+ def within(val_b, val_a, val_c):
+ """Checks if float val_b is between val_a and val_c
+
+ .. versionadded:: 1.2"""
+ return val_a <= val_b <= val_c or val_c <= val_b <= val_a
+
+ @staticmethod
+ def is_line(previous, segment, rtol=1e-5, atol=1e-8):
+ """Check whether csp segment (two points) can be expressed as a line has retracted handles or the handles
+ can be retracted without loss of information (i.e. both handles lie on the
+ line)
+
+ .. versionchanged:: 1.2
+ Previously, it was only checked if both control points have retracted
+ handles. Now it is also checked if the handles can be retracted without
+ (visible) loss of information (i.e. both handles lie on the line connecting
+ the nodes).
+
+ Arguments:
+ previous: first node in superpath notation
+ segment: second node in superpath notation
+ rtol (float, optional): relative tolerance, passed to
+ :func:`inkex.transforms.ImmutableVector2d.is_close` for checking handle
+ retraction. Defaults to 1e-5.
+
+ .. versionadded:: 1.2
+ atol (float, optional): absolute tolerance, passed to
+ :func:`inkex.transforms.ImmutableVector2d.is_close` for checking handle
+ retraction and
+ :func:`inkex.paths.CubicSuperPath.is_on` for checking if all points
+ (nodes + handles) lie on a line. Defaults to 1e-8.
+
+ .. versionadded:: 1.2
+ """
+
+ retracted = Vector2d(previous[1]).is_close(
+ previous[2], rtol, atol
+ ) and Vector2d(segment[0]).is_close(segment[1], rtol, atol)
+
+ if retracted:
+ return True
+
+ # Can both handles be retracted without loss of information?
+ # Definitely the case if the handles lie on the same line as the two nodes and
+ # in the correct order
+ # E.g. cspbezsplitatlength outputs non-retracted handles when splitting a
+ # straight line
+ return CubicSuperPath.is_on(
+ segment[0], segment[1], previous[2], atol
+ ) and CubicSuperPath.is_on(previous[2], previous[1], segment[0], atol)
+
+
+def arc_to_path(point, params):
+ """Approximates an arc with cubic bezier segments.
+
+ Arguments:
+ point: Starting point (absolute coords)
+ params: Arcs parameters as per
+ https://www.w3.org/TR/SVG/paths.html#PathDataEllipticalArcCommands
+
+ Returns a list of triplets of points :
+ [control_point_before, node, control_point_after]
+ (first and last returned triplets are [p1, p1, *] and [*, p2, p2])
+ """
+
+ # pylint: disable=invalid-name, too-many-locals
+ A = point[:]
+ rx, ry, teta, longflag, sweepflag, x2, y2 = params[:]
+ teta = teta * pi / 180.0
+ B = [x2, y2]
+ # Degenerate ellipse
+ if rx == 0 or ry == 0 or A == B:
+ return [[A[:], A[:], A[:]], [B[:], B[:], B[:]]]
+
+ # turn coordinates so that the ellipse morph into a *unit circle* (not 0-centered)
+ mat = matprod((rotmat(teta), [[1.0 / rx, 0.0], [0.0, 1.0 / ry]], rotmat(-teta)))
+ applymat(mat, A)
+ applymat(mat, B)
+
+ k = [-(B[1] - A[1]), B[0] - A[0]]
+ d = k[0] * k[0] + k[1] * k[1]
+ k[0] /= sqrt(d)
+ k[1] /= sqrt(d)
+ d = sqrt(max(0, 1 - d / 4.0))
+ # k is the unit normal to AB vector, pointing to center O
+ # d is distance from center to AB segment (distance from O to the midpoint of AB)
+ # for the last line, remember this is a unit circle, and kd vector is ortogonal to
+ # AB (Pythagorean thm)
+
+ if longflag == sweepflag:
+ # top-right ellipse in SVG example
+ # https://www.w3.org/TR/SVG/images/paths/arcs02.svg
+ d *= -1
+
+ O = [(B[0] + A[0]) / 2.0 + d * k[0], (B[1] + A[1]) / 2.0 + d * k[1]]
+ OA = [A[0] - O[0], A[1] - O[1]]
+ OB = [B[0] - O[0], B[1] - O[1]]
+ start = acos(OA[0] / norm(OA))
+ if OA[1] < 0:
+ start *= -1
+ end = acos(OB[0] / norm(OB))
+ if OB[1] < 0:
+ end *= -1
+ # start and end are the angles from center of the circle to A and to B respectively
+
+ if sweepflag and start > end:
+ end += 2 * pi
+ if (not sweepflag) and start < end:
+ end -= 2 * pi
+
+ NbSectors = int(abs(start - end) * 2 / pi) + 1
+ dTeta = (end - start) / NbSectors
+ v = 4 * tan(dTeta / 4.0) / 3.0
+ # I would use v = tan(dTeta/2)*4*(sqrt(2)-1)/3 ?
+ p = []
+ for i in range(0, NbSectors + 1, 1):
+ angle = start + i * dTeta
+ v1 = [
+ O[0] + cos(angle) - (-v) * sin(angle),
+ O[1] + sin(angle) + (-v) * cos(angle),
+ ]
+ pt = [O[0] + cos(angle), O[1] + sin(angle)]
+ v2 = [O[0] + cos(angle) - v * sin(angle), O[1] + sin(angle) + v * cos(angle)]
+ p.append([v1, pt, v2])
+ p[0][0] = p[0][1][:]
+ p[-1][2] = p[-1][1][:]
+
+ # go back to the original coordinate system
+ mat = matprod((rotmat(teta), [[rx, 0], [0, ry]], rotmat(-teta)))
+ for pts in p:
+ applymat(mat, pts[0])
+ applymat(mat, pts[1])
+ applymat(mat, pts[2])
+ return p
+
+
+def matprod(mlist):
+ """Get the product of the mat"""
+ prod = mlist[0]
+ for mat in mlist[1:]:
+ a00 = prod[0][0] * mat[0][0] + prod[0][1] * mat[1][0]
+ a01 = prod[0][0] * mat[0][1] + prod[0][1] * mat[1][1]
+ a10 = prod[1][0] * mat[0][0] + prod[1][1] * mat[1][0]
+ a11 = prod[1][0] * mat[0][1] + prod[1][1] * mat[1][1]
+ prod = [[a00, a01], [a10, a11]]
+ return prod
+
+
+def rotmat(teta):
+ """Rotate the mat"""
+ return [[cos(teta), -sin(teta)], [sin(teta), cos(teta)]]
+
+
+def applymat(mat, point):
+ """Apply the given mat"""
+ x = mat[0][0] * point[0] + mat[0][1] * point[1]
+ y = mat[1][0] * point[0] + mat[1][1] * point[1]
+ point[0] = x
+ point[1] = y
+
+
+def norm(point):
+ """Normalise"""
+ return sqrt(point[0] * point[0] + point[1] * point[1])