Source code for ppb_vector

import typing
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from math import atan2, copysign, cos, degrees, hypot, isclose, radians, sin, sqrt

__all__ = ('Vector',)

#: ppb_vector's current version.
#: It follows the semantic versioning convention.
__version__ = "1.0"


# Anything convertable to a Vector, including lists, tuples, and dicts
VectorLike = typing.Union[
    'Vector',  # Or subclasses, unconnected to the Vector typevar above
    typing.Tuple[typing.SupportsFloat, typing.SupportsFloat],
    typing.Sequence[typing.SupportsFloat],  # TODO: Length 2
    typing.Mapping[str, typing.SupportsFloat],  # TODO: Length 2, keys 'x', 'y'
]


[docs]@dataclass(eq=False, frozen=True, init=False, repr=False) class Vector: """The immutable, 2D vector class of the PursuedPyBear project. :py:class:`Vector` is an immutable 2D Vector, which can be instantiated as expected: >>> from ppb_vector import Vector >>> Vector(3, 4) Vector(3.0, 4.0) :py:class:`Vector` implements many convenience features, as well as useful mathematical operations for 2D geometry and linear algebra. :py:class:`Vector` acts as an iterable and a sequence, allowing usage like converting, indexing, and unpacking: >>> v = Vector(-3, -5) >>> list(v) [-3.0, -5.0] >>> tuple(v) (-3.0, -5.0) >>> v[0] -3.0 >>> x, y = Vector(1, 2) >>> x 1.0 >>> print( *Vector(1, 2) ) 1.0 2.0 It also acts mostly like a mapping, when it does not conflict with being a sequence. In particular, the coordinates may be accessed by subscripting: >>> v["y"] -5.0 >>> v["x"] -3.0 """ x: float y: float # Tell CPython that this isn't an extendable dict __slots__ = ('x', 'y', '__weakref__') @typing.overload def __new__(cls, x: typing.SupportsFloat, y: typing.SupportsFloat): pass @typing.overload def __new__(cls, other: VectorLike): pass
[docs] def __new__(cls, *args, **kwargs): """ Make a vector from coordinates, or convert a vector-like. A vector-like can be: - a length-2 :py:class:`Sequence <collections.abc.Sequence>`, whose contents are interpreted as the ``x`` and ``y`` coordinates like ``(4, 2)`` - a length-2 :py:class:`Mapping <collections.abc.Mapping>`, whose keys are ``x`` and ``y`` like ``{'x': 4, 'y': 2}`` - any instance of :py:class:`Vector` or any subclass. """ if args and kwargs: raise TypeError("Got a mix of positional and keyword arguments") if not args and not kwargs or len(args) > 2: raise TypeError("Expected 1 vector-like or 2 float-like arguments, " f"got {len(args) + len(kwargs)}") if kwargs and frozenset(kwargs) != {'x', 'y'}: raise TypeError(f"Expected keyword arguments x and y, got: {kwargs.keys().join(', ')}") if kwargs: x, y = kwargs['x'], kwargs['y'] elif len(args) == 1: value = args[0] if isinstance(value, cls): # Short circuit if already a valid instance return value x, y = Vector._unpack(value) elif len(args) == 2: x, y = args self = super().__new__(cls) try: # The @dataclass decorator made the class frozen, so we need to # bypass the class' default assignment function : # # https://docs.python.org/3/library/dataclasses.html#frozen-instances object.__setattr__(self, 'x', float(x)) except ValueError: raise TypeError(f"{type(x).__name__} object not convertable to float") try: object.__setattr__(self, 'y', float(y)) except ValueError: raise TypeError(f"{type(y).__name__} object not convertable to float") return self
[docs] def __reduce__(self): return Vector, (self.x, self.y)
[docs] def update(self, x: typing.Optional[typing.SupportsFloat] = None, y: typing.Optional[typing.SupportsFloat] = None): """Return a new :py:class:`Vector` replacing specified fields with new values.""" if x is None and y is None: return self return Vector(self.x if x is None else x, self.y if y is None else y)
@staticmethod def _unpack(value: VectorLike) -> typing.Tuple[float, float]: if isinstance(value, Vector): return value.x, value.y elif isinstance(value, Sequence) and len(value) == 2: return float(value[0]), float(value[1]) elif isinstance(value, Mapping) and 'x' in value and 'y' in value and len(value) == 2: return float(value['x']), float(value['y']) else: raise ValueError(f"Cannot use {value} as a vector-like")
[docs] def __bool__(self) -> bool: """Check whether the vector is non-zero. >>> assert Vector(1, 1) >>> assert not Vector(0, 0) """ return self != (0, 0)
@property def length(self) -> float: """Compute the length of a vector. >>> Vector(45, 60).length 75.0 """ # Surprisingly, caching this value provides no descernable performance # benefit, according to microbenchmarks. return hypot(self.x, self.y)
[docs] def asdict(self) -> typing.Mapping[str, float]: """Convert a vector to a vector-like dictionary. >>> v = Vector(42, 69) >>> v.asdict() {'x': 42.0, 'y': 69.0} The conversion can be reversed by constructing: >>> assert v == Vector(v.asdict()) """ return {'x': self.x, 'y': self.y}
def __len__(self) -> int: return 2
[docs] def __add__(self, other: VectorLike) -> 'Vector': """Add two vectors. :param other: A :py:class:`Vector` or a vector-like. For a description of vector-likes, see :py:func:`__new__`. >>> Vector(1, 0) + (0, 1) Vector(1.0, 1.0) >>> (0, 1) + Vector(1, 0) Vector(1.0, 1.0) """ try: other_x, other_y = Vector._unpack(other) except ValueError: return NotImplemented return Vector(self.x + other_x, self.y + other_y)
def __radd__(self, other: VectorLike) -> 'Vector': return self + other
[docs] def __sub__(self, other: VectorLike) -> 'Vector': """Subtract one vector from another. :param other: A :py:class:`Vector` or a vector-like. For a description of vector-likes, see :py:func:`__new__`. >>> Vector(3, 3) - (1, 1) Vector(2.0, 2.0) """ try: other_x, other_y = Vector._unpack(other) except ValueError: return NotImplemented return Vector(self.x - other_x, self.y - other_y)
[docs] def dot(self, other: VectorLike) -> float: """Compute the dot product of two vectors. :param other: A :py:class:`Vector` or a vector-like. For a description of vector-likes, see :py:func:`__new__`. >>> Vector(1, 1).dot((-1, -1)) -2.0 This can also be expressed with :py:meth:`* <__mul__>`: >>> assert Vector(1, 2).dot([2, 1]) == Vector(1, 2) * [2, 1] """ other_x, other_y = Vector._unpack(other) return self.x * other_x + self.y * other_y
[docs] def scale_by(self, scalar: typing.SupportsFloat) -> 'Vector': """Compute a vector-scalar multiplication. >>> Vector(1, 2).scale_by(3) Vector(3.0, 6.0) Can also be expressed with :py:meth:`* <__mul__>`: >>> assert Vector(1, 2).scale_by(3) == 3 * Vector(1, 2) """ scalar = float(scalar) return Vector(scalar * self.x, scalar * self.y)
@typing.overload def __mul__(self, other: VectorLike) -> float: pass @typing.overload def __mul__(self, other: typing.SupportsFloat) -> 'Vector': pass
[docs] def __mul__(self, other): """Perform a dot product or a scalar product, based on the parameter type. :param other: If ``other`` is a scalar (an instance of :py:class:`typing.SupportsFloat`), return :py:meth:`self.scale_by(other) <scale_by>`. >>> v = Vector(1, 1) >>> 3 * v Vector(3.0, 3.0) >>> assert 3 * v == v * 3 == v.scale_by(3) A :py:class:`Vector` can also be divided by a scalar, using the :py:meth:`/ <__truediv__>` operator: >>> assert 3 * v / 3 == v :param other: If ``other`` is a vector-like, return :py:meth:`self.dot(other) <dot>`. >>> v * (-1, -1) -2.0 >>> assert v * (-1, -1) == (-1, -1) * v == v.dot((-1, -1)) Vector-likes are defined in :py:meth:`convert`. """ if isinstance(other, (float, int)): return self.scale_by(other) try: return self.dot(other) except (TypeError, ValueError): return NotImplemented
@typing.overload def __rmul__(self, other: VectorLike) -> float: pass @typing.overload def __rmul__(self, other: typing.SupportsFloat) -> 'Vector': pass def __rmul__(self, other): return self.__mul__(other)
[docs] def __truediv__(self, other: typing.SupportsFloat) -> 'Vector': """Perform a division between a vector and a scalar. >>> Vector(3, 3) / 3 Vector(1.0, 1.0) """ other = float(other) return Vector(self.x / other, self.y / other)
def __getitem__(self, item: typing.Union[str, int]) -> float: if hasattr(item, '__index__'): item = item.__index__() # type: ignore if isinstance(item, str): if item == 'x': return self.x elif item == 'y': return self.y else: raise KeyError elif isinstance(item, int): if item == 0: return self.x elif item == 1: return self.y else: raise IndexError else: raise TypeError def __repr__(self) -> str: return f"Vector({self.x}, {self.y})"
[docs] def __eq__(self, other: typing.Any) -> bool: """Test wheter two vectors are equal. :param other: A :py:class:`Vector` or a vector-like. For a description of vector-likes, see :py:func:`__new__`. >>> Vector(1, 0) == (0, 1) False """ try: other_x, other_y = Vector._unpack(other) except (TypeError, ValueError): return NotImplemented else: return self.x == other_x and self.y == other_y
def __iter__(self) -> typing.Iterator[float]: yield self.x yield self.y
[docs] def __neg__(self) -> 'Vector': """Negate a vector. Negating a :py:class:`Vector` produces one with identical length and opposite direction. It is equivalent to multiplying it by -1. >>> -Vector(1, 1) Vector(-1.0, -1.0) """ return self.scale_by(-1)
[docs] def angle(self, other: VectorLike) -> float: """Compute the angle between two vectors. :param other: A :py:class:`Vector` or a vector-like. For a description of vector-likes, see :py:func:`__new__`. >>> Vector(1, 0).angle( (0, 1) ) 90.0 As with :py:meth:`rotate`, angles are expressed in degrees, signed, and refer to a direct coordinate system (i.e. positive rotations are counter-clockwise). :py:meth:`angle` is guaranteed to produce an angle between -180° and 180°. """ other_x, other_y = Vector._unpack(other) rv = degrees(atan2(other_x, -other_y) - atan2(self.x, -self.y)) # This normalizes the value to (-180, +180], which is the opposite of # what Python usually does but is normal for angles if rv <= -180: rv += 360 elif rv > 180: rv -= 360 return rv
[docs] def isclose(self, other: VectorLike, *, abs_tol: typing.SupportsFloat = 1e-09, rel_tol: typing.SupportsFloat = 1e-09, rel_to: typing.Sequence[VectorLike] = ()) -> bool: """Perform an approximate comparison of two vectors. :param other: A :py:class:`Vector` or a vector-like. For a description of vector-likes, see :py:func:`__new__`. >>> assert Vector(1, 0).isclose((1, 1e-10)) :py:meth:`isclose` takes optional, keyword arguments, akin to those of :py:func:`math.isclose`: :param abs_tol: the absolute tolerance is the minimum magnitude (of the difference vector) under which two inputs are considered close, without consideration for (the magnitude of) the input values. :param rel_tol: the relative tolerance: if the length of the difference vector is less than ``rel_tol * input.length`` for any ``input``, the two vectors are considered close. :param rel_to: an iterable of additional vector-likes which are considered to be inputs, for the purpose of the relative tolerance. """ abs_tol, rel_tol = float(abs_tol), float(rel_tol) if abs_tol < 0 or rel_tol < 0: raise ValueError("Vector.isclose takes non-negative tolerances") other = Vector(other) rel_length = max( self.length, other.length, *map(lambda v: Vector(v).length, rel_to), ) diff = (self - other).length return (diff <= rel_tol * rel_length or diff <= float(abs_tol))
@staticmethod def _trig(angle: typing.SupportsFloat) -> typing.Tuple[float, float]: r = radians(angle) r_cos, r_sin = cos(r), sin(r) if abs(r_cos) > abs(r_sin): # From the equation sin(r)² + cos(r)² = 1, we get # sin(r) = ±√(1 - cos(r)²), so we can fix r_sin to that value # preserving its original sign. # This way, r_sin² + r_cos² is closer to 1, meaning that the length # of rotated vectors is better preserved r_sin = copysign(sqrt(1 - r_cos * r_cos), r_sin) else: # Same for r_cos r_cos = copysign(sqrt(1 - r_sin * r_sin), r_cos) return r_cos, r_sin
[docs] def rotate(self, angle: typing.SupportsFloat) -> 'Vector': """Rotate a vector. Rotate a vector in relation to the origin and return a new :py:class:`Vector`. >>> Vector(1, 0).rotate(90) Vector(0.0, 1.0) Positive rotation is counter/anti-clockwise. """ r_cos, r_sin = Vector._trig(angle) x = self.x * r_cos - self.y * r_sin y = self.x * r_sin + self.y * r_cos return Vector(x, y)
[docs] def normalize(self) -> 'Vector': """Return a vector with the same direction and unit length. >>> Vector(3, 4).normalize() Vector(0.6, 0.8) Note that :py:meth:`normalize()` is equivalent to :py:meth:`scale(1) <scale>`: >>> assert Vector(7, 24).normalize() == Vector(7, 24).scale_to(1) """ return self.scale(1)
[docs] def truncate(self, max_length: typing.SupportsFloat) -> 'Vector': """Scale a given :py:class:`Vector` down to a given length, if it is larger. >>> Vector(7, 24).truncate(3) Vector(0.84, 2.88) It produces a vector with the same direction, but possibly a different length. Note that :py:meth:`vector.scale(max_length) <scale>` is equivalent to :py:meth:`vector.truncate(max_length) <truncate>` when :py:meth:`max_length ≨ vector.length <length>`. >>> Vector(3, 4).scale(4) Vector(2.4, 3.2) >>> Vector(3, 4).truncate(4) Vector(2.4, 3.2) >>> Vector(3, 4).scale(6) Vector(3.6, 4.8) >>> Vector(3, 4).truncate(6) Vector(3.0, 4.0) Note: :py:meth:`x.truncate(max_length) <truncate>` may sometimes be slightly-larger than ``max_length``, due to floating-point rounding effects. """ max_length = float(max_length) if self.length <= max_length: return self return self.scale_to(max_length)
[docs] def scale_to(self, length: typing.SupportsFloat) -> 'Vector': """Scale a given :py:class:`Vector` to a certain length. >>> Vector(7, 24).scale_to(2) Vector(0.56, 1.92) """ length = float(length) if length < 0: raise ValueError("Vector.scale_to takes non-negative lengths.") if length == 0: return Vector(0, 0) return (length * self) / self.length
scale = scale_to
[docs] def reflect(self, surface_normal: VectorLike) -> 'Vector': """Reflect a vector against a surface. :param other: A :py:class:`Vector` or a vector-like. For a description of vector-likes, see :py:func:`__new__`. Compute the reflection of a :py:class:`Vector` on a surface going through the origin, described by its normal vector. >>> Vector(5, 3).reflect( (-1, 0) ) Vector(-5.0, 3.0) >>> Vector(5, 3).reflect( Vector(-1, -2).normalize() ) Vector(0.5999999999999996, -5.800000000000001) """ surface_normal = Vector(surface_normal) if not isclose(surface_normal.length, 1): raise ValueError("Reflection requires a normalized vector.") return self - (2 * (self * surface_normal) * surface_normal)
Sequence.register(Vector)