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,122 @@
# 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.
#
"""Paths module.
Most of the functions derivative, unit_tangent, curvature, point, split, length, ilength
for the individual path commands are ported from
https://github.com/mathandy/svgpathtools/ (MIT licensed)
"""
from typing import Union
from ..transforms import ComplexLike
from .interfaces import (
PathCommand,
AbsolutePathCommand,
RelativePathCommand,
LengthSettings,
ILengthSettings,
)
from .lines import Line, line, Move, move, ZoneClose, zoneClose, Horz, horz, Vert, vert
from .curves import curve, Curve, smooth, Smooth
from .quadratic import quadratic, Quadratic, tepidQuadratic, TepidQuadratic
from .arc import Arc, arc, arc_to_path, matprod, rotmat, applymat, norm
from .path import CubicSuperPath, Path, InvalidPath
import numpy as np
np.seterr(invalid="raise")
# definitions that can't be inside the class due to circular dependencies
def to_curve(self, prev: ComplexLike, prev_prev: ComplexLike = 0) -> Curve:
"""Convert command to :py:class:`Curve`
Curve().to_curve() returns a copy
"""
return Curve(*self.ccurve_points(0 + 0j, complex(prev), complex(prev_prev)))
def to_line(self, prev: ComplexLike) -> Line:
"""Converts this segment to a line (copies if already a line)"""
return Line(self.cend_point(0, complex(prev)))
PathCommand.to_curve = to_curve # type: ignore
PathCommand.to_line = to_line # type: ignore
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,
}
# 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",
# structs
"LengthSettings",
"ILengthSettings",
)

View File

