Source code for inkex.paths

# 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

if False:  # pylint: disable=using-constant-test
    from typing import Type, Dict, Optional, Union, Tuple, List  # pylint: disable=unused-import
    from typing import TypeVar

    T = TypeVar('T')

# All the names that get added to the inkex API itself.
__all__ = ('Path', 'CubicSuperPath')

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 = {}

    @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) -> List[Vector2d]
        """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.
         Updated at the end of call.
        :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, Optional[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, Optional[Vector2d]) -> List[Curve]
        """Convert command to list of :py:class:`Curve` commands """
        return [self.to_curve(prev, prev_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) -> List[Vector2d]
        return self.to_absolute(prev).control_points(first, prev, prev_prev)

    def to_relative(self, prev):  # type: (T, Vector2d) -> T
        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, Optional[Vector2d]) -> "Curve"
        return self.to_absolute(prev).to_curve(prev)

    def to_curves(self, prev, prev_prev=Vector2d()):  # type: (Vector2d, Optional[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: (T, Vector2d) -> T
        return self.__class__(*self.args)

    def transform(self, transform):  # type: (T, Transform) -> T
        """Returns new transformed segment

        :param transform: a transformation to apply
        """
        raise NotImplementedError()

    def rotate(self, degrees, center):  # type: (T, float, Vector2d) -> T
        """
        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: (T, Vector2d) -> T
        """Translate or scale this path command by dr"""
        return self.transform(Transform(translate=dr))

    def scale(self, factor):  # type: (T, Union[float, Tuple[float,float]]) -> T
        """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))
        last_two_points[-1] = Vector2d(self.x, self.y)

    def control_points(self, first, prev, prev_prev):  # type: (Vector2d, Vector2d, Vector2d) -> List[Vector2d]
        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: (T, Transform) -> T
        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)
        last_two_points[-1] = Vector2d(self.x, self.y)

    def control_points(self, first, prev, prev_prev):  # type: (Vector2d, Vector2d, Vector2d) -> List[Vector2d]
        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: (T, Transform) -> T
        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):
        last_two_points[-1] = first

    def transform(self, transform):  # type: (T, Transform) -> T
        return ZoneClose()

    def control_points(self, first, prev, prev_prev):  # type: (Vector2d, Vector2d, Vector2d) -> List[Vector2d]
        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)
        last_two_points[-1] = Vector2d(self.x, last_two_points[-1].y)

    def control_points(self, first, prev, prev_prev):  # type: (Vector2d, Vector2d, Vector2d) -> List[Vector2d]
        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: (T, Transform) -> T
        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))
        last_two_points[-1] = Vector2d(last_two_points[-1].x, self.y)

    def transform(self, transform):  # type: (T, Transform) -> T
        raise ValueError("Vertical lines can't be transformed directly.")

    def control_points(self, first, prev, prev_prev):  # type: (Vector2d, Vector2d, Vector2d) -> List[Vector2d]
        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)

        last_two_points[-2] = Vector2d(x3, y3)
        last_two_points[-1] = Vector2d(x4, y4)

    def transform(self, transform):  # type: (T, Transform) -> T
        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) -> List[Vector2d]
        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"""
        prev_prev.x, prev_prev.y = self.x3, self.y3
        return Curve(*self.args)


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) -> List[Vector2d]

        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: (T, Transform) -> T
        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, Optional[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)
        prev_prev.x, prev_prev.y = self.x3, self.y3
        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)

        last_two_points[-2] = Vector2d(x2, y2)
        last_two_points[-1] = Vector2d(x3, y3)

    def control_points(self, first, prev, prev_prev):  # type: (Vector2d, Vector2d, Vector2d) -> List[Vector2d]
        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: (T, Transform) -> T
        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, Optional[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
        prev_prev.x, prev_prev.y = self.x2, self.y2
        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) -> List[Vector2d]

        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: (T, Transform) -> T
        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, Optional[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)
        prev_prev.x, prev_prev.y = x2, y2
        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):
        for seg in self.to_curves(prev=last_two_points[-1]):
            seg.update_bounding_box(first, last_two_points, bbox)

    def control_points(self, first, prev, prev_prev):  # type: (Vector2d, Vector2d, Vector2d) -> List[Vector2d]
        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()
        prev_prev.x, prev_prev.y = path[-1].x3, path[-1].y3
        # Ignore the first move command from to_path()
        return list(path)[1:]

    def transform(self, transform):  # type: (T, Transform) -> T
        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
        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
}


[docs]class Path(list): """A list of segment commands which combine to draw a shape""" 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))
[docs] @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
[docs] def bounding_box(self): """Return the top,left and bottom,right coords""" bbox = BoundingBox() abspath = self.to_absolute() if len(abspath) > 0: assert isinstance(abspath[0], Move) first = Vector2d(abspath[0].x, abspath[0].y) prev_points = [Vector2d(0, 0), first] bbox += BoundingBox(abspath[0].x, abspath[0].y) for seg in abspath[1:]: seg.update_bounding_box(first, prev_points, bbox) return bbox
[docs] 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)
[docs] 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)
[docs] 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)
[docs] 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
[docs] def transform(self, transform, inplace=False): """Convert to new path""" result = Path() previous = Vector2d() previous_new = Vector2d() first = 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 inplace: self[i] = new_seg else: result.append(new_seg) previous = seg.end_point(first, previous) previous_new = new_seg.end_point(first, previous_new) if inplace: return self else: return result
[docs] def reverse(self): """Returns a reversed path""" pass
[docs] def to_absolute(self): """Convert this path to use only absolute coordinates""" abspath = Path() previous = Vector2d() first = Vector2d() for i, seg in enumerate(self): # type: PathCommand if i == 0: first = seg.end_point(first, previous) abspath.append(seg.to_absolute(previous)) previous = seg.end_point(first, previous) return abspath
[docs] def to_relative(self): """Convert this path to use only relative coordinates""" abspath = Path() previous = Vector2d() first = Vector2d() for i, seg in enumerate(self): # type: PathCommand if i == 0: 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
[docs] 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()]
[docs] def to_superpath(self): """Convert this path into a cubic super path""" return CubicSuperPath(self)
[docs] def copy(self): """Make a copy""" return copy.deepcopy(self)
[docs]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 coverted 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())
[docs] 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): 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]]) return else: is_quadratic = isinstance(item, (Quadratic, TepidQuadratic, quadratic, tepidQuadratic)) if isinstance(item, (Horz, Vert)): item = item.to_line(self._prev) item = item.to_curve(self._prev, self._prev_prev) if isinstance(item, Curve): # Curves are cut into three tuples for the super path. item = [list(item.args[:2]), list(item.args[2:4]), list(item.args[4:6])] 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(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])
@property def _first(self): try: return Vector2d(self[-1][0][0]) except IndexError: return Vector2d()
[docs] def to_path(self): """Convert the super path back to an svg path""" return Path(list(self.to_segments()))
[docs] def to_segments(self): """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][:]) else: yield Curve(*(previous[2][:] + segment[0][:] + segment[1][:])) previous = segment
[docs] def transform(self, transform): """Apply a transformation matrix to this super path""" return self.to_path().transform(transform).to_superpath()
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])