Source code for pymead.core.bezier

import typing

import numpy as np
from matplotlib import pyplot as plt
from scipy.optimize import fsolve
from rust_nurbs import *

from pymead.core.parametric_curve import ParametricCurve, PCurveData
from pymead.core.point import PointSequence, Point
from pymead.post.fonts_and_colors import font
from pymead.post.plot_formatters import format_axis_scientific
from pymead.utils.nchoosek import nchoosek


[docs] class Bezier(ParametricCurve):
[docs] def __init__(self, point_sequence: PointSequence or typing.List[Point], default_nt: int or None = None, name: str or None = None, t_start: float = None, t_end: float = None, **kwargs): r""" Computes the Bézier curve through the control points :math:`\mathbf{P}_i` according to .. math:: \mathbf{C}(t)=\sum_{i=0}^n \mathbf{P}_i B_{i,n}(t) where :math:`B_{i,n}(t)` is the Bernstein polynomial, given by .. math:: B_{i,n}(t)={n \choose i} t^i (1-t)^{n-i} .. _cubic-bezier: .. figure:: ../images/cubic_bezier_light.* :class: only-light :width: 600 :align: center Cubic Bézier curve .. figure:: ../images/cubic_bezier_dark.* :class: only-dark :width: 600 :align: center Cubic Bézier curve An example cubic Bézier curve (degree :math:`n=3`) is shown above. Note that the curve passes through the first and last control points and has a local slope at :math:`P_0` equal to the slope of the line passing through :math:`P_0` and :math:`P_1`. Similarly, the local slope at :math:`P_3` is equal to the slope of the line passing through :math:`P_2` and :math:`P_3`. These properties of Bézier curves allow us to easily enforce :math:`G^0` and :math:`G^1` continuity at Bézier curve "joints" (common endpoints of connected Bézier curves). Parameters ---------- point_sequence: PointSequence Sequence of points defining the control points for the Bézier curve name: str or ``None`` Optional name for the curve. Default: ``None`` t_start: float or ``None`` Optional starting parameter vector value for the Bézier curve. Not specifying this value automatically gives a value of ``0.0``. Default: ``None`` t_end: float or ``None`` Optional ending parameter vector value for the Bézier curve. Not specifying this value automatically gives a value of ``1.0``. Default: ``None`` """ super().__init__(sub_container="bezier", **kwargs) self._point_sequence = None self.degree = None self.default_nt = default_nt point_sequence = PointSequence(point_sequence) if isinstance(point_sequence, list) else point_sequence self.set_point_sequence(point_sequence) self.t_start = t_start self.t_end = t_end name = "Bezier-1" if name is None else name self.set_name(name) self.curve_connections = [] self._add_references()
def _add_references(self): for idx, point in enumerate(self.point_sequence().points()): # If any curves are found at the start point, add their pointers as curve connections if idx == 0: for curve in point.curves: if not curve.reference: # Do not include reference curves self.curve_connections.append(curve) # If any other curves are found at the end point, add their pointers as curve connections elif idx == len(self.point_sequence()) - 1: for curve in point.curves: if not curve.reference: # Do not include reference curves self.curve_connections.append(curve) # Add the object reference to each point in the curve if self not in point.curves: point.curves.append(self) def point_sequence(self): return self._point_sequence def points(self): return self.point_sequence().points() def get_control_point_array(self): return self.point_sequence().as_array() def set_point_sequence(self, point_sequence: PointSequence): self._point_sequence = point_sequence self.degree = len(point_sequence) - 1 def reverse_point_sequence(self): self.point_sequence().reverse() def insert_point(self, idx: int, point: Point): self.point_sequence().insert_point(idx, point) self.degree += 1 if self not in point.curves: point.curves.append(self) if self.canvas_item is not None: self.canvas_item.point_items.insert(idx, point.canvas_item) self.canvas_item.updateCurveItem(self.evaluate()) def insert_point_after_point(self, point_to_add: Point, preceding_point: Point): idx = self.point_sequence().point_idx_from_ref(preceding_point) + 1 self.insert_point(idx, point_to_add) def point_removal_deletes_curve(self): return len(self.point_sequence()) <= 3 def remove_point(self, idx: int or None = None, point: Point or None = None): if isinstance(point, Point): idx = self.point_sequence().point_idx_from_ref(point) self.point_sequence().remove_point(idx) self.degree -= 1 if len(self.point_sequence()) > 2: delete_curve = False else: delete_curve = True return delete_curve def remove(self): if self.canvas_item is not None: self.canvas_item.sigRemove.emit(self.canvas_item)
[docs] @staticmethod def bernstein_poly(n: int, i: int, t: int or float or np.ndarray): r""" Calculates the Bernstein polynomial for a given Bézier curve order, index, and parameter vector. The Bernstein polynomial is described by .. math:: B_{i,n}(t)={n \choose i} t^i (1-t)^{n-i} Arguments ========= n: int Bézier curve degree (one less than the number of control points in the Bézier curve) i: int Bézier curve index t: int, float, or np.ndarray Parameter vector for the Bézier curve Returns ======= np.ndarray Array of values of the Bernstein polynomial evaluated for each point in the parameter vector """ return nchoosek(n, i) * t ** i * (1.0 - t) ** (n - i)
[docs] @staticmethod def finite_diff_P(P: np.ndarray, k: int, i: int): """Calculates the finite difference of the control points as shown in https://pages.mtu.edu/~shene/COURSES/cs3621/NOTES/spline/Bezier/bezier-der.html Arguments ========= P: np.ndarray Array of control points for the Bézier curve k: int Finite difference level (e.g., k = 1 is the first derivative finite difference) i: int An index referencing a location in the control point array """ def finite_diff_recursive(_k, _i): if _k > 1: return finite_diff_recursive(_k - 1, _i + 1) - finite_diff_recursive(_k - 1, _i) else: return P[_i + 1, :] - P[_i, :] return finite_diff_recursive(k, i)
[docs] def hodograph(self) -> "Bezier": """ Generates another ``Bezier`` object representing the derivative ("hodograph") of the original curve Returns ------- Bezier Hodograph of the curve """ P = self.get_control_point_array() if len(P) <= 1: point_sequence = PointSequence.generate_from_array(np.array([[0.0, 0.0]])) return Bezier(point_sequence, default_nt=self.default_nt) point_sequence = PointSequence.generate_from_array(np.array([ [self.degree * (p1[0] - p0[0]), self.degree * (p1[1] - p0[1])] for p0, p1 in zip(P[:-1, :], P[1:, :]) ])) return Bezier(point_sequence, default_nt=self.default_nt)
[docs] def derivative(self, t: np.ndarray, order: int): r""" Calculates an arbitrary-order derivative of the Bézier curve. Parameters ========== t: np.ndarray The parameter vector order: int The derivative order. For example, ``order=2`` returns the second derivative. Returns ======= np.ndarray An array of ``shape=(N,2)`` where ``N`` is the number of evaluated points specified by the :math:`t` vector. The columns represent :math:`C^{(m)}_x(t)` and :math:`C^{(m)}_y(t)`, where :math:`m` is the derivative order. """ assert order >= 0 curve = self for n in range(order): curve = curve.hodograph() return curve.evaluate_xy(t)
def evaluate_xy(self, t: np.ndarray or None = None, **kwargs) -> np.ndarray: # Generate the parameter vector if self.default_nt is not None: kwargs["nt"] = self.default_nt t = ParametricCurve.generate_t_vec(**kwargs) if t is None else t # Evaluate the curve return np.array(bezier_curve_eval_tvec(self.point_sequence().as_array(), t))
[docs] def evaluate(self, t: np.array or None = None, **kwargs) -> PCurveData: r""" Evaluates the curve using an optionally specified parameter vector. Also included are first derivative, second derivative, and curvature information. These are given by .. math:: \mathbf{C}'(t)=n \sum_{i=0}^{n-1} (\mathbf{P}_{i+1} - \mathbf{P}_i) B_{i,n-1}(t) .. math:: \mathbf{C}''(t)=n(n-1) \sum_{i=0}^{n-2} (\mathbf{P}_{i+2}-2\mathbf{P}_{i+1}+\mathbf{P}_i) B_{i,n-2}(t) .. math:: \kappa(t)=\frac{C'_x(t) C''_y(t) - C'_y(t) C''_x(t)}{[(C'_x)^2(t) + (C'_y)^2(t)]^{3/2}} Here, the :math:`'` and :math:`''` superscripts are the first and second derivatives with respect to :math:`x` and :math:`y`, not the parameter :math:`t`. The result of :math:`\vec{C}''(t)`, for example, is a vector with two components, :math:`C''_x(t)` and :math:`C''_y(t)`. Parameters ---------- t: np.ndarray or ``None`` Optional direct specification of the parameter vector for the curve. Not specifying this value gives a linearly spaced parameter vector from ``t_start`` or ``t_end`` with the default size. Default: ``None`` kwargs Additional keyword arguments to pass to ``ParametricCurve.generate_t_vec`` Returns ------- PCurveData Data class specifying the following information about the Bézier curve: .. math:: C_x(t), C_y(t), C'_x(t), C'_y(t), C''_x(t), C''_y(t), \kappa(t) where the :math:`x` and :math:`y` subscripts represent the :math:`x` and :math:`y` components of the vector-valued functions :math:`\mathbf{C}(t)`, :math:`\mathbf{C}'(t)`, and :math:`\mathbf{C}''(t)`. """ # Generate the parameter vector if self.default_nt is not None: kwargs["nt"] = self.default_nt t = ParametricCurve.generate_t_vec(**kwargs) if t is None else t # Number of control points, curve degree, control point array P = self.point_sequence().as_array() # Evaluate the curve xy = np.array(bezier_curve_eval_tvec(P, t)) # Calculate the first derivative xpyp = np.array(bezier_curve_dcdt_tvec(P, t)) xp, yp = xpyp[:, 0], xpyp[:, 1] # Calculate the second derivative xppypp = np.array(bezier_curve_d2cdt2_tvec(P, t)) xpp, ypp = xppypp[:, 0], xppypp[:, 1] # Combine the derivative x and y data xpyp = np.column_stack((xp, yp)) xppypp = np.column_stack((xpp, ypp)) # Calculate the curvature with np.errstate(divide='ignore', invalid='ignore'): # Calculate the curvature of the Bézier curve (k = kappa = 1 / R, where R is the radius of curvature) k = np.true_divide((xp * ypp - yp * xpp), (xp ** 2 + yp ** 2) ** (3 / 2)) # Calculate the radius of curvature: R = 1 / kappa with np.errstate(divide='ignore', invalid='ignore'): R = np.true_divide(1, k) return PCurveData(t=t, xy=xy, xpyp=xpyp, xppypp=xppypp, k=k, R=R)
def compute_t_corresponding_to_x(self, x_seek: float, t0: float = 0.5): def bez_root_find_func(t): point = self.evaluate_xy(t)[0] return np.array([point[0] - x_seek]) return fsolve(bez_root_find_func, x0=np.array([t0]))[0] def compute_t_corresponding_to_y(self, y_seek: float, t0: float = 0.5): def bez_root_find_func(t): point = self.evaluate_xy(t)[0] return np.array([point[1] - y_seek]) return fsolve(bez_root_find_func, x0=np.array([t0]))[0] def split(self, t_split: float): # Number of control points, curve degree, control point array n_ctrl_points = len(self.point_sequence()) degree = n_ctrl_points - 1 P = self.point_sequence().as_array() def de_casteljau(i: int, j: int) -> np.ndarray: """ Based on https://en.wikipedia.org/wiki/De_Casteljau%27s_algorithm. Recursive algorithm where the base case is just the value of the ith original control point. Parameters ---------- i: int Lower index j: int Upper index Returns ------- np.ndarray A one-dimensional array containing the :math:`x` and :math:`y` values of a control point evaluated at :math:`(i,j)` for a Bézier curve split at the parameter value ``t_split`` """ if j == 0: return P[i, :] return de_casteljau(i, j - 1) * (1 - t_split) + de_casteljau(i + 1, j - 1) * t_split bez_split_1_P = np.array([de_casteljau(i=0, j=i) for i in range(n_ctrl_points)]) bez_split_2_P = np.array([de_casteljau(i=i, j=degree - i) for i in range(n_ctrl_points)]) if self.geo_col is None: bez_1_points = [self.point_sequence().points()[0]] + [Point(*xy.tolist()) for xy in bez_split_1_P[1:, :]] bez_2_points = [bez_1_points[-1]] + [Point(*xy.tolist()) for xy in bez_split_2_P[1:-1, :]] + [ self.point_sequence().points()[-1]] else: bez_1_points = [self.point_sequence().points()[0]] + [ self.geo_col.add_point(*xy.tolist()) for xy in bez_split_1_P[1:, :]] bez_2_points = [bez_1_points[-1]] + [ self.geo_col.add_point(*xy.tolist()) for xy in bez_split_2_P[1:-1, :]] + [ self.point_sequence().points()[-1]] bez_1_point_seq = PointSequence(bez_1_points) bez_2_point_seq = PointSequence(bez_2_points) if self.geo_col is None: return ( Bezier(point_sequence=bez_1_point_seq), Bezier(point_sequence=bez_2_point_seq) ) else: for point in self.point_sequence().points()[1:-1]: self.geo_col.remove_pymead_obj(point) return ( self.geo_col.add_bezier(point_sequence=bez_1_point_seq, name="BezSplit"), self.geo_col.add_bezier(point_sequence=bez_2_point_seq, name="BezSplit") )
[docs] def plot(self, ax: plt.Axes or None = None, nt: int = 100, show: bool = True, save_file: str or None = None, **plt_kwargs): """ Plots the airfoil to a ``matplotlib`` figure. Parameters ---------- ax: plt.Axes or None Matplotlib Axes object on which the curve will be plotted. If specified, this method will only. If ``None``, a new figure will be created. Default: ``None`` nt: int Number of parametric values to evaluate along the curve. Default: 100 show: bool Whether to immediately show the curve plot. Ignored if ``ax`` is not ``None``. Default: ``True`` save_file: str or None Name of the file to save. If ``None``, the curve image will not be saved to file. Ignored if ``ax`` is not ``None``. Default: ``None`` plt_kwargs Additional keyword arguments to pass to ``matplotlib.pyplot.plot`` """ ax_specified = ax is not None if ax_specified: fig = ax.figure else: fig, ax = plt.subplots(figsize=(10, 2)) # Plot the curves curve_data = self.evaluate_xy(np.linspace(0.0, 1.0, nt)) ax.plot(curve_data[:, 0], curve_data[:, 1], **plt_kwargs) if ax_specified: return # Plot settings ax.set_aspect("equal") ax.set_xlabel("x", fontdict=font) ax.set_ylabel("y", fontdict=font) format_axis_scientific(ax=ax) # Save and/or show if save_file is not None: fig.savefig(save_file, bbox_inches="tight") if show: plt.show()
[docs] def get_dict_rep(self): return {"points": [pt.name() for pt in self.point_sequence().points()], "default_nt": self.default_nt}