@@ -0,0 +1,670 @@
# 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.
#
"""Arc path commands"""
from __future__ import annotations
from math import atan2, pi, sqrt, sin, cos, tan, acos, radians, degrees
from cmath import exp
from typing import overload, Tuple, List, Union, TYPE_CHECKING
import numpy as np
from ..transforms import Transform, Vector2d, ComplexLike
from .interfaces import (
AbsolutePathCommand,
RelativePathCommand,
LengthSettings,
ILengthSettings,
BezierArcComputationMixin,
)
if TYPE_CHECKING:
from .curves import Curve
class Arc(BezierArcComputationMixin, AbsolutePathCommand):
"""Special Arc segment"""
letter = "A"
nargs = 7
radius: complex
"""Radius of the Arc"""
x_axis_rotation: float
large_arc: bool
sweep: bool
endpoint: complex
"""Endpoint (absolute) of the Arc"""
@property
def rx(self) -> float:
"""x radius of the Arc"""
return self.radius.real
@property
def ry(self) -> float:
"""y radius of the Arc"""
return self.radius.imag
@property
def x(self) -> float:
"""x coordinate of the (absolute) endpoint of the Arc"""
return self.endpoint.real
@property
def y(self) -> float:
"""x coordinate of the (relative) endpoint of the Arc"""
return self.endpoint.imag
@property
def args(self):
return (
self.rx,
self.ry,
self.x_axis_rotation,
self.large_arc,
self.sweep,
self.x,
self.y,
)
@property
def cargs(self):
"""Set of arguments in complex form"""
return (
self.radius,
self.x_axis_rotation,
self.large_arc,
self.sweep,
self.endpoint,
)
@overload
def __init__(
self,
radius: ComplexLike,
x_axis_rotation: float,
large_arc: bool | int,
sweep: bool | int,
endpoint: ComplexLike,
) -> None: ...
@overload
def __init__(
self,
rx: float,
ry: float,
x_axis_rotation: float,
large_arc: bool | int,
sweep: bool | int,
x: float,
y: float,
) -> None: ... # pylint: disable=too-many-arguments
def __init__(self, *args):
if len(args) == 5:
(
self.radius,
self.x_axis_rotation,
self.large_arc,
self.sweep,
self.endpoint,
) = args
self.radius = complex(self.radius)
self.endpoint = complex(self.endpoint)
elif len(args) == 7:
self.radius = args[0] + args[1] * 1j
self.x_axis_rotation, self.large_arc, self.sweep = args[2:5]
self.endpoint = args[5] + args[6] * 1j
def parametrize(self, prev):
"""Return the parametrisation of the arc:
(radius, phi, rot_matrix, center, theta1, deltatheta)
See http://www.w3.org/TR/SVG/implnote.html#ArcImplementationNotes
.. versionadded:: 1.4"""
# my notation roughly follows theirs
phi = radians(self.x_axis_rotation)
rot_matrix = exp(1j * phi)
radius = self.radius
rx = self.radius.real
ry = self.radius.imag
rx_sqd = rx * rx
ry_sqd = ry * ry
# Transform z-> z' = x' + 1j*y'
# = self.rot_matrix**(-1)*(z - (end+start)/2)
# coordinates. This translates the ellipse so that the midpoint
# between self.end and self.start lies on the origin and rotates
# the ellipse so that the its axes align with the xy-coordinate axes.
# Note: This sends self.end to -self.start
zp1 = (1 / rot_matrix) * (prev - self.cend_point(0j, prev)) / 2
x1p, y1p = zp1.real, zp1.imag
x1p_sqd = x1p * x1p
y1p_sqd = y1p * y1p
# Correct out of range radii
radius_check = (x1p_sqd / rx_sqd) + (y1p_sqd / ry_sqd)
if radius_check > 1:
rx *= sqrt(radius_check)
ry *= sqrt(radius_check)
radius = rx + 1j * ry
rx_sqd = rx * rx
ry_sqd = ry * ry
# Compute c'=(c_x', c_y'), the center of the ellipse in (x', y') coords
# Noting that, in our new coord system, (x_2', y_2') = (-x_1', -x_2')
# and our ellipse is cut out by of the plane by the algebraic equation
# (x'-c_x')**2 / r_x**2 + (y'-c_y')**2 / r_y**2 = 1,
# we can find c' by solving the system of two quadratics given by
# plugging our transformed endpoints (x_1', y_1') and (x_2', y_2')
tmp = rx_sqd * y1p_sqd + ry_sqd * x1p_sqd
radicand = (rx_sqd * ry_sqd - tmp) / tmp
radical = 0 if np.isclose(radicand, 0) else sqrt(radicand)
if self.large_arc == self.sweep:
cp = -radical * (rx * y1p / ry - 1j * ry * x1p / rx)
else:
cp = radical * (rx * y1p / ry - 1j * ry * x1p / rx)
# The center in (x,y) coordinates is easy to find knowing c'
center = exp(1j * phi) * cp + (prev + self.cend_point(0j, prev)) / 2
# Now we do a second transformation, from (x', y') to (u_x, u_y)
# coordinates, which is a translation moving the center of the
# ellipse to the origin and a dilation stretching the ellipse to be
# the unit circle
u1 = (x1p - cp.real) / rx + 1j * (y1p - cp.imag) / ry # transformed start
u2 = (-x1p - cp.real) / rx + 1j * (-y1p - cp.imag) / ry # transformed end
# clip in case of floating point error
u1 = np.clip(u1.real, -1, 1) + 1j * np.clip(u1.imag, -1, 1)
u2 = np.clip(u2.real, -1, 1) + 1j * np.clip(u2.imag, -1, 1)
# Now compute theta and delta (we'll define them as we go)
# delta is the angular distance of the arc (w.r.t the circle)
# theta is the angle between the positive x'-axis and the start point
# on the circle
if u1.imag > 0:
theta1 = degrees(acos(u1.real))
elif u1.imag < 0:
theta1 = -degrees(acos(u1.real))
else:
if u1.real > 0: # start is on pos u_x axis
theta1 = 0
else: # start is on neg u_x axis
# Note: This behavior disagrees with behavior documented in
# http://www.w3.org/TR/SVG/implnote.html#ArcImplementationNotes
# where theta is set to 0 in this case.
theta1 = 180
det_uv = u1.real * u2.imag - u1.imag * u2.real
acosand = u1.real * u2.real + u1.imag * u2.imag
acosand = np.clip(acosand.real, -1, 1) + np.clip(acosand.imag, -1, 1)
if det_uv > 0:
deltatheta = degrees(acos(acosand))
elif det_uv < 0:
deltatheta = -degrees(acos(acosand))
else:
if u1.real * u2.real + u1.imag * u2.imag > 0:
# u1 == u2
deltatheta = 0
else:
# u1 == -u2
# Note: This behavior disagrees with behavior documented in
# http://www.w3.org/TR/SVG/implnote.html#ArcImplementationNotes
# where deltatheta is set to 0 in this case.
deltatheta = 180
if not self.sweep and deltatheta >= 0:
deltatheta -= 360
elif self.large_arc and deltatheta <= 0:
deltatheta += 360
return radius, phi, rot_matrix, center, theta1, deltatheta
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.cend_point(first, prev)
def ccontrol_points(
self, first: complex, prev: complex, prev_prev: complex
) -> Tuple[complex, ...]:
return (self.endpoint,)
def ccurve_points(
self, first: complex, prev: complex, prev_prev: complex
) -> Tuple[complex, ...]:
return NotImplemented
def to_curves(self, prev: ComplexLike, prev_prev: ComplexLike = 0j) -> List[Curve]:
"""Convert this arc into bezier curves"""
# TODO Refactor out CubicSuperPath
from .path import CubicSuperPath
path = CubicSuperPath(
[arc_to_path(Vector2d.c2t(complex(prev)), self.args)]
).to_path(curves_only=True)
# Ignore the first move command from to_path()
return list(path)[1:]
def transform(self, transform: Transform) -> Arc:
# pylint: disable=invalid-name, too-many-locals
newend = transform.capply_to_point(self.endpoint)
T: Transform = 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)
def get_degen():
return Arc(
self.radius,
self.x_axis_rotation,
self.large_arc,
self.sweep,
newend,
)
if rx == 0.0 or ry == 0.0 or detT2 == 0.0:
# degenerate arc
# transform only last point
return get_degen()
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
try:
rx_ = 1.0 / sqrt(half + delta)
ry_ = 1.0 / sqrt(half - delta)
if detT > 0:
sweep = self.sweep
else:
sweep = not self.sweep > 0
return Arc(rx_ + 1j * ry_, theta_deg, self.large_arc, sweep, newend)
except ZeroDivisionError:
return get_degen()
def to_relative(self, prev: ComplexLike) -> RelativePathCommand:
return arc(
self.radius,
self.x_axis_rotation,
self.large_arc,
self.sweep,
self.endpoint - prev,
)
def cend_point(self, first: complex, prev: complex) -> complex:
return self.endpoint
def reverse(self, first: ComplexLike, prev: ComplexLike) -> Arc:
return Arc(
self.radius, self.x_axis_rotation, self.large_arc, not self.sweep, prev
)
def _cpoint(
self, first: complex, prev: complex, prev_control: complex, t: float
) -> complex:
# TODO surely this can be expressed in a shorter way using complex geometry?
radius, _, rot_matrix, center, theta1, deltatheta = self.parametrize(prev)
angle = (theta1 + t * deltatheta) * pi / 180
cosphi = rot_matrix.real
sinphi = rot_matrix.imag
rx = radius.real
ry = radius.imag
x = rx * cosphi * cos(angle) - ry * sinphi * sin(angle) + center.real
y = rx * sinphi * cos(angle) + ry * cosphi * sin(angle) + center.imag
return x + y * 1j
def _cderivative(
self, first: complex, prev: complex, prev_control: complex, t: float, n: int = 1
) -> complex:
"""returns the nth derivative of the segment at t."""
radius, phi, _, _, theta1, deltatheta = self.parametrize(prev)
angle = radians(theta1 + t * deltatheta)
rx = radius.real
ry = radius.imag
k = (deltatheta * pi / 180) ** n # ((d/dt)angle)**n
if n % 4 == 0 and n > 0:
return (
rx * cos(phi) * cos(angle)
- ry * sin(phi) * sin(angle)
+ 1j * (rx * sin(phi) * cos(angle) + ry * cos(phi) * sin(angle))
)
elif n % 4 == 1:
return k * (
-rx * cos(phi) * sin(angle)
- ry * sin(phi) * cos(angle)
+ 1j * (-rx * sin(phi) * sin(angle) + ry * cos(phi) * cos(angle))
)
elif n % 4 == 2:
return k * (
-rx * cos(phi) * cos(angle)
+ ry * sin(phi) * sin(angle)
+ 1j * (-rx * sin(phi) * cos(angle) - ry * cos(phi) * sin(angle))
)
elif n % 4 == 3:
return k * (
rx * cos(phi) * sin(angle)
+ ry * sin(phi) * cos(angle)
+ 1j * (rx * sin(phi) * sin(angle) - ry * cos(phi) * cos(angle))
)
else:
raise ValueError("n should be a positive integer.")
def _split(
self, first: complex, prev: complex, prev_control: complex, t: float
) -> Tuple[Arc, Arc]:
"""returns two segments, whose union is this segment and which join
at self.point(t)."""
radius, _, _, _, _, deltatheta = self.parametrize(prev)
def crop(t0, t1):
return Arc(
radius,
self.x_axis_rotation,
not abs(deltatheta * (t1 - t0)) <= 180,
self.sweep,
self.cpoint(0j, prev, 0j, t1),
)
return crop(0, t), crop(t, 1)
def _ilength(
self,
first: complex,
prev: complex,
prev_control: complex,
length: float,
settings: ILengthSettings = ILengthSettings(),
):
# ilength calls self.parametrize very often, so we cache the result
param = self.parametrize
params = param(prev)
def cached(prev): # pylint: disable=unused-argument
return params
self.parametrize = cached # type: ignore
try:
return super()._ilength(first, prev, prev_control, length, settings)
finally:
self.parametrize = param # type: ignore
class arc(RelativePathCommand, Arc): # pylint: disable=invalid-name
"""Relative Arc line segment"""
letter = "a"
nargs = 7
endpoint: complex
"""Endpoint (relative) of the arc"""
@property
def rx(self) -> float:
"""x radius of the arc"""
return self.radius.real
@property
def ry(self) -> float:
"""y radius of the arc"""
return self.radius.imag
@property
def dx(self) -> float:
"""x coordinate of the (relative) endpoint of the arc"""
return self.endpoint.real
@property
def dy(self) -> float:
"""x coordinate of the (relative) endpoint of the arc"""
return self.endpoint.imag
@property
def args(self):
return (
self.rx,
self.ry,
self.x_axis_rotation,
self.large_arc,
self.sweep,
self.dx,
self.dy,
)
@overload
def __init__(
self,
radius: ComplexLike,
x_axis_rotation: float,
large_arc: bool,
sweep: bool,
endpoint: ComplexLike,
) -> None: ...
@overload
def __init__(
self,
rx: float,
ry: float,
x_axis_rotation: float,
large_arc: bool,
sweep: bool,
dx: float,
dy: float,
) -> None: ... # pylint: disable=too-many-arguments
def __init__(self, *args):
if len(args) == 5:
(
self.radius,
self.x_axis_rotation,
self.large_arc,
self.sweep,
self.endpoint,
) = args
self.radius = complex(self.radius)
self.endpoint = complex(self.endpoint)
elif len(args) == 7:
self.radius = args[0] + args[1] * 1j
self.x_axis_rotation, self.large_arc, self.sweep = args[2:5]
self.endpoint = args[5] + args[6] * 1j
def to_absolute(self, prev: ComplexLike) -> Arc:
return Arc(
self.radius,
self.x_axis_rotation,
self.large_arc,
self.sweep,
self.endpoint + prev,
)
def cend_point(self, first: complex, prev: complex) -> complex:
return self.endpoint + prev
def ccontrol_points(
self, first: complex, prev: complex, prev_prev: complex
) -> Tuple[complex, ...]:
return (self.endpoint + prev,)
def ccurve_points(
self, first: complex, prev: complex, prev_prev: complex
) -> Tuple[complex, ...]:
return NotImplemented
def reverse(self, first: ComplexLike, prev: ComplexLike) -> arc:
return arc(
self.radius,
self.x_axis_rotation,
self.large_arc,
not self.sweep,
-self.endpoint,
)
def to_curves(self, prev: ComplexLike, prev_prev: ComplexLike = 0j) -> List[Curve]:
return self.to_absolute(prev).to_curves(prev, prev_prev)
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])

