Source code for pantr.bezier._bezier

"""Bezier geometric object: the Bezier class."""

from __future__ import annotations

from collections.abc import Sequence
from typing import TYPE_CHECKING, Any, Literal, overload

import numpy as np
from numpy import typing as npt

from .._transform_control_points import _apply_affine_to_control_points
from ._bezier_collapse import _collapse_along_axis
from ._bezier_compose import _compose_bezier
from ._bezier_degree import (
    _degree_elevate_bezier,
    _degree_reduce_bezier,
    _minimize_degree_bezier,
)
from ._bezier_derivative import _derivative_bezier
from ._bezier_eval import _evaluate_bezier, _evaluate_bezier_deriv
from ._bezier_product import _multiply_bezier
from ._bezier_restrict import _restrict_bezier
from ._bezier_slice import _slice_bezier
from ._bezier_split import _split_bezier

if TYPE_CHECKING:
    from ..bspline import Bspline
    from ..quad import PointsLattice
    from ..transform import AffineTransform


[docs] class Bezier: """A parametric Bézier curve, surface, or volume defined by control points. Stores only control points and an ``is_rational`` flag. The polynomial degree in each parametric direction is inferred from the control point array shape: ``degree[d] = control_points.shape[d] - 1``. Attributes: _control_points (npt.NDArray[np.float32 | np.float64]): Control point array with shape ``(*degrees_plus_1, rank)``, where the last axis is the output rank (including the homogeneous weight for rational). _is_rational (bool): Whether the Bézier is rational (last control point coordinate is a homogeneous weight). """
[docs] def __init__( self, control_points: npt.ArrayLike, is_rational: bool = False, ) -> None: """Initialize a Bézier from control points. Args: control_points (npt.ArrayLike): Control points. Shape must be at least 2D: ``(*degrees_plus_1, rank)``. A 1D input of shape ``(n,)`` is reshaped to ``(n, 1)`` (scalar field). Integer arrays are cast to ``float64``. is_rational (bool): Whether the Bézier is rational. Defaults to False. Raises: ValueError: If the control points have fewer than 1 entry in any parametric direction. ValueError: If the Bézier has rank smaller than 1. """ cp = np.asarray(control_points) if np.issubdtype(cp.dtype, np.integer): cp = cp.astype(np.float64) if cp.ndim < 1: raise ValueError("Control points must be at least 1D.") if cp.ndim == 1: cp = cp[:, np.newaxis] if cp.ndim < 2: # pragma: no cover - guarded by the reshape above # noqa: PLR2004 raise ValueError("Control points must be at least 2D after reshape.") for d in range(cp.ndim - 1): if cp.shape[d] < 1: raise ValueError( f"Control points must have at least 1 entry in parametric " f"direction {d}, got {cp.shape[d]}." ) self._control_points: npt.NDArray[np.float32 | np.float64] = cp self._is_rational = is_rational if self.rank <= 0: raise ValueError(f"The Bézier must have at least rank one. Got rank {self.rank}.")
@property def dim(self) -> int: """Get the parametric dimension of the Bézier. Returns: int: Number of parametric dimensions. """ return self._control_points.ndim - 1 @property def degree(self) -> tuple[int, ...]: """Get the polynomial degrees per parametric direction. Returns: tuple[int, ...]: Polynomial degree for each parametric dimension, computed as ``shape[d] - 1``. """ return tuple(s - 1 for s in self._control_points.shape[:-1]) @property def control_points(self) -> npt.NDArray[np.float32 | np.float64]: """Get the control points of the Bézier. Returns: npt.NDArray[np.float32 | np.float64]: Control point array with shape ``(*degrees_plus_1, rank)``. """ return self._control_points @property def is_rational(self) -> bool: """Check whether the Bézier is rational. Returns: bool: True if the Bézier is rational (i.e., the last control point coordinate is a homogeneous weight), False otherwise. """ return self._is_rational @property def rank(self) -> int: """Get the output rank of the Bézier. The rank is the number of value dimensions produced by the mapping. For a scalar field it is 1; for a 3D curve it is 3. For rational Béziers the weight coordinate is excluded. Returns: int: Output rank of the Bézier. """ rk = int(self._control_points.shape[-1]) return rk - 1 if self.is_rational else rk @property def dtype(self) -> npt.DTypeLike: """Get the floating-point dtype of the Bézier. Returns: npt.DTypeLike: The numpy dtype (float32 or float64) of the control point array. """ return self._control_points.dtype # ------------------------------------------------------------------ # Evaluation # ------------------------------------------------------------------
[docs] def evaluate( self, pts: npt.NDArray[np.float32 | np.float64] | PointsLattice, out: npt.NDArray[np.float32 | np.float64] | None = None, ) -> npt.NDArray[np.float32 | np.float64]: """Evaluate the Bézier at the given parametric points. Args: pts (npt.NDArray[np.float32 | np.float64] | PointsLattice): The parametric points at which to evaluate. For 1D Bézier, must be a 1D array of shape ``(n_pts,)``. For multi-dimensional Bézier, must be a 2D array of shape ``(n_pts, dim)`` or a :class:`~pantr.quad.PointsLattice`. out (npt.NDArray[np.float32 | np.float64] | None): Optional output array. Defaults to None. Returns: npt.NDArray[np.float32 | np.float64]: Bézier values at the given points. Raises: ValueError: If the points dtype does not match the Bézier dtype, or if ``out`` has incorrect shape or dtype. """ return _evaluate_bezier(self, pts, out)
[docs] def evaluate_derivatives( self, pts: npt.NDArray[np.float32 | np.float64] | PointsLattice, orders: int | Sequence[int], out: npt.NDArray[np.float32 | np.float64] | None = None, ) -> npt.NDArray[np.float32 | np.float64]: """Evaluate a specific partial derivative of the Bézier. Computes the single partial derivative specified by ``orders``, where ``orders[d]`` is the derivative order in parametric direction ``d``. For rational Bézier the generalised quotient rule is applied. Args: pts (npt.NDArray[np.float32 | np.float64] | PointsLattice): The parametric points at which to evaluate. orders (int | Sequence[int]): Derivative order(s). A single ``int`` is broadcast to all ``self.dim`` directions. A sequence must contain one non-negative integer per parametric direction. out (npt.NDArray[np.float32 | np.float64] | None): Optional pre-allocated output array. Defaults to None. Returns: npt.NDArray[np.float32 | np.float64]: Mixed partial derivative values. Raises: ValueError: If ``len(orders) != self.dim``, if any order is negative, or if the points dtype does not match the Bézier dtype. Example: >>> result = bezier.evaluate_derivatives(pts, 2) >>> result = bezier.evaluate_derivatives(pts, [1, 2]) """ orders_seq: Sequence[int] = [orders] * self.dim if isinstance(orders, int) else orders return _evaluate_bezier_deriv(self, pts, orders_seq, out)
# ------------------------------------------------------------------ # Derivative (returns new Bezier) # ------------------------------------------------------------------
[docs] def derivative(self, direction: int = 0, *, keep_degree: bool = False) -> Bezier: """Return a Bézier representing the first derivative in the given direction. Computes the hodograph: a new Bézier whose value at every parametric point equals the partial derivative of this Bézier with respect to parametric direction ``direction``. For non-rational Bézier of degree ``p`` in direction ``d``, the result has degree ``p - 1`` (or ``p`` when ``keep_degree=True``). For rational Bézier, the quotient rule is applied, producing a rational Bézier of degree ``2p`` in direction ``d`` (or the original degree when ``keep_degree=True``). Args: direction (int): Parametric direction for differentiation. Must be in ``[0, dim)``. Defaults to 0. keep_degree (bool): If ``True``, the result preserves the same degree as the original Bézier by fusing derivative and degree elevation. This is useful, for instance, when computing derivatives of rational polynomials (in the numerator). Defaults to ``False``. Returns: Bezier: A new Bézier representing the derivative. Raises: ValueError: If ``direction`` is out of range ``[0, dim)``. ValueError: If the degree in the given direction is 0. Example: >>> f_prime = f.derivative() >>> df_dv = surface.derivative(direction=1) >>> f_prime_same_deg = f.derivative(keep_degree=True) """ if direction < 0 or direction >= self.dim: raise ValueError(f"direction must be in [0, {self.dim}), got {direction}.") if self.degree[direction] < 1: raise ValueError("Derivative of a degree-0 Bézier is not defined.") return _derivative_bezier(self, direction, keep_degree=keep_degree)
# ------------------------------------------------------------------ # Degree elevation # ------------------------------------------------------------------
[docs] def elevate_degree(self, degree_increments: int | Sequence[int]) -> Bezier: """Elevate the polynomial degree of the Bézier. Creates a new Bézier that represents the same mapping as the original but with higher polynomial degree. Args: degree_increments (int | Sequence[int]): Number of degrees to increase. If an integer, the same increment is applied to all parametric directions. If a sequence, must have length equal to ``self.dim``. Returns: Bezier: A new Bézier with elevated degrees. Raises: ValueError: If any degree increment is negative. ValueError: If all degree increments are zero. ValueError: If the number of increments does not match the dimension. """ if isinstance(degree_increments, int): increments = (degree_increments,) * self.dim else: increments = tuple(degree_increments) if len(increments) != self.dim: raise ValueError( f"Number of degree increments ({len(increments)}) " f"must match dimension ({self.dim})." ) if any(inc < 0 for inc in increments): raise ValueError("Degree increments must be non-negative.") if all(inc == 0 for inc in increments): raise ValueError("At least one degree increment must be positive.") return _degree_elevate_bezier(self, increments)
# ------------------------------------------------------------------ # Degree reduction # ------------------------------------------------------------------
[docs] def reduce_degree(self, degree_decrements: int | Sequence[int]) -> Bezier: """Reduce the polynomial degree of the Bézier via least-squares approximation. Creates a new Bézier whose degree is lower by the requested amount in each parametric direction. The reduction minimises the squared error under the Bernstein degree-elevation matrix using QR factorisation with Givens rotations. Unlike :meth:`elevate_degree`, this operation is **not exact** in general: the result is an approximation of the original mapping. Args: degree_decrements (int | Sequence[int]): Number of degrees to reduce. If an integer, the same decrement is applied to all parametric directions. If a sequence, must have length equal to ``self.dim``. Returns: Bezier: A new Bézier with reduced degrees. Raises: ValueError: If any degree decrement is negative. ValueError: If all degree decrements are zero. ValueError: If the number of decrements does not match the dimension. ValueError: If any decrement exceeds the current degree in that direction. """ if isinstance(degree_decrements, int): decrements = (degree_decrements,) * self.dim else: decrements = tuple(degree_decrements) if len(decrements) != self.dim: raise ValueError( f"Number of degree decrements ({len(decrements)}) " f"must match dimension ({self.dim})." ) if any(dec < 0 for dec in decrements): raise ValueError("Degree decrements must be non-negative.") if all(dec == 0 for dec in decrements): raise ValueError("At least one degree decrement must be positive.") for d, dec in enumerate(decrements): if dec > self.degree[d]: raise ValueError( f"Degree decrement ({dec}) in direction {d} exceeds " f"current degree ({self.degree[d]})." ) return _degree_reduce_bezier(self, decrements)
[docs] def minimize_degree(self, tol: float | None = None) -> Bezier: """Find the lowest degree that preserves accuracy within tolerance. Iterates over each parametric direction and repeatedly tries to reduce the degree by 1. A reduction is accepted when the round-trip (reduce then elevate) relative L2 error stays below ``tol``. For vector-valued Bézier, all rank components are checked simultaneously. Args: tol (float | None): Relative tolerance for accepting a degree reduction. If *None*, uses a default based on machine epsilon (``1e3 * eps``). Returns: Bezier: A new Bézier with the lowest degree that preserves accuracy. If no reduction is possible, returns a copy. Example: >>> import numpy as np >>> b = Bezier(np.array([3.0, 3.0, 3.0, 1.0]).reshape(4, 1)) >>> b.degree (3,) >>> b_min = b.minimize_degree() """ return _minimize_degree_bezier(self, tol)
# ------------------------------------------------------------------ # Multiply # ------------------------------------------------------------------
[docs] def multiply(self, other: Bezier) -> Bezier: """Return the exact pointwise product of this Bézier and another. Given Bézier ``self`` and ``other`` over the same parametric domain ``[0, 1]^dim``, returns a new Bézier ``h`` such that ``h(t) = self(t) * other(t)``. The result has degree ``p_d + q_d`` per direction. Args: other (Bezier): The second Bézier operand. Must have the same dimension, dtype, and rank as ``self``. Returns: Bezier: A new Bézier representing ``self * other``. Raises: ValueError: If the operands have different dimensions, dtypes, or ranks. Example: >>> h = f.multiply(g) >>> h2 = f * g """ return _multiply_bezier(self, other)
__mul__ = multiply # ------------------------------------------------------------------ # Compose # ------------------------------------------------------------------
[docs] def compose(self, inner: Bezier) -> Bezier: """Compose this Bézier with another: ``result(t) = self(inner(t))``. Computes the exact composition of two non-rational Bézier objects. The result is a new Bézier with parametric dimension equal to ``inner.dim``, rank equal to ``self.rank``, and degree ``sum(self.degree) * inner.degree[s]`` in each direction ``s``. Args: inner (Bezier): The inner Bézier (reparametrization). Must be non-rational and satisfy ``inner.rank == self.dim``. Returns: Bezier: A new Bézier representing ``self(inner(t))``. Raises: TypeError: If either Bézier is rational. ValueError: If ``inner.rank != self.dim``. ValueError: If the operands have different dtypes. Example: >>> f = Bezier(np.array([[0.0, 0.0], [1.0, 1.0], [2.0, 0.0]])) >>> g = Bezier(np.array([[0.2], [0.8]])) >>> h = f.compose(g) """ return _compose_bezier(self, inner)
# ------------------------------------------------------------------ # Reverse and permute # ------------------------------------------------------------------ @overload def reverse(self, direction: int = ..., *, in_place: Literal[False] = ...) -> Bezier: ... @overload def reverse(self, direction: int = ..., *, in_place: Literal[True]) -> None: ...
[docs] def reverse(self, direction: int = 0, *, in_place: bool = False) -> Bezier | None: """Reverse the orientation of one parametric direction. Flips the control points along the given parametric axis so that the mapping is reparametrised in the opposite sense along that direction. Args: direction (int): Parametric direction to reverse. Must be in ``[0, dim)``. Defaults to 0. in_place (bool): If ``True``, modify this Bézier in place and return ``None``. If ``False`` (default), return a new Bézier. Returns: Bezier | None: The reversed Bézier, or ``None`` when ``in_place=True``. Raises: ValueError: If ``direction`` is out of range ``[0, dim)``. Example: >>> rev = bezier.reverse(direction=0) >>> bezier.reverse(direction=1, in_place=True) """ if direction < 0 or direction >= self.dim: raise ValueError(f"direction must be in [0, {self.dim}), got {direction}.") from .._control_points_utils import _reverse_control_points # noqa: PLC0415 new_cp = _reverse_control_points(self._control_points, direction, in_place=in_place) if in_place: return None return Bezier(new_cp, is_rational=self._is_rational)
@overload def permute_directions( self, permutation: Sequence[int], *, in_place: Literal[False] = ... ) -> Bezier: ... @overload def permute_directions( self, permutation: Sequence[int], *, in_place: Literal[True] ) -> None: ...
[docs] def permute_directions( self, permutation: Sequence[int], *, in_place: bool = False ) -> Bezier | None: """Reorder the parametric directions according to a permutation. Given a permutation ``[i_0, i_1, …]``, the new direction ``k`` is the old direction ``permutation[k]``. For example, ``[1, 2, 0]`` on a 3D volume maps old direction 1 → new 0, old 2 → new 1, old 0 → new 2. Args: permutation (Sequence[int]): A permutation of ``range(dim)``. in_place (bool): If ``True``, modify this Bézier in place and return ``None``. If ``False`` (default), return a new Bézier. Returns: Bezier | None: The permuted Bézier, or ``None`` when ``in_place=True``. Raises: ValueError: If ``permutation`` is not a valid permutation of ``range(dim)``. Example: >>> surface.permute_directions([1, 0]) # swap u ↔ v """ perm = list(permutation) if sorted(perm) != list(range(self.dim)): raise ValueError(f"permutation must be a permutation of range({self.dim}), got {perm}.") from .._control_points_utils import _permute_control_points # noqa: PLC0415 new_cp = _permute_control_points(self._control_points, perm, self.dim) if in_place: self._control_points = new_cp return None return Bezier(new_cp, is_rational=self._is_rational)
# ------------------------------------------------------------------ # Affine transformation # ------------------------------------------------------------------ @overload def transform(self, affine: AffineTransform, *, in_place: Literal[False] = ...) -> Bezier: ... @overload def transform(self, affine: AffineTransform, *, in_place: Literal[True]) -> None: ...
[docs] def transform( self, affine: AffineTransform, *, in_place: bool = False, ) -> Bezier | None: """Apply an affine transformation to the control points. For non-rational Bézier, every control point ``P`` is mapped to ``A @ P + b``. For rational Bézier the weighted homogeneous coordinates are updated so that the projected geometry undergoes the same affine map while the weights are preserved. Args: affine (~pantr.transform.AffineTransform): The affine transformation to apply. in_place (bool): If ``True``, the control points are modified in place and ``None`` is returned. If ``False`` (default), a new :class:`Bezier` is returned. Returns: Bezier | None: The transformed Bézier, or ``None`` when ``in_place=True``. Raises: ValueError: If the transform dimension does not match the geometric rank of the Bézier. Example: >>> from pantr.transform import AffineTransform >>> T = AffineTransform.translation([1.0, 2.0]) >>> shifted = bezier.transform(T) """ new_cp = _apply_affine_to_control_points( self._control_points, self._is_rational, affine.matrix, affine.offset, in_place=in_place, ) if in_place: return None return Bezier(new_cp, is_rational=self._is_rational)
# ------------------------------------------------------------------ # Restrict # ------------------------------------------------------------------
[docs] def restrict( self, bounds: tuple[float, float] | Sequence[tuple[float, float] | None], ) -> Bezier: """Return a Bézier restricted to a sub-region of ``[0, 1]^dim``. Extracts the portion of the Bézier defined on the given sub-interval and reparametrizes the result back to ``[0, 1]^dim``. The returned Bézier has the same degree but different control points that encode the restricted mapping. Uses two de Casteljau passes per direction for direct Bernstein coefficient computation without B-spline conversion. The order of the passes is chosen for numerical stability. Args: bounds (tuple[float, float] | Sequence[tuple[float, float] | None]): For a 1D Bézier, a ``(lower, upper)`` tuple within ``[0, 1]``. For multi-dimensional Bézier, a sequence of length ``dim`` where each element is a ``(lower, upper)`` tuple for that direction, or ``None`` to keep the full ``[0, 1]`` range. At least one direction must have non-``None`` bounds that restrict the domain. Returns: Bezier: New Bézier on ``[0, 1]^dim`` representing the restriction. Raises: ValueError: If the sequence length does not match ``dim``. ValueError: If all directions are ``None`` or match the full domain. ValueError: If any bound lies outside ``[0, 1]``. ValueError: If ``lower >= upper`` in any direction. """ if self.dim == 1: bounds_per_dim: list[tuple[float, float] | None] = [ bounds # type: ignore[list-item] ] else: seq = list(bounds) # type: ignore[arg-type,unused-ignore] if len(seq) != self.dim: raise ValueError( f"bounds sequence length ({len(seq)}) must match dim ({self.dim})." ) bounds_per_dim = seq # type: ignore[assignment] # Validate bounds. for i, b in enumerate(bounds_per_dim): if b is None: continue lower, upper = b if lower >= upper: raise ValueError( f"Lower bound ({lower}) must be strictly less than upper bound ({upper}) " f"in direction {i}." ) if lower < 0.0 or upper > 1.0: raise ValueError( f"Bounds ({lower}, {upper}) must lie within [0, 1] in direction {i}." ) return _restrict_bezier(self, bounds_per_dim)
# ------------------------------------------------------------------ # Split # ------------------------------------------------------------------
[docs] def split(self, direction: int, value: float) -> tuple[Bezier, Bezier]: """Split the Bézier into two at a parameter value in one direction. Uses the de Casteljau algorithm to subdivide the Bézier into a left half (representing the original on ``[0, value]``) and a right half (representing the original on ``[value, 1]``), both reparametrized to ``[0, 1]``. Args: direction (int): Parametric direction along which to split. Must be in ``[0, dim)``. value (float): Parameter value at which to split. Must lie strictly inside ``(0, 1)``. Returns: tuple[Bezier, Bezier]: A pair ``(left, right)`` of Béziers on ``[0, 1]^dim``. Raises: ValueError: If ``direction`` is out of range ``[0, dim)``. ValueError: If ``value`` is not strictly inside ``(0, 1)``. Example: >>> left, right = curve.split(0, 0.5) >>> left, right = surface.split(1, 0.3) """ if direction < 0 or direction >= self.dim: raise ValueError(f"direction must be in [0, {self.dim}), got {direction}.") if value <= 0.0 or value >= 1.0: raise ValueError(f"value must be strictly inside (0, 1), got {value}.") return _split_bezier(self, direction, value)
# ------------------------------------------------------------------ # Slice and boundary # ------------------------------------------------------------------
[docs] def slice(self, axis: int, value: float) -> Bezier | npt.NDArray[np.float32 | np.float64]: """Slice the Bézier by fixing one parametric direction at a given value. Reduces the parametric dimension by one using the de Casteljau algorithm on the control points. A surface becomes a curve, a curve becomes a point (returned as a NumPy array). At the boundary values ``0`` and ``1`` the result is obtained in O(1) by direct control point lookup. Args: axis (int): Parametric direction to fix (0-indexed). Must be in ``[0, dim)``. value (float): Parameter value at which to slice. Must lie within ``[0, 1]``. Returns: Bezier | npt.NDArray[np.float32 | np.float64]: A Bézier with ``dim - 1`` dimensions when ``dim >= 2``, or a NumPy array of shape ``(rank,)`` when ``dim == 1``. Rational Béziers preserve the rational structure when ``dim >= 2``; for ``dim == 1`` the result is projected to physical coordinates. Raises: ValueError: If ``axis`` is out of range ``[0, dim)``. ValueError: If ``value`` is outside ``[0, 1]``. Example: >>> # Slice a surface at v=0.5 to get a curve >>> curve = surface.slice(1, 0.5) >>> # Composable: surface -> curve -> point >>> pt = surface.slice(1, 0.5).slice(0, 0.2) """ if axis < 0 or axis >= self.dim: raise ValueError(f"axis must be in [0, {self.dim}), got {axis}.") if value < 0.0 or value > 1.0: raise ValueError(f"value must be in [0, 1], got {value}.") return _slice_bezier(self, axis, value)
[docs] def boundary(self, axis: int, side: int) -> Bezier | npt.NDArray[np.float32 | np.float64]: """Extract the boundary of the Bézier along one parametric direction. Returns the restriction of the Bézier to one end of the ``[0, 1]`` domain in the given direction. Args: axis (int): Parametric direction (0-indexed). Must be in ``[0, dim)``. side (int): Which end of the domain: ``0`` for the start, ``1`` for the end. Returns: Bezier | npt.NDArray[np.float32 | np.float64]: A Bézier with ``dim - 1`` dimensions when ``dim >= 2``, or a NumPy array of shape ``(rank,)`` when ``dim == 1``. Raises: ValueError: If ``axis`` is out of range ``[0, dim)``. ValueError: If ``side`` is not 0 or 1. Example: >>> # Extract left boundary of a surface along direction 0 >>> left_curve = surface.boundary(0, 0) """ if side not in (0, 1): raise ValueError(f"side must be 0 or 1, got {side}.") if axis < 0 or axis >= self.dim: raise ValueError(f"axis must be in [0, {self.dim}), got {axis}.") value = 0.0 if side == 0 else 1.0 return self.slice(axis, value)
# ------------------------------------------------------------------ # Collapse along axis # ------------------------------------------------------------------
[docs] def collapse_along_axis( self, axis: int, values: npt.ArrayLike, ) -> Bezier: """Collapse to a univariate Bézier along one parametric direction. Fixes all parametric directions except ``axis`` at the given parameter values, producing a 1D Bézier whose control points are the Bernstein coefficients along ``axis``. This is a tensor contraction: for each collapsed direction, the Bernstein basis is evaluated at the given value and contracted with the control point array. Args: axis (int): Parametric direction to keep (0-indexed). Must be in ``[0, dim)``. values (npt.ArrayLike): Parameter values for all directions except ``axis``. Must have length ``dim - 1`` with all values in ``[0, 1]``. ``values[i]`` corresponds to direction ``i`` for ``i < axis``, and direction ``i + 1`` for ``i >= axis``. Returns: Bezier: A 1D Bézier with degree ``self.degree[axis]`` and the same rank and rationality as the input. Raises: ValueError: If ``dim < 2`` (nothing to collapse). ValueError: If ``axis`` is out of range ``[0, dim)``. ValueError: If ``values`` does not have length ``dim - 1``. ValueError: If any value is outside ``[0, 1]``. Example: >>> # Collapse a 3D volume along axis 1 at (u=0.3, w=0.7) >>> curve = volume.collapse_along_axis(1, [0.3, 0.7]) """ if self.dim < 2: # noqa: PLR2004 raise ValueError("collapse_along_axis requires dim >= 2.") if axis < 0 or axis >= self.dim: raise ValueError(f"axis must be in [0, {self.dim}), got {axis}.") return _collapse_along_axis(self, axis, values)
# ------------------------------------------------------------------ # Conversion # ------------------------------------------------------------------
[docs] def to_bspline(self, *, copy: bool = True) -> Bspline: """Convert to an equivalent B-spline with Bézier knot vectors. Creates a :class:`~pantr.bspline.Bspline` with open knot vectors ``[0]*(p+1) + [1]*(p+1)`` in each parametric direction. Args: copy (bool): If ``True`` (default), the control points are deep-copied into the new B-spline. If ``False``, the B-spline shares the same underlying control point array. Returns: ~pantr.bspline.Bspline: Equivalent B-spline representation. """ from ..bspline import Bspline as BsplineCls # noqa: PLC0415 from ..bspline import BsplineSpace, BsplineSpace1D # noqa: PLC0415 dtype = self.dtype spaces: list[BsplineSpace1D] = [] for p in self.degree: knots = np.zeros(2 * (p + 1), dtype=dtype) knots[p + 1 :] = 1.0 spaces.append(BsplineSpace1D(knots, p)) cp = self._control_points.copy() if copy else self._control_points return BsplineCls(BsplineSpace(spaces), cp, self._is_rational)
# ------------------------------------------------------------------ # Visualization # ------------------------------------------------------------------
[docs] def plot( self, *, color: str | None = None, show_control_polygon: bool = False, **plotter_kwargs: Any, # noqa: ANN401 ) -> object: """Quick interactive visualization of this Bézier (requires pyvista). For finer control, use ``pantr.viz.Scene`` directly. Args: color: Surface color. show_control_polygon: Render control polygon (points and wireframe). **plotter_kwargs: Additional keyword arguments for ``pv.Plotter()``. Returns: object: The pyvista ``Plotter`` after showing. Raises: ImportError: If pyvista is not installed. """ from ..viz import plot as _plot # noqa: PLC0415 return _plot( self, color=color, show_control_polygon=show_control_polygon, **plotter_kwargs, )
[docs] def create_from_bspline(bspline: Bspline, *, copy: bool = True) -> Bezier: """Create a Bézier from a B-spline with Bézier-like knot vectors. Validates that the B-spline has Bézier-like knots (open knots with ``num_basis == degree + 1`` in each direction) and extracts the control points. Args: bspline (~pantr.bspline.Bspline): A B-spline with Bézier-like knot structure. copy (bool): If ``True`` (default), the control points are deep-copied into the new Bézier. If ``False``, the Bézier shares the same underlying control point array. Returns: Bezier: The equivalent Bézier. Raises: ValueError: If the B-spline does not have Bézier-like knots. """ if not bspline.space.has_Bezier_like_knots(): raise ValueError("B-spline does not have Bézier-like knots. Cannot convert to Bézier.") cp = bspline.control_points.copy() if copy else bspline.control_points return Bezier(cp, is_rational=bspline.is_rational)