summaryrefslogtreecommitdiffstats
path: root/share/extensions/inkex/transforms.py
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 18:24:48 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 18:24:48 +0000
commitcca66b9ec4e494c1d919bff0f71a820d8afab1fa (patch)
tree146f39ded1c938019e1ed42d30923c2ac9e86789 /share/extensions/inkex/transforms.py
parentInitial commit. (diff)
downloadinkscape-cca66b9ec4e494c1d919bff0f71a820d8afab1fa.tar.xz
inkscape-cca66b9ec4e494c1d919bff0f71a820d8afab1fa.zip
Adding upstream version 1.2.2.upstream/1.2.2upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'share/extensions/inkex/transforms.py')
-rw-r--r--share/extensions/inkex/transforms.py1250
1 files changed, 1250 insertions, 0 deletions
diff --git a/share/extensions/inkex/transforms.py b/share/extensions/inkex/transforms.py
new file mode 100644
index 0000000..3a37a38
--- /dev/null
+++ b/share/extensions/inkex/transforms.py
@@ -0,0 +1,1250 @@
+# coding=utf-8
+#
+# Copyright (C) 2006 Jean-Francois Barraud, barraud@math.univ-lille1.fr
+# Copyright (C) 2010 Alvin Penner, penner@vaxxine.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.
+# barraud@math.univ-lille1.fr
+#
+# This code defines several functions to make handling of transform
+# attribute easier.
+#
+"""
+Provide transformation parsing to extensions
+"""
+from __future__ import annotations
+import re
+from decimal import Decimal
+from math import cos, radians, sin, sqrt, tan, fabs, atan2, hypot, pi, isfinite
+from typing import (
+ overload,
+ cast,
+ Callable,
+ Generator,
+ Iterator,
+ Tuple,
+ Union,
+ Optional,
+ List,
+)
+
+
+from .utils import strargs, KeyDict
+
+
+VectorLike = Union[
+ "ImmutableVector2d", Tuple[float, float]
+] # pylint: disable=invalid-name
+MatrixLike = Union[
+ str,
+ Tuple[Tuple[float, float, float], Tuple[float, float, float]],
+ Tuple[float, float, float, float, float, float],
+ "Transform",
+]
+BoundingIntervalArgs = Union[
+ "BoundingInterval", Tuple[float, float], float
+] # pylint: disable=invalid-name
+
+# All the names that get added to the inkex API itself.
+__all__ = (
+ "BoundingBox",
+ "DirectedLineSegment",
+ "ImmutableVector2d",
+ "Transform",
+ "Vector2d",
+)
+
+
+# Old settings, supported because users click 'ok' without looking.
+XAN = KeyDict({"l": "left", "r": "right", "m": "center_x"})
+YAN = KeyDict({"t": "top", "b": "bottom", "m": "center_y"})
+# Anchoring objects with given directions (see inx options)
+CUSTOM_DIRECTION = {270: "tb", 90: "bt", 0: "lr", 360: "lr", 180: "rl"}
+DIRECTION = ["tb", "bt", "lr", "rl", "ro", "ri"]
+
+
+class ImmutableVector2d:
+ """Represents an immutable element of 2-dimensional Euclidean space"""
+
+ _x = 0.0
+ _y = 0.0
+
+ x = property(lambda self: self._x)
+ y = property(lambda self: self._y)
+
+ @overload
+ def __init__(self):
+ # type: () -> None
+ pass
+
+ @overload
+ def __init__(self, v, fallback=None):
+ # type: (Union[VectorLike, str], Optional[Union[VectorLike, str]]) -> None
+ pass
+
+ @overload
+ def __init__(self, x, y):
+ # type: (float, float) -> None
+ pass
+
+ def __init__(self, *args, fallback=None):
+ try:
+ if len(args) == 0:
+ x, y = 0.0, 0.0
+ elif len(args) == 1:
+ x, y = self._parse(args[0])
+ elif len(args) == 2:
+ x, y = map(float, args)
+ else:
+ raise ValueError("too many arguments")
+ except (ValueError, TypeError) as error:
+ if fallback is None:
+ raise ValueError("Cannot parse vector and no fallback given") from error
+ x, y = ImmutableVector2d(fallback)
+ self._x, self._y = float(x), float(y)
+
+ @staticmethod
+ def _parse(point):
+ # type: (Union[VectorLike, str]) -> Tuple[float, float]
+ if isinstance(point, ImmutableVector2d):
+ x, y = point.x, point.y
+ elif isinstance(point, (tuple, list)) and len(point) == 2:
+ x, y = map(float, point)
+ elif isinstance(point, str) and point.count(",") == 1:
+ x, y = map(float, point.split(","))
+ else:
+ raise ValueError(f"Can't parse {repr(point)}")
+ return x, y
+
+ def __add__(self, other):
+ # type: (VectorLike) -> Vector2d
+ other = Vector2d(other)
+ return Vector2d(self.x + other.x, self.y + other.y)
+
+ def __radd__(self, other):
+ # type: (VectorLike) -> Vector2d
+ other = Vector2d(other)
+ return Vector2d(self.x + other.x, self.y + other.y)
+
+ def __sub__(self, other):
+ # type: (VectorLike) -> Vector2d
+ other = Vector2d(other)
+ return Vector2d(self.x - other.x, self.y - other.y)
+
+ def __rsub__(self, other):
+ # type: (VectorLike) -> Vector2d
+ other = Vector2d(other)
+ return Vector2d(-self.x + other.x, -self.y + other.y)
+
+ def __neg__(self):
+ # type: () -> Vector2d
+ return Vector2d(-self.x, -self.y)
+
+ def __pos__(self):
+ # type: () -> Vector2d
+ return Vector2d(self.x, self.y)
+
+ def __floordiv__(self, factor):
+ # type: (float) -> Vector2d
+ return Vector2d(self.x / float(factor), self.y / float(factor))
+
+ def __truediv__(self, factor):
+ # type: (float) -> Vector2d
+ return Vector2d(self.x / float(factor), self.y / float(factor))
+
+ def __div__(self, factor):
+ # type: (float) -> Vector2d
+ return Vector2d(self.x / float(factor), self.y / float(factor))
+
+ def __mul__(self, factor):
+ # type: (float) -> Vector2d
+ return Vector2d(self.x * factor, self.y * factor)
+
+ def __abs__(self):
+ # type: () -> float
+ return self.length
+
+ def __rmul__(self, factor):
+ # type: (float) -> VectorLike
+ return Vector2d(self.x * factor, self.y * factor)
+
+ def __repr__(self):
+ # type: () -> str
+ return f"Vector2d({self.x:.6g}, {self.y:.6g})"
+
+ def __str__(self):
+ # type: () -> str
+ return f"{self.x:.6g}, {self.y:.6g}"
+
+ def __iter__(self) -> Generator[float, None, None]:
+ yield self.x
+ yield self.y
+
+ def __len__(self):
+ # type: () -> int
+ return 2
+
+ def __getitem__(self, item):
+ # type: (int) -> float
+ return (self.x, self.y)[item]
+
+ def to_tuple(self) -> Tuple[float, float]:
+ """A tuple of the vector's components"""
+ return cast(Tuple[float, float], tuple(self))
+
+ def to_polar_tuple(self):
+ # type: () -> Tuple[float, Optional[float]]
+ """A tuple of the vector's magnitude and direction
+
+ .. versionadded:: 1.1"""
+ return self.length, self.angle
+
+ def dot(self, other: VectorLike) -> float:
+ """Multiply Vectors component-wise"""
+ other = Vector2d(other)
+ return self.x * other.x + self.y * other.y
+
+ def cross(self, other):
+ # type: (VectorLike) -> float
+ """Z component of the cross product of the vectors extended into 3D
+
+ .. versionadded:: 1.1"""
+ other = Vector2d(other)
+ return self.x * other.y - self.y * other.x
+
+ def is_close(
+ self,
+ other: Union[VectorLike, str, Tuple[float, float]],
+ rtol: float = 1e-5,
+ atol: float = 1e-8,
+ ) -> float:
+ """Checks if two vectors are (almost) identical, up to both absolute and
+ relative tolerance."""
+ other = Vector2d(other)
+ delta = (self - other).length
+ return delta < (atol + rtol * other.length)
+
+ @property
+ def length(self) -> float:
+ """Returns the length of the vector"""
+ return sqrt(self.dot(self))
+
+ @property
+ def angle(self):
+ # type: () -> Optional[float]
+ """The angle of the vector when represented in polar coordinates
+
+ .. versionadded:: 1.1"""
+ if self.x == 0 and self.y == 0:
+ return None
+ return atan2(self.y, self.x)
+
+
+class Vector2d(ImmutableVector2d):
+ """Represents an element of 2-dimensional Euclidean space"""
+
+ @staticmethod
+ def from_polar(radius, theta):
+ # type: (float, Optional[float]) -> Optional[Vector2d]
+ """Creates a Vector2d from polar coordinates
+
+ None is returned when theta is None and radius is not zero.
+
+ .. versionadded:: 1.1
+ """
+ if radius == 0.0:
+ return Vector2d(0.0, 0.0)
+ if theta is not None:
+ return Vector2d(radius * cos(theta), radius * sin(theta))
+ # A vector with a radius but no direction is invalid
+ return None
+
+ @ImmutableVector2d.x.setter
+ def x(self, value):
+ # type: (Union[float, int, str]) -> None
+ self._x = float(value)
+
+ @ImmutableVector2d.y.setter
+ def y(self, value):
+ # type: (Union[float, int, str]) -> None
+ self._y = float(value)
+
+ def __iadd__(self, other):
+ # type: (VectorLike) -> Vector2d
+ other = Vector2d(other)
+ self.x += other.x
+ self.y += other.y
+ return self
+
+ def __isub__(self, other):
+ # type: (VectorLike) -> Vector2d
+ other = Vector2d(other)
+ self.x -= other.x
+ self.y -= other.y
+ return self
+
+ def __imul__(self, factor):
+ # type: (float) -> Vector2d
+ self.x *= factor
+ self.y *= factor
+ return self
+
+ def __idiv__(self, factor):
+ # type: (float) -> Vector2d
+ self.x /= factor
+ self.y /= factor
+ return self
+
+ def __itruediv__(self, factor):
+ # type: (float) -> Vector2d
+ self.x /= factor
+ self.y /= factor
+ return self
+
+ def __ifloordiv__(self, factor):
+ # type: (float) -> Vector2d
+ self.x /= factor
+ self.y /= factor
+ return self
+
+ @overload
+ def assign(self, x, y):
+ # type: (float, float) -> VectorLike
+ pass
+
+ @overload
+ def assign(self, other):
+ # type: (VectorLike, str) -> VectorLike
+ pass
+
+ def assign(self, *args):
+ """Assigns a different vector in place"""
+ self.x, self.y = Vector2d(*args)
+ return self
+
+
+class Transform:
+ """A transformation object which will always reduce to a matrix and can
+ then be used in combination with other transformations for reducing
+ finding a point and printing svg ready output.
+
+ Use with svg transform attribute input:
+
+ tr = Transform("scale(45, 32)")
+
+ Use with triad matrix input (internal representation):
+
+ tr = Transform(((1.0, 0.0, 0.0), (0.0, 1.0, 0.0)))
+
+ Use with hexad matrix input (i.e. svg matrix(...)):
+
+ tr = Transform((1.0, 0.0, 0.0, 1.0, 0.0, 0.0))
+
+ Once you have a transformation you can operate tr * tr to compose,
+ any of the above inputs are also valid operators for composing.
+ """
+
+ TRM = re.compile(r"(translate|scale|rotate|skewX|skewY|matrix)\s*\(([^)]*)\)\s*,?")
+ absolute_tolerance = 1e-5 # type: float
+
+ def __init__(
+ self,
+ matrix=None, # type: Optional[MatrixLike]
+ callback=None, # type: Optional[Callable[[Transform], Transform]]
+ **extra,
+ ):
+ # type: (...) -> None
+ self.callback = None
+ self.matrix = ((1.0, 0.0, 0.0), (0.0, 1.0, 0.0))
+ if matrix is not None:
+ self._set_matrix(matrix)
+
+ self.add_kwargs(**extra)
+ # Set callback last, so it doesn't kick off just setting up the internal value
+ self.callback = callback
+
+ def _set_matrix(self, matrix):
+ # type: (MatrixLike) -> None
+ """Parse a given string as an svg transformation instruction.
+
+ .. versionadded:: 1.1"""
+ if isinstance(matrix, str):
+ for func, values in self.TRM.findall(matrix.strip()):
+ getattr(self, "add_" + func.lower())(*strargs(values))
+ elif isinstance(matrix, Transform):
+ self.matrix = matrix.matrix
+ elif isinstance(matrix, (tuple, list)) and len(matrix) == 2:
+ row1 = matrix[0]
+ row2 = matrix[1]
+ if isinstance(row1, (tuple, list)) and isinstance(row2, (tuple, list)):
+ if len(row1) == 3 and len(row2) == 3:
+ row1 = cast(Tuple[float, float, float], tuple(map(float, row1)))
+ row2 = cast(Tuple[float, float, float], tuple(map(float, row2)))
+ self.matrix = row1, row2
+ else:
+ raise ValueError(
+ f"Matrix '{matrix}' is not a valid transformation matrix"
+ )
+ else:
+ raise ValueError(
+ f"Matrix '{matrix}' is not a valid transformation matrix"
+ )
+ elif isinstance(matrix, (list, tuple)) and len(matrix) == 6:
+ tmatrix = cast(
+ Union[List[float], Tuple[float, float, float, float, float, float]],
+ matrix,
+ )
+ row1 = (float(tmatrix[0]), float(tmatrix[2]), float(tmatrix[4]))
+ row2 = (float(tmatrix[1]), float(tmatrix[3]), float(tmatrix[5]))
+ self.matrix = row1, row2
+ elif not isinstance(matrix, (list, tuple)):
+ raise ValueError(f"Invalid transform type: {type(matrix).__name__}")
+ else:
+ raise ValueError(f"Matrix '{matrix}' is not a valid transformation matrix")
+
+ # These provide quick access to the svg matrix:
+ #
+ # [ a, c, e ]
+ # [ b, d, f ]
+ #
+ a = property(lambda self: self.matrix[0][0]) # pylint: disable=invalid-name
+ b = property(lambda self: self.matrix[1][0]) # pylint: disable=invalid-name
+ c = property(lambda self: self.matrix[0][1]) # pylint: disable=invalid-name
+ d = property(lambda self: self.matrix[1][1]) # pylint: disable=invalid-name
+ e = property(lambda self: self.matrix[0][2]) # pylint: disable=invalid-name
+ f = property(lambda self: self.matrix[1][2]) # pylint: disable=invalid-name
+
+ def __bool__(self):
+ # type: () -> bool
+ return not self.__eq__(Transform())
+
+ __nonzero__ = __bool__
+
+ @overload
+ def add_matrix(self, a):
+ # type: (MatrixLike) -> Transform
+ pass
+
+ @overload
+ def add_matrix( # pylint: disable=too-many-arguments
+ self, a: float, b: float, c: float, d: float, e: float, f: float
+ ) -> Transform:
+ pass
+
+ @overload
+ def add_matrix(self, a, b):
+ # type: (Tuple[float, float, float], Tuple[float, float, float]) -> Transform
+ pass
+
+ def add_matrix(self, *args):
+ """Add matrix in order they appear in the svg hexad"""
+ if len(args) == 1:
+ self.__imatmul__(Transform(args[0]))
+ elif len(args) == 2 or len(args) == 6:
+ self.__imatmul__(Transform(args))
+ else:
+ raise ValueError(f"Invalid number of arguments {args}")
+ return self
+
+ def add_kwargs(self, **kwargs):
+ """Add translations, scales, rotations etc using key word arguments"""
+ for key, value in reversed(list(kwargs.items())):
+ func = getattr(self, "add_" + key)
+ if isinstance(value, tuple):
+ func(*value)
+ elif value is not None:
+ func(value)
+ return self
+
+ @overload
+ def add_translate(self, dr):
+ # type: (VectorLike) -> Transform
+ pass
+
+ @overload
+ def add_translate(self, tr_x, tr_y=0.0):
+ # type: (float, Optional[float]) -> Transform
+ pass
+
+ def add_translate(self, *args):
+ """Add translate to this transformation"""
+ if len(args) == 1 and isinstance(args[0], (int, float)):
+ tr_x, tr_y = args[0], 0.0
+ else:
+ tr_x, tr_y = Vector2d(*args)
+ self.__imatmul__(((1.0, 0.0, tr_x), (0.0, 1.0, tr_y)))
+ return self
+
+ def add_scale(self, sc_x, sc_y=None):
+ """Add scale to this transformation"""
+ sc_y = sc_x if sc_y is None else sc_y
+ self.__imatmul__(((sc_x, 0.0, 0.0), (0.0, sc_y, 0.0)))
+ return self
+
+ @overload
+ def add_rotate(self, deg, center):
+ # type: (float, VectorLike) -> Transform
+ pass
+
+ @overload
+ def add_rotate(self, deg, center_x, center_y):
+ # type: (float, float, float) -> Transform
+ pass
+
+ @overload
+ def add_rotate(self, deg):
+ # type: (float) -> Transform
+ pass
+
+ @overload
+ def add_rotate(self, deg, a):
+ # type: (float, Union[VectorLike, str]) -> Transform
+ pass
+
+ @overload
+ def add_rotate(self, deg, a, b):
+ # type: (float, float, float) -> Transform
+ pass
+
+ def add_rotate(self, deg, *args):
+ """Add rotation to this transformation"""
+ center_x, center_y = Vector2d(*args)
+ _cos, _sin = cos(radians(deg)), sin(radians(deg))
+ self.__imatmul__(((_cos, -_sin, center_x), (_sin, _cos, center_y)))
+ self.__imatmul__(((1.0, 0.0, -center_x), (0.0, 1.0, -center_y)))
+ return self
+
+ def add_skewx(self, deg):
+ # type: (float) -> Transform
+ """Add skew x to this transformation"""
+ self.__imatmul__(((1.0, tan(radians(deg)), 0.0), (0.0, 1.0, 0.0)))
+ return self
+
+ def add_skewy(self, deg):
+ # type: (float) -> Transform
+ """Add skew y to this transformation"""
+ self.__imatmul__(((1.0, 0.0, 0.0), (tan(radians(deg)), 1.0, 0.0)))
+ return self
+
+ def to_hexad(self):
+ # type: () -> Iterator[float]
+ """Returns the transform as a hexad matrix (used in svg)"""
+ return (val for lst in zip(*self.matrix) for val in lst)
+
+ def is_translate(self, exactly=False):
+ # type: (bool) -> bool
+ """Returns True if this transformation is ONLY translate"""
+ tol = self.absolute_tolerance if not exactly else 0.0
+ return (
+ fabs(self.a - 1) <= tol
+ and abs(self.d - 1) <= tol
+ and fabs(self.b) <= tol
+ and fabs(self.c) <= tol
+ )
+
+ def is_scale(self, exactly=False):
+ # type: (bool) -> bool
+ """Returns True if this transformation is ONLY scale"""
+ tol = self.absolute_tolerance if not exactly else 0.0
+ return (
+ fabs(self.e) <= tol
+ and fabs(self.f) <= tol
+ and fabs(self.b) <= tol
+ and fabs(self.c) <= tol
+ )
+
+ def is_rotate(self, exactly=False):
+ # type: (bool) -> bool
+ """Returns True if this transformation is ONLY rotate"""
+ tol = self.absolute_tolerance if not exactly else 0.0
+ return (
+ self._is_URT(exactly=exactly)
+ and fabs(self.e) <= tol
+ and fabs(self.f) <= tol
+ and fabs(self.a**2 + self.b**2 - 1) <= tol
+ )
+
+ def rotation_degrees(self):
+ # type: () -> float
+ """Return the amount of rotation in this transform"""
+ if not self._is_URT(exactly=False):
+ raise ValueError(
+ "Rotation angle is undefined for non-uniformly scaled or skewed "
+ "matrices"
+ )
+ return atan2(self.b, self.a) * 180 / pi
+
+ def __str__(self):
+ # type: () -> str
+ """Format the given matrix into a string representation for svg"""
+ hexad = tuple(self.to_hexad())
+ if self.is_translate():
+ if not self:
+ return ""
+ return f"translate({self.e:.6g}, {self.f:.6g})"
+ if self.is_scale():
+ return f"scale({self.a:.6g}, {self.d:.6g})"
+ if self.is_rotate():
+ return f"rotate({self.rotation_degrees():.6g})"
+ return f"matrix({' '.join(f'{var:.6g}' for var in hexad)})"
+
+ def __repr__(self) -> str:
+ """String representation of this object"""
+ return (
+ f"{type(self).__name__}(("
+ f"({', '.join(f'{var:.6g}' for var in self.matrix[0])}), "
+ f"({', '.join(f'{var:.6g}' for var in self.matrix[1])})))"
+ )
+
+ def __eq__(self, matrix):
+ # typing this requires writing a proof for mypy that matrix is really
+ # MatrixLike
+ """Test if this transformation is equal to the given matrix"""
+ if isinstance(matrix, (str, tuple, list, Transform)):
+ val = all(
+ fabs(l - r) <= self.absolute_tolerance
+ for l, r in zip(self.to_hexad(), Transform(matrix).to_hexad())
+ )
+ else:
+ val = False
+ return val
+
+ def __matmul__(self, matrix):
+ # type: (MatrixLike) -> Transform
+ """Combine this transform's internal matrix with the given matrix"""
+ # Conform the input to a known quantity (and convert if needed)
+ other = Transform(matrix)
+ # Return a transformation as the combined result
+ return Transform(
+ (
+ self.a * other.a + self.c * other.b,
+ self.b * other.a + self.d * other.b,
+ self.a * other.c + self.c * other.d,
+ self.b * other.c + self.d * other.d,
+ self.a * other.e + self.c * other.f + self.e,
+ self.b * other.e + self.d * other.f + self.f,
+ )
+ )
+
+ def __imatmul__(self, matrix):
+ # type: (MatrixLike) -> Transform
+ """In place multiplication of transform matrices"""
+ self.matrix = (self @ matrix).matrix
+ if self.callback is not None:
+ self.callback(self)
+ return self
+
+ def __neg__(self):
+ # type: () -> Transform
+ """Returns an inverted transformation"""
+ det = (self.a * self.d) - (self.c * self.b)
+ # invert the rotation/scaling part
+ new_a = self.d / det
+ new_d = self.a / det
+ new_c = -self.c / det
+ new_b = -self.b / det
+ # invert the translational part
+ new_e = -(new_a * self.e + new_c * self.f)
+ new_f = -(new_b * self.e + new_d * self.f)
+ return Transform((new_a, new_b, new_c, new_d, new_e, new_f))
+
+ def apply_to_point(self, point):
+ # type: (VectorLike) -> Vector2d
+ """Transform a tuple (X, Y)"""
+ if isinstance(point, str):
+ raise ValueError(f"Will not transform string '{point}'")
+ point = Vector2d(point)
+ return Vector2d(
+ self.a * point.x + self.c * point.y + self.e,
+ self.b * point.x + self.d * point.y + self.f,
+ )
+
+ def _is_URT(self, exactly=False):
+ # type: (bool) -> bool
+ """
+ Checks that transformation can be decomposed into product of
+ Uniform scale (U), Rotation around origin (R) and translation (T)
+
+ :return: decomposition as U*R*T is possible
+ """
+ tol = self.absolute_tolerance if not exactly else 0.0
+ return (fabs(self.a - self.d) <= tol) and (fabs(self.b + self.c) <= tol)
+
+ def interpolate(self, other, fraction):
+ # type: (Transform, float) -> Transform
+ """Interpolate with another Transform.
+
+ .. versionadded:: 1.1
+ """
+ from .tween import TransformInterpolator
+
+ return TransformInterpolator(self, other).interpolate(fraction)
+
+
+class BoundingInterval: # pylint: disable=too-few-public-methods
+ """A pair of numbers that represent the minimum and maximum values."""
+
+ @overload
+ def __init__(self, other=None):
+ # type: (Optional[BoundingInterval]) -> None
+ pass
+
+ @overload
+ def __init__(self, pair):
+ # type: (Tuple[float, float]) -> None
+ pass
+
+ @overload
+ def __init__(self, value):
+ # type: (float) -> None
+ pass
+
+ @overload
+ def __init__(self, x, y):
+ # type: (float, float) -> None
+ pass
+
+ def __init__(self, x=None, y=None):
+ self.x: Union[int, float, Decimal]
+ self.y: Union[int, float, Decimal]
+ self.minimum: float
+ self.maximum: float
+ if y is not None:
+ if isinstance(x, (int, float, Decimal)) and isinstance(
+ y, (int, float, Decimal)
+ ):
+ self.minimum = float(x)
+ self.maximum = float(y)
+ else:
+ raise ValueError(
+ f"Not a number for scaling: {str((x, y))} "
+ f"({type(x).__name__},{type(y).__name__})"
+ )
+
+ else:
+ value = x
+ if value is None:
+ # identity for addition, zero for intersection
+ self.minimum, self.maximum = float("+inf"), float("-inf")
+ elif isinstance(value, BoundingInterval):
+ self.minimum = value.minimum
+ self.maximum = value.maximum
+ elif isinstance(value, (tuple, list)) and len(value) == 2:
+ self.minimum, self.maximum = min(value), max(value)
+ elif isinstance(value, (int, float, Decimal)):
+ self.minimum = self.maximum = float(value)
+ else:
+ raise ValueError(
+ f"Not a number for scaling: {str(value)} ({type(value).__name__})"
+ )
+
+ def __bool__(self):
+ # type: () -> bool
+ return isfinite(self.minimum) and isfinite(self.maximum)
+
+ __nonzero__ = __bool__
+
+ def __neg__(self):
+ # type: () -> BoundingInterval
+ return BoundingInterval((-1 * self.maximum, -1 * self.minimum))
+
+ def __add__(self, other):
+ # type: (BoundingInterval) -> BoundingInterval
+ """Calculate the bounding interval that covers both given bounding intervals"""
+ new = BoundingInterval(self)
+ if other is not None:
+ new += other
+ return new
+
+ def __iadd__(self, other):
+ # type: (BoundingInterval) -> BoundingInterval
+ other = BoundingInterval(other)
+ self.minimum = min((self.minimum, other.minimum))
+ self.maximum = max((self.maximum, other.maximum))
+ return self
+
+ def __radd__(self, other):
+ # type: (BoundingInterval) -> BoundingInterval
+ if other is None:
+ return BoundingInterval(self)
+ return self + other
+
+ def __and__(self, other: BoundingInterval) -> BoundingInterval:
+ """Calculate the bounding interval where both given bounding intervals
+ overlap"""
+ new = BoundingInterval(self)
+ if other is not None:
+ new &= other
+ return new
+
+ def __iand__(self, other):
+ # type: (BoundingInterval) -> BoundingInterval
+ other = BoundingInterval(other)
+ self.minimum = max((self.minimum, other.minimum))
+ self.maximum = min((self.maximum, other.maximum))
+ if self.minimum > self.maximum:
+ self.minimum, self.maximum = float("+inf"), float("-inf")
+ return self
+
+ def __rand__(self, other):
+ # type: (BoundingInterval) -> BoundingInterval
+ if other is None:
+ return BoundingInterval(self)
+ return self & other
+
+ def __mul__(self, other: float) -> BoundingInterval:
+ new = BoundingInterval(self)
+ if other is not None:
+ new *= other
+ return new
+
+ def __imul__(self, other: float) -> BoundingInterval:
+ self.minimum *= other
+ self.maximum *= other
+ return self
+
+ def __iter__(self) -> Generator[float, None, None]:
+ yield self.minimum
+ yield self.maximum
+
+ def __eq__(self, other) -> bool:
+ return tuple(self) == tuple(BoundingInterval(other))
+
+ def __contains__(self, value: float) -> bool:
+ return self.minimum <= value <= self.maximum
+
+ def __repr__(self) -> str:
+ return f"BoundingInterval({self.minimum}, {self.maximum})"
+
+ @property
+ def center(self):
+ # type: () -> float
+ """Pick the middle of the line"""
+ return self.minimum + ((self.maximum - self.minimum) / 2)
+
+ @property
+ def size(self):
+ # type: () -> float
+ """Return the size difference minimum and maximum"""
+ return self.maximum - self.minimum
+
+
+class BoundingBox: # pylint: disable=too-few-public-methods
+ """
+ Some functions to compute a rough bbox of a given list of objects.
+
+ BoundingBox(other)
+ BoundingBox(x, y)
+ BoundingBox((x1, x2), (y1, y2))
+ """
+
+ width = property(lambda self: self.x.size)
+ height = property(lambda self: self.y.size)
+ top = property(lambda self: self.y.minimum)
+ left = property(lambda self: self.x.minimum)
+ bottom = property(lambda self: self.y.maximum)
+ right = property(lambda self: self.x.maximum)
+ center_x = property(lambda self: self.x.center)
+ center_y = property(lambda self: self.y.center)
+
+ @overload
+ def __init__(self, other=None):
+ # type: (Optional[BoundingBox]) -> None
+ pass
+
+ @overload
+ def __init__(self, x, y):
+ # type: (BoundingIntervalArgs, BoundingIntervalArgs) -> None
+ pass
+
+ def __init__(self, x=None, y=None):
+ if y is None:
+ if x is None:
+ # identity for addition, zero for intersection
+ pass
+ elif isinstance(x, BoundingBox):
+ x, y = x.x, x.y
+ else:
+ raise ValueError(
+ f"Not a number for scaling: {str(x)} ({type(x).__name__})"
+ )
+ self.x = BoundingInterval(x)
+ self.y = BoundingInterval(y)
+
+ @staticmethod
+ def new_xywh(x: float, y: float, width: float, height: float) -> BoundingBox:
+ """Create a bounding box using x, y, width and height
+
+ .. versionadded:: 1.2"""
+ return BoundingBox((x, x + width), (y, y + height))
+
+ def __bool__(self):
+ # type: () -> bool
+ return bool(self.x) and bool(self.y)
+
+ __nonzero__ = __bool__
+
+ def __neg__(self):
+ # type: () -> BoundingBox
+ return BoundingBox(-self.x, -self.y)
+
+ def __add__(self, other):
+ # type: (Optional[BoundingBox]) -> BoundingBox
+ """Calculate the bounding box that covers both given bounding boxes"""
+ new = BoundingBox(self)
+ new += BoundingBox(other)
+ return new
+
+ def __iadd__(self, other):
+ # type: (Optional[BoundingBox]) -> BoundingBox
+ other = BoundingBox(other)
+ self.x += other.x
+ self.y += other.y
+ return self
+
+ def __radd__(self, other):
+ # type: (Optional[BoundingBox]) -> BoundingBox
+ return self + other
+
+ def __and__(self, other):
+ # type: (Optional[BoundingBox]) -> BoundingBox
+ """Calculate the bounding box where both given bounding boxes overlap"""
+ new = BoundingBox(self)
+ new &= BoundingBox(other)
+ return new
+
+ def __iand__(self, other: Optional[BoundingBox]) -> BoundingBox:
+ other = BoundingBox(other)
+ self.x = self.x & other.x
+ self.y = self.y & other.y
+ if not self.x or not self.y:
+ self.x, self.y = BoundingInterval(), BoundingInterval()
+ return self
+
+ def __rand__(self, other):
+ # type: (Optional[BoundingBox]) -> BoundingBox
+ return self & other
+
+ def __mul__(self, factor):
+ # type: (float) -> BoundingBox
+ new = BoundingBox(self)
+ new *= factor
+ return new
+
+ def __imul__(self, factor):
+ # type: (float) -> BoundingBox
+ self.x *= factor
+ self.y *= factor
+ return self
+
+ def __eq__(self, other):
+ # type (object) -> bool
+ if isinstance(other, BoundingBox):
+ return tuple(self) == tuple(other)
+ return False
+
+ def __iter__(self) -> Generator[BoundingBox, None, None]:
+ yield self.x
+ yield self.y
+
+ @property
+ def area(self):
+ """Return area of the bounding box
+
+ .. versionadded:: 1.2"""
+ return self.width * self.height
+
+ @property
+ def minimum(self):
+ # type: () -> Vector2d
+ """Return the minimum x,y coords"""
+ return Vector2d(self.x.minimum, self.y.minimum)
+
+ @property
+ def maximum(self):
+ # type: () -> Vector2d
+ """Return the maximum x,y coords"""
+ return Vector2d(self.x.maximum, self.y.maximum)
+
+ def __repr__(self):
+ # type: () -> str
+ return f"BoundingBox({tuple(self.x)},{tuple(self.y)})"
+
+ @property
+ def center(self):
+ # type: () -> Vector2d
+ """Returns the middle of the bounding box"""
+ return Vector2d(self.x.center, self.y.center)
+
+ @property
+ def size(self):
+ """Returns a vector containing width and height of the bounding box
+
+ .. versionadded:: 1.2"""
+ return Vector2d(self.x.size, self.y.size)
+
+ def get_anchor(self, xanchor, yanchor, direction=0, selbox=None):
+ # type: (str, str, Union[int, str], Optional[BoundingBox]) -> float
+ """Calls get_distance with the given anchor options"""
+ return self.anchor_distance(
+ getattr(self, XAN[xanchor]),
+ getattr(self, YAN[yanchor]),
+ direction=direction,
+ selbox=selbox,
+ )
+
+ @staticmethod
+ def anchor_distance(
+ x: float,
+ y: float,
+ direction: Union[int, str] = 0,
+ selbox: Optional[BoundingBox] = None,
+ ) -> float:
+ """Using the x,y returns a single sortable value based on direction and angle
+
+ Args:
+ x (float): input x coordinate
+ y (float): input y coordinate
+ direction (Union[int, str], optional): int/float (custom angle),
+ tb/bt (top/bottom), lr/rl (left/right), ri/ro (radial). Defaults to 0.
+ selbox (Optional[BoundingBox], optional): The bounding box of the whole
+ selection for radial anchors. Defaults to None.
+
+ Raises:
+ ValueError: if radial distance is requested without the optional selbox
+ parameter.
+
+ Returns:
+ float: the anchor distance with respect to the direction.
+ """
+
+ rot = 0.0
+ if isinstance(direction, (int, float)): # Angle
+ if direction not in CUSTOM_DIRECTION:
+ return hypot(x, y) * (cos(radians(-direction) - atan2(y, x)))
+ direction = CUSTOM_DIRECTION[direction]
+
+ if direction in ("ro", "ri"):
+ if selbox is None:
+ raise ValueError(
+ "Radial distance not available without selection bounding box"
+ )
+ rot = hypot(selbox.x.center - x, selbox.y.center - y)
+
+ return [y, -y, x, -x, rot, -rot][DIRECTION.index(direction)]
+
+ def resize(self, delta_x: float, delta_y: Optional[float] = None) -> BoundingBox:
+ """Enlarges / shrinks a bounding box by a constant value. If only delta_x
+ is given, each side is moved by the same amount; if delta_y is given,
+ different deltas are applied to horizontal and vertical intervals.
+
+ .. versionadded:: 1.2"""
+ delta_y = delta_y or delta_x
+ return BoundingBox(
+ (self.x.minimum - delta_x, self.x.maximum + delta_x),
+ (self.y.minimum - delta_y, self.y.maximum + delta_y),
+ )
+
+
+class DirectedLineSegment:
+ """
+ A directed line segment
+
+ DirectedLineSegment(((x0, y0), (x1, y1)))
+ """
+
+ start = Vector2d() # start point of segment
+ end = Vector2d() # end point of segment
+
+ x0 = property(lambda self: self.start.x) # pylint: disable=invalid-name
+ y0 = property(lambda self: self.start.y) # pylint: disable=invalid-name
+ x1 = property(lambda self: self.end.x)
+ y1 = property(lambda self: self.end.y)
+ dx = property(lambda self: self.vector.x) # pylint: disable=invalid-name
+ dy = property(lambda self: self.vector.y) # pylint: disable=invalid-name
+
+ @overload
+ def __init__(self):
+ # type: () -> None
+ pass
+
+ @overload
+ def __init__(self, other):
+ # type: (DirectedLineSegment) -> None
+ pass
+
+ @overload
+ def __init__(self, start, end):
+ # type: (VectorLike, VectorLike) -> None
+ pass
+
+ def __init__(self, *args):
+ if not args: # overload 0
+ start, end = Vector2d(), Vector2d()
+ elif len(args) == 1: # overload 1
+ (other,) = args
+ start, end = other.start, other.end
+ elif len(args) == 2: # overload 2
+ start, end = args
+ else:
+ raise ValueError(f"DirectedLineSegment() can't be constructed from {args}")
+
+ self.start = Vector2d(start)
+ self.end = Vector2d(end)
+
+ def __eq__(self, other):
+ # type: (object) -> bool
+ if isinstance(other, (tuple, DirectedLineSegment)):
+ return tuple(self) == tuple(other)
+ return False
+
+ def __iter__(self):
+ # type: () -> Generator[DirectedLineSegment, None, None]
+ yield self.x0
+ yield self.x1
+ yield self.y0
+ yield self.y1
+
+ @property
+ def vector(self):
+ # type: () -> Vector2d
+ """The vector of the directed line segment.
+
+ The vector of the directed line segment represents the length
+ and direction of segment, but not the starting point.
+
+ .. versionadded:: 1.1
+ """
+ return self.end - self.start
+
+ @property
+ def length(self):
+ # type: () -> float
+ """Get the length of the line segment"""
+ return self.vector.length
+
+ @property
+ def angle(self):
+ # type: () -> float
+ """Get the angle of the line created by this segment"""
+ return atan2(self.dy, self.dx)
+
+ def distance_to_point(self, x, y):
+ # type: (float, float) -> Union[DirectedLineSegment, Optional[float]]
+ """Get the distance to the given point (x, y)"""
+ segment2 = DirectedLineSegment(self.start, (x, y))
+ dot2 = segment2.dot(self)
+ if dot2 <= 0:
+ return DirectedLineSegment((x, y), self.start).length
+ if self.dot(self) <= dot2:
+ return DirectedLineSegment((x, y), self.end).length
+ return self.perp_distance(x, y)
+
+ def perp_distance(self, x, y):
+ # type: (float, float) -> Optional[float]
+ """Perpendicular distance to the given point"""
+ if self.length == 0:
+ return None
+ return fabs((self.dx * (self.y0 - y)) - ((self.x0 - x) * self.dy)) / self.length
+
+ def dot(self, other):
+ # type: (DirectedLineSegment) -> float
+ """Get the dot product with the segment with another"""
+ return self.vector.dot(other.vector)
+
+ def point_at_ratio(self, ratio):
+ # type: (float) -> Tuple[float, float]
+ """Get the point at the given ratio along the line"""
+ return self.x0 + ratio * self.dx, self.y0 + ratio * self.dy
+
+ def point_at_length(self, length):
+ # type: (float) -> Tuple[float, float]
+ """Get the point as the length along the line"""
+ return self.point_at_ratio(length / self.length)
+
+ def parallel(self, x, y):
+ # type: (float, float) -> DirectedLineSegment
+ """Create parallel Segment"""
+ return DirectedLineSegment((x + self.dx, y + self.dy), (x, y))
+
+ def intersect(self, other):
+ # type: (DirectedLineSegment) -> Optional[Vector2d]
+ """Get the intersection between two segments"""
+ other = DirectedLineSegment(other)
+ denom = self.vector.cross(other.vector)
+ num = other.vector.cross(self.start - other.start)
+
+ if denom != 0:
+ return Vector2d(self.point_at_ratio(num / denom))
+ return None
+
+ def __repr__(self):
+ # type: () -> str
+ return f"DirectedLineSegment(({self.start}), ({self.end}))"
+
+
+def cubic_extrema(py0, py1, py2, py3):
+ # type: (float, float, float, float) -> Tuple[float, float]
+ """Returns the extreme value, given a set of bezier coordinates"""
+
+ atol = 1e-9
+ cmin, cmax = min(py0, py3), max(py0, py3)
+ pd1 = py1 - py0
+ pd2 = py2 - py1
+ pd3 = py3 - py2
+
+ def _is_bigger(point):
+ if 0 < point < 1:
+ pyx = (
+ py0 * (1 - point) * (1 - point) * (1 - point)
+ + 3 * py1 * point * (1 - point) * (1 - point)
+ + 3 * py2 * point * point * (1 - point)
+ + py3 * point * point * point
+ )
+ return min(cmin, pyx), max(cmax, pyx)
+ return cmin, cmax
+
+ if fabs(pd1 - 2 * pd2 + pd3) > atol:
+ if pd2 * pd2 > pd1 * pd3:
+ pds = sqrt(pd2 * pd2 - pd1 * pd3)
+ cmin, cmax = _is_bigger((pd1 - pd2 + pds) / (pd1 - 2 * pd2 + pd3))
+ cmin, cmax = _is_bigger((pd1 - pd2 - pds) / (pd1 - 2 * pd2 + pd3))
+
+ elif fabs(pd2 - pd1) > atol:
+ cmin, cmax = _is_bigger(-pd1 / (2 * (pd2 - pd1)))
+
+ return cmin, cmax
+
+
+def quadratic_extrema(py0, py1, py2):
+ # type: (float, float, float) -> Tuple[float, float]
+ """Returns the extreme value, given a set of quadratic bezier coordinates"""
+ atol = 1e-9
+ cmin, cmax = min(py0, py2), max(py0, py2)
+
+ def _is_bigger(point):
+ if 0 < point < 1:
+ pyx = (
+ py0 * (1 - point) * (1 - point)
+ + 2 * py1 * point * (1 - point)
+ + py2 * point * point
+ )
+ return min(cmin, pyx), max(cmax, pyx)
+ return cmin, cmax
+
+ if fabs(py0 + py2 - 2 * py1) > atol:
+ cmin, cmax = _is_bigger((py0 - py1) / (py0 + py2 - 2 * py1))
+
+ return cmin, cmax