"""Two-dimensional Bezier curves built from scalar Bernstein components."""
from __future__ import annotations
from collections.abc import Sequence
import numpy as np
from buffalo_core.typing import FloatArray, FloatInput, FloatScalar
from .bezier_common import (
BezierDemotionContinuity,
bezier_degree,
demote_bezier_coefficients,
evaluate_bernstein,
evaluate_bernstein_u,
evaluate_bernstein_uu,
promote_bezier_coefficients,
validate_bezier_coefficients,
)
from .bezier_curve_1d import BezierCurve1D
NUM_CONTROL_POINT_DIMENSIONS = 2
type ControlPoint2D = tuple[FloatScalar, FloatScalar]
type BezierCurve2DInput = Sequence[ControlPoint2D] | FloatArray
[docs]
class BezierCurve2D:
"""Two-dimensional Bezier curve."""
def __init__(
self,
*,
control_points: BezierCurve2DInput,
) -> None:
"""
Initialize one two-dimensional Bezier curve.
Parameters
----------
control_points : Sequence[tuple[FloatScalar, FloatScalar]] | FloatArray
Planar control points ordered from ``u = 0`` to ``u = 1``.
Raises
------
ValueError
If ``control_points`` is empty or does not have shape ``(n, 2)``.
"""
control_point_array = validate_bezier_coefficients(
control_points,
name="BezierCurve2D control_points",
)
if control_point_array.size == 0:
msg = "BezierCurve2D requires at least one control point."
raise ValueError(msg)
if (
control_point_array.ndim != NUM_CONTROL_POINT_DIMENSIONS
or control_point_array.shape[1] != NUM_CONTROL_POINT_DIMENSIONS
):
msg = "BezierCurve2D control_points must have shape (n, 2)."
raise ValueError(msg)
control_point_array.setflags(write=False)
self._control_points = control_point_array
[docs]
@classmethod
def from_coordinate_curves(
cls,
*,
x_curve: BezierCurve1D,
y_curve: BezierCurve1D,
) -> BezierCurve2D:
"""
Build a two-dimensional Bezier curve from coordinate curves.
Parameters
----------
x_curve : BezierCurve1D
One-dimensional Bezier curve for the x-coordinate.
y_curve : BezierCurve1D
One-dimensional Bezier curve for the y-coordinate.
Returns
-------
BezierCurve2D
Two-dimensional Bezier curve composed from the coordinate
curves.
Raises
------
ValueError
If ``x_curve`` and ``y_curve`` do not share the same degree.
"""
if x_curve.degree != y_curve.degree:
msg = "x_curve and y_curve must have the same Bezier degree."
raise ValueError(msg)
return cls(
control_points=np.column_stack((
x_curve.coefficients,
y_curve.coefficients,
))
)
@property
def control_points(self) -> FloatArray:
"""
Return the stored control points.
Returns
-------
FloatArray
Read-only ``(n, 2)`` array of stored planar control points.
"""
return self._control_points
@property
def degree(self) -> int:
"""
Return the Bezier degree.
Returns
-------
int
Polynomial degree of the two-dimensional Bezier curve.
"""
return bezier_degree(self.control_points)
@property
def x_curve(self) -> BezierCurve1D:
"""
Return the one-dimensional x-coordinate curve.
Returns
-------
BezierCurve1D
One-dimensional Bezier curve for the x-coordinate.
"""
return BezierCurve1D(coefficients=self.control_points[:, 0])
@property
def y_curve(self) -> BezierCurve1D:
"""
Return the one-dimensional y-coordinate curve.
Returns
-------
BezierCurve1D
One-dimensional Bezier curve for the y-coordinate.
"""
return BezierCurve1D(coefficients=self.control_points[:, 1])
[docs]
def xy(self, u: FloatInput) -> tuple[FloatArray, FloatArray]:
"""
Evaluate the two-dimensional Bezier curve.
Parameters
----------
u : FloatInput
Bezier parameter values.
Returns
-------
FloatArray
X-coordinates evaluated at ``u``.
FloatArray
Y-coordinates evaluated at ``u``.
"""
points = evaluate_bernstein(self.control_points, u)
return points[..., 0], points[..., 1]
[docs]
def xy_u(self, u: FloatInput) -> tuple[FloatArray, FloatArray]:
"""
Evaluate first derivatives of the two-dimensional curve.
Parameters
----------
u : FloatInput
Bezier parameter values.
Returns
-------
FloatArray
X-derivatives evaluated at ``u``.
FloatArray
Y-derivatives evaluated at ``u``.
"""
tangent = evaluate_bernstein_u(self.control_points, u)
return tangent[..., 0], tangent[..., 1]
[docs]
def xy_uu(self, u: FloatInput) -> tuple[FloatArray, FloatArray]:
"""
Evaluate second derivatives of the two-dimensional curve.
Parameters
----------
u : FloatInput
Bezier parameter values.
Returns
-------
FloatArray
Second x-derivatives evaluated at ``u``.
FloatArray
Second y-derivatives evaluated at ``u``.
"""
curvature = evaluate_bernstein_uu(self.control_points, u)
return curvature[..., 0], curvature[..., 1]
[docs]
def demote_degree(
self,
*,
count: int = 1,
continuity: BezierDemotionContinuity = "NOT_CONNECTED",
) -> BezierCurve2D:
"""
Lower the Bezier degree with constrained least-squares demotion.
This operation is intentionally approximate.
It applies constrained least-squares degree demotion to the shared
control-point array and therefore does not guarantee exact
preservation of the original curve, except in the special cases where
the curve is exactly reducible to the requested lower degree.
Parameters
----------
count : int, default=1
Number of degree-reduction steps to apply.
continuity : {"NOT_CONNECTED", "C0", "C1", "C2"},
default="NOT_CONNECTED"
Symmetric endpoint continuity to preserve during each demotion
step.
``"NOT_CONNECTED"`` leaves the endpoints unconstrained.
``"C0"``, ``"C1"``, and ``"C2"`` preserve endpoint value,
value-plus-first-derivative, and value-plus-first-two-derivatives,
respectively, when the current degree allows it.
Returns
-------
BezierCurve2D
Reduced-degree two-dimensional Bezier curve produced by a
constrained least-squares approximate demotion.
"""
return BezierCurve2D(
control_points=demote_bezier_coefficients(
self.control_points,
count=count,
continuity=continuity,
)
)
[docs]
def control_polygon(self) -> FloatArray:
"""
Return the control polygon as a float64 array.
Returns
-------
FloatArray
``(n, 2)`` array of planar control points.
"""
return self.control_points.copy()