Source code for hazy.primitives

"""Geometric primitives with frame awareness for coordinate system transformations.

This module provides Point and Vector classes that carry frame information and
support arithmetic operations with proper type semantics.
"""

from __future__ import annotations

from collections.abc import Callable, Iterator, Sequence
from typing import TYPE_CHECKING, Any, Self, TypeVar, overload

import numpy as np

from hazy.constants import VSMALL, VVSMALL
from hazy.utils import all_same_type, check_same_frame

if TYPE_CHECKING:
    from numpy.typing import ArrayLike, NDArray

    from hazy import Frame


F = TypeVar("F", bound=Callable[..., Any])
HANDLED_ARRAY_FUNCTIONS: dict[Callable[..., Any], Callable[..., Any]] = {}


def implements(numpy_function: Callable[..., Any]) -> Callable[[F], F]:
    """Register a function as implementation for a NumPy array function."""

    def decorator(func: F) -> F:
        HANDLED_ARRAY_FUNCTIONS[numpy_function] = func
        return func

    return decorator


class GeometricPrimitive:
    """Base class for geometric primitives (Point and Vector) with frame awareness.

    Uses homogeneous coordinates (x, y, z, w) for unified transformation handling.
    Points have w=1, Vectors have w=0.
    """

    def __init__(self, x: float, y: float, z: float, w: float, frame: Frame):
        """Initialize geometric primitive in homogeneous coordinates.

        Args:
            x: X coordinate
            y: Y coordinate
            z: Z coordinate
            w: Homogeneous coordinate (1 for Point, 0 for Vector)
            frame: Reference frame for this primitive
        """
        self._homogeneous = np.array([x, y, z, w], dtype=float)
        self.frame = frame

    def __array__(self, dtype=None, copy=None) -> np.ndarray:
        """Return Cartesian coordinates for numpy operations.

        Enables usage like: np.array(point) or np.add(point1, point2)

        Args:
            dtype: Desired array data type
            copy: Whether to copy the data

        Returns:
            Numpy array of Cartesian coordinates
        """
        coords = self._homogeneous[:3]
        if dtype is not None:
            coords = coords.astype(dtype)
        return coords if copy is False else coords.copy()

    @property
    def x(self) -> float:
        """X coordinate."""
        return self._homogeneous[0]

    @property
    def y(self) -> float:
        """Y coordinate."""
        return self._homogeneous[1]

    @property
    def z(self) -> float:
        """Z coordinate."""
        return self._homogeneous[2]

    def __eq__(self, value: object) -> bool:
        """Check equality by comparing global coordinates.

        Args:
            value: Object to compare with

        Returns:
            True if both primitives represent the same position/direction in
            global space

        Raises:
            ValueError: If comparing with non-GeometricPrimitive
        """
        if isinstance(value, GeometricPrimitive):
            self_global = self.to_global()
            value_global = value.to_global()
            return np.allclose(
                self_global._homogeneous, value_global._homogeneous, atol=VVSMALL
            )
        else:
            raise ValueError(
                f"Cannot compare {self.__class__.__qualname__} "
                f"with {type(value).__name__}.\n"
                "Only GeometricPrimitive instances can be compared.\n"
                "Use:\n"
                "  np.allclose(np.array(obj1), np.array(obj2))  # Compare coordinates\n"
                "Or convert to array first:\n"
                "  np.array(obj) == other_array  # Element-wise comparison"
            )

    @overload
    def __getitem__(self, index: Sequence[int]) -> NDArray: ...

    @overload
    def __getitem__(self, index: int) -> float: ...

    def __getitem__(self, index: int | Sequence[int]) -> float | NDArray:
        """Access coordinates by index: primitive[0] for x, primitive[1] for y, etc."""
        return np.array(self)[index]

    def __iter__(self) -> Iterator[np.floating]:
        """Iterate over Cartesian coordinates."""
        return iter(np.array(self))

    def to_frame(self, target_frame: Frame) -> Self:
        """Return copy of primitive transformed to a different reference frame.

        Creates a new primitive with coordinates transformed to the target frame.
        The original primitive remains unchanged.

        Args:
            target_frame: Target reference frame

        Returns:
            New primitive of same type in target frame

        Examples:
            >>> world = Frame.make_root("world")
            >>> camera = world.make_child("camera").translate(x=1.0)
            >>> p_world = world.point(0, 0, 0)
            >>> p_camera = p_world.to_frame(camera)
            >>> p_camera.x  # -1.0 (camera is 1 unit away in x)
        """
        transformation = self.frame.transform_to(target=target_frame)
        x, y, z, w = transformation @ self._homogeneous

        if isinstance(self, Point):
            # Points (w=1): normalize by w after transformation
            return type(self)(x=x / w, y=y / w, z=z / w, w=1.0, frame=target_frame)
        else:
            # Vectors (w=0): do not normalize, w stays 0
            return type(self)(x=x, y=y, z=z, w=0.0, frame=target_frame)

    def to_global(self) -> Self:
        """Return copy of primitive transformed to the root frame.

        Creates a new primitive with coordinates in the root (top-most parent) frame.
        The original primitive remains unchanged.

        For frames with parents, this transforms to the top-most parent.
        For orphan frames, this returns coordinates in the orphan frame itself.

        Returns:
            New primitive in root frame coordinates
        """
        return self.to_frame(target_frame=self.frame.root)

    def __repr__(self) -> str:
        return (
            f"{self.__class__.__qualname__}("
            f"x={self.x}, "
            f"y={self.y}, "
            f"z={self.z}, "
            f"frame={self.frame.name})"
        )

    def __format__(self, format_spec: str) -> str:
        """Format coordinates using f-string format specifiers.

        Supports standard float formatting like :6.2f, :.3f, :10.4e, etc.
        Formats all three coordinates (x, y, z) with the same spec.

        Custom format flags (prefix before float spec):
        - 'a': Array-only format - just [x, y, z] without class name or frame
        - 'n': No-frame format - Point(x, y, z) without frame info
        - Default: Full format with class name and frame

        Args:
            format_spec: Format specification, optionally prefixed with 'a' or 'n'
                        Examples: '.2f', 'a.2f', 'n6.3f', 'a.3e'

        Returns:
            Formatted string in requested format

        Examples:
            >>> point = world.point(1.23456, 2.34567, 3.45678)
            >>> f"{point:.2f}"    # 'Point(1.23, 2.35, 3.46, frame=world)'
            >>> f"{point:n.2f}"   # 'Point(1.23, 2.35, 3.46)'
            >>> f"{point:a.2f}"   # '[1.23, 2.35, 3.46]'
            >>> f"{point:a6.3f}"  # '[ 1.235,  2.346,  3.457]'
        """
        array_only = False
        show_frame = True

        if format_spec.startswith("a"):
            array_only = True
            format_spec = format_spec[1:]
        elif format_spec.startswith("n"):
            show_frame = False
            format_spec = format_spec[1:]

        x_str = format(self.x, format_spec)
        y_str = format(self.y, format_spec)
        z_str = format(self.z, format_spec)

        if array_only:
            return f"[{x_str}, {y_str}, {z_str}]"
        elif show_frame:
            return (
                f"{self.__class__.__qualname__}({x_str}, {y_str}, {z_str}, "
                f"frame={self.frame.name})"
            )
        else:
            return f"{self.__class__.__qualname__}({x_str}, {y_str}, {z_str})"

    def __array_ufunc__(self, ufunc, method, *inputs, **kwargs):
        """Handle numpy universal functions to maintain type consistency.

        This prevents numpy from converting our custom types to plain arrays
        when using operators like *, +, -, etc.
        """

        # For binary operations, handle scalar multiplication/division for Vectors only
        if method == "__call__" and len(inputs) == 2:
            # Identify which input is the geometric primitive
            geom_idx = 0 if isinstance(inputs[0], GeometricPrimitive) else 1
            other_idx = 1 - geom_idx
            geom_primitive = inputs[geom_idx]
            other = inputs[other_idx]

            # Handle multiplication/division with scalars (only for Vector)
            if (
                ufunc in (np.multiply, np.divide, np.true_divide)
                and np.isscalar(other)
                and isinstance(geom_primitive, Vector)
            ):
                if ufunc == np.multiply:
                    return geom_primitive.__mul__(other)
                elif ufunc in (np.divide, np.true_divide) and geom_idx == 0:
                    return geom_primitive.__truediv__(other)

        # delegate arithmetic to __dunder__ methods
        if ufunc in (
            np.add,
            np.subtract,
            np.multiply,
            np.divide,
            np.true_divide,
            np.negative,
        ):
            return NotImplemented

        # delegate comparison to __dunder__ methods
        if ufunc in (
            np.equal,
            np.not_equal,
            np.less,
            np.less_equal,
            np.greater,
            np.greater_equal,
        ):
            return NotImplemented

        # Rounding operations - preserve geometric type
        if (
            ufunc in (np.floor, np.ceil, np.trunc, np.rint, np.fix)
            and method == "__call__"
        ):
            coords = getattr(ufunc, method)(np.array(inputs[0]), **kwargs)
            result = inputs[0].copy()
            result._homogeneous[:3] = coords
            return result

        # Type queries - return boolean arrays
        if (
            ufunc in (np.isnan, np.isinf, np.isfinite, np.signbit)
            and method == "__call__"
        ):
            return getattr(ufunc, method)(np.array(inputs[0]), **kwargs)

        # Absolute value - special handling
        if ufunc == np.absolute and method == "__call__":
            if isinstance(inputs[0], Vector):
                return inputs[0].magnitude
            raise TypeError(
                f"abs({inputs[0].__class__.__qualname__}) is geometrically undefined.\n"
                "Use np.abs(np.array(point)) for coordinate-wise absolute values."
            )

        # Everything else explicit error:
        # For other ufuncs, convert to array and return array result
        # This handles operations where maintaining custom type doesn't make sense
        raise TypeError(
            f"ufunc '{ufunc.__name__}' not supported "
            f"for {self.__class__.__qualname__}.\n"
            f"Convert explicitly: np.{ufunc.__name__}(np.array(obj))."
        )

    def __array_function__(self, func, types, inputs, kwargs):
        """Handle numpy array functions to maintain type consistency.

        Dispatches to registered implementations in HANDLED_ARRAY_FUNCTIONS.
        """
        if func not in HANDLED_ARRAY_FUNCTIONS:
            return NotImplemented

        if not all(
            issubclass(t, (GeometricPrimitive, np.ndarray)) or t is type(None)
            for t in types
        ):
            return NotImplemented

        return HANDLED_ARRAY_FUNCTIONS[func](*inputs, **kwargs)

    def copy(self) -> Self:
        """Create a copy of this geometric primitive in the same frame."""
        return type(self)(
            self.x, self.y, self.z, frame=self.frame, w=self._homogeneous[3]
        )

    def round(self, decimals: int = 0) -> Self:
        """Round coordinates to given number of decimals.

        Args:
            decimals: Number of decimal places

        Returns:
            New primitive with rounded coordinates
        """
        out = self.copy()
        out._homogeneous[:3] = np.round(self._homogeneous[:3], decimals=decimals)
        return out

    def round_(self, decimals: int = 0) -> Self:
        """Round coordinates in-place to given number of decimals.

        Args:
            decimals: Number of decimal places

        Returns:
            Self for method chaining
        """
        self._homogeneous[:3] = np.round(self._homogeneous[:3], decimals=decimals)
        return self

    def clip(self, a_min: float = -np.inf, a_max: float = np.inf) -> Self:
        """Clip coordinates to given range.

        Args:
            a_min: Minimum value
            a_max: Maximum value

        Returns:
            New primitive with clipped coordinates
        """
        out = self.copy()
        out._homogeneous[:3] = np.clip(self._homogeneous[:3], a_min=a_min, a_max=a_max)
        return out

    def clip_(self, a_min: float = -np.inf, a_max: float = np.inf) -> Self:
        """Clip coordinates in-place to given range.

        Args:
            a_min: Minimum value
            a_max: Maximum value

        Returns:
            Self for method chaining
        """
        self._homogeneous[:3] = np.clip(self._homogeneous[:3], a_min=a_min, a_max=a_max)
        return self

    def floor(self) -> Self:
        """Return primitive with coordinates rounded down to nearest integer.

        Returns:
            New primitive with floored coordinates
        """
        copy = self.copy()
        copy._homogeneous[:3] = np.floor(copy._homogeneous[:3])
        return copy

    def floor_(self) -> Self:
        """Round coordinates down to nearest integer in-place.

        Returns:
            Self for method chaining
        """
        self._homogeneous[:3] = np.floor(self._homogeneous[:3])
        return self

    def fix(self) -> Self:
        """Return primitive with coordinates rounded toward zero.

        Returns:
            New primitive with fixed coordinates
        """
        copy = self.copy()
        copy._homogeneous[:3] = np.fix(copy._homogeneous[:3])
        return copy

    def fix_(self) -> Self:
        """Round coordinates toward zero in-place.

        Returns:
            Self for method chaining
        """
        self._homogeneous[:3] = np.fix(self._homogeneous[:3])
        return self

    def ceil(self) -> Self:
        """Return primitive with coordinates rounded up to nearest integer.

        Returns:
            New primitive with ceiling coordinates
        """
        copy = self.copy()
        copy._homogeneous[:3] = np.ceil(copy._homogeneous[:3])
        return copy

    def ceil_(self) -> Self:
        """Round coordinates up to nearest integer in-place.

        Returns:
            Self for method chaining
        """
        self._homogeneous[:3] = np.ceil(self._homogeneous[:3])
        return self

    def trunc(self) -> Self:
        """Return primitive with coordinates truncated toward zero.

        Returns:
            New primitive with truncated coordinates
        """
        copy = self.copy()
        copy._homogeneous[:3] = np.trunc(copy._homogeneous[:3])
        return copy

    def trunc_(self) -> Self:
        """Truncate coordinates toward zero in-place.

        Returns:
            Self for method chaining
        """
        self._homogeneous[:3] = np.trunc(self._homogeneous[:3])
        return self

    def rint(self) -> Self:
        """Return primitive with coordinates rounded to nearest integer.

        Returns:
            New primitive with rounded coordinates
        """
        copy = self.copy()
        copy._homogeneous[:3] = np.rint(copy._homogeneous[:3])
        return copy

    def rint_(self) -> Self:
        """Round coordinates to nearest integer in-place.

        Returns:
            Self for method chaining
        """
        self._homogeneous[:3] = np.rint(self._homogeneous[:3])
        return self

    @classmethod
    def from_array(cls, array: ArrayLike, frame: Frame) -> Self:
        """Create geometric primitive from array with homogeneous coordinates.

        Args:
            array: Array-like with 4 elements [x, y, z, w]
            frame: Reference frame

        Returns:
            New geometric primitive

        Raises:
            ValueError: If array doesn't have exactly 4 elements
        """
        array = np.asarray(array).flatten()
        if array.shape != (4,):
            raise ValueError(
                f"Expected 4 homogeneous coordinates [x, y, z, w], "
                f"got shape {array.shape}.\n"
                "This is an internal method - use Vector.from_array() or "
                "Point.from_array() for Cartesian coordinates [x, y, z]."
            )
        return cls(x=array[0], y=array[1], z=array[2], w=array[3], frame=frame)


[docs] class Vector(GeometricPrimitive): """Geometric vector representing direction and magnitude. Vectors have homogeneous coordinate w=0, making them invariant to translation. Arithmetic semantics: - Vector + Vector = Vector (combine displacements) - Vector - Vector = Vector (difference of displacements) - Vector + Point = Point (displace position) - Vector - Point = ERROR (undefined operation) """ def __init__(self, x: float, y: float, z: float, frame: Frame, *, w=0.0): """Initialize vector in given frame. Args: x: X component y: Y component z: Z component frame: Reference frame w: Homogeneous coordinate (should be 0 for vectors) Examples: >>> frame = Frame() >>> v = Vector(x=1.0, y=2.0, z=3.0, frame=frame) >>> frame.vector(1.0, 2.0, 3.0) # Convenience method """ super().__init__(x=x, y=y, z=z, w=w, frame=frame) @overload def __add__(self, other: Point) -> Point: ... @overload def __add__(self, other: Vector) -> Vector: ... @overload def __add__(self, other: NDArray[np.floating]) -> NDArray[np.floating]: ... def __add__( self, other: Point | Vector | NDArray[np.floating] ) -> Point | Vector | NDArray[np.floating]: """Add vector to another vector or point. Args: other: Vector, Point, or numpy array Returns: Vector if adding to Vector, Point if adding to Point Raises: RuntimeError: If frames don't match Examples: >>> frame = Frame() >>> v1 = frame.vector(1, 0, 0) >>> v2 = frame.vector(0, 1, 0) >>> v1 + v2 # Vector(x=1, y=1, z=0) >>> p = frame.point(1, 2, 3) >>> v1 + p # Point(x=2, y=2, z=3) """ if isinstance(other, Point): check_same_frame(self, other) x, y, z = np.array(self) + np.array(other) return Point(x, y, z, frame=self.frame) elif isinstance(other, Vector): check_same_frame(self, other) x, y, z = np.array(self) + np.array(other) return Vector(x, y, z, frame=self.frame) else: return other.__add__(self) @overload def __sub__(self, other: Vector) -> Vector: ... @overload def __sub__(self, other: NDArray[np.floating]) -> NDArray[np.floating]: ... def __sub__( self, other: Vector | NDArray[np.floating] ) -> Vector | NDArray[np.floating]: """Subtract vector from this vector. Args: other: Vector or numpy array Returns: Resulting vector Raises: TypeError: If attempting to subtract Point from Vector RuntimeError: If frames don't match Examples: >>> frame = Frame() >>> v1 = frame.vector(3, 2, 1) >>> v2 = frame.vector(1, 1, 1) >>> v1 - v2 # Vector(x=2, y=1, z=0) """ if isinstance(other, Point): raise TypeError( "Cannot subtract Point from Vector (geometrically undefined).\n" "Did you mean:\n" " point - vector # Point (subtract vector from point)\n" "Or convert to arrays:\n" " Point.from_array(np.array(vector) - np.array(point), frame=frame)" ) elif isinstance(other, Vector): check_same_frame(self, other) x, y, z = np.array(self) - np.array(other) return Vector(x, y, z, frame=self.frame) else: return other.__rsub__(self) def __mul__(self, other: float | np.generic) -> Vector: """Multiply vector by scalar. Args: other: Scalar value Returns: New vector scaled by scalar Raises: TypeError: If other is not a scalar or is complex Examples: >>> frame = Frame() >>> v = frame.vector(1, 2, 3) >>> v * 2 # Vector(x=2, y=4, z=6) >>> 0.5 * v # Vector(x=0.5, y=1.0, z=1.5) """ if not np.isscalar(other): raise TypeError( f"Can only multiply Vector by scalar, got {type(other).__name__}.\n" "For element-wise or matrix operations, convert to array:\n" " np.array(vector) * other # Element-wise multiplication\n" " Vector.from_array(np.array(vector) * other, frame=frame)" ) if isinstance(other, complex | np.complexfloating): raise TypeError( "Complex number multiplication not supported " f"for {self.__class__.__qualname__}.\n" "Use np.array(vector) for complex operations." ) copy = self.copy() copy._homogeneous[:3] *= other return copy def __rmul__(self, other: float | np.generic) -> Vector: """Multiply scalar by vector (commutative). Args: other: Scalar value Returns: New vector scaled by scalar """ return self.__mul__(other) def __truediv__(self, other: float | np.generic) -> Vector: """Divide vector by scalar. Args: other: Scalar value Returns: New vector divided by scalar Raises: TypeError: If other is not a scalar ZeroDivisionError: If dividing by zero Examples: >>> frame = Frame() >>> v = frame.vector(2, 4, 6) >>> v / 2 # Vector(x=1, y=2, z=3) """ if not np.isscalar(other): raise TypeError( f"Can only divide Vector by scalar, got {type(other).__name__}.\n" "For element-wise division, convert to array:\n" " np.array(vector) / other # Element-wise division\n" " Vector.from_array(np.array(vector) / other, frame=frame)" ) if other == 0: raise ZeroDivisionError("Cannot divide vector by zero.") copy = self.copy() copy._homogeneous[:3] /= other return copy @property def magnitude(self) -> float: """Euclidean length of the vector.""" return float(np.linalg.norm(self._homogeneous[:3])) @property def is_zero(self) -> bool: """Check if vector has near-zero magnitude.""" return self.magnitude < VSMALL
[docs] def normalize(self) -> Self: """Normalize vector to unit length in-place. Returns: Self for method chaining Raises: RuntimeError: If vector has zero length Examples: >>> frame = Frame() >>> v = frame.vector(3, 4, 0) >>> v.magnitude # 5.0 >>> v.normalize() >>> v.magnitude # 1.0 """ if self.is_zero: raise RuntimeError(f"Cannot normalize zero-length vector {self}.") self._homogeneous[:3] /= self.magnitude return self
[docs] @classmethod def nan(cls, frame: Frame) -> Vector: """Create vector with NaN coordinates. Args: frame: Reference frame Returns: Vector with all coordinates set to NaN """ return cls(x=np.nan, y=np.nan, z=np.nan, frame=frame)
def __neg__(self) -> Vector: """Negate the vector, inverting its direction. Returns: Vector with inverted x, y, z components in the same frame Examples: >>> frame = Frame() >>> v = frame.vector(1, -2, 3) >>> -v # Vector(x=-1, y=2, z=-3) """ return Vector(-self.x, -self.y, -self.z, frame=self.frame)
[docs] def cross(self, other: Vector) -> Vector: """Compute cross product with another vector. Args: other: Vector to cross with Returns: Vector perpendicular to both input vectors Raises: RuntimeError: If frames don't match Examples: >>> frame = Frame() >>> v1 = frame.vector(1, 0, 0) >>> v2 = frame.vector(0, 1, 0) >>> v1.cross(v2) # Vector(x=0, y=0, z=1) """ check_same_frame(self, other) x, y, z = np.cross(np.array(self), np.array(other)) return Vector(x, y, z, frame=self.frame)
[docs] def dot(self, other: Vector) -> float: """Compute dot product with another vector. Args: other: Vector to dot with Returns: scalar dot product between both vectors Raises: RuntimeError: If frames don't match Examples: >>> frame = Frame() >>> v1 = frame.vector(1, 2, 3) >>> v2 = frame.vector(4, 5, 6) >>> v1.dot(v2) # 32.0 """ check_same_frame(self, other) return np.dot(np.array(self), np.array(other))
[docs] @classmethod def from_array(cls, array: ArrayLike, frame: Frame) -> Self: """Create vector from array with Cartesian coordinates. Args: array: Array-like with 3 elements [x, y, z] frame: Reference frame Returns: New vector (w=0 set automatically) Raises: ValueError: If array doesn't have exactly 3 elements Examples: >>> frame = Frame() >>> v = Vector.from_array([1, 2, 3], frame=frame) >>> v = Vector.from_array(np.array([1.0, 2.0, 3.0]), frame=frame) """ array = np.asarray(array).flatten() if array.shape != (3,): raise ValueError( f"Expected 3 coordinates [x, y, z], got shape {array.shape}.\n" "Use:\n" " Vector.from_array([x, y, z], frame=frame)\n" " Vector.from_array(np.array([x, y, z]), frame=frame)" ) return cls(x=array[0], y=array[1], z=array[2], w=0.0, frame=frame)
[docs] class Point(GeometricPrimitive): """Geometric point representing position in space. Points have homogeneous coordinate w=1, making them affected by translation. Arithmetic semantics: - Point - Point = Vector (displacement between positions) - Point + Vector = Point (displace position) - Point - Vector = Point (displace position backwards) - Point + Point = ERROR (undefined operation) """ def __init__(self, x: float, y: float, z: float, frame: Frame, *, w=1.0): """Initialize point in given frame. Args: x: X coordinate y: Y coordinate z: Z coordinate frame: Reference frame w: Homogeneous coordinate (should be 1 for points) Examples: >>> frame = Frame() >>> p = Point(x=1.0, y=2.0, z=3.0, frame=frame) >>> frame.point(1.0, 2.0, 3.0) # Convenience method """ super().__init__(x=x, y=y, z=z, w=w, frame=frame) @overload def __sub__(self, other: Point) -> Vector: ... @overload def __sub__(self, other: Vector) -> Point: ... @overload def __sub__(self, other: NDArray[np.floating]) -> NDArray[np.floating]: ... def __sub__( self, other: Point | Vector | NDArray[np.floating] ) -> Point | Vector | NDArray[np.floating]: """Subtract point or vector from this point. Args: other: Point, Vector, or numpy array Returns: Vector if subtracting Point, Point if subtracting Vector Raises: RuntimeError: If frames don't match Examples: >>> frame = Frame() >>> p1 = frame.point(5, 3, 1) >>> p2 = frame.point(2, 1, 0) >>> p1 - p2 # Vector(x=3, y=2, z=1) >>> v = frame.vector(1, 1, 1) >>> p1 - v # Point(x=4, y=2, z=0) """ if isinstance(other, Point): check_same_frame(self, other) x, y, z = np.array(self) - np.array(other) return Vector(x, y, z, frame=self.frame) elif isinstance(other, Vector): check_same_frame(self, other) x, y, z = np.array(self) - np.array(other) return Point(x, y, z, frame=self.frame) else: return other.__rsub__(self) @overload def __add__(self, other: Vector) -> Point: ... @overload def __add__(self, other: NDArray[np.floating]) -> NDArray[np.floating]: ... def __add__( self, other: Vector | NDArray[np.floating] ) -> Point | NDArray[np.floating]: """Add vector to this point. Args: other: Vector or numpy array Returns: Resulting point Raises: TypeError: If attempting to add two Points RuntimeError: If frames don't match Examples: >>> frame = Frame() >>> p = frame.point(1, 2, 3) >>> v = frame.vector(1, 0, 0) >>> p + v # Point(x=2, y=2, z=3) """ if isinstance(other, Point): raise TypeError( "Cannot add two Points (geometrically undefined).\n" "Use:\n" " point + vector # Point (displace point by vector)\n" " point - point # Vector (displacement between points)\n" "Or convert to arrays:\n" " Point.from_array(np.array(p1) + np.array(p2), frame=frame)" ) elif isinstance(other, Vector): check_same_frame(self, other) x, y, z = np.array(self) + np.array(other) return Point(x, y, z, frame=self.frame) else: return other.__add__(self) def __mul__(self, other: Any) -> None: """Multiplication is undefined for points. Raises: TypeError: Always, with explanation of alternatives """ raise TypeError( f"Scalar multiplication of {self.__class__.__qualname__} is undefined.\n" "Points can only be scaled relative to an origin.\n" "Use:\n" " (point - origin) * scalar + origin # Scale relative to origin\n" "Or convert to array:\n" " np.array(point) * scalar # Coordinate manipulation" ) def __truediv__(self, other: Any) -> None: """Division is undefined for points. Raises: TypeError: Always, with explanation of alternatives """ raise TypeError( f"Scalar division of {self.__class__.__qualname__} is undefined.\n" "Points can only be scaled relative to an origin.\n" "Use:\n" " (point - origin) / scalar + origin # Scale relative to origin\n" "Or convert to array:\n" " np.array(point) / scalar # Coordinate manipulation" )
[docs] @classmethod def create_origin(cls, frame: Frame) -> Point: """Return a point at the origin of the specified coordinate system""" return cls(x=0.0, y=0.0, z=0.0, frame=frame)
[docs] @classmethod def create_nan(cls, frame: Frame) -> Point: """Create point with NaN coordinates. Args: frame: Reference frame Returns: Point with all coordinates set to NaN """ return cls(x=np.nan, y=np.nan, z=np.nan, frame=frame)
[docs] @classmethod def from_array(cls, array: ArrayLike, frame: Frame) -> Self: """Create point from array with Cartesian coordinates. Args: array: Array-like with 3 elements [x, y, z] frame: Reference frame Returns: New point (w=1 set automatically) Raises: ValueError: If array doesn't have exactly 3 elements Examples: >>> frame = Frame() >>> p = Point.from_array([1, 2, 3], frame=frame) >>> p = Point.from_array(np.array([1.0, 2.0, 3.0]), frame=frame) """ array = np.asarray(array).flatten() if array.shape != (3,): raise ValueError( f"Expected 3 coordinates [x, y, z], got shape {array.shape}.\n" "Use:\n" " Point.from_array([x, y, z], frame=frame)\n" " Point.from_array(np.array([x, y, z]), frame=frame)" ) return cls(x=array[0], y=array[1], z=array[2], w=1.0, frame=frame)
[docs] @classmethod def list_from_array(cls, points: ArrayLike, frame: Frame) -> list[Point]: """Create list of points from array. Args: points: Array-like with shape (..., 3) where last dimension is coordinates frame: Reference frame for all points Returns: List of Point instances Raises: ValueError: If last dimension is not 3 Examples: >>> frame = Frame() >>> points_array = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]]) >>> points = Point.list_from_array(points_array, frame=frame) >>> len(points) # 3 """ points = np.asarray(points) if points.shape[-1] != 3: raise ValueError( "Expected last dimension to be 3 (x, y, z), " f"got shape {points.shape}.\n" "Use:\n" " Point.list_from_array([[x1, y1, z1], " "[x2, y2, z2], ...], frame=frame)\n" " Point.list_from_array(np.array([[x, y, z], ...]), frame=frame)" ) points = points.reshape((-1, 3)) return [cls.from_array(arr, frame=frame) for arr in points]
@implements(np.copy) def _copy_geometric( a: GeometricPrimitive, order: str = "K", # Ignored for geometric primitives subok: bool = False, # Ignored for geometric primitives ) -> GeometricPrimitive: return a.copy() @implements(np.asarray) def _asarray_geometric( a: GeometricPrimitive, dtype=None, order=None, *, like=None ) -> np.ndarray: """Convert geometric primitive to array (returns coordinates).""" if dtype is not None and not np.issubdtype(dtype, np.floating): raise TypeError( f"Geometric primitives require floating-point dtype, got {dtype}.\n" "Use dtype=float, dtype=np.float32, or dtype=np.float64." ) return a.__array__(dtype=dtype) @implements(np.round) def _round_geometric[T: GeometricPrimitive]( a: T, decimals: int = 0, out: None = None ) -> T: if out is not None: raise TypeError( "out parameter not supported for GeometricPrimitive.\n" "Use .round_() for in-place modification." ) return a.round(decimals=decimals) @implements(np.clip) def _clip_geometric[T: GeometricPrimitive]( a: T, a_min: float = -np.inf, a_max: float = np.inf, out: None = None, ) -> T: if out is not None: raise TypeError( "out parameter not supported for GeometricPrimitive.\n" "Use .clip_() for in-place modification." ) return a.clip(a_max=a_max, a_min=a_min) @implements(np.floor) def _floor_geometric[T: GeometricPrimitive](a: T, out=None) -> T: """Floor coordinates to nearest integer below.""" return a.floor() @implements(np.ceil) def _ceil_geometric[T: GeometricPrimitive](a: T, out=None) -> T: """Ceil coordinates to nearest integer above.""" return a.ceil() @implements(np.trunc) def _trunc_geometric[T: GeometricPrimitive](a: T, out=None) -> T: """Truncate coordinates toward zero.""" return a.trunc() @implements(np.rint) def _rint_geometric[T: GeometricPrimitive](a: T, out=None) -> T: """Round coordinates to nearest integer.""" return a.rint() @implements(np.fix) def _fix_geometric[T: GeometricPrimitive](a: T, out=None) -> T: """Round coordinates toward zero.""" return a.fix() @implements(np.isnan) def _isnan_geometric(a: GeometricPrimitive) -> NDArray[np.bool_]: """Check which coordinates are NaN.""" return np.isnan(a._homogeneous[:3]) @implements(np.isinf) def _isinf_geometric(a: GeometricPrimitive) -> NDArray[np.bool_]: """Check which coordinates are infinite.""" return np.isinf(a._homogeneous[:3]) @implements(np.isfinite) def _isfinite_geometric(a: GeometricPrimitive) -> NDArray[np.bool_]: """Check which coordinates are finite.""" return np.isfinite(a._homogeneous[:3]) @implements(np.absolute) def _absolute_geometric(a: GeometricPrimitive) -> float: """Absolute value: magnitude for Vector, error for Point.""" if isinstance(a, Vector): return a.magnitude raise TypeError( f"abs({a.__class__.__qualname__}) is geometrically undefined.\n" "Use np.abs(np.array(point)) for coordinate-wise absolute values." ) @implements(np.cross) def _cross_geometric(a: Vector, b: Vector, **kwargs) -> Vector: """Cross product for Vectors (frame-aware). Args: a: First vector (must be Vector for frame-aware operation) b: Second vector (must be Vector and same frame as a) **kwargs: Additional arguments passed to np.cross on arrays Returns: Vector perpendicular to both inputs (if both are Vectors) ndarray otherwise Raises: TypeError: If a is not a Vector RuntimeError: If frames don't match """ if not isinstance(a, Vector) or not isinstance(b, Vector): raise TypeError( f"np.cross requires both Vector arguments, got {type(a).__name__} " f"and {type(b).__name__}.\n" "For array operations, use:\n" " np.cross(np.array(obj1), np.array(obj2))" ) return a.cross(b) @implements(np.dot) def _dot_geometric(a: Vector, b: Vector, out=None) -> float: """Dot product for Vectors (frame-aware). Args: a: First vector (must be Vector for frame-aware operation) b: Second vector (must be Vector and same frame as a) out: Output array (not supported for GeometricPrimitives) Returns: Scalar dot product (if both are Vectors) ndarray otherwise Raises: TypeError: If a is not a Vector or out is specified RuntimeError: If frames don't match """ if out is not None: raise TypeError( "out parameter not supported for GeometricPrimitive dot product." ) if not isinstance(a, Vector) or not isinstance(b, Vector): raise TypeError( f"np.dot requires both Vector arguments, got {type(a).__name__} " f"and {type(b).__name__}.\n" "For array operations, use:\n" " np.dot(np.array(obj1), np.array(obj2))" ) return a.dot(b) @implements(np.linalg.norm) def _norm_geometric( x: GeometricPrimitive, ord=None, axis=None, keepdims=False, ) -> float: """Compute norm for geometric primitives. Args: x: Vector to compute norm of ord: Order of the norm (only default supported) axis: Axis parameter (not supported for GeometricPrimitives) keepdims: Keep dimensions parameter (not supported for GeometricPrimitives) Returns: Magnitude of the vector Raises: TypeError: If x is not a Vector or unsupported parameters are used """ if not isinstance(x, Vector): raise TypeError( f"np.linalg.norm for {x.__class__.__qualname__} is undefined.\n" "Use np.linalg.norm(np.array(obj)) for coordinate-wise norm." ) if ord is not None: raise TypeError( "ord parameter not supported for Vector.\n" "Use np.linalg.norm(np.array(vector), ord=...) for custom norms." ) if axis is not None: raise TypeError( "axis parameter not supported for Vector.\n" "Use np.linalg.norm(np.array(vector), axis=...) for array operations." ) if keepdims: raise TypeError( "keepdims parameter not supported for Vector.\n" "Use np.linalg.norm(np.array(vector), keepdims=...) for array operations." ) return x.magnitude @implements(np.stack) def _stack_geometric( arrays: list[GeometricPrimitive] | tuple[GeometricPrimitive, ...], axis: int = 0, out=None, **kwargs, ) -> NDArray[np.floating]: """Stack geometric primitives into array. All primitives must be of same type and in the same frame. Args: arrays: Sequence of Points or Vectors axis: Axis along which to stack (default: 0) out: Output array (not supported) **kwargs: Additional arguments passed to np.stack Returns: Stacked array of coordinates with shape determined by axis Raises: TypeError: If out is specified or items are mixed types RuntimeError: If items are in different frames Examples: >>> v1, v2, v3 = [frame.vector(i, i+1, i+2) for i in range(3)] >>> np.stack([v1, v2, v3]) # (3, 3) array >>> np.stack([v1, v2, v3], axis=1) # (3, 3) array """ if out is not None: raise TypeError("out parameter not supported for GeometricPrimitive stacking.") if not arrays: raise ValueError("Cannot stack empty sequence.") arrays_list = list(arrays) if not all_same_type(arrays_list): types = {type(a).__name__ for a in arrays_list} raise TypeError(f"All items must be same type, got: {types}.") check_same_frame(*arrays_list) coord_arrays = [np.array(a) for a in arrays_list] return np.stack(coord_arrays, axis=axis, **kwargs) @implements(np.vstack) def _vstack_geometric( tup: list[GeometricPrimitive] | tuple[GeometricPrimitive, ...], **kwargs, ) -> NDArray[np.floating]: """Vertically stack geometric primitives. All primitives must be of same type and in the same frame. Equivalent to np.stack(arrays, axis=0). Args: tup: Sequence of Points or Vectors **kwargs: Additional arguments passed to np.vstack Returns: Vertically stacked array of coordinates (N, 3) Raises: TypeError: If items are mixed types RuntimeError: If items are in different frames Examples: >>> p1, p2, p3 = [frame.point(i, i+1, i+2) for i in range(3)] >>> np.vstack([p1, p2, p3]) # (3, 3) array """ if not tup: raise ValueError("Cannot vstack empty sequence.") tup_list = list(tup) if not all_same_type(tup_list): types = {type(t).__name__ for t in tup_list} raise TypeError(f"All items must be same type, got: {types}.") check_same_frame(*tup_list) coord_arrays = [np.array(t) for t in tup_list] return np.vstack(coord_arrays, **kwargs) @implements(np.hstack) def _hstack_geometric( tup: list[GeometricPrimitive] | tuple[GeometricPrimitive, ...], **kwargs, ) -> NDArray[np.floating]: """Horizontally stack geometric primitives. All primitives must be of same type and in the same frame. Creates array with shape (3, N) where N is number of primitives. Args: tup: Sequence of Points or Vectors **kwargs: Additional arguments passed to np.hstack Returns: Horizontally stacked array of coordinates (3, N) Raises: TypeError: If items are mixed types RuntimeError: If items are in different frames Examples: >>> v1, v2, v3 = [frame.vector(i, i+1, i+2) for i in range(3)] >>> np.hstack([v1, v2, v3]) # (3, 3) array - coordinates as columns """ if not tup: raise ValueError("Cannot hstack empty sequence.") tup_list = list(tup) if not all_same_type(tup_list): types = {type(t).__name__ for t in tup_list} raise TypeError(f"All items must be same type, got: {types}.") check_same_frame(*tup_list) coord_arrays = [np.array(t) for t in tup_list] return np.hstack(coord_arrays, **kwargs)