View File

@@ -0,0 +1,499 @@
# 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.
#
"""Curve and Smooth Path Commands"""
from __future__ import annotations
from typing import overload, Tuple, Callable, Union, cast
import numpy as np
from ..transforms import cubic_extrema, Transform, Vector2d, ComplexLike
from .interfaces import (
AbsolutePathCommand,
RelativePathCommand,
BezierArcComputationMixin,
BezierComputationMixin,
)
class CurveMixin(BezierComputationMixin, BezierArcComputationMixin):
"""Common functionality for curves"""
ccontrol_points: Callable[[complex, complex, complex], Tuple[complex, ...]]
def ccurve_points(
self, first: complex, prev: complex, prev_prev: complex
) -> Tuple[complex, ...]:
"""Common implementation of ccurve_points for Curves"""
return self.ccontrol_points(first, prev, prev_prev)
def _cderivative(
self, first: complex, prev: complex, prev_control: complex, t: float, n: int = 1
) -> complex:
"""Returns the nth derivative of the segment at t.
.. hint:: Bezier curves can have points where their derivative vanishes.
If you are interested in the tangent direction, use the :func:`unit_tangent`
method instead."""
points = self.ccontrol_points(first, prev, prev_control)
if n == 1:
return (
3 * (points[0] - prev) * ((1 - t) ** 2)
+ 6 * (points[1] - points[0]) * (1 - t) * t
+ 3 * (points[2] - points[1]) * t**2
)
elif n == 2:
return 6 * (
(1 - t) * (points[1] - 2 * points[0] + prev)
+ t * (points[2] - 2 * points[1] + points[0])
)
elif n == 3:
return 6 * (points[2] - 3 * (points[1] - points[0]) - prev)
elif n > 3:
return complex(0, 0)
else:
raise ValueError("n should be a positive integer.")
def poly(self, prev, prev_control, return_coeffs=False):
"""Returns a the cubic as a complex Polynomial object.
.. versionadded:: 1.4"""
points = self.ccontrol_points(0j, prev, prev_control)
coeffs = (
-prev + 3 * (points[0] - points[1]) + points[2],
3 * (prev - 2 * points[0] + points[1]),
3 * (prev + points[0]),
prev,
)
if return_coeffs:
return coeffs
return np.poly1d(coeffs)
def _cunit_tangent(
self, first: complex, prev: complex, prev_control: complex, t: float
) -> complex:
return self.bezier_unit_tangent(prev, prev_control, t)
def _curvature(
self, first: complex, prev: complex, prev_control: complex, t: float
):
return self.segment_curvature(prev, prev_control, t)
def _abssplit(
self, prev: complex, prev_control: complex, t: float
) -> Tuple[Curve, Curve]:
"""Split this curve and return two Curves using DeCasteljau's algorithm"""
p1, p2, p3 = self.ccontrol_points(0j, prev, prev_control)
p1_1 = (1 - t) * prev + t * p1
p1_2 = (1 - t) * p1 + t * p2
p1_3 = (1 - t) * p2 + t * p3
p2_1 = (1 - t) * p1_1 + t * p1_2
p2_2 = (1 - t) * p1_2 + t * p1_3
p3_1 = (1 - t) * p2_1 + t * p2_2
return Curve(p1_1, p2_1, p3_1), Curve(p2_2, p1_3, p3)
def _relsplit(self, prev: complex, prev_control: complex, t: float):
"""Split this curve and return two curves"""
c1abs, c2abs = self._abssplit(prev, prev_control, t)
return c1abs.to_relative(prev), c2abs.to_relative(c1abs.arg3)
def _cpoint(
self, first: complex, prev: complex, prev_control: complex, t: float
) -> complex:
control1, control2, end = self.ccontrol_points(first, prev, prev_control)
return (
(1 - t) ** 3 * prev
+ 3 * t * (1 - t) ** 2 * control1
+ 3 * t**2 * (1 - t) * control2
+ t**3 * end
)
class Curve(CurveMixin, AbsolutePathCommand):
"""Absolute Curved Line segment"""
letter = "C"
nargs = 6
arg1: complex
"""The (absolute) first control point"""
arg2: complex
"""The (absolute) second control point"""
arg3: complex
"""The (absolute) end point"""
@property
def x2(self) -> float:
"""x coordinate of the (absolute) first control point"""
return self.arg1.real
@property
def y2(self) -> float:
"""y coordinate of the (absolute) first control point"""
return self.arg1.imag
@property
def x3(self) -> float:
"""x coordinate of the (absolute) second control point"""
return self.arg2.real
@property
def y3(self) -> float:
"""y coordinate of the (absolute) second control point"""
return self.arg2.imag
@property
def x4(self) -> float:
"""x coordinate of the (absolute) end point"""
return self.arg3.real
@property
def y4(self) -> float:
"""y coordinate of the (absolute) end point"""
return self.arg3.imag
@property
def args(self):
return (
self.arg1.real,
self.arg1.imag,
self.arg2.real,
self.arg2.imag,
self.arg3.real,
self.arg3.imag,
)
@overload
def __init__(self, x2: ComplexLike, x3: ComplexLike, x4: ComplexLike): ...
@overload
def __init__(
self, x2: float, y2: float, x3: float, y3: float, x4: float, y4: float
): ... # pylint: disable=too-many-arguments
def __init__(self, x2, y2, x3, y3=None, x4=None, y4=None): # pylint: disable=too-many-arguments
if y3 is not None:
self.arg1 = x2 + y2 * 1j
self.arg2 = x3 + y3 * 1j
self.arg3 = x4 + y4 * 1j
else:
self.arg1, self.arg2, self.arg3 = complex(x2), complex(y2), complex(x3)
def update_bounding_box(self, first, last_two_points, bbox):
x1, x2, x3, x4 = last_two_points[-1].real, self.x2, self.x3, self.x4
y1, y2, y3, y4 = last_two_points[-1].imag, 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: Transform) -> Curve:
return Curve(
transform.capply_to_point(self.arg1),
transform.capply_to_point(self.arg2),
transform.capply_to_point(self.arg3),
)
def ccontrol_points(
self, first: complex, prev: complex, prev_prev: complex
) -> Tuple[complex, ...]:
# pylint: disable=unused-argument
return (self.arg1, self.arg2, self.arg3)
def to_relative(self, prev: ComplexLike) -> curve:
return curve(self.arg1 - prev, self.arg2 - prev, self.arg3 - prev)
def cend_point(self, first: complex, prev: complex) -> complex:
return self.arg3
def reverse(self, first: ComplexLike, prev: ComplexLike) -> Curve:
return Curve(self.arg2, self.arg1, prev)
def to_bez(self):
"""Convert to [[c1x, c1y], [c2x, c2y], [end_x, end_y]]"""
return [Vector2d.c2t(i) for i in self.ccontrol_points(0j, 0j, 0j)]
def _split(
self, first: complex, prev: complex, prev_control: complex, t: float
) -> Tuple[Curve, Curve]:
return self._abssplit(prev, prev_control, t)
class curve(CurveMixin, RelativePathCommand): # pylint: disable=invalid-name
"""Relative curved line segment"""
letter = "c"
nargs = 6
arg1: complex
"""The (relative) first control point"""
arg2: complex
"""The (relative) second control point"""
arg3: complex
"""The (relative) end point"""
@property
def dx2(self) -> float:
"""x coordinate of the (relative) first control point"""
return self.arg1.real
@property
def dy2(self) -> float:
"""y coordinate of the (relative) first control point"""
return self.arg1.imag
@property
def dx3(self) -> float:
"""x coordinate of the (relative) second control point"""
return self.arg2.real
@property
def dy3(self) -> float:
"""y coordinate of the (relative) second control point"""
return self.arg2.imag
@property
def dx4(self) -> float:
"""x coordinate of the (relative) end point"""
return self.arg3.real
@property
def dy4(self) -> float:
"""y coordinate of the (relative) end point"""
return self.arg3.imag
@overload
def __init__(self, dx2: ComplexLike, dx3: ComplexLike, dx4: ComplexLike): ...
@overload
def __init__(
self, dx2: float, dy2: float, dx3: float, dy3: float, dx4: float, dy4: float
): ... # pylint: disable=too-many-arguments
def __init__(self, dx2, dy2, dx3, dy3=None, dx4=None, dy4=None): # pylint: disable=too-many-arguments
if dy3 is not None:
self.arg1 = dx2 + dy2 * 1j
self.arg2 = dx3 + dy3 * 1j
self.arg3 = dx4 + dy4 * 1j
else:
self.arg1, self.arg2, self.arg3 = complex(dx2), complex(dy2), complex(dx3)
@property
def args(self):
return self.dx2, self.dy2, self.dx3, self.dy3, self.dx4, self.dy4
def to_absolute(self, prev: ComplexLike) -> Curve:
return Curve(*self.ccurve_points(0j, complex(prev), 0j))
def cend_point(self, first: complex, prev: complex) -> complex:
return self.arg3 + prev
def reverse(self, first: ComplexLike, prev: ComplexLike) -> curve:
return curve(-self.arg3 + self.arg2, -self.arg3 + self.arg1, -self.arg3)
def ccontrol_points(
self, first: complex, prev: complex, prev_prev: complex
) -> Tuple[complex, ...]:
# pylint: disable=unused-argument
return (
self.arg1 + prev,
self.arg2 + prev,
self.arg3 + prev,
)
def _split(
self, first: complex, prev: complex, prev_control: complex, t: float
) -> Tuple[curve, curve]:
return self._relsplit(prev, prev_control, t)
class Smooth(CurveMixin, AbsolutePathCommand):
"""Absolute Smoothed Curved Line segment"""
letter = "S"
nargs = 4
arg1: complex
"""The (absolute) control point"""
arg2: complex
"""The (absolute) end point"""
@property
def x3(self) -> float:
"""x coordinate of the (absolute) control point"""
return self.arg1.real
@property
def y3(self) -> float:
"""y coordinate of the (absolute) control point"""
return self.arg1.imag
@property
def x4(self) -> float:
"""x coordinate of the (absolute) end point"""
return self.arg2.real
@property
def y4(self) -> float:
"""y coordinate of the (absolute) end point"""
return self.arg2.imag
@property
def args(self):
return self.x3, self.y3, self.x4, self.y4
@overload
def __init__(self, x3: ComplexLike, x4: ComplexLike): ...
@overload
def __init__(self, x3: float, y3: float, x4: float, y4: float): ...
def __init__(self, x3, y3, x4=None, y4=None):
if x4 is not None:
self.arg1 = x3 + y3 * 1j
self.arg2 = x4 + y4 * 1j
else:
self.arg1, self.arg2 = complex(x3), complex(y3)
def update_bounding_box(self, first, last_two_points, bbox):
# pylint: disable=no-member
self.to_curve(last_two_points[-1], last_two_points[-2]).update_bounding_box(
first, last_two_points, bbox
)
def ccontrol_points(
self, first: complex, prev: complex, prev_prev: complex
) -> Tuple[complex, ...]:
# pylint: disable=unused-argument
return (2 * prev - prev_prev, self.arg1, self.arg2)
def to_non_shorthand(self, prev: ComplexLike, prev_control: ComplexLike) -> Curve:
return self.to_curve(prev, prev_control)
def to_relative(self, prev: ComplexLike) -> smooth:
return smooth(self.arg1 - prev, self.arg2 - prev)
def transform(self, transform: Transform) -> Smooth:
return Smooth(
transform.capply_to_point(self.arg1), transform.capply_to_point(self.arg2)
)
def cend_point(self, first: complex, prev: complex) -> complex:
return self.arg2
def reverse(self, first: ComplexLike, prev: ComplexLike) -> Smooth:
return Smooth(self.arg1, prev)
def _split(
self, first: complex, prev: complex, prev_control: complex, t: float
) -> Tuple[Curve, Curve]:
# We can't preserve the smooth type for a split because de Casteljau's
# algorithm changes the handles (obviously).
# Only in special cases such as splitting to subsequent smooth segments at
# t=1/2 such a preservation would be possible
crv = cast(Curve, self.to_non_shorthand(prev, prev_control))
return crv._split( # pylint: disable=protected-access, no-member
first, prev, prev_control, t
)
class smooth(CurveMixin, RelativePathCommand): # pylint: disable=invalid-name
"""Relative smoothed curved line segment"""
letter = "s"
nargs = 4
arg1: complex
"""The (absolute) control point"""
arg2: complex
"""The (absolute) end point"""
@property
def dx3(self) -> float:
"""x coordinate of the (relative) control point"""
return self.arg1.real
@property
def dy3(self) -> float:
"""y coordinate of the (relative) control point"""
return self.arg1.imag
@property
def dx4(self) -> float:
"""x coordinate of the (relative) end point"""
return self.arg2.real
@property
def dy4(self) -> float:
"""y coordinate of the (relative) end point"""
return self.arg2.imag
@property
def args(self):
return self.dx3, self.dy3, self.dx4, self.dy4
@overload
def __init__(self, dx3: ComplexLike, dx4: ComplexLike): ...
@overload
def __init__(self, dx3: float, dy3: float, dx4: float, dy4: float): ...
def __init__(self, dx3, dy3, dx4=None, dy4=None):
if dx4 is not None:
self.arg1 = dx3 + dy3 * 1j
self.arg2 = dx4 + dy4 * 1j
else:
self.arg1, self.arg2 = complex(dx3), complex(dy3)
def to_absolute(self, prev: ComplexLike) -> Smooth:
return Smooth(self.arg1 + prev, self.arg2 + prev)
def cend_point(self, first: complex, prev: complex) -> complex:
return self.arg2 + prev
def to_non_shorthand(self, prev: ComplexLike, prev_control: ComplexLike) -> Curve:
return self.to_absolute(prev).to_non_shorthand(prev, prev_control)
def reverse(self, first: ComplexLike, prev: ComplexLike):
return smooth(-self.arg2 + self.arg1, -self.arg2)
def ccontrol_points(
self, first: complex, prev: complex, prev_prev: complex
) -> Tuple[complex, ...]:
# pylint: disable=unused-argument
return (2 * prev - prev_prev, self.arg1 + prev, self.arg2 + prev)
def _split(
self, first: complex, prev: complex, prev_control: complex, t: float
) -> Tuple[curve, curve]:
return self._relsplit(prev, prev_control, t)

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

