diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 18:24:48 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 18:24:48 +0000 |
commit | cca66b9ec4e494c1d919bff0f71a820d8afab1fa (patch) | |
tree | 146f39ded1c938019e1ed42d30923c2ac9e86789 /share/extensions/inkex/transforms.py | |
parent | Initial commit. (diff) | |
download | inkscape-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.py | 1250 |
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 |