From cca66b9ec4e494c1d919bff0f71a820d8afab1fa Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sun, 7 Apr 2024 20:24:48 +0200 Subject: Adding upstream version 1.2.2. Signed-off-by: Daniel Baumann --- share/extensions/inkex/paths.py | 2018 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 2018 insertions(+) create mode 100644 share/extensions/inkex/paths.py (limited to 'share/extensions/inkex/paths.py') 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 +# +# 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]) -- cgit v1.2.3