View File

@@ -0,0 +1,601 @@
# 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.
#
"""Line-like path commands (Line, Horz, Vert, ZoneClose and their relative siblings)"""
from __future__ import annotations
from typing import overload, Tuple, Optional, TYPE_CHECKING, Callable, Union
from inkex.paths.interfaces import ILengthSettings, LengthSettings
from ..transforms import Transform, BoundingBox, ComplexLike
from .interfaces import AbsolutePathCommand, RelativePathCommand, ILengthSettings
if TYPE_CHECKING:
from .curves import Curve
class LineMixin:
"""Common Line functions"""
# pylint: disable=unused-argument
arg1: complex
cend_point: Callable[[complex, complex], complex]
def ccurve_points(self, first: complex, prev: complex, prev_prev: complex):
"""Common implementation of ccurve_points for Lines"""
arg1 = self.cend_point(first, prev)
return prev, arg1, arg1
def ccontrol_points(
self,
first: complex,
prev: complex,
prev_prev: complex, # pylint: disable=unused-argument
) -> Tuple[complex, ...]:
"""Common implementation of ccontrol_points for Lines"""
return (self.cend_point(first, prev),)
def _cderivative(
self, first: complex, prev: complex, prev_control: complex, t: float, n: int = 1
) -> complex:
start = self.cend_point(first, prev)
if prev == start:
raise ValueError("Derivative is not defined for zero-length segments")
if n == 1:
return start - prev
return 0j
def _curvature(
self, first: complex, prev: complex, prev_control: complex, t: float
) -> float:
return 0
def _cpoint(
self, first: complex, prev: complex, prev_control: complex, t: float
) -> complex:
return self.cend_point(first, prev) * t + (1 - t) * prev
def _length(
self,
first: complex,
prev: complex,
prev_control: complex,
t0: float = 0,
t1: float = 1,
settings=LengthSettings(),
) -> float:
return abs(self.cend_point(first, prev) - prev) * (t1 - t0)
def _ilength(
self,
first: complex,
prev: complex,
prev_control: complex,
length: float,
settings: ILengthSettings = ILengthSettings(),
):
return length / self._length(first, prev, prev_control)
class Line(LineMixin, AbsolutePathCommand):
"""Line segment"""
letter = "L"
nargs = 2
arg1: complex
"""The (absolute) end points of the line."""
@property
def x(self):
"""x coordinate of the line's (absolute) end point."""
return self.arg1.real
@property
def y(self):
"""y coordinate of the line's (absolute) end point."""
return self.arg1.imag
@property
def args(self):
return self.x, self.y
@overload
def __init__(self, x: ComplexLike): ...
@overload
def __init__(self, x: float, y: float): ...
def __init__(self, x, y=None):
if y is not None:
self.arg1 = x + y * 1j
else:
self.arg1 = complex(x)
def update_bounding_box(self, first, last_two_points, bbox):
bbox += BoundingBox(
(last_two_points[-1].real, self.x), (last_two_points[-1].imag, self.y)
)
def to_relative(self, prev: ComplexLike) -> line:
return line(self.arg1 - prev)
def transform(self, transform) -> Line:
return Line(transform.capply_to_point(self.arg1))
def cend_point(self, first: complex, prev: complex) -> complex:
# pylint: disable=unused-argument
return self.arg1
def reverse(self, first: ComplexLike, prev: ComplexLike) -> Line:
return Line(prev)
def _split(
self, first: complex, prev: complex, prev_control: complex, t: float
) -> Tuple[Line, Line]:
return Line(self._cpoint(first, prev, prev_control, t)), Line(self.arg1)
class line(LineMixin, RelativePathCommand): # pylint: disable=invalid-name
"""Relative line segment"""
letter = "l"
nargs = 2
arg1: complex
"""The (relative) end points of the line."""
@property
def dx(self):
"""x coordinate of the line's (relative) end point."""
return self.arg1.real
@property
def dy(self):
"""y coordinate of the line's (relative) end point."""
return self.arg1.imag
@property
def args(self):
return self.dx, self.dy
@overload
def __init__(self, dx: ComplexLike): ...
@overload
def __init__(self, dx: float, dy: float): ...
def __init__(self, dx, dy=None):
if dy is not None:
self.arg1 = dx + dy * 1j
else:
self.arg1 = complex(dx)
def to_absolute(self, prev: ComplexLike) -> Line:
return Line(prev + self.arg1)
def cend_point(self, first: complex, prev: complex) -> complex:
# pylint: disable=unused-argument
return self.arg1 + prev
def reverse(self, first: ComplexLike, prev: ComplexLike) -> line:
return line(-self.arg1)
def to_curve(
self, prev: ComplexLike, prev_prev: Optional[ComplexLike] = 0j
) -> Curve:
raise ValueError("Move segments can not be changed into curves.")
def _split(
self, first: complex, prev: complex, prev_control: complex, t: float
) -> Tuple[line, line]:
dx1 = self.cpoint(first, prev, prev_control, t) - prev
return line(dx1), line(self.arg1 - dx1)
class MoveMixin:
"""Disable derivative / length method for Move command."""
def _cderivative(
self, first: complex, prev: complex, prev_control: complex, t: float, n: int = 1
) -> complex:
raise ValueError("Derivative is not supported for move/Move")
def _cunit_tangent(
self, first: complex, prev: complex, prev_control: complex, t: float
) -> complex:
raise ValueError("Unit Tangent is not supported for move/Move")
def _curvature(
self, first: complex, prev: complex, prev_control: complex, t: float
) -> float:
raise ValueError("Curvature is not supported for move/Move")
def _cpoint(
self, first: complex, prev: complex, prev_control: complex, t: float
) -> complex:
raise ValueError("Point is not supported for move/Move")
def _length(
self,
first: complex,
prev: complex,
prev_control: complex,
t0: float = 0,
t1: float = 1,
settings=LengthSettings(),
) -> float:
raise ValueError("Length is not supported for move/Move")
def _ilength(
self,
first: complex,
prev: complex,
prev_control: complex,
length: float,
settings: ILengthSettings = ILengthSettings(),
):
raise ValueError("ILength is not supported for move/Move")
def _split(
self, first: complex, prev: complex, prev_control: complex, t: float
) -> Tuple[Move, Move]:
raise ValueError("Split is not supported for move/Move")
class Move(MoveMixin, AbsolutePathCommand):
"""Move pen segment without a line"""
letter = "M"
nargs = 2
next_command = Line
arg1: complex
"""The (absolute) end points of the Move command"""
@property
def x(self):
"""x coordinate of the Moves's (absolute) end point."""
return self.arg1.real
@property
def y(self):
"""y coordinate of the Move's (absolute) end point."""
return self.arg1.imag
@property
def args(self):
return self.x, self.y
@overload
def __init__(self, x: ComplexLike): ...
@overload
def __init__(self, x: float, y: float): ...
def __init__(self, x, y=None):
if y is not None:
self.arg1 = x + y * 1j
else:
self.arg1 = complex(x)
def update_bounding_box(self, first, last_two_points, bbox):
bbox += BoundingBox(self.x, self.y)
def ccurve_points(self, first: complex, prev: complex, prev_prev: complex):
return prev, self.arg1, self.arg1
def ccontrol_points(
self, first: complex, prev: complex, prev_prev: complex
) -> Tuple[complex, ...]:
return (self.arg1,)
def to_relative(self, prev: ComplexLike) -> move:
return move(self.arg1 - prev)
def transform(self, transform: Transform) -> Move:
return Move(transform.capply_to_point(self.arg1))
def cend_point(self, first: complex, prev: complex) -> complex:
return self.arg1
def to_curve(
self, prev: ComplexLike, prev_prev: Optional[ComplexLike] = 0j
) -> Curve:
raise ValueError("Move segments can not be changed into curves.")
def reverse(self, first: ComplexLike, prev: ComplexLike) -> Move:
return Move(prev)
class move(MoveMixin, RelativePathCommand): # pylint: disable=invalid-name
"""Relative move segment"""
letter = "m"
nargs = 2
next_command = line
@property
def dx(self):
"""x coordinate of the moves's (relative) end point."""
return self.arg1.real
@property
def dy(self):
"""y coordinate of the move's (relative) end point."""
return self.arg1.imag
@property
def args(self):
return self.dx, self.dy
@overload
def __init__(self, dx: ComplexLike): ...
@overload
def __init__(self, dx: float, dy: float): ...
def __init__(self, dx, dy=None):
if dy is not None:
self.arg1 = dx + dy * 1j
else:
self.arg1 = complex(dx)
def ccurve_points(self, first: complex, prev: complex, prev_prev: complex):
return prev, self.arg1 + prev, self.arg1 + prev
def ccontrol_points(
self, first: complex, prev: complex, prev_prev: complex
) -> Tuple[complex, ...]:
return (self.arg1 + prev,)
def cend_point(self, first: complex, prev: complex) -> complex:
return self.arg1 + prev
def to_absolute(self, prev: ComplexLike) -> Move:
return Move(prev + self.arg1)
def reverse(self, first: ComplexLike, prev: ComplexLike) -> move:
return move(prev - first)
def to_curve(
self, prev: ComplexLike, prev_prev: Optional[ComplexLike] = 0j
) -> Curve:
raise ValueError("Move segments can not be changed into curves.")
class ZoneClose(LineMixin, AbsolutePathCommand):
"""Close segment to finish a path"""
letter = "Z"
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: Transform) -> ZoneClose:
return ZoneClose()
def to_relative(self, prev: ComplexLike) -> zoneClose:
return zoneClose()
def cend_point(self, first: complex, prev: complex) -> complex:
# pylint: disable=unused-argument
return first
def to_curve(
self, prev: ComplexLike, prev_prev: Optional[ComplexLike] = 0j
) -> Curve:
raise ValueError("ZoneClose segments can not be changed into curves.")
def reverse(self, first: ComplexLike, prev: ComplexLike) -> Line:
return Line(prev)
def _split(
self, first: complex, prev: complex, prev_control: complex, t: float
) -> Tuple[Line, ZoneClose]:
return Line(self._cpoint(first, prev, prev_control, t)), ZoneClose()
class zoneClose(LineMixin, RelativePathCommand): # pylint: disable=invalid-name
"""Same as above (svg says no difference)"""
letter = "z"
nargs = 0
next_command = Move
@property
def args(self):
return ()
def to_absolute(self, prev: ComplexLike):
return ZoneClose()
def reverse(self, first: ComplexLike, prev: ComplexLike) -> line:
return line(prev - first)
def cend_point(self, first: complex, prev: complex) -> complex:
# pylint: disable=unused-argument
return first
def to_curve(
self, prev: ComplexLike, prev_prev: Optional[ComplexLike] = 0j
) -> Curve:
raise ValueError("ZoneClose segments can not be changed into curves.")
def _split(
self, first: complex, prev: complex, prev_control: complex, t: float
) -> Tuple[line, zoneClose]:
return line(self.cpoint(first, prev, prev_control, t) - prev), zoneClose()
class Horz(LineMixin, AbsolutePathCommand):
"""Horizontal Line segment"""
letter = "H"
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].real, self.x), last_two_points[-1].imag
)
def to_relative(self, prev: ComplexLike) -> horz:
return horz(self.x - complex(prev).real)
def to_non_shorthand(self, prev: ComplexLike, prev_control: ComplexLike) -> Line:
return self.to_line(prev)
def transform(self, transform: Transform) -> AbsolutePathCommand:
raise ValueError("Horizontal lines can't be transformed directly.")
def cend_point(self, first: complex, prev: complex) -> complex:
# pylint: disable=unused-argument
return self.x + prev.imag * 1j
def reverse(self, first: ComplexLike, prev: ComplexLike) -> Horz:
return Horz(complex(prev).real)
def _split(
self, first: complex, prev: complex, prev_control: complex, t: float
) -> Tuple[Horz, Horz]:
return Horz(self.cpoint(first, prev, prev_control, t).real), Horz(self.x)
class horz(LineMixin, RelativePathCommand): # pylint: disable=invalid-name
"""Relative horz line segment"""
letter = "h"
nargs = 1
@property
def args(self):
return (self.dx,)
def __init__(self, dx):
self.dx = dx
def to_absolute(self, prev: ComplexLike) -> Horz:
return Horz(complex(prev).real + self.dx)
def to_non_shorthand(self, prev: ComplexLike, prev_control: ComplexLike) -> Line:
return self.to_line(prev)
def cend_point(self, first: complex, prev: complex) -> complex:
# pylint: disable=unused-argument
return (self.dx + prev.real) + prev.imag * 1j
def reverse(self, first: ComplexLike, prev: ComplexLike) -> horz:
return horz(-self.dx)
def _split(
self, first: complex, prev: complex, prev_control: complex, t: float
) -> Tuple[horz, horz]:
dx1 = (self.cpoint(first, prev, prev_control, t) - prev).real
return horz(dx1), horz(self.dx - dx1)
class Vert(LineMixin, AbsolutePathCommand):
"""Vertical Line segment"""
letter = "V"
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].real, (last_two_points[-1].imag, self.y)
)
def transform(self, transform: Transform) -> AbsolutePathCommand:
raise ValueError("Vertical lines can't be transformed directly.")
def to_non_shorthand(self, prev: ComplexLike, prev_control: ComplexLike) -> Line:
return self.to_line(prev)
def to_relative(self, prev: ComplexLike) -> vert:
return vert(self.y - complex(prev).imag)
def cend_point(self, first: complex, prev: complex) -> complex:
# pylint: disable=unused-argument
return prev.real + self.y * 1j
def reverse(self, first: ComplexLike, prev: ComplexLike) -> Vert:
return Vert(complex(prev).imag)
def _split(
self, first: complex, prev: complex, prev_control: complex, t: float
) -> Tuple[Vert, Vert]:
return Vert(self.cpoint(first, prev, prev_control, t).imag), Vert(self.y)
class vert(LineMixin, RelativePathCommand): # pylint: disable=invalid-name
"""Relative vertical line segment"""
letter = "v"
nargs = 1
@property
def args(self):
return (self.dy,)
def __init__(self, dy):
self.dy = dy
def to_absolute(self, prev: ComplexLike) -> Vert:
return Vert(complex(prev).imag + self.dy)
def to_non_shorthand(self, prev: ComplexLike, prev_control: ComplexLike) -> Line:
return self.to_line(prev)
def cend_point(self, first: complex, prev: complex) -> complex:
# pylint: disable=unused-argument
return prev.real + (prev.imag + self.dy) * 1j
def reverse(self, first: ComplexLike, prev: ComplexLike) -> vert:
return vert(-self.dy)
def _split(
self, first: complex, prev: complex, prev_control: complex, t: float
) -> Tuple[vert, vert]:
dy1 = (self.cpoint(first, prev, prev_control, t) - prev).imag
return vert(dy1), vert(self.dy - dy1)

View 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)

View File

@@ -0,0 +1,456 @@
# 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.
#
"""Quadratic and TepidQuadratic path commands"""
from __future__ import annotations
from typing import overload, Tuple, Callable, Union
from math import sqrt
import numpy as np
from ..transforms import quadratic_extrema, Transform, ComplexLike
from .interfaces import (
AbsolutePathCommand,
RelativePathCommand,
BezierComputationMixin,
BezierArcComputationMixin,
LengthSettings,
)
class QuadraticMixin(BezierComputationMixin, BezierArcComputationMixin):
# pylint: disable=unused-argument
ccontrol_points: Callable[[complex, complex, complex], Tuple[complex, ...]]
def _cderivative(
self, first: complex, prev: complex, prev_control: complex, t: float, n: int = 1
) -> complex:
points = self.ccontrol_points(first, prev, prev_control)
if n == 1:
return 2 * ((points[0] - prev) * (1 - t) + (points[1] - points[0]) * t)
if n == 2:
return 2 * (prev - 2 * points[0] + points[1])
if n > 2:
return 0j
raise ValueError("n should be a positive integer.")
def _cunit_tangent(
self, first: complex, prev: complex, prev_control: complex, t: float
) -> complex:
return self.bezier_unit_tangent(prev, prev_control, t)
def _cpoint(
self, first: complex, prev: complex, prev_control: complex, t: float
) -> complex:
control, end = self.ccontrol_points(first, prev, prev_control)
return (1 - t) ** 2 * prev + 2 * t * (1 - t) * control + t**2 * end
# TODO maybe better treatment of degenerate beziers from
# https://github.com/linebender/kurbo/blob/c229a914d303c5989c9e6b1d766def2df27a8185/src/quadbez.rs#L239
# Ported from https://github.com/mathandy/svgpathtools/blob/19df25b99b405ec4fc7616b58384eca7879b6fd4/svgpathtools/path.py#L919
# (MIT licensed)
def _length(
self,
first: complex,
prev: complex,
prev_control: complex,
t0: float = 0,
t1: float = 1,
settings=LengthSettings(),
) -> float:
control, end = self.ccontrol_points(first, prev, prev_control)
a = prev - 2 * control + end
b = 2 * (control - prev)
if abs(a) < 1e-12:
s = abs(b) * (t1 - t0)
else:
c2 = 4 * (a.real**2 + a.imag**2)
c1 = 4 * (a.real * b.real + a.imag * b.imag)
c0 = b.real**2 + b.imag**2
beta = c1 / (2 * c2)
gamma = c0 / c2 - beta**2
dq1_mag = sqrt(c2 * t1**2 + c1 * t1 + c0)
dq0_mag = sqrt(c2 * t0**2 + c1 * t0 + c0)
# this implicitly handles division by zero
try:
logarand = (sqrt(c2) * (t1 + beta) + dq1_mag) / (
sqrt(c2) * (t0 + beta) + dq0_mag
)
s = (
(t1 + beta) * dq1_mag
- (t0 + beta) * dq0_mag
+ gamma * sqrt(c2) * np.log(logarand)
) / 2
except ZeroDivisionError:
s = np.nan
if np.isnan(s):
tstar = abs(b) / (2 * abs(a))
if t1 < tstar:
return abs(a) * (t0**2 - t1**2) - abs(b) * (t0 - t1)
elif tstar < t0:
return abs(a) * (t1**2 - t0**2) - abs(b) * (t1 - t0)
else:
return (
abs(a) * (t1**2 + t0**2)
- abs(b) * (t1 + t0)
+ abs(b) ** 2 / (2 * abs(a))
)
return s
def _abssplit(
self, prev: complex, prev_control: complex, t: float
) -> Tuple[Quadratic, Quadratic]:
"""Split this Quadratic and return two Quadratics using DeCasteljau's algorithm"""
p1, p2 = self.ccontrol_points(0j, prev, prev_control)
p1_1 = (1 - t) * prev + t * p1
p1_2 = (1 - t) * p1 + t * p2
p2_1 = (1 - t) * p1_1 + t * p1_2
return Quadratic(p1_1, p2_1), Quadratic(p1_2, p2)
def _relsplit(self, prev: complex, prev_control: complex, t: float):
"""Split this curve and return two curves"""
c1abs, c2abs = self._abssplit(prev, prev_control, t)
return c1abs.to_relative(prev), c2abs.to_relative(c1abs.arg2)
class Quadratic(QuadraticMixin, AbsolutePathCommand):
"""Absolute Quadratic Curved Line segment"""
letter = "Q"
nargs = 4
arg1: complex
"""The (absolute) control point"""
arg2: complex
"""The (absolute) end point"""
@property
def x2(self) -> float:
"""x coordinate of the (absolute) control point"""
return self.arg1.real
@property
def y2(self) -> float:
"""y coordinate of the (absolute) control point"""
return self.arg1.imag
@property
def x3(self) -> float:
"""x coordinate of the (absolute) end point"""
return self.arg2.real
@property
def y3(self) -> float:
"""y coordinate of the (absolute) end point"""
return self.arg2.imag
@property
def args(self):
return self.x2, self.y2, self.x3, self.y3
@overload
def __init__(self, x2: ComplexLike, x3: ComplexLike): ...
@overload
def __init__(self, x2: float, y2: float, x3: float, y3: float): ...
def __init__(self, x2, y2, x3=None, y3=None):
if x3 is not None:
self.arg1 = x2 + y2 * 1j
self.arg2 = x3 + y3 * 1j
else:
self.arg1, self.arg2 = complex(x2), complex(y2)
def update_bounding_box(self, first, last_two_points, bbox):
x1, x2, x3 = last_two_points[-1].real, self.x2, self.x3
y1, y2, y3 = last_two_points[-1].imag, 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 ccontrol_points(
self, first: complex, prev: complex, prev_prev: complex
) -> Tuple[complex, ...]:
return (self.arg1, self.arg2)
def to_relative(self, prev: ComplexLike) -> quadratic:
return quadratic(self.arg1 - prev, self.arg2 - prev)
def transform(self, transform: Transform) -> Quadratic:
return Quadratic(
transform.capply_to_point(self.arg1), transform.capply_to_point(self.arg2)
)
def cend_point(self, first: complex, prev: complex) -> complex:
return self.arg2
def ccurve_points(
self, first: complex, prev: complex, prev_prev: complex
) -> Tuple[complex, ...]:
pt1 = 1.0 / 3 * prev + 2.0 / 3 * self.arg1
pt2 = 2.0 / 3 * self.arg1 + 1.0 / 3 * self.arg2
return pt1, pt2, self.arg2
def reverse(self, first: ComplexLike, prev: ComplexLike) -> Quadratic:
prev = complex(prev)
return Quadratic(self.x2, self.y2, prev.real, prev.imag)
def _split(
self, first: complex, prev: complex, prev_control: complex, t: float
) -> Tuple[Quadratic, Quadratic]:
return self._abssplit(prev, prev_control, t)
class quadratic(QuadraticMixin, RelativePathCommand): # pylint: disable=invalid-name
"""Relative quadratic line segment"""
letter = "q"
nargs = 4
arg1: complex
"""The (relative) control point"""
arg2: complex
"""The (relative) end point"""
@property
def dx2(self) -> float:
"""x coordinate of the (relative) control point"""
return self.arg1.real
@property
def dy2(self) -> float:
"""y coordinate of the (relative) control point"""
return self.arg1.imag
@property
def dx3(self) -> float:
"""x coordinate of the (relative) end point"""
return self.arg2.real
@property
def dy3(self) -> float:
"""y coordinate of the (relative) end point"""
return self.arg2.imag
@property
def args(self):
return self.dx2, self.dy2, self.dx3, self.dy3
@overload
def __init__(self, dx2: ComplexLike, dx3: ComplexLike): ...
@overload
def __init__(self, dx2: float, dy2: float, dx3: float, dy3: float): ...
def __init__(self, dx2, dy2, dx3=None, dy3=None):
if dx3 is not None:
self.arg1 = dx2 + dy2 * 1j
self.arg2 = dx3 + dy3 * 1j
else:
self.arg1, self.arg2 = complex(dx2), complex(dy2)
def ccontrol_points(
self, first: complex, prev: complex, prev_prev: complex
) -> Tuple[complex, ...]:
return (self.arg1 + prev, self.arg2 + prev)
def to_absolute(self, prev: ComplexLike) -> Quadratic:
return Quadratic(self.arg1 + prev, self.arg2 + prev)
def ccurve_points(
self, first: complex, prev: complex, prev_prev: complex
) -> Tuple[complex, ...]:
pt1 = 1.0 / 3 * prev + 2.0 / 3 * (prev + self.arg1)
pt2 = 2.0 / 3 * (prev + self.arg1) + 1.0 / 3 * (prev + self.arg2)
return pt1, pt2, prev + self.arg2
def cend_point(self, first: complex, prev: complex) -> complex:
return self.arg2 + prev
def reverse(self, first: ComplexLike, prev: ComplexLike) -> quadratic:
return quadratic(-self.arg2 + self.arg1, -self.arg2)
def _split(
self, first: complex, prev: complex, prev_control: complex, t: float
) -> Tuple[quadratic, quadratic]:
return self._relsplit(prev, prev_control, t)
class TepidQuadratic(QuadraticMixin, AbsolutePathCommand):
"""Continued Quadratic Line segment"""
letter = "T"
nargs = 2
arg1: complex
"""The (absolute) control point"""
@property
def x3(self) -> float:
"""x coordinate of the (absolute) end point"""
return self.arg1.real
@property
def y3(self) -> float:
"""y coordinate of the (absolute) end point"""
return self.arg1.imag
@property
def args(self):
return self.x3, self.y3
@overload
def __init__(self, x3: ComplexLike): ...
@overload
def __init__(self, x3: float, y3: float): ...
def __init__(self, x3, y3=None):
if y3 is not None:
self.arg1 = x3 + y3 * 1j
else:
self.arg1 = complex(x3)
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 ccontrol_points(
self, first: complex, prev: complex, prev_prev: complex
) -> Tuple[complex, ...]:
return (2 * prev - prev_prev, self.arg1)
def to_non_shorthand(
self, prev: ComplexLike, prev_control: ComplexLike
) -> Quadratic:
return self.to_quadratic(prev, prev_control)
def to_relative(self, prev: ComplexLike) -> tepidQuadratic:
return tepidQuadratic(self.arg1 - prev)
def transform(self, transform: Transform) -> TepidQuadratic:
return TepidQuadratic(transform.capply_to_point(self.arg1))
def ccurve_points(
self, first: complex, prev: complex, prev_prev: complex
) -> Tuple[complex, ...]:
qp1 = 2 * prev - prev_prev
qp2 = self.arg1
pt1 = 1.0 / 3 * prev + 2.0 / 3 * qp1
pt2 = 2.0 / 3 * qp1 + 1.0 / 3 * qp2
return pt1, pt2, qp2
def cend_point(self, first: complex, prev: complex) -> complex:
return self.arg1
def to_quadratic(self, prev: ComplexLike, prev_prev: ComplexLike) -> Quadratic:
"""Convert this continued quadratic into a full quadratic"""
return Quadratic(
*self.ccontrol_points(complex(prev), complex(prev), complex(prev_prev))
)
def reverse(self, first: ComplexLike, prev: ComplexLike) -> TepidQuadratic:
return TepidQuadratic(prev)
def _split(
self, first: complex, prev: complex, prev_control: complex, t: float
) -> Tuple[Quadratic, Quadratic]:
return self._abssplit(prev, prev_control, t)
class tepidQuadratic(QuadraticMixin, RelativePathCommand): # pylint: disable=invalid-name
"""Relative continued quadratic line segment"""
letter = "t"
nargs = 2
arg1: complex
"""The (relative) control point"""
@property
def dx3(self) -> float:
"""x coordinate of the (relative) end point"""
return self.arg1.real
@property
def dy3(self) -> float:
"""y coordinate of the (relative) end point"""
return self.arg1.imag
@property
def args(self):
return self.dx3, self.dy3
@overload
def __init__(self, dx3: ComplexLike): ...
@overload
def __init__(self, dx3: float, dy3: float): ...
def __init__(self, dx3, dy3=None):
if dy3 is not None:
self.arg1 = dx3 + dy3 * 1j
else:
self.arg1 = complex(dx3)
def ccontrol_points(
self, first: complex, prev: complex, prev_prev: complex
) -> Tuple[complex, ...]:
return (2 * prev - prev_prev, self.arg1 + prev)
def ccurve_points(
self, first: complex, prev: complex, prev_prev: complex
) -> Tuple[complex, ...]:
qp1 = 2 * prev - prev_prev
qp2 = self.arg1 + prev
pt1 = 1.0 / 3 * prev + 2.0 / 3 * qp1
pt2 = 2.0 / 3 * qp1 + 1.0 / 3 * qp2
return pt1, pt2, qp2
def to_absolute(self, prev: ComplexLike) -> TepidQuadratic:
return TepidQuadratic(self.arg1 + prev)
def to_non_shorthand(
self, prev: ComplexLike, prev_control: ComplexLike
) -> Quadratic:
return self.to_absolute(prev).to_non_shorthand(prev, prev_control)
def cend_point(self, first: complex, prev: complex) -> complex:
return self.arg1 + prev
def reverse(self, first: ComplexLike, prev: ComplexLike) -> tepidQuadratic:
return tepidQuadratic(-self.arg1)
def _split(
self, first: complex, prev: complex, prev_control: complex, t: float
) -> Tuple[quadratic, quadratic]:
return self._relsplit(prev, prev_control, t)