diff options
Diffstat (limited to 'share/extensions/inkex/paths.py')
-rw-r--r-- | share/extensions/inkex/paths.py | 1547 |
1 files changed, 1547 insertions, 0 deletions
diff --git a/share/extensions/inkex/paths.py b/share/extensions/inkex/paths.py new file mode 100644 index 0000000..b3620e8 --- /dev/null +++ b/share/extensions/inkex/paths.py @@ -0,0 +1,1547 @@ +# 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 into a simple list structure +""" + +import re +import copy + +from math import atan2, cos, pi, sin, sqrt, acos, tan + +from .transforms import Transform, BoundingBox, Vector2d +from .utils import classproperty, strargs + +try: # pylint: disable=using-constant-test + from typing import overload, Any, Type, Dict, Optional, Union, Tuple, List, Iterator, Generator # pylint: disable=unused-import + from typing import TypeVar + Pathlike = TypeVar('Pathlike', bound="PathCommand") + AbsolutePathlike = TypeVar('AbsolutePathlike', bound="AbsolutePathCommand") +except ImportError: + overload = lambda x: x + +# All the names that get added to the inkex API itself. +__all__ = ( + 'Path', 'CubicSuperPath', + # 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(object): + """ + Base class of all path commands + """ + + # Number of arguments that follow this path commands letter + nargs = -1 + + # The full name of the segment (i.e. Line, Arc, etc) + name = classproperty(lambda cls: cls.__name__) + + # The single letter representation of this command (i.e. L, A, etc) + letter = classproperty(lambda cls: cls.name[0]) + + # 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. + @classproperty + def next_command(self): + return self + + @property + def is_relative(self): # type: () -> bool + raise NotImplementedError + + @property + def is_absolute(self): # type: () -> bool + 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 + + # 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, prev, prev_prev): + # type: (Vector2d, Vector2d, 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 "{} {}".format(self.letter, self._argt(" ").format(*self.args)).strip() + + def __repr__(self): + 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, last_two_points, bbox): + # type: (Vector2d, List[Vector2d], BoundingBox) -> None + # pylint: disable=unused-argument + """ + Enlarges given bbox to contain path element. + + :param (tuple of float) first: first point of path. Required to calculate Z segment + :param (list of tuple) last_two_points: list with last two control points in abs coords. + :param (BoundingBox) bbox: bounding box to update + """ + raise NotImplementedError("Bounding box is not implemented for {}".format(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("To curve not supported for {}".format(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, prev, prev_prev): + # type: (Vector2d, Vector2d, 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, previous): # 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) + + + +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) + + +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.") + + +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) + + +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.") + + +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() + + +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 transform(self, transformation): + # 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) + + +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_line(self, prev): # type: (Vector2d) -> Line + """Return this path command as a Line instead""" + return Line(prev.x + self.dx, prev.y) + + +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_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) + + +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_line(self, prev): # type: (Vector2d) -> Line + """Return this path command as a line instead""" + return Line(prev.x, prev.y + 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): + 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): + from .transforms import cubic_extrema + + 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])] + +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): + 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 + ) + + +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_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) + + +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 + ) + + +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): + from .transforms import quadratic_extrema + + 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. / 3 * prev.x + 2. / 3 * self.x2 + x2 = 2. / 3 * self.x2 + 1. / 3 * self.x3 + y1 = 1. / 3 * prev.y + 2. / 3 * self.y2 + y2 = 2. / 3 * self.y2 + 1. / 3 * self.y3 + return Curve(x1, y1, x2, y2, self.x3, self.y3) + + +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 + ) + + +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_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) + + +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 + ) + + +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): + 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 + 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) + + +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): + 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) + + +PathCommand._letter_to_class = { + "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(object): + """ + A handy class for Path traverse and coordinate access + + Reduces number of arguments in user code compared to bare :py: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): + return self.command.name + + @property + def letter(self): + return self.command.letter + + @property + def next_command(self): + return self.command.next_command + + @property + def is_relative(self): + return self.command.is_relative + + @property + def is_absolute(self): + return self.command.is_absolute + + @property + def args(self): + return self.command.args + + @property + def control_points(self): + return self.command.control_points(self.first_point, self.previous_end_point, self.prev2_control_point) + + @property + def end_point(self): + return self.command.end_point(self.first_point, self.previous_end_point) + + def to_curve(self): + return self.command.to_curve(self.previous_end_point, self.prev2_control_point) + + def to_curves(self): + return self.command.to_curves(self.previous_end_point, self.prev2_control_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(Path, self).__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("Bad path type: {}({}, ...): {}".format( + type(path_d).__name__, 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: + 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(Path, self).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): + + prev = Vector2d() + prev_prev = Vector2d() + first = Vector2d() + + for i, seg in enumerate(self): # type: PathCommand + if i == 0: + first = seg.end_point(first, prev) + for cp in seg.control_points(first, prev, prev_prev): + prev_prev = prev + prev = cp + yield cp + + @property + def end_points(self): + prev = Vector2d() + first = Vector2d() + + for i, seg in enumerate(self): # type: PathCommand + if i == 0: + first = seg.end_point(first, prev) + end_point = seg.end_point(first, prev) + prev = end_point + yield end_point + + def transform(self, transform, inplace=False): + """Convert to new path""" + result = Path() + previous = Vector2d() + previous_new = Vector2d() + first = Vector2d() + first_new = Vector2d() + + for i, seg in enumerate(self): # type: PathCommand + if i == 0: + 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 i == 0: + 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) + if inplace: + return self + return result + + def reverse(self): + """Returns a reversed path""" + pass + + 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 i, seg in enumerate(self): # type: PathCommand + if i == 0: + prev_prev = previous = 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""" + 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_absolute(previous)) + 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_absolute()] + + 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(CubicSuperPath, self).__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): + """Accept multiple different formats for the data""" + if isinstance(item, list) and len(item) == 2 and isinstance(item[0], str): + item = PathCommand.letter_to_class(item[0])(*item[1]) + is_quadratic = False + if isinstance(item, PathCommand): + if isinstance(item, Move): + if self._closed is False: + super(CubicSuperPath, self).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]]) + 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) + pp = self._prev_prev + if is_quadratic: + self._prev_prev = list(item.control_points(self._first, self._prev, pp))[-2:-1][0] + item = item.to_curve(self._prev, pp) + + 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("Unknown super curve item type: {}".format(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(CubicSuperPath, self).append(self._clean(item)) + self._prev_prev = Vector2d(self[-1][-1][0]) + self._prev = Vector2d(self[-1][-1][1]) + return + raise ValueError("Unknown super curve list format: {}".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(CubicSuperPath, self).append([]) + + 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:]) + + 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): + """Convert the super path back to an svg path""" + return Path(list(self.to_segments(curves_only))) + + def to_segments(self, curves_only=False): + """Generate a set of segments for this cubic super path""" + for subpath in self: + previous = [] + for segment in subpath: + if not previous: + yield Move(*segment[1][:]) + elif self.is_line(previous, segment) and not curves_only: + if segment is subpath[-1] and Vector2d(segment[1]).is_close(subpath[0][1]): + 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_line(previous, segment): + """Check whether csp segment (two points) has retracted handles.""" + return Vector2d(previous[1]).is_close(previous[2]) and \ + Vector2d(segment[0]).is_close(segment[1]) + +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]) + """ + 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.) / 3. + # 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]) |