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