# 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 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
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])