Source code for pybosl2.paths

# 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

"""Abstract :class:`Path` base class for 2-D and 3-D path types.

Concrete math helpers live in :mod:`pybosl2._path_math`.
"""

# LibFile: pybosl2/paths.py
# FileSummary: Abstract Path base class + CutPoint type.
# DocCategory: Paths, regions & surfaces
# FileGroup: BOSL2

from __future__ import annotations

from abc import ABC, abstractmethod
from dataclasses import dataclass
from enum import Enum
from typing import TYPE_CHECKING, Any, Self, cast

import numpy as np

from pybosl2.caps import CapSpec, CapType
from pybosl2.math import EPSILON, deriv
from pybosl2.points import Point


[docs] @dataclass(frozen=True, slots=True) class CutPoint: """A point along a path where it was cut, with the index of the next segment. Returned by :meth:`~pybosl2.path2d.Path2D.cut_points` and related methods. When requested with ``direction=True``, the *direction* and *normal* attributes are populated; otherwise they are ``None``. """ point: Point next_index: int direction: np.ndarray | None = None normal: np.ndarray | None = None @property def is_directed(self) -> bool: """True if direction and normal vectors are present.""" return self.direction is not None and self.normal is not None
if TYPE_CHECKING: from collections.abc import Iterator, Sequence from numpy.typing import NDArray __all__ = ["CutPoint", "Path", "SubdivideMethod"]
[docs] class SubdivideMethod(Enum): """Method for subdividing a path.""" LENGTH = "length" SEGMENT = "segment"
# -- Path -- dimension-agnostic path-math kernels shared by Path2D and Path3D
[docs] class Path(ABC): """Dimension-agnostic numeric path operations shared by :class:`Path2D` and :class:`Path3D`. Abstract base class. Subclasses must provide ``_points`` (:class:`numpy.ndarray`) and ``closed`` (:class:`bool`). """ _points: np.ndarray closed: bool def __new__( cls, points: Sequence[Sequence[float]] | None = None, closed: bool = False, # noqa: ARG004 ) -> Self: """Create a concrete Path2D or Path3D instance. Determine the point dimensionality and return the appropriate subclass. """ if cls is Path: if points is None: raise ValueError("Cannot instantiate abstract Path class without points to determine dimension.") pts = np.asarray(points, dtype=float) dim = pts.shape[-1] if len(pts.shape) > 1 else 0 if dim == 2: from pybosl2.path2d import Path2D return cast("Self", super().__new__(Path2D)) elif dim == 3: from pybosl2.path3d import Path3D return cast("Self", super().__new__(Path3D)) else: raise ValueError("Path points must be 2-D or 3-D.") return super().__new__(cls) @abstractmethod def __init__(self, points: Sequence[Sequence[float]], closed: bool = False) -> None: """Initialize the instance.""" ... def __len__(self) -> int: """Return the number of items.""" return len(self._points) def __getitem__(self, key: int | slice | tuple[int, ...]) -> np.ndarray | Point: """Return the item at index.""" result = self._points[key] if isinstance(key, int): return Point.from_seq(result) return result def __iter__(self) -> Iterator[np.ndarray]: """Return an iterator.""" return iter(self._points) def __eq__(self, other: object) -> bool: """Return whether two objects are equal.""" if not isinstance(other, Path): return NotImplemented return bool(np.allclose(self._points, other._points)) and self.closed == other.closed @property def array(self) -> np.ndarray: """The points as an (N, D) numpy array.""" return self._points # -- Path2D length calculation -----------------------------------------------------------
[docs] @abstractmethod def segment_lengths(self, closed: bool | None = None) -> NDArray[np.float64]: """Length of each segment of the path, as an ndarray. Args: closed: Override the instance's closed flag; uses ``self.closed`` by default. Returns: An ndarray of segment lengths. """ ...
[docs] @abstractmethod def perimeter(self) -> float: """Total length along the path. Returns: The total path length as a float. """ ...
[docs] @abstractmethod def length_fractions(self, closed: bool | None = None) -> NDArray[np.float64]: """Distance fraction of each point in the path (0 at start, 1 at end). Args: closed: Override the instance's closed flag; uses ``self.closed`` by default. Returns: An ndarray of cumulative length fractions, from 0 to 1. """ ...
[docs] @abstractmethod def closest_point(self, pt: Point | Sequence[float], closed: bool | None = None) -> Point: """Return the closest point on the path to *pt*. Args: pt: The query point as :class:`~pybosl2.points.Point` or ``[x, y, z]``. closed: Override the instance's closed flag; uses ``self.closed`` by default. Returns: A :class:`~pybosl2.points.Point` of the closest point on the path. """ ...
[docs] def tangent_array(self, closed: bool | None = None, uniform: bool = True) -> NDArray[np.float64]: """Return the unit tangent at every point of the path, as an (N, D) array (BOSL2 path_tangents). The shared implementation behind :meth:`tangents` for both dimensions. Always returns one tangent per path point, never one per segment. A path of fewer than two points has no direction to derive -- there is no neighbour to difference against. Rather than raise, each such point is given **+x** (``[1, 0]`` / ``[1, 0, 0]``). That is a CONVENTION, not a measurement: it is arbitrary, inherited from the original implementation, and kept only so callers get a usable unit vector and a predictable ``(N, D)`` shape. Do not read meaning into the direction, and do not change it casually -- :meth:`normals` rotates whatever comes back, so anything downstream of a one-point path moves with it. Args: closed: Override the instance's closed flag; uses ``self.closed`` by default. uniform: If True, estimate the derivative assuming equally spaced points. If False, sample it at the true (non-uniform) segment lengths, which tracks a path whose points are unevenly spaced far better. Returns: An ndarray of unit tangent vectors, one per path point. Raises: AssertionError: If two adjacent points coincide, leaving a zero-length tangent. """ if closed is None: closed = self.closed pts = self._points if len(pts) < 2: # The +x convention documented above. An empty Path2D holds a 1-D zero-length array # rather than an (0, 2) one, so this cannot go through zeros_like: build the (N, D) # result from the point count instead. straight: NDArray[np.float64] = np.zeros((len(pts), pts.shape[1] if pts.ndim > 1 else 2)) straight[:, 0] = 1.0 return straight height: float | NDArray[np.float64] = 1.0 if uniform else self.segment_lengths(closed=closed) derivs = np.asarray(deriv(pts, height=height, closed=closed), dtype=float) norms = np.linalg.norm(derivs, axis=1, keepdims=True) assert np.all(norms.ravel() > EPSILON), "Cannot normalize a zero vector" result: NDArray[np.float64] = derivs / norms return result
[docs] @abstractmethod def tangents(self, closed: bool | None = None, uniform: bool = True) -> list[Point]: """Return normalized tangent vector at each point of the path, as an ndarray. Args: closed: Override the instance's closed flag; uses ``self.closed`` by default. uniform: If True, use uniform parameter spacing; if False, weight by segment lengths. Returns: An ndarray of unit tangent vectors, one per path point. """ ...
[docs] @abstractmethod def normals(self, tangents: list[Point] | None = None, closed: bool | None = None) -> list[Point]: """Return normal vector (perpendicular to tangent, in the plane of the curve) at each point. For 2-D paths this is a 90-degree rotation of the tangent. For 3-D paths it is the principal normal estimated via the triple-product cross. Args: tangents: Optional pre-computed tangent vectors; computed automatically if None. closed: Override the instance's closed flag; uses ``self.closed`` by default. Returns: An ndarray of unit normal vectors, one per path point. """ ...
[docs] @abstractmethod def curvature(self, closed: bool | None = None) -> NDArray[np.float64]: """Numeric curvature estimate of the path at each point, as an ndarray. Args: closed: Override the instance's closed flag; uses ``self.closed`` by default. Returns: An ndarray of curvature values, one per path point. """ ...
[docs] @abstractmethod def torsion(self, closed: bool | None = None) -> NDArray[np.float64]: """Numeric torsion estimate of the path at each point, as an ndarray. Args: closed: Override the instance's closed flag; uses ``self.closed`` by default. Returns: An ndarray of torsion values, one per path point. """ ...
[docs] @abstractmethod def cut(self, cutdist: float | Sequence[float], closed: bool | None = None) -> list[Any]: """Cut path into subpaths at the given ascending list of distances (or a single distance). Args: cutdist: A single distance or a list of ascending distances from the start. closed: Override the instance's closed flag; uses ``self.closed`` by default. Returns: A list of subpath point lists. """ ...
[docs] @abstractmethod def cut_getpaths(self, cutlist: list[CutPoint], closed: bool) -> Sequence[Path]: """Reconstruct sub-paths from the output of cut_points(). Args: cutlist: Output from cut_points(), a list of :class:`CutPoint` entries. closed: Whether the path is closed. Returns: A list of subpath point lists. """ ...
[docs] @abstractmethod def cut_points( self, cutdist: float | Sequence[float], closed: bool | None = None, direction: bool = False, ) -> list[CutPoint]: """Cut path at given distance(s) from start. Returns a list of :class:`CutPoint` entries (or :class:`` if direction is True). Args: cutdist: A single distance or a list of ascending distances from the start. closed: Override the instance's closed flag; uses ``self.closed`` by default. direction: If True, also include direction and normal at each cut point. Returns: A list of :class:`CutPoint` or :class:`` entries, one per cut distance. """ ...
[docs] @abstractmethod def cut_points_recurse(self, dists: Sequence[float], closed: bool = False) -> list[CutPoint]: """Walk the path accumulating distance until each cut distance is reached. Args: dists: Ordered list of distances from the start at which to cut. closed: Whether the path is closed. Returns: A list of :class:`CutPoint` entries, one per cut distance. """ ...
[docs] @abstractmethod def cut_single(self, dist: float, closed: bool = False, ind: int = 0, eps: float = 1e-7) -> CutPoint: """Find the single cut point at distance dist from segment ind. Args: dist: Distance along the path from the given segment index. closed: Whether the path is closed. ind: The segment index to start searching from. eps: Epsilon for distance comparison. Returns: A :class:`CutPoint` with the cut point and its next segment index. """ ...
[docs] @abstractmethod def cuts_path_normals(self, cuts: list[CutPoint], closed: bool = False) -> list[Point]: """Compute normals at each cut point from the path geometry. Args: cuts: List of cut entries from cut_points(). closed: Whether the path is closed. Returns: A list of normal vectors, one per cut point. """ ...
[docs] @abstractmethod def plane(self, ind: int, i: int, closed: bool = False) -> list[Point]: """Find the local plane defined by point ind, ind-1, and the nearest non-collinear point. Args: ind: Index of the first point defining the plane. i: Index of the search start for the third non-collinear point. closed: Whether the path is closed. Returns: A 2x3 ndarray of two basis vectors defining the local plane, or None if no non-collinear point is found. """ ...
[docs] @abstractmethod def cuts_dir(self, cuts: list[CutPoint], closed: bool = False, eps: float = 1e-2) -> list[Point]: """Compute direction vectors at each cut point (blended from adjacent segments). Args: cuts: List of cut entries from cut_points(). closed: Whether the path is closed. eps: Epsilon for numerical comparisons. Returns: A list of direction vectors, one per cut point. """ ...
[docs] @abstractmethod def subdivide_path( self, points: int | None = None, points_per_segment: Sequence[int] | None = None, maxlen: float | None = None, exact: bool = True, closed: bool | None = None, method: SubdivideMethod = SubdivideMethod.LENGTH, ) -> Path: """Subdivide the path into evenly spaced points. Args: points: Target total number of points. points_per_segment: Number of points to add to each segment index. maxlen: Maximum allowed segment length. exact: If False, favor uniform sampling — point count may differ. closed: Override the instance's closed flag. method: Subdivision method — ``LENGTH`` (uniform along path) or ``SEGMENT`` (per segment). Returns: A new path with the subdivided points. """ ...
[docs] @abstractmethod def resample_path( self, num_copies: int | None = None, spacing: float | None = None, closed: bool | None = None, ) -> Path: """Uniformly resample path to num_copies points, or to a spacing near spacing. Args: num_copies: Target number of points. spacing: Approximate spacing between points. closed: Override the instance's closed flag; uses ``self.closed`` by default. Returns: A list of uniformly resampled path points. """ ...
[docs] @abstractmethod def select(self, s1: int, u1: float, s2: int, u2: float, closed: bool | None = None) -> Path: """Portion of path from the u1 fraction of segment s1 to the u2 fraction of segment s2. Args: s1: Starting segment index. u1: Fraction along segment s1 (0 to 1). s2: Ending segment index. u2: Fraction along segment s2 (0 to 1). closed: Override the instance's closed flag; uses ``self.closed`` by default. Returns: A list of points representing the selected portion of the path. """ ...
[docs] @abstractmethod def stroke( self, width: float = 1, closed: bool | None = None, endcaps: CapType | CapSpec = CapType.ROUND, endcap1: CapType | CapSpec = CapType.ROUND, endcap2: CapType | CapSpec = CapType.ROUND, joints: CapType | CapSpec = CapType.ROUND, ) -> Any: """Render the path as a stroked polygon outline (2-D) or solid tube (3-D). Args: width: Stroke line width. closed: Override the instance's closed flag. endcaps: Default endcap style for both ends. endcap1: Start endcap style (overrides endcaps). endcap2: End endcap style (overrides endcaps). joints: Joint style at vertices. Returns: A :class:`Path2D` for 2-D strokes, :class:`Bosl2Solid` for 3-D. """ ...
[docs] @abstractmethod def dashed_stroke( self, dashpat: Sequence[float] | None = None, closed: bool | None = None, fit: bool = True, mindash: float = 0.5, ) -> Any: """Break the path into dashed segments and stroke them. Args: dashpat: Dash pattern [line_len, space_len, ...]. closed: Override the instance's closed flag. fit: Scale the pattern to fit a whole number of repeats. mindash: Drop a trailing dash shorter than this. Returns: A :class:`Region` for 2-D, :class:`Bosl2Solid` for 3-D. """ ...
[docs] @abstractmethod def merge_collinear(self, closed: bool | None = None, eps: float = 1e-9) -> Path: """Remove sequential collinear points and return a new path. Args: closed: Override the instance's closed flag. eps: Epsilon for collinearity comparison. Returns: A new path with collinear points removed. """ ...
[docs] @abstractmethod def deduplicate(self, closed: bool | None = None, eps: float = 1e-9) -> Path: """Remove duplicate consecutive points and return a new path. Args: closed: Override the instance's closed flag. eps: Epsilon for distance comparison. Returns: A new path with duplicate points removed. """ ...