"""Piecewise-linear open-curve runtime for authored section geometry."""
from __future__ import annotations
from typing import override
import numpy as np
from buffalo_core.numeric import as_float_array, as_float_scalar
from buffalo_core.typing import FloatArray, FloatInput, FloatScalar
from .curve import Curve
from .schema.common import Point2DSpec
_MIN_POLYLINE_POINTS = 2
[docs]
class PolylineCurve(Curve):
"""
Piecewise-linear open-curve runtime for authored section geometry.
Notes
-----
The native parameter ``u`` is the normalized cumulative arc-length
fraction along the polyline, so every segment occupies a parameter span
proportional to its physical length.
"""
def __init__(
self,
*,
points: list[Point2DSpec],
) -> None:
"""Store one polyline curve after validating its segment geometry."""
if len(points) < _MIN_POLYLINE_POINTS:
msg = "PolylineCurve requires at least two points."
raise ValueError(msg)
coordinates = np.asarray(points, dtype=np.float64)
deltas = np.diff(coordinates, axis=0)
segment_lengths = np.hypot(deltas[:, 0], deltas[:, 1])
if np.any(segment_lengths <= 0.0):
msg = "PolylineCurve requires strictly positive segment lengths."
raise ValueError(msg)
total_length = float(np.sum(segment_lengths))
cumulative = np.concatenate((
np.array([0.0], dtype=np.float64),
np.cumsum(segment_lengths, dtype=np.float64) / total_length,
))
self._coordinates = coordinates
self._deltas = deltas
self._segment_lengths = segment_lengths
self._breakpoint_values = cumulative
self._total_length = total_length
@property
def points(self) -> FloatArray:
"""Return the stored polyline vertices as an ``(n, 2)`` array."""
return as_float_array(self._coordinates)
[docs]
@override
def xy_from_u(self, u: FloatInput) -> tuple[FloatArray, FloatArray]:
"""Evaluate polyline coordinates by arc-fraction interpolation."""
u_array = as_float_array(u)
x = np.interp(u_array, self._breakpoint_values, self._coordinates[:, 0])
y = np.interp(u_array, self._breakpoint_values, self._coordinates[:, 1])
return as_float_array(x), as_float_array(y)
[docs]
@override
def xy_u(self, u: FloatInput) -> tuple[FloatArray, FloatArray]:
"""Return the piecewise-constant derivative on each segment."""
u_array = as_float_array(u)
segment_index = (
np.searchsorted(
self._breakpoint_values,
u_array,
side="left",
)
- 1
)
segment_index = np.clip(segment_index, 0, self._deltas.shape[0] - 1)
du = np.diff(self._breakpoint_values)[segment_index]
dx = self._deltas[segment_index, 0] / du
dy = self._deltas[segment_index, 1] / du
return as_float_array(dx), as_float_array(dy)
[docs]
@override
def xy_uu(self, u: FloatInput) -> tuple[FloatArray, FloatArray]:
"""Return zero second derivatives away from breakpoints."""
u_array = as_float_array(u)
return np.zeros_like(u_array), np.zeros_like(u_array)
[docs]
@override
def breakpoints(self) -> list[FloatScalar]:
"""Return normalized arc-fraction breakpoints along the polyline."""
return [as_float_scalar(value) for value in self._breakpoint_values]