bundle: update (2026-01-18)

This commit is contained in:
2026-01-18 04:47:51 +00:00
parent 822898e4a3
commit 7597f2f156
223 changed files with 31167 additions and 0 deletions

View File

@@ -0,0 +1,731 @@
# 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.
#
"""Interfaces for path commands"""
from __future__ import annotations
import abc
import math
from typing import (
List,
Any,
Tuple,
Dict,
Type,
Generator,
Union,
TYPE_CHECKING,
Optional,
Callable,
)
from dataclasses import dataclass
import numpy as np
from ..utils import classproperty, rational_limit
from ..transforms import Vector2d, BoundingBox, Transform, ComplexLike
if TYPE_CHECKING:
from .curves import Curve
from .lines import Line
@dataclass
class LengthSettings:
"""Settings for :func:`PathCommand.length`
.. versionadded:: 1.4"""
min_depth: int = 5
error: float = 1e-5
@dataclass
class ILengthSettings:
"""Settings for :func:`PathCommand.ilength`
.. versionadded:: 1.4"""
min_depth: int = 5
error: float = 1e-5
"""
Error tolerance for the computations of the test segment that is performed
for each iteration.
The defaults from svgpathtools are ILENGTH_ERROR=ILENGTH_LENGTH_TOL=1e-12.
This is rather slow, particularly _length (which then subdivides the path into
2^12 or more segments and adds up the length). For visual editing, this is rather
irrelevant (and a lot more accurate than the previous methods)."""
length_tol: float = 1e-5
"""Total (absolute) tolerance of the resulting s value."""
maxits: int = 10000
class PathCommand(abc.ABC):
"""
Base class of all path commands
"""
letter = ""
# 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 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) -> 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) -> bool:
"""Whether the command is defined in absolute coordinates (upper case path
command letter)"""
raise NotImplementedError
def to_relative(self, prev: ComplexLike) -> RelativePathCommand:
"""Return absolute counterpart for absolute commands or copy for relative"""
raise NotImplementedError
def to_absolute(self, prev: ComplexLike) -> AbsolutePathCommand:
"""Return relative counterpart for relative commands or copy for absolute"""
raise NotImplementedError
def reverse(self, first: ComplexLike, prev: ComplexLike) -> PathCommand:
"""Reverse path command
.. versionadded:: 1.1"""
raise NotImplementedError
def to_non_shorthand(
self,
prev: ComplexLike,
prev_control: ComplexLike, # pylint: disable=unused-argument
) -> 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: Dict[str, Type[Any]] = {}
@staticmethod
def letter_to_class(letter):
"""Returns class for given path command letter"""
return PathCommand._letter_to_class[letter]
@property
@abc.abstractmethod
def args(self) -> List[float]:
"""Returns path command arguments as tuple of floats"""
def control_points(
self,
first: ComplexLike,
prev: ComplexLike,
prev_prev: ComplexLike,
) -> Generator[Vector2d, None, None]:
"""Returns list of path command control points"""
first, prev, prev_prev = complex(first), complex(prev), complex(prev_prev)
yield from [Vector2d(i) for i in self.ccontrol_points(first, prev, prev_prev)]
@abc.abstractmethod
def ccontrol_points(
self, first: complex, prev: complex, prev_prev: complex
) -> Tuple[complex, ...]:
"""Returns list of path command control points"""
@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 = 0j
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
@abc.abstractmethod
def cend_point(self, first: complex, prev: complex) -> complex:
"""Complex version of end_point"""
def end_point(self, first: ComplexLike, prev: ComplexLike) -> Vector2d:
"""Returns last control point of path command"""
return Vector2d(self.cend_point(complex(first or 0), complex(prev or 0)))
@abc.abstractmethod
def update_bounding_box(
self, first: complex, last_two_points: List[complex], bbox: BoundingBox
):
"""Enlarges given bbox to contain path element.
Args:
first (complex): first point of path. Required to calculate Z segment
last_two_points (List[complex]): list with last two control points in abs
coords.
bbox (BoundingBox): bounding box to update
"""
def to_curve(self, prev: ComplexLike, prev_prev: ComplexLike = 0) -> Curve:
# pylint: disable=unused-argument
"""Convert command to :py:class:`Curve`
Curve().to_curve() returns a copy
"""
return NotImplemented
def to_curves(self, prev: ComplexLike, prev_prev: ComplexLike = 0) -> List[Curve]:
"""Convert command to list of :py:class:`Curve` commands"""
return [self.to_curve(prev, prev_prev)]
def to_line(self, prev: ComplexLike) -> Line:
# pylint: disable=unused-argument
"""Converts this segment to a line (copies if already a line)"""
return NotImplemented
@abc.abstractmethod
def ccurve_points(
self, first: complex, prev: complex, prev_prev: complex
) -> Tuple[complex, ...]:
# pylint: disable=unused-argument
"""Converts the path element into a single cubic bezier"""
# Derivation functionality
def __check_t(self, t: Optional[float], allow_none=True):
if not allow_none and (t is None and self.letter not in "zZmMlLhHvV"):
raise ValueError("t=None only supported for Line-like commands")
if t is not None and not 0 <= t <= 1:
raise ValueError("t should be between 0 and 1")
return t if t is not None else 0.0
def cderivative(
self,
first: complex,
prev: complex,
prev_control: complex,
t: Optional[float] = None,
n: int = 1,
) -> complex:
"""Returns the nth derivative of the segment at t as a complex number.
.. versionadded:: 1.4
"""
if n < 1:
raise ValueError("n should be a positive integer")
# pylint: disable=protected-access
return self._cderivative(first, prev, prev_control, self.__check_t(t), n)
def derivative(
self,
first: ComplexLike,
prev: ComplexLike,
prev_control: ComplexLike,
t: Optional[float] = None,
n: int = 1,
) -> Vector2d:
"""Returns the nth derivative of the segment at t as a :class:`Vector2D`.
.. versionadded:: 1.4
"""
return Vector2d(
self.cderivative(
complex(first or 0),
complex(prev or 0),
complex(prev_control or 0),
t,
n,
)
)
@abc.abstractmethod
def _cderivative(
self, first: complex, prev: complex, prev_control: complex, t: float, n: int = 1
) -> complex: ...
def cunit_tangent(
self,
first: complex,
prev: complex,
prev_control: complex,
t: Optional[float] = None,
) -> complex:
"""Returns the unit tangent of the segment at t as a complex number.
..versionadded:: 1.4
"""
return self._cunit_tangent(first, prev, prev_control, self.__check_t(t))
def unit_tangent(
self,
first: ComplexLike,
prev: ComplexLike,
prev_control: ComplexLike,
t: Optional[float] = None,
) -> Vector2d:
"""Returns the unit tangent of the segment at t as a :class:`Vector2D`.
..versionadded:: 1.4
"""
return Vector2d(
self.cunit_tangent(
complex(first or 0), complex(prev or 0), complex(prev_control or 0), t
)
)
def _cunit_tangent(
self, first: complex, prev: complex, prev_control: complex, t: float
) -> complex:
dseg = self._cderivative(first, prev, t, 1)
return dseg / abs(dseg)
def cnormal(
self,
first: complex,
prev: complex,
prev_control: complex,
t: Optional[float] = None,
) -> complex:
"""Returns the (right-hand-rule) normal vector of the segment at t as a complex
number.
..versionadded:: 1.4
"""
return self.cunit_tangent(first, prev, prev_control, t) * 1j
def normal(
self,
first: ComplexLike,
prev: ComplexLike,
prev_control: ComplexLike,
t: Optional[float] = None,
) -> Vector2d:
"""Returns the (right-hand-rule) normal vector of the segment at t as
:class:`Vector2D`.
..versionadded:: 1.4
"""
return Vector2d(
self.cnormal(
complex(first or 0), complex(prev or 0), complex(prev_control or 0), t
)
)
def curvature(
self,
first: ComplexLike,
prev: ComplexLike,
prev_control: ComplexLike,
t: Optional[float] = None,
) -> float:
"""Returns the curvature of the segment at t.
..versionadded:: 1.4
"""
# pylint: disable=protected-access
return self._curvature(
complex(first or 0),
complex(prev or 0),
complex(prev_control or 0),
self.__check_t(t),
)
@abc.abstractmethod
def _curvature(
self, first: complex, prev: complex, prev_control: complex, t: float
) -> float: ...
# Point evaluation, splitting
def cpoint(
self, first: complex, prev: complex, prev_control: complex, t: float
) -> complex:
"""Returns the coordinates of the Bezier curve evaluated at t as complex number.
.. versionadded:: 1.4"""
return self._cpoint(first, prev, prev_control, self.__check_t(t, False))
def point(
self, first: ComplexLike, prev: ComplexLike, prev_control: ComplexLike, t: float
) -> Vector2d:
"""Returns the coordinates of the Bezier curve evaluated at t as :class:`Vector2d`.
.. versionadded:: 1.4"""
return Vector2d(
self.cpoint(
complex(first or 0), complex(prev or 0), complex(prev_control or 0), t
)
)
@abc.abstractmethod
def _cpoint(
self, first: complex, prev: complex, prev_control: complex, t: float
) -> complex: ...
def split(
self, first: ComplexLike, prev: ComplexLike, prev_control: ComplexLike, t: float
) -> Tuple[PathCommand, PathCommand]:
"""Returns two segments, whose union is this segment and which join at
self.point(t).
.. versionadded:: 1.4"""
# no simplification here, we want to preserve the original type
return self._split(
complex(first),
complex(prev),
complex(prev_control),
self.__check_t(t, False),
)
@abc.abstractmethod
def _split(
self, first: complex, prev: complex, prev_control: complex, t: float
) -> Tuple[PathCommand, PathCommand]: ...
# Line integration
def length(
self,
first: ComplexLike,
prev: ComplexLike,
prev_control: ComplexLike,
t0: float = 0,
t1: float = 1,
settings=LengthSettings(),
) -> float:
"""Returns the length of the segment between t0 and t1.
.. versionadded:: 1.4"""
# pylint: disable=protected-access
return self._length(
complex(first),
complex(prev),
complex(prev_control),
self.__check_t(t0, False),
self.__check_t(t1, False),
settings,
)
@abc.abstractmethod
def _length(
self,
first: complex,
prev: complex,
prev_control: complex,
t0: float = 0,
t1: float = 1,
settings=LengthSettings(),
) -> float: ...
def ilength(
self,
first: ComplexLike,
prev: ComplexLike,
prev_control: ComplexLike,
length: float,
settings: ILengthSettings = ILengthSettings(),
):
"""Returns a float ``t``, such that ``self.length(0, t)`` is approximately
``length``.
.. versionadded:: 1.4"""
# pylint: disable=protected-access
return self._ilength(
complex(first), complex(prev), complex(prev_control), length, settings
)
@abc.abstractmethod
def _ilength(
self,
first: complex,
prev: complex,
prev_control: complex,
length: float,
settings: ILengthSettings = ILengthSettings(),
): ...
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 to_relative(self, prev: ComplexLike) -> RelativePathCommand:
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
)
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: ComplexLike) -> AbsolutePathCommand:
return self.__class__(*self.args)
@abc.abstractmethod
def transform(self, transform: Transform) -> AbsolutePathCommand:
"""Returns new transformed segment
:param transform: a transformation to apply
"""
def rotate(self, degrees: float, center: Vector2d) -> AbsolutePathCommand:
"""
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: Vector2d) -> AbsolutePathCommand:
"""Translate or scale this path command by dr"""
return self.transform(Transform(translate=dr))
def scale(self, factor: Union[float, Tuple[float, float]]) -> AbsolutePathCommand:
"""Returns new transformed segment
:param factor: scale or (scale_x, scale_y)
"""
return self.transform(Transform(scale=factor))
class BezierArcComputationMixin:
"""Functionality that works the same way for Arcs, Cubic and Quadratic
.. versionadded:: 1.4"""
_cpoint: Callable[[complex, complex, complex, float], complex]
_cderivative: Callable[..., complex]
poly: Callable[[complex, complex, complex, Optional[bool]], np.poly1d]
def inv_arclength(self, prev, prev_control, s, settings=ILengthSettings()):
"""Ported from https://github.com/mathandy/svgpathtools/blob/19df25b99b405ec4fc7616b58384eca7879b6fd4/svgpathtools/path.py#L541
(MIT Licensed)"""
t_upper = 1
t_lower = 0
iteration = 0
while iteration < settings.maxits:
iteration += 1
t = (t_lower + t_upper) / 2
s_t = self._length(0j, prev, prev_control, t1=t, settings=settings)
if abs(s_t - s) < settings.length_tol:
return t
elif s_t < s: # t too small
t_lower = t
else: # s < s_t, t too big
t_upper = t
if t_upper == t_lower:
# warn("t is as close as a float can be to the correct value, "
# "but |s(t) - s| = {} > s_tol".format(abs(s_t-s)))
return t
raise Exception(f"Maximum iterations reached with s(t) - s = {s_t - s}.")
def segment_length(
self,
start,
end,
start_point,
end_point,
pos_eval,
settings=LengthSettings(),
depth=0,
):
"""
Ported from https://github.com/mathandy/svgpathtools/blob/19df25b99b405ec4fc7616b58384eca7879b6fd4/svgpathtools/path.py#L479
(MIT licensed)
# TODO better treatment of degenerate beziers from
# https://github.com/linebender/kurbo/blob/c229a914d303c5989c9e6b1d766def2df27a8185/src/cubicbez.rs#L431
"""
mid = (start + end) / 2
mid_point = pos_eval(mid)
length = abs(end_point - start_point)
first_half = abs(mid_point - start_point)
second_half = abs(end_point - mid_point)
length2 = first_half + second_half
if (length2 - length > settings.error) or (depth < settings.min_depth):
# Calculate the length of each segment:
depth += 1
return self.segment_length(
start, mid, start_point, mid_point, pos_eval, settings, depth
) + self.segment_length(
mid, end, mid_point, end_point, pos_eval, settings, depth
)
# This is accurate enough.
return length2
def segment_curvature(self, prev: complex, prev_prev: complex, t: float):
"""Returns the curvature of the segment at t.
Ported from https://github.com/mathandy/svgpathtools/blob/19df25b99b405ec4fc7616b58384eca7879b6fd4/svgpathtools/path.py#L386
(MIT licensed)
"""
dz = self._cderivative(0j, prev, prev_prev, t)
ddz = self._cderivative(0j, prev, prev_prev, t, n=2)
dx, dy = dz.real, dz.imag
ddx, ddy = ddz.real, ddz.imag
try:
kappa = abs(dx * ddy - dy * ddx) / math.sqrt(dx * dx + dy * dy) ** 3
except (ZeroDivisionError, FloatingPointError):
# tangent vector is zero at t, use polytools to find limit
p = self.poly(0j, prev, prev_prev, False)
dp = p.deriv()
ddp = dp.deriv()
dx2, dy2 = np.real(dp), np.imag(dp)
ddx2, ddy2 = np.real(ddp), np.imag(ddp)
f2 = (dx2 * ddy2 - dy2 * ddx2) ** 2
g2 = (dx2 * dx2 + dy2 * dy2) ** 3
lim2 = rational_limit(f2, g2, t)
if lim2 < 0: # impossible, must be numerical error
return 0
kappa = math.sqrt(lim2)
return kappa
def _length(
self,
first: complex,
prev: complex,
prev_control: complex,
t0=0,
t1=1,
settings=LengthSettings(),
) -> float:
return self.segment_length(
t0,
t1,
self._cpoint(first, prev, prev_control, t0),
self._cpoint(first, prev, prev_control, t1),
lambda t: self._cpoint(first, prev, prev_control, t),
settings,
0,
)
def _ilength(
self,
first: complex,
prev: complex,
prev_control: complex,
length,
settings=ILengthSettings(),
):
return self.inv_arclength(prev, prev_control, length, settings)
def _curvature(
self, first: complex, prev: complex, prev_control: complex, t: float
) -> float:
return self.segment_curvature(prev, prev_control, t)
class BezierComputationMixin:
"""Functionality that works the same for all Beziers (Quadratic, Cubic)
.. versionadded:: 1.4"""
def _bpoints(self, prev, prev_prev):
return (prev,) + self.ccontrol_points(0j, prev, prev_prev)
def bezier_unit_tangent(self, prev, prev_prev, t):
"""Returns the unit tangent of the segment at t.
Ported from https://github.com/mathandy/svgpathtools/blob/19df25b99b405ec4fc7616b58384eca7879b6fd4/svgpathtools/path.py#L348
(MIT licensed)"""
dseg = self._cderivative(0j, prev, prev_prev, t)
try:
unit_tangent = dseg / abs(dseg)
except (ZeroDivisionError, FloatingPointError):
# This may be a removable singularity, if so we just need to compute
# the limit.
# Note: limit{{dseg / abs(dseg)} = sqrt(limit{dseg**2 / abs(dseg)**2})
dseg_poly = self.poly(prev, prev_prev).deriv()
dseg_abs_squared_poly = np.real(dseg_poly) ** 2 + np.imag(dseg_poly) ** 2
try:
unit_tangent = np.sqrt(
rational_limit(dseg_poly**2, dseg_abs_squared_poly, t)
)
except ValueError:
bef = self.poly(prev, prev_prev).deriv()(t - 1e-4)
aft = self.poly(prev, prev_prev).deriv()(t + 1e-4)
mes = (
"Unit tangent appears to not be well-defined at "
f"t = {t}, \n"
f"seg.poly().deriv()(t - 1e-4) = {bef}\n"
f"seg.poly().deriv()(t + 1e-4) = {aft}"
)
raise ValueError(mes)
return unit_tangent