bundle: update (2026-01-18)
This commit is contained in:
937
extensions/botbox3000/deps/inkex/paths/path.py
Normal file
937
extensions/botbox3000/deps/inkex/paths/path.py
Normal file
@@ -0,0 +1,937 @@
|
||||
# coding=utf-8
|
||||
#
|
||||
# Copyright (C) 2018 Martin Owens <doctormo@gmail.com>
|
||||
# Copyright (C) 2023 Jonathan Neuhauser <jonathan.neuhauser@outlook.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.
|
||||
#
|
||||
"""
|
||||
Path and CubicSuperPath classes
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import copy
|
||||
import warnings
|
||||
from cmath import isclose
|
||||
|
||||
from typing import Optional, Tuple, List, TypeVar, Iterator, Callable, Union
|
||||
from ..transforms import (
|
||||
Transform,
|
||||
BoundingBox,
|
||||
Vector2d,
|
||||
ComplexLike,
|
||||
)
|
||||
from ..utils import strargs
|
||||
|
||||
from .lines import Line, Move, move, ZoneClose, zoneClose
|
||||
from .curves import Curve
|
||||
from .interfaces import (
|
||||
ILengthSettings,
|
||||
LengthSettings,
|
||||
PathCommand,
|
||||
AbsolutePathCommand,
|
||||
)
|
||||
|
||||
Pathlike = TypeVar("Pathlike", bound="PathCommand")
|
||||
AbsolutePathlike = TypeVar("AbsolutePathlike", bound="AbsolutePathCommand")
|
||||
|
||||
LEX_REX = re.compile(r"([MLHVCSQTAZmlhvcsqtaz])([^MLHVCSQTAZmlhvcsqtaz]*)")
|
||||
|
||||
|
||||
class InvalidPath(ValueError):
|
||||
"""Raised when given an invalid path string"""
|
||||
|
||||
|
||||
class Path(list):
|
||||
"""A list of segment commands which combine to draw a shape"""
|
||||
|
||||
callback: Optional[Callable] = None
|
||||
|
||||
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: PathCommand,
|
||||
first_point: ComplexLike,
|
||||
previous_end_point: ComplexLike,
|
||||
prev2_control_point: ComplexLike,
|
||||
):
|
||||
self.command = command
|
||||
self.cfirst_point = complex(first_point)
|
||||
self.cprevious_end_point = complex(previous_end_point)
|
||||
self.cprev2_control_point = complex(prev2_control_point)
|
||||
|
||||
@property
|
||||
def first_point(self) -> Vector2d:
|
||||
"""First point of the current subpath"""
|
||||
return Vector2d(self.cfirst_point)
|
||||
|
||||
@property
|
||||
def previous_end_point(self) -> Vector2d:
|
||||
"""End point of the previous command"""
|
||||
return Vector2d(self.cprevious_end_point)
|
||||
|
||||
@property
|
||||
def prev2_control_point(self) -> Vector2d:
|
||||
"""Last control point of the previous command"""
|
||||
return Vector2d(self.cprev2_control_point)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""The full name of the segment (i.e. Line, Arc, etc)"""
|
||||
return self.command.name
|
||||
|
||||
@property
|
||||
def letter(self) -> str:
|
||||
"""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) -> bool:
|
||||
"""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) -> bool:
|
||||
"""Whether the command is defined in absolute coordinates (upper case path
|
||||
command letter)"""
|
||||
return self.command.is_absolute
|
||||
|
||||
@property
|
||||
def args(self) -> List[float]:
|
||||
"""Returns path command arguments as tuple of floats"""
|
||||
return self.command.args
|
||||
|
||||
@property
|
||||
def control_points(self) -> List[Vector2d]:
|
||||
"""Returns list of path command control points"""
|
||||
return list(
|
||||
self.command.control_points(
|
||||
self.cfirst_point,
|
||||
self.cprevious_end_point,
|
||||
self.cprev2_control_point,
|
||||
)
|
||||
)
|
||||
|
||||
@property
|
||||
def end_point(self) -> Vector2d:
|
||||
"""Returns last control point of path command"""
|
||||
return Vector2d(self.cend_point)
|
||||
|
||||
@property
|
||||
def cend_point(self) -> complex:
|
||||
"""Returns last control point of path command (in complex form)"""
|
||||
return self.command.cend_point(self.cfirst_point, self.cprevious_end_point)
|
||||
|
||||
def reverse(self) -> PathCommand:
|
||||
"""Reverse path command"""
|
||||
return self.command.reverse(self.cend_point, self.cprevious_end_point)
|
||||
|
||||
def to_curve(self) -> Curve:
|
||||
"""Convert command to :py:class:`Curve`
|
||||
Curve().to_curve() returns a copy
|
||||
"""
|
||||
return self.command.to_curve(
|
||||
self.cprevious_end_point, self.cprev2_control_point
|
||||
)
|
||||
|
||||
def to_curves(self) -> List[Curve]:
|
||||
"""Convert command to list of :py:class:`Curve` commands"""
|
||||
return self.command.to_curves(
|
||||
self.cprevious_end_point, self.cprev2_control_point
|
||||
)
|
||||
|
||||
def to_absolute(self) -> AbsolutePathCommand:
|
||||
"""Return relative counterpart for relative commands or copy for absolute"""
|
||||
return self.command.to_absolute(self.cprevious_end_point)
|
||||
|
||||
def to_non_shorthand(self) -> AbsolutePathCommand:
|
||||
"""Returns an absolute non-shorthand command
|
||||
|
||||
.. versionadded:: 1.4"""
|
||||
return self.command.to_non_shorthand(
|
||||
self.cprevious_end_point, self.cprev2_control_point
|
||||
)
|
||||
|
||||
def split(self, time) -> Tuple[Path.PathCommandProxy, Path.PathCommandProxy]:
|
||||
"""Split this path command into two PathCommandProxy segments.
|
||||
Raises ValueError for Move commands.
|
||||
|
||||
.. versionadded:: 1.4"""
|
||||
result = self.command.split(
|
||||
self.cfirst_point,
|
||||
self.cprevious_end_point,
|
||||
self.cprev2_control_point,
|
||||
time,
|
||||
)
|
||||
p1 = Path.PathCommandProxy(
|
||||
result[0],
|
||||
self.cfirst_point,
|
||||
self.previous_end_point,
|
||||
self.prev2_control_point,
|
||||
)
|
||||
prev2: ComplexLike = (
|
||||
0j if len(p1.control_points) < 2 else p1.control_points[-2]
|
||||
)
|
||||
p2 = Path.PathCommandProxy(
|
||||
result[1], self.cfirst_point, p1.end_point, prev2
|
||||
)
|
||||
return (p1, p2)
|
||||
|
||||
def cpoint(self, time) -> complex:
|
||||
"""Returns the coordinates of the Bezier curve evaluated at t as complex number.
|
||||
|
||||
.. versionadded:: 1.4"""
|
||||
return self.command.cpoint(
|
||||
self.cfirst_point,
|
||||
self.cprevious_end_point,
|
||||
self.cprev2_control_point,
|
||||
time,
|
||||
)
|
||||
|
||||
def point(self, time) -> Vector2d:
|
||||
"""Returns the coordinates of the Bezier curve evaluated at t as :class:`Vector2d`.
|
||||
|
||||
.. versionadded:: 1.4"""
|
||||
return self.command.point(
|
||||
self.cfirst_point,
|
||||
self.cprevious_end_point,
|
||||
self.cprev2_control_point,
|
||||
time,
|
||||
)
|
||||
|
||||
def length(self, t0=0, t1=1, settings=LengthSettings()) -> float:
|
||||
"""Get the length of the command between t0 and t1 in user units
|
||||
|
||||
.. versionadded:: 1.4"""
|
||||
return self.command.length(
|
||||
self.cfirst_point,
|
||||
self.cprevious_end_point,
|
||||
self.cprev2_control_point,
|
||||
t0,
|
||||
t1,
|
||||
settings,
|
||||
)
|
||||
|
||||
def ilength(self, length, settings=ILengthSettings()) -> float:
|
||||
"""Tries to compute the time t at which the path segment has the given
|
||||
length along its trajectory
|
||||
|
||||
.. versionadded:: 1.4"""
|
||||
return self.command.ilength(
|
||||
self.cfirst_point,
|
||||
self.cprevious_end_point,
|
||||
self.cprev2_control_point,
|
||||
length,
|
||||
settings,
|
||||
)
|
||||
|
||||
def cunit_tangent(self, t) -> complex:
|
||||
"""Returns the unit tangent at t as complex number
|
||||
|
||||
.. versionadded:: 1.4"""
|
||||
return self.command.cunit_tangent(
|
||||
self.cfirst_point,
|
||||
self.cprevious_end_point,
|
||||
self.cprev2_control_point,
|
||||
t,
|
||||
)
|
||||
|
||||
def unit_tangent(self, t) -> Vector2d:
|
||||
"""Returns the unit tangent at t as :class:`inkex.Vector2D`
|
||||
|
||||
.. versionadded:: 1.4"""
|
||||
return self.command.unit_tangent(
|
||||
self.cfirst_point,
|
||||
self.cprevious_end_point,
|
||||
self.cprev2_control_point,
|
||||
t,
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return str(self.command)
|
||||
|
||||
def __repr__(self):
|
||||
return "<" + self.__class__.__name__ + ">" + repr(self.command)
|
||||
|
||||
def __init__(self, path_d=None) -> 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:
|
||||
if len(self) == 0:
|
||||
self.append(Move(*item))
|
||||
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) -> 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(
|
||||
complex(proxy.first_point),
|
||||
[
|
||||
proxy.cprev2_control_point,
|
||||
proxy.cprevious_end_point,
|
||||
],
|
||||
bbox,
|
||||
)
|
||||
except StopIteration:
|
||||
return bbox
|
||||
|
||||
def append(self, cmd):
|
||||
"""Append a command to this path."""
|
||||
try:
|
||||
cmd.letter # pylint: disable=pointless-statement
|
||||
super().append(cmd)
|
||||
except AttributeError:
|
||||
self.extend(cmd)
|
||||
warnings.warn(
|
||||
"Passing a list to Path.add is deprecated, please use Path.extend",
|
||||
category=DeprecationWarning,
|
||||
)
|
||||
|
||||
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) -> Iterator[Vector2d]:
|
||||
"""Returns all control points of the Path"""
|
||||
prev: complex = 0
|
||||
prev_prev: complex = 0
|
||||
first: complex = 0
|
||||
|
||||
seg: PathCommand
|
||||
for seg in self:
|
||||
cpts = seg.ccontrol_points(first, prev, prev_prev)
|
||||
if seg.letter in "zZmM":
|
||||
first = cpts[-1]
|
||||
for cpt in cpts:
|
||||
prev_prev = prev
|
||||
prev = cpt
|
||||
yield Vector2d(cpt)
|
||||
|
||||
@property
|
||||
def cend_points(self) -> Iterator[complex]:
|
||||
"""Complex version of end_points"""
|
||||
prev = 0j
|
||||
first = 0j
|
||||
|
||||
seg: PathCommand
|
||||
for seg in self:
|
||||
end_point = seg.cend_point(first, prev)
|
||||
if seg.letter in "zZmM":
|
||||
first = end_point
|
||||
prev = end_point
|
||||
yield end_point
|
||||
|
||||
@property
|
||||
def end_points(self) -> Iterator[Vector2d]:
|
||||
"""Returns all endpoints of all path commands (i.e. the nodes)"""
|
||||
for i in self.cend_points:
|
||||
yield Vector2d(i)
|
||||
|
||||
def transform(self, transform, inplace=False):
|
||||
"""Convert to new path"""
|
||||
result = Path()
|
||||
previous = 0j
|
||||
previous_new = 0j
|
||||
start_zone = True
|
||||
first = 0j
|
||||
first_new = 0j
|
||||
|
||||
seg: PathCommand
|
||||
for i, seg in enumerate(self):
|
||||
if start_zone:
|
||||
first = seg.cend_point(first, previous)
|
||||
|
||||
if seg.letter in "hHVv":
|
||||
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.cend_point(first_new, previous_new)
|
||||
|
||||
if inplace:
|
||||
self[i] = new_seg
|
||||
else:
|
||||
result.append(new_seg)
|
||||
previous = seg.cend_point(first, previous)
|
||||
previous_new = new_seg.cend_point(first_new, previous_new)
|
||||
start_zone = seg.letter in "zZ"
|
||||
if inplace:
|
||||
return self
|
||||
return result
|
||||
|
||||
def reverse(self):
|
||||
"""Returns a reversed path"""
|
||||
result = Path()
|
||||
try:
|
||||
*_, first = self.cend_points
|
||||
except ValueError:
|
||||
# Empty path, return empty path
|
||||
return result
|
||||
closer = None
|
||||
|
||||
# Go through the path in reverse order
|
||||
for index, prcom in reversed(list(enumerate(self.proxy_iterator()))):
|
||||
if prcom.letter in "MmZz":
|
||||
if closer is not None:
|
||||
if len(result) > 0 and result[-1].letter in "LlVvHh":
|
||||
result.pop() # We can replace simple lines with Z
|
||||
result.append(closer) # replace with same type (rel or abs)
|
||||
if prcom.letter in "Zz":
|
||||
closer = prcom.command
|
||||
else:
|
||||
closer = None
|
||||
|
||||
if index == 0:
|
||||
if prcom.letter == "M":
|
||||
result.insert(0, Move(first))
|
||||
elif prcom.letter == "m":
|
||||
result.insert(0, move(first))
|
||||
else:
|
||||
result.append(prcom.reverse())
|
||||
|
||||
return result
|
||||
|
||||
def break_apart(self) -> List[Path]:
|
||||
"""Breaks apart a path into its subpaths
|
||||
|
||||
.. versionadded:: 1.3"""
|
||||
result = [Path()]
|
||||
current = result[0]
|
||||
|
||||
for cmnd in self.proxy_iterator():
|
||||
if cmnd.letter.lower() == "m":
|
||||
current = Path()
|
||||
result.append(current)
|
||||
current.append(Move(cmnd.cend_point))
|
||||
else:
|
||||
current.append(cmnd.command)
|
||||
# Remove all subpaths that are empty or only contain move commands
|
||||
return [
|
||||
i
|
||||
for i in result
|
||||
if len(i) != 0 and not all(j.letter.lower() == "m" for j in i)
|
||||
]
|
||||
|
||||
def close(self):
|
||||
"""Attempt to close the last path segment"""
|
||||
if self and not self[-1].letter in "zZ":
|
||||
self.append(ZoneClose())
|
||||
|
||||
def proxy_iterator(self) -> Iterator[PathCommandProxy]:
|
||||
"""
|
||||
Yields :py:class:`AugmentedPathIterator`
|
||||
|
||||
:rtype: Iterator[ Path.PathCommandProxy ]
|
||||
"""
|
||||
|
||||
previous = 0j
|
||||
prev_prev = 0j
|
||||
first = 0j
|
||||
seg: PathCommand
|
||||
for seg in self:
|
||||
if seg.letter in "zZmM":
|
||||
first = seg.cend_point(first, previous)
|
||||
yield Path.PathCommandProxy(seg, first, previous, prev_prev)
|
||||
if seg.letter in "ctqsCTQS":
|
||||
prev_prev = seg.ccontrol_points(first, previous, prev_prev)[-2]
|
||||
previous = seg.cend_point(first, previous)
|
||||
|
||||
def subpath_iterator(self):
|
||||
"""Yield Path for each subpath."""
|
||||
start_id = 0
|
||||
|
||||
for i, seg in enumerate(self):
|
||||
if isinstance(seg, (move, Move)):
|
||||
if start_id > -1 and i > 0: # add previous path (open path)
|
||||
yield Path(self[start_id:i])
|
||||
start_id = i
|
||||
elif isinstance(seg, (zoneClose, ZoneClose)): # add current path (closed)
|
||||
yield Path(self[start_id : i + 1])
|
||||
start_id = -1
|
||||
elif i == len(self) - 1 and start_id > -1: # add last path (open)
|
||||
yield Path(self[start_id:])
|
||||
|
||||
def to_absolute(self):
|
||||
"""Convert this path to use only absolute coordinates"""
|
||||
return self._to_absolute(True)
|
||||
|
||||
def to_non_shorthand(self) -> 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 = 0j
|
||||
first = 0j
|
||||
seg: PathCommand
|
||||
for seg in self:
|
||||
if seg.letter in "mM":
|
||||
first = seg.cend_point(first, previous)
|
||||
|
||||
if shorthand:
|
||||
abspath.append(seg.to_absolute(previous))
|
||||
else:
|
||||
if abspath and abspath[-1].letter in "QC":
|
||||
prev_control = list(abspath[-1].control_points(0, 0, 0))[-2]
|
||||
else:
|
||||
prev_control = previous
|
||||
|
||||
abspath.append(seg.to_non_shorthand(previous, prev_control))
|
||||
|
||||
previous = seg.cend_point(first, previous)
|
||||
|
||||
return abspath
|
||||
|
||||
def to_relative(self):
|
||||
"""Convert this path to use only relative coordinates"""
|
||||
abspath = Path()
|
||||
|
||||
previous = 0j
|
||||
first = 0j
|
||||
seg: PathCommand
|
||||
for seg in self:
|
||||
if seg.letter in "mM":
|
||||
first = seg.cend_point(first, previous)
|
||||
|
||||
abspath.append(seg.to_relative(previous))
|
||||
previous = seg.cend_point(first, previous)
|
||||
|
||||
return abspath
|
||||
|
||||
def __str__(self):
|
||||
return " ".join([str(seg) for seg in self])
|
||||
|
||||
@staticmethod
|
||||
def __add_helper__(other):
|
||||
"""Prepare a path for adding (either add or iadd)"""
|
||||
if isinstance(other, str):
|
||||
other = Path(other)
|
||||
return other
|
||||
|
||||
def __iadd__(self, value):
|
||||
return super().__iadd__(self.__add_helper__(value))
|
||||
|
||||
def __add__(self, other):
|
||||
acopy = copy.deepcopy(self)
|
||||
other = self.__add_helper__(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)
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, type, value, traceback):
|
||||
if self.callback is not None:
|
||||
self.callback(self) # pylint: disable=not-callable
|
||||
|
||||
|
||||
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 = 0j
|
||||
self._prev_prev = 0j
|
||||
|
||||
if isinstance(items, str):
|
||||
items = Path(items)
|
||||
|
||||
if isinstance(items, Path):
|
||||
for item in items:
|
||||
self.append_path_command(item)
|
||||
return
|
||||
|
||||
for item in items:
|
||||
self.append(item)
|
||||
|
||||
def __str__(self):
|
||||
return str(self.to_path())
|
||||
|
||||
def append_node_with_handles(self, command: List[Tuple[float, float]]):
|
||||
"""First item: left handle, second item: node coords,
|
||||
third item: right handle"""
|
||||
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([])
|
||||
|
||||
self[-1].append(command)
|
||||
self._prev_prev = command[0][0] + command[0][1] * 1j
|
||||
self._prev = command[1][0] + command[1][1] * 1j
|
||||
|
||||
def append_path_command(self, command: PathCommand):
|
||||
"""Append a path command.
|
||||
|
||||
For ordinary commands:
|
||||
|
||||
..code ::
|
||||
|
||||
old last entry -> [[.., ..], [.., ..], [x1, y1]]
|
||||
new last entry -> [[x2, y2], [x3, y3], [x3, y3]]
|
||||
|
||||
The last tuple is duplicated (retracted handle): either it's the last command
|
||||
of the subpath, then the handle will stay retracted, or it will be replaced
|
||||
with the next path command.
|
||||
"""
|
||||
if command.letter in "mM":
|
||||
carg = command.cend_point(self._first, self._prev)
|
||||
arg = Vector2d.c2t(carg)
|
||||
super().append([[arg[:], arg[:], arg[:]]])
|
||||
self._prev = self._prev_prev = carg
|
||||
self._closed = False
|
||||
return
|
||||
if command.letter in "zZ" and self:
|
||||
# This duplicates the first segment to 'close' the path
|
||||
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 = self._first
|
||||
return
|
||||
if command.letter in "aA":
|
||||
# Arcs are made up of (possibly) more than one curve, depending on their
|
||||
# angle (approximated)
|
||||
for arc_curve in command.to_curves(self._prev, self._prev_prev):
|
||||
self.append_path_command(arc_curve)
|
||||
return
|
||||
# Handle regular curves.
|
||||
|
||||
if self._closed:
|
||||
# Previous segment is closed. Append a new segment first.
|
||||
self._closed = False
|
||||
super().append([])
|
||||
|
||||
cp1, cp2, cp3 = command.ccurve_points(0j, self._prev, self._prev_prev)
|
||||
|
||||
item = [Vector2d.c2t(cp1), Vector2d.c2t(cp2), Vector2d.c2t(cp3)]
|
||||
self._prev = cp3
|
||||
if not command.letter in "QT":
|
||||
self._prev_prev = cp2
|
||||
else:
|
||||
self._prev_prev = command.ccontrol_points(0j, self._prev, self._prev_prev)[
|
||||
0
|
||||
]
|
||||
|
||||
if self[-1]: # There exists a previous segment, replace its outgoing handle.
|
||||
self[-1][-1][-1] = item[0]
|
||||
# Append the segment with the last coordinate (node pos) repeated.
|
||||
self[-1].append(item[1:] + [item[-1][:]])
|
||||
|
||||
def append(self, item):
|
||||
"""Append a segment/node to the superpath and update the internal state.
|
||||
|
||||
item may be specified in any of the following formats:
|
||||
|
||||
- PathCommand
|
||||
- [str, List[float]] - A path command letter and its arguments
|
||||
- [[float, float], [float, float], [float, float]] - Incoming handle, node,
|
||||
outgoing handle.
|
||||
- List[[float, float], [float, float], [float, float]] - An entire subpath.
|
||||
|
||||
|
||||
"""
|
||||
if isinstance(item, list) and len(item) == 2 and isinstance(item[0], str):
|
||||
item = PathCommand.letter_to_class(item[0])(*item[1])
|
||||
if isinstance(item, PathCommand):
|
||||
self.append_path_command(item)
|
||||
return
|
||||
|
||||
if isinstance(item, list):
|
||||
# Item is a subpath: List[Handle, node, Handle]. Just append the
|
||||
# subpath, and update the prev/ prev_prev positions.
|
||||
if (
|
||||
(len(item) != 3 or not all(len(bit) == 2 for bit in item))
|
||||
and len(item[0]) == 3
|
||||
and all(len(bit) == 2 for bit in item[0])
|
||||
):
|
||||
super().append(self._clean(item))
|
||||
|
||||
elif len(item) == 3 and all(len(bit) == 2 for bit in item):
|
||||
# Item is already a csp segment [Handle, node, Handle].
|
||||
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([])
|
||||
|
||||
# Item is already a csp segment and has already been shifted.
|
||||
self[-1].append([i.copy() for i in item])
|
||||
else:
|
||||
raise ValueError(f"Unknown super curve list format: {item}")
|
||||
|
||||
self._prev_prev = Vector2d.t2c(self[-1][-1][0])
|
||||
self._prev = Vector2d.t2c(self[-1][-1][1])
|
||||
else:
|
||||
raise ValueError(f"Unknown super curve list format: {item}")
|
||||
|
||||
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 self[-1][0][0][0] + self[-1][0][0][1] * 1j
|
||||
except IndexError:
|
||||
return 0 + 0j
|
||||
|
||||
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(Vector2d(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(
|
||||
Vector2d(subpath[0][1]), rtol, atol
|
||||
):
|
||||
yield ZoneClose()
|
||||
else:
|
||||
yield Line(Vector2d(segment[1]))
|
||||
else:
|
||||
yield Curve(
|
||||
Vector2d(previous[2]),
|
||||
Vector2d(segment[0]),
|
||||
Vector2d(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 abs(pt_a[0] - pt_b[0]) > 1e-13
|
||||
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 = isclose(
|
||||
Vector2d(previous[1]), Vector2d(previous[2]), rel_tol=rtol, abs_tol=atol
|
||||
) and isclose(
|
||||
Vector2d(segment[0]), Vector2d(segment[1]), rel_tol=rtol, abs_tol=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)
|
||||
Reference in New Issue
Block a user