Source code for pybosl2.points

# Copyright (c) 2026, pinkfish
#
# Licensed under the BSD 2-Clause License. See the LICENSE file in the project
# root for the full license text.
# SPDX-License-Identifier: BSD-2-Clause
# LibFile: pybosl2/points.py
# FileSummary: The Point and Vector types every geometry API is expressed in.
# DocCategory: Math & geometry
# FileGroup: BOSL2

"""Lightweight 2‑D / 3‑D point and vector type shared across the pybosl2 geometry layer.

Provides :class:`Point` (mutable, ``x``/``y``/optional ``z``) with
elementwise arithmetic and numpy integration.  :class:`Vector` is a
backward-compatible alias for :class:`Point`.
"""

from __future__ import annotations

from collections.abc import Iterator, Sequence
from dataclasses import dataclass
from typing import TYPE_CHECKING, overload

import numpy as np
from numpy.typing import NDArray

from pybosl2.exceptions import Bosl2ValueError

if TYPE_CHECKING:
    from typing import TypeAlias


__all__ = ["Point", "PointLike", "Vector"]


# ---------------------------------------------------------------------------
# Point — mutable dataclass, 2‑D / 3‑D
# ---------------------------------------------------------------------------


[docs] @dataclass class Point(Sequence[float]): """A mutable 2‑D or 3‑D point and vector. Inherits from :class:`~collections.abc.Sequence` for compatibility with functions that accept ``Sequence[float]``. If *z* is ``None`` the point is 2‑D (``is_2d`` returns ``True``); a concrete *z* makes it a 3‑D point. Supports iteration, indexing, ``len()``, elementwise arithmetic (returning ``Point``), and ``np.asarray()``. Examples: .. code-block:: python from pybosl2 import Point p2 = Point(10.0, 20.0) assert p2.is_2d assert len(p2) == 2 p3 = Point(10.0, 20.0, 5.0) assert not p3.is_2d assert len(p3) == 3 """ x: float y: float z: float | None = None def __init__( self, x: float | Sequence[float] | np.ndarray | Point = 0.0, y: float | None = None, z: float | None = None, ) -> None: """Initialize the instance.""" if isinstance(x, Point): self.x, self.y, self.z = x.x, x.y, x.z elif isinstance(x, (int, float)): self.x = float(x) self.y = float(y) if y is not None else 0.0 self.z = float(z) if z is not None else None else: arr = list(x) if len(arr) >= 3: self.x, self.y, self.z = float(arr[0]), float(arr[1]), float(arr[2]) elif len(arr) == 2: self.x, self.y, self.z = float(arr[0]), float(arr[1]), None elif len(arr) == 1: self.x, self.y, self.z = float(arr[0]), 0.0, None else: raise Bosl2ValueError(f"Expected 1-3 values, got {len(arr)}") @property def is_2d(self) -> bool: """``True`` when *z* is ``None`` (a 2‑D point).""" return self.z is None def __iter__(self) -> Iterator[float]: """Return an iterator.""" return iter((self.x, self.y, self.z) if not self.is_2d else (self.x, self.y)) # type: ignore[arg-type] def __len__(self) -> int: """Return the number of items.""" return 2 if self.is_2d else 3 @overload def __getitem__(self, index: int) -> float: ... @overload def __getitem__(self, index: slice) -> Sequence[float]: ... def __getitem__(self, index: int | slice) -> float | Sequence[float]: """Return the item at index.""" if isinstance(index, slice): return tuple(self)[index] if self.is_2d: return (self.x, self.y)[index] assert self.z is not None return (self.x, self.y, self.z)[index] def __repr__(self) -> str: """Return a string representation.""" if self.is_2d: return f"Point({self.x!r}, {self.y!r})" return f"Point({self.x!r}, {self.y!r}, {self.z!r})" def __array__(self, dtype: None = None, copy: None = None) -> np.ndarray: """Return a numpy array representation.""" if self.is_2d: arr = [self.x, self.y] else: assert self.z is not None arr = [self.x, self.y, self.z] return np.array(arr, dtype=dtype or float) def __add__(self, other: Sequence[float] | np.ndarray) -> Point: """Return self + other.""" return Point.from_seq(np.asarray(self) + np.asarray(other, dtype=float)) def __radd__(self, other: Sequence[float] | np.ndarray) -> Point: """Return other + self.""" return Point.from_seq(np.asarray(other, dtype=float) + np.asarray(self)) def __sub__(self, other: Sequence[float] | np.ndarray) -> Point: """Return self - other.""" return Point.from_seq(np.asarray(self) - np.asarray(other, dtype=float)) def __rsub__(self, other: Sequence[float] | np.ndarray) -> Point: """Return other - self.""" return Point.from_seq(np.asarray(other, dtype=float) - np.asarray(self)) def __neg__(self) -> Point: """Return -self.""" return Point.from_seq(-np.asarray(self)) def __eq__(self, other: object) -> bool: """Return whether two objects are equal.""" if isinstance(other, Point): if self.is_2d != other.is_2d: return False if self.is_2d: return bool(self.x == other.x and self.y == other.y) return bool(self.x == other.x and self.y == other.y and self.z == other.z) if not isinstance(other, Sequence): return NotImplemented return bool(np.allclose(np.asarray(self), np.asarray(other, dtype=float))) def __truediv__(self, scalar: float) -> Point: """Return self / scalar.""" return Point.from_seq(np.asarray(self) / scalar) def __rtruediv__(self, scalar: float) -> Point: """Return scalar / self.""" return Point.from_seq(scalar / np.asarray(self)) def __mul__(self, scalar: float) -> Point: """Return self * scalar.""" return Point.from_seq(np.asarray(self) * scalar) def __rmul__(self, scalar: float) -> Point: """Return scalar * self.""" return Point.from_seq(np.asarray(self) * scalar) def __abs__(self) -> float: """Return the absolute value.""" return float(np.linalg.norm(np.asarray(self))) def __copy__(self) -> Point: """Return a shallow copy.""" return Point(self.x, self.y, self.z)
[docs] def copy(self) -> Point: """Return a copy of this point.""" return Point(self.x, self.y, self.z)
[docs] def dot(self, other: Sequence[float] | np.ndarray) -> float: """Dot product with another vector (2‑D or 3‑D). Args: other: The point to compare or combine with. """ return float(np.dot(np.asarray(self), np.asarray(other, dtype=float)))
[docs] def cross(self, other: Sequence[float] | np.ndarray) -> Point: """Cross product with another 3‑D vector, returning a :class:`Point`. Args: other: The point to compare or combine with. Raises: ValueError: If this point is 2‑D (cross product requires 3‑D vectors). """ if self.is_2d: raise Bosl2ValueError("cross() requires a 3‑D point") return Point.from_seq(np.cross(np.asarray(self), np.asarray(other, dtype=float)))
[docs] @classmethod def from_seq(cls, seq: Sequence[float] | np.ndarray) -> "Point": """Create a :class:`Point` from any array-like sequence of 2 or 3 values. Args: seq: A sequence, list, tuple, or ndarray of ``[x, y]`` or ``[x, y, z]``. Returns: A new :class:`Point`. Raises: ValueError: If the sequence has fewer than 2 or more than 3 elements. """ arr = np.asarray(seq, dtype=float) if arr.shape[0] == 2: return cls(float(arr[0]), float(arr[1])) if arr.shape[0] == 3: return cls(float(arr[0]), float(arr[1]), float(arr[2])) raise Bosl2ValueError(f"Expected 2 or 3 values, got {arr.shape[0]}")
[docs] def astuple(self) -> tuple[float, float] | tuple[float, float, float]: """Return the point as a ``(x, y)`` or ``(x, y, z)`` tuple.""" if self.is_2d: return (self.x, self.y) assert self.z is not None return (self.x, self.y, self.z)
[docs] def tolist(self) -> list[float]: """Return the point as a ``[x, y]`` or ``[x, y, z]`` list.""" if self.is_2d: return [self.x, self.y] assert self.z is not None return [self.x, self.y, self.z]
@property def norm(self) -> float: """Euclidean length of the vector from origin to this point.""" return float(np.linalg.norm(np.asarray(self)))
[docs] def to_3d(self, z: float = 0.0) -> Point: """Return a 3‑D copy with the given *z*. For a 2‑D point this adds the Z coordinate. For a 3‑D point this returns a copy with *z* replaced (unless *z* equals ``self.z``). Args: z: Z coordinate to give a 2-D point when lifting it to 3-D. """ return Point(self.x, self.y, self.z if self.z is not None and z == 0.0 else z)
[docs] def normalized(self, error: Point | Sequence[float] | np.ndarray | None = None) -> Point: """Normalize this vector to unit length, returning a new Point. If it has (near) zero length, returns *error* if given, else raises ValueError. Args: error: Raise on a degenerate input rather than returning a fallback. """ from pybosl2.math import EPSILON arr = np.asarray(self, dtype=float) sides = float(np.linalg.norm(arr)) if sides < EPSILON: if error is not None: return Point.from_seq(error) raise Bosl2ValueError("Cannot normalize a zero vector") return Point.from_seq(arr / sides)
[docs] def angle(self, other: Point) -> float: """Angle between this vector and *other* in radians. The result is always in the range [0, pi]. Args: other: The point to compare or combine with. """ import math from pybosl2.math import EPSILON, constrain if len(self) != len(other): raise Bosl2ValueError(f"Vectors must have the same dimension, got {len(self)} and {len(other)}") norm_a: float = math.hypot(*self) norm_b: float = math.hypot(*other) if norm_a < EPSILON or norm_b < EPSILON: raise Bosl2ValueError("Cannot compute angle with a zero-length vector") dot: float = constrain( sum(self[i] * other[i] for i in range(len(self))) / (norm_a * norm_b), -1.0, 1.0, ) return math.acos(dot)
[docs] def axis(self, other: Point) -> tuple[list[float], float]: """Return the axis vector (cross product) and angle between this vector and *other*. Requires 3-D vectors. Args: other: The point to compare or combine with. """ import math from pybosl2.math import EPSILON if len(self) != 3 or len(other) != 3: raise Bosl2ValueError(f"axis requires 3-D vectors, got sizes {len(self)} and {len(other)}") norm_a: float = math.hypot(*self) norm_b: float = math.hypot(*other) if norm_a < EPSILON or norm_b < EPSILON: raise Bosl2ValueError("Cannot compute axis with a zero-length vector") ang: float = self.angle(other) u: list[float] = [x / norm_a for x in self] v: list[float] = [x / norm_b for x in other] cross: list[float] = [ u[1] * v[2] - u[2] * v[1], u[2] * v[0] - u[0] * v[2], u[0] * v[1] - u[1] * v[0], ] cross_norm: float = math.hypot(*cross) if cross_norm < EPSILON: return ([0.0, 0.0, 1.0], ang) return ([x / cross_norm for x in cross], ang)
[docs] def bisect(self, other: Point) -> Point | None: """Return a unit vector that bisects the minor angle between this vector and *other*. Returns None if they are directly opposite. Args: other: The point to compare or combine with. """ import math from pybosl2.math import EPSILON if len(self) != len(other): raise Bosl2ValueError(f"Vectors must have the same dimension, got {len(self)} and {len(other)}") norm_a: float = math.hypot(*self) norm_b: float = math.hypot(*other) if norm_a < EPSILON or norm_b < EPSILON: raise Bosl2ValueError("Cannot bisect a zero-length vector") u: list[float] = [x / norm_a for x in self] v: list[float] = [x / norm_b for x in other] mid: list[float] = [u[i] + v[i] for i in range(len(u))] mid_norm: float = math.hypot(*mid) if mid_norm < EPSILON: return None return Point.from_seq([x / mid_norm for x in mid])
[docs] def closest(self, points: Sequence[Point]) -> int: """Return the index of the closest point in *points* to this point. Args: points: The points to operate on. """ if len(points) == 0: raise Bosl2ValueError("Cannot find closest point in an empty list") result: int = 0 result_dist_sq: float = float("inf") for i, candidate in enumerate(points): dist_sq: float = sum((candidate[j] - self[j]) ** 2 for j in range(len(self))) if dist_sq < result_dist_sq: result_dist_sq = dist_sq result = i return result
[docs] def furthest(self, points: Sequence[Point]) -> int: """Return the index of the furthest point in *points* from this point. Args: points: The points to operate on. """ if len(points) == 0: raise Bosl2ValueError("Cannot find furthest point in an empty list") result: int = 0 result_dist_sq: float = -1.0 for i, candidate in enumerate(points): dist_sq: float = sum((candidate[j] - self[j]) ** 2 for j in range(len(self))) if dist_sq > result_dist_sq: result_dist_sq = dist_sq result = i return result
# --------------------------------------------------------------------------- # Vector — backward-compatible alias for Point # --------------------------------------------------------------------------- Vector = Point #: What a parameter meaning "one point" accepts (PLAN T-4, the point-shaped twin of #: :data:`~pybosl2.paths.PathLike`): a :class:`Point`, the plain list or tuple a caller writes, or #: a NumPy array. Normalise on the first line of the body with ``Point(x)`` or #: ``np.asarray(x, dtype=float)``. #: #: Typing such a parameter `np.ndarray` is the defect this exists to stop: it rejected #: ``Bezier.begin([0, 0], 45)`` -- which is what the docstring examples write, and what any caller #: writes -- while accepting only the form the library happens to hand back. #: #: Declared here rather than at the top of the module so its value can be a real expression: as a #: string it is a forward reference the linter cannot see through, and the `NDArray` import it #: needs gets pruned as unused. PointLike: "TypeAlias" = Point | Sequence[float] | NDArray[np.float64]