#
##
## This file is part of pyFormex 2.0 (Mon Sep 14 12:29:05 CEST 2020)
## pyFormex is a tool for generating, manipulating and transforming 3D
## geometrical models by sequences of mathematical operations.
## Home page: http://pyformex.org
## Project page: http://savannah.nongnu.org/projects/pyformex/
## Copyright 2004-2020 (C) Benedict Verhegghe (benedict.verhegghe@ugent.be)
## Distributed under the GNU General Public License version 3 or later.
##
## This program is free software: you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
## the Free Software Foundation, either version 3 of the License, or
## (at your option) any later version.
##
## This program is distributed in the hope that it will be useful,
## but WITHOUT ANY WARRANTY; without even the implied warranty of
## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
## GNU General Public License for more details.
##
## You should have received a copy of the GNU General Public License
## along with this program. If not, see http://www.gnu.org/licenses/.
##
"""Using NURBS in pyFormex.
The :mod:`nurbs` module defines functions and classes to manipulate
NURBS curves and surface in pyFormex.
"""
import numpy as np
from pyformex import arraytools as at
from pyformex import geomtools as gt
from pyformex.coords import Coords
from pyformex.attributes import Attributes
from pyformex.lib import nurbs
from pyformex.plugins import curve
from pyformex import utils
###########################################################################
##
## class Coords4
##
#########################
#
[docs]class Coords4(np.ndarray):
"""A collection of points represented by their homogeneous coordinates.
While most of the pyFormex implementation is based on the 3D Cartesian
coordinates class :class:`Coords`, some applications may benefit from using
homogeneous coordinates. The class :class:`Coords4` provides some basic
functions and conversion to and from cartesian coordinates.
Through the conversion, all other pyFormex functions, such as
transformations, are available.
:class:`Coords4` is implemented as a float type :class:`numpy.ndarray`
whose last axis has a length equal to 4.
Each set of 4 values (x,y,z,w) along the last axis represents a
single point in 3D space. The cartesian coordinates of the point
are obtained by dividing the first three values by the fourth:
(x/w, y/w, z/w). A zero w-value represents a point at infinity.
Converting such points to :class:`Coords` will result in Inf or NaN
values in the resulting object.
The float datatype is only checked at creation time. It is the
responsibility of the user to keep this consistent throughout the
lifetime of the object.
Just like :class:`Coords`, the class :class:`Coords4` is derived from
:class:`numpy.ndarray`.
Parameters
----------
data: :term:`array_like`, optional
An array of floats, with the length of its last axis not larger
than 4. If equal to four, each tuple along the last axis
represents a single point in homogeneous coordinates.
If smaller than four, the last axis will be expanded to four by
adding values zero in the second and third position and values
1 in the last position (the w-coordinate).
If no data are given, a single point (0.,0.,0.,1.) will be
created.
w: :term:`array_like`, optional
If specified, the w values are used to denormalize the
homogeneous data such that the last component becomes w.
dtyp: data-type, optional
The datatype to be used. It not specified, the datatype of `data`
is used, or the default :data:`Float` (which is equivalent to
:data:`numpy.float32`).
copy: bool, optional
If ``True``, the data are copied. By default, the original data
are used if possible, e.g. if a correctly shaped and typed
:class:`numpy.ndarray` is provided.
"""
def __new__(cls, data=None, w=None, normalize=True, dtyp=at.Float,
copy=False):
"""Create a new instance of :class:`Coords4`."""
if data is None:
# create an empty array
ar = np.ndarray((0, 4), dtype=dtyp)
else:
# turn the data into an array, and copy if requested
ar = np.array(data, dtype=dtyp, copy=copy)
if ar.shape[-1] in [3, 4]:
pass
elif ar.shape[-1] in [1, 2]:
# make last axis length 3, adding 0 values
ar = at.growAxis(ar, 3-ar.shape[-1], -1)
elif ar.shape[-1] == 0:
# allow empty coords objects
ar = ar.reshape(0, 3)
else:
raise ValueError("Expected a length 1,2,3 or 4 for last array axis")
# Make sure dtype is a float type
if ar.dtype.kind != 'f':
ar = ar.astype(at.Float)
# We should now have a float array with last axis 3 or 4
if ar.shape[-1] == 3:
# Expand last axis to length 4, adding values 1
ar = at.growAxis(ar, 1, -1, 1.0)
if w is not None:
if normalize:
ar[..., :3] /= w
else:
# Insert weight
ar[..., 3] = w
# Transform 'subarr' from an ndarray to our new subclass.
ar = ar.view(cls)
return ar
[docs] def normalize(self):
"""Normalize the homogeneous coordinates.
Two sets of homogeneous coordinates that differ only by a
multiplicative constant refer to the same points in cartesian space.
Normalization of the coordinates is a way to make the representation
of a single point unique. Normalization is done so that the last
component (w) is equal to 1.
The normalization of the coordinates is done in place.
.. warning:: Normalizing points at infinity will result in Inf or
NaN values.
"""
self /= self[..., 3:]
[docs] def deNormalize(self, w):
"""Denormalizes the homogeneous coordinates.
This multiplies the homogeneous coordinates with the values w.
w normally is a constant or an array with shape
self.shape[:-1] + (1,).
It then multiplies all 4 coordinates of a point with the same
value, thus resulting in a denormalization while keeping the
position of the point unchanged.
The denormalization of the coordinates is done in place.
If the Coords4 object was normalized, it will have precisely w as
its 4-th coordinate value after the call.
"""
self *= w
[docs] def toCoords(self):
"""Convert homogeneous coordinates to cartesian coordinates.
Returns
-------
A :class:`Coords` object with the cartesian coordinates
of the points. Points at infinity (w=0) will result in
Inf or NaN value. If there are no points at infinity, the
resulting :class:`Coords` point set is equivalent to the
:class:`Coords4` one.
"""
return Coords(self[..., :3] / self[..., 3:])
[docs] def npoints(self):
"""Return the total number of points."""
return np.asarray(self.shape[:-1]).prod()
ncoords = npoints
[docs] def x(self):
"""Return the x-plane"""
return self[..., 0]
[docs] def y(self):
"""Return the y-plane"""
return self[..., 1]
[docs] def z(self):
"""Return the z-plane"""
return self[..., 2]
[docs] def w(self):
"""Return the w-plane"""
return self[..., 3]
[docs] def bbox(self):
"""Return the bounding box of a set of points.
Returns the bounding box of the cartesian coordinates of
the object.
"""
return self.toCoords().bbox()
[docs] def actor(self, **kargs):
"""Graphical representation"""
return self.toCoords().actor()
[docs]class Geometry4(object):
"""This is a preliminary class intended to provide some transforms in 4D
"""
def __init__(self):
"""Initialize a Geometry4"""
self.attrib = Attributes()
def scale(self, *args, **kargs):
self.coords[..., :3] = Coords(self.coords[..., :3]).scale(*args, **kargs)
return self
[docs]class KnotVector(object):
"""A knot vector
A knot vector is sequence of float values sorted in ascending order.
Values can occur multiple times. In they typical use case for this
class (Nurbs) most values do indeed occur multiple times, and the
multiplicity of the values is an important quantity.
Therefore, the knot vector is stored in two arrays of the same length::
- `v`: the unique float values, a strictly ascending sequence
- `m`: the multiplicity of each of the values
Examples
--------
>>> K = KnotVector([0.,0.,0.,0.5,0.5,1.,1.,1.])
>>> print(K.val)
[ 0. 0.5 1. ]
>>> print(K.mul)
[3 2 3]
>>> print(K)
KnotVector: 0*3, 0.5*2, 1*3
>>> print([(v,m) for v,m in zip(K.val,K.mul)])
[(0.0, 3), (0.5, 2), (1.0, 3)]
>>> print(K.values())
[ 0. 0. 0. 0.5 0.5 1. 1. 1. ]
>>> K.index(0.5)
1
>>> K.span(1.0)
5
>>> K.mult(0.5)
2
>>> K.mult(0.7)
0
>>> K[4],K[-1]
(0.5, 1.0)
>>> K[4:6]
array([ 0.5, 1. ])
"""
# This makes the KnotVector string format customizable.
# The {v} field formats the value, {m} the multiplicity of the knot.
_knot_format = "{v:.6g}*{m}"
def __init__(self, data=None, val=None, mul=None):
"""Initialize a KnotVector"""
if data is not None:
data = at.checkArray(data, (-1,), 'f', 'i')
val, inv = np.unique(data, return_inverse=True)
mul, bins = at.multiplicity(inv)
else:
val = at.checkArray(val, (-1,), 'f', 'i')
mul = at.checkArray(mul, val.shape, 'i')
self.val = val
self.mul = mul
self.csum = self.mul.cumsum() - 1
[docs] def nknots(self):
"""Return the total number of knots"""
return self.mul.sum()
# def knots(self):
# """Return the knots as tuples (value,multiplicity)"""
# return zip(self.val,self.mul)
[docs] def values(self):
"""Return the full list of knot values"""
return np.concatenate([[v]*m for v, m in zip(self.val, self.mul)])
def __str__(self):
"""Format the knot vector as a string."""
return "KnotVector: " + ", ".join([
KnotVector._knot_format.format(v=v, m=m)
for v, m in zip(self.val, self.mul)])
[docs] def index(self, u):
"""Find the index of knot value u.
If the value does not exist, a ValueError is raised.
"""
w = np.where(np.isclose(self.val, u))[0]
if len(w) <= 0:
raise ValueError(
f"The value {u} does not appear in the KnotVector")
return w[0]
[docs] def mult(self, u):
"""Return the multiplicity of knot value u.
Returns an int with the multiplicity of the knot value u, or 0 if
the value is not in the KNotVector.
"""
try:
i = self.index(u)
return self.mul[i]
except Exception:
return 0
[docs] def span(self, u):
"""Find the (first) index of knot value u in the full knot values vector.
If the value does not exist, a ValueError is raised.
"""
i = self.index(u)
return self.mul[:i].sum()
# def value(self,i):
# """Return knot i.
#
# Returns the knot with the index i, taking into account
# the multiplicity of the knots.
# """
# ind = self.csum.searchsorted(i)
# return self.val[ind]
#
def __getitem__(self, i):
"""Return knot i.
Returns the knot with the index i, taking into account
the multiplicity of the knots.
"""
if isinstance(i, slice):
i = np.arange(i.start, i.stop, i.step)
i %= self.nknots()
ind = self.csum.searchsorted(i)
return self.val[ind]
[docs] def copy(self):
"""Return a copy of the KnotVector.
Changing the copy will not change the original.
"""
return KnotVector(val=self.val, mul=self.mul)
[docs] def reverse(self):
"""Return the reverse knot vector.
Examples
--------
>>> print(KnotVector([0,0,0,1,3,6,6,8,8,8]).reverse().values())
[ 0. 0. 0. 2. 2. 5. 7. 8. 8. 8.]
"""
val = self.val.min() + self.val.max() - self.val
return KnotVector(val=val[::-1], mul=self.mul[::-1])
[docs]def genKnotVector(nctrl, degree, blended=True, closed=False):
"""Compute sensible knot vector for a Nurbs curve.
A knot vector is a sequence of non-decreasing parametric values. These
values define the `knots`, i.e. the points where the analytical expression
of the Nurbs curve may change. The knot values are only meaningful upon a
multiplicative constant, and they are usually normalized to the range
[0.0..1.0].
A Nurbs curve with ``nctrl`` points and of given ``degree`` needs a knot
vector with ``nknots = nctrl+degree+1`` values. A ``degree`` curve needs
at least ``nctrl = degree+1`` control points, and thus at least
``nknots = 2*(degree+1)`` knot values.
To make an open curve start and end in its end points, it needs knots with
multiplicity ``degree+1`` at its ends. Thus, for an open blended curve, the
default policy is to set the knot values at the ends to 0.0, resp. 1.0,
both with multiplicity ``degree+1``, and to spread the remaining
``nctrl - degree - 1`` values equally over the interval.
For a closed (blended) curve, the knots are equally spread over the
interval, all having a multiplicity 1 for maximum continuity of the curve.
For an open unblended curve, all internal knots get multiplicity ``degree``.
This results in a curve that is only one time continuously derivable at
the knots, thus the curve is smooth, but the curvature may be discontinuous.
There is an extra requirement in this case: ``nctrl`` should be a multiple
of ``degree`` plus 1.
Returns a KnotVector instance.
Examples
--------
>>> print(genKnotVector(7,3))
KnotVector: 0*4, 0.25*1, 0.5*1, 0.75*1, 1*4
>>> print(genKnotVector(7,3,blended=False))
KnotVector: 0*4, 1*3, 2*4
>>> print(genKnotVector(3,2,closed=True))
KnotVector: 0*1, 0.2*1, 0.4*1, 0.6*1, 0.8*1, 1*1
"""
nknots = nctrl+degree+1
if closed or blended:
nval = nknots
if not closed:
nval -= 2*degree
val = at.uniformParamValues(nval-1) # Returns nval values!
mul = np.ones(nval, dtype=at.Int)
if not closed:
mul[0] = mul[-1] = degree+1
else:
nparts = (nctrl-1) // degree
if nparts*degree+1 != nctrl:
raise ValueError(
"Discrete knot vectors can only be used if the number of "
"control points is a multiple of the degree, plus one.")
val = np.arange(nparts+1).astype(at.Float)
mul = np.ones(nparts+1, dtype=at.Int) * degree
mul[0] = mul[-1] = degree+1
return KnotVector(val=val, mul=mul)
[docs]class NurbsCurve(Geometry4):
"""A NURBS curve
The Nurbs curve is defined by nctrl control points, a degree (>= 1) and
a knot vector with nknots = nctrl+degree+1 parameter values.
Parameters
----------
control: Coords-like (nctrl,3)
The vertices of the control polygon.
degree: int
The degree of the Nurbs curve. If not specified, it is
derived from the length of the knot vector (`knots`).
wts: float array (nctrl)
Weights to be attributed to the control
points. Default is to attribute a weight 1.0 to all points. Using
different weights allows for more versatile modeling (like perfect
circles and arcs.)
knots: KnotVector or list of floats
The knot values to be used. If a list, the values should be
in ascending order. Identical values have to be repeated to
their multiplicity.
The values are only defined upon a multiplicative constant and will
be normalized to set the last value to 1.
If `degree` is specified, default values are constructed automatically
by calling :func:`genKnotVector`.
If no knots are given and no degree is specified, the degree is set to
the nctrl-1 if the curve is blended. If not blended, the degree is not
set larger than 3.
closed: bool, optional
Determines whether the curve is closed. Default is False.
The use of closed NurbsCurves is currently very limited.
blended: bool, optional
Determines that the curve is blended. Default is True.
Set blended==False to define a nonblended curve.
A nonblended curve is a chain of independent curves, Bezier
curves if the weights are all ones. See also :meth:`decompose`.
The number of control points should be a multiple of the degree,
plus one. This parameter is only used if no knots are specified.
"""
N_approx = 100
#
# order (2,3,4,...) = degree+1 = min. number of control points
# ncontrol >= order
# nknots = order + ncontrol >= 2*order
#
# convenient solutions:
# OPEN:
# nparts = (ncontrol-1) // degree
# nintern =
#
def __init__(self, control, degree=None, wts=None, knots=None,
closed=False, blended=True):
Geometry4.__init__(self)
self.closed = closed
nctrl = len(control)
if knots is not None:
if not isinstance(knots, KnotVector):
knots = KnotVector(knots)
nknots = knots.nknots()
if degree is None:
if knots is None:
degree = nctrl-1
if not blended:
degree = min(degree, 3)
else:
degree = nknots - nctrl -1
if degree <= 0:
raise ValueError(
f"Length of knot vector ({nknots}) must be at least"
f"number of control points ({nctrl}) plus 2")
order = degree+1
control = Coords4(control)
if wts is not None:
control.deNormalize(wts.reshape(wts.shape[-1], 1))
if closed:
# We need to wrap nwrap control points
if knots is None:
nwrap = degree
else:
nwrap = nknots - nctrl - order
# # We split them over the start and end
# nextra1 = (nwrap+1) // 2
# nextra2 = nwrap-nextra1
# print("extra %s = %s + %s" % (nwrap, nextra1, nextra2))
# control = Coords4(concatenate([
# control[-nextra1:], control, control[:nextra2]], axis=0))
# !! Changed: wrap at the end
control = Coords4(np.concatenate([control, control[:nwrap]], axis=0))
nctrl = control.shape[0]
if nctrl < order:
raise ValueError(f"Number of control points ({nctrl}) "
f"must not be smaller than order ({order})")
if knots is None:
knots = genKnotVector(nctrl, degree, blended=blended, closed=closed)
nknots = knots.nknots()
if nknots != nctrl+order:
raise ValueError(
f"Length of knot vector ({nknots}) must be equal to number "
f"of control points ({nctrl}) plus order ({order})")
self.coords = control
self.knotu = knots
self.degree = degree
self.closed = closed
@property
def knots(self):
"""Return the full list of knot values"""
return self.knotu.values()
# This is commented out because there is only 1 place where we set
# the knots: __init__
# @knots.setter
# def knots(self,value):
# """Set the knot values"""
# self.knotu = KnotVector(knots)
[docs] def nctrl(self):
"""Return the number of control points"""
return self.coords.shape[0]
[docs] def nknots(self):
"""Return the number of knots"""
return self.knotu.nknots()
[docs] def order(self):
"""Return the order of the Nurbs curve"""
return self.nknots()-self.nctrl()
[docs] def urange(self):
"""Return the parameter range on which the curve is defined.
Returns a (2,) float array with the minimum and maximum parameter
value for which the curve is defined.
"""
p = self.degree
return [self.knotu[p], self.knotu[-1-p]]
[docs] def isClamped(self):
"""Return True if the NurbsCurve uses a clamped knot vector.
A clamped knot vector has a multiplicity p+1 for the first and
last knot. All our generated knot vectors are clamped.
"""
return self.knotu.mul[0] == self.knotu.mul[-1] == (self.degree + 1)
[docs] def isRational(self):
"""Return True if the NurbsCurve is rational.
The curve is rational if the weights are not constant.
The curve is polygonal if the weights are constant.
Returns True for a rational curve, False for a polygonal curve.
"""
w = self.coords[:, 3]
return not np.isclose(w[1:], w[0]).all()
[docs] def isBlended(self):
"""Return True if the NurbsCurve is blended.
An clamped NurbsCurve is unblended (or decomposed) if it consists
of a chain of independent Bezier curves.
Such a curve has multiplicity p for all internal knots and p+1
for the end knots of an open curve.
Any other NurbsCurve is blended.
Returns True for a blended curve, False for an unblended one.
Note: for testing whether an unclamped curve is blended or not,
first clamp it.
"""
return self.isClamped() and (self.knotu.mul[1:-1] == self.degree).all()
[docs] def bbox(self):
"""Return the bounding box of the NURBS curve.
"""
return self.coords.toCoords().bbox()
def __str__(self):
return (f"NURBS Curve, degree = {self.degree}, "
f"nctrl = {len(self.coords)}, "
f"nknots = {self.nknots()}\n "
f"closed: {self.closed}; "
f"clamped: {self.isClamped()}; "
f"uniform: {self.isUniform()}; "
f"rational: {self.isRational()}\n "
f"Control points:\n{self.coords}\n "
f"{self.knotu}\n "
f"urange: {self.urange()}")
[docs] def copy(self):
"""Return a (deep) copy of self.
Changing the copy will not change the original.
"""
return NurbsCurve(control=self.coords.copy(), degree=self.degree,
knots=self.knotu.copy(), closed=self.closed)
[docs] def pointsAt(self, u):
"""Return the points on the Nurbs curve at given parametric values.
Parameters:
- `u`: (nu,) shaped float array, parametric values at which a point
is to be placed. Note that valid points are only obtained for
parameter values in the range self.range().
Returns (nu,3) shaped Coords with nu points at the specified
parametric values.
"""
ctrl = self.coords.astype(np.double)
knots = self.knotu.values().astype(np.double)
u = np.atleast_1d(u).astype(np.double)
try:
pts = nurbs.curvePoints(ctrl, knots, u)
if np.isnan(pts).any():
print("We got a NaN")
raise RuntimeError
except Exception:
raise RuntimeError("Some error occurred during the evaluation "
"of the Nurbs curve")
if pts.shape[-1] == 4:
pts = Coords4(pts).toCoords()
else:
pts = Coords(pts)
return pts
[docs] def derivs(self, u, d=1):
"""Returns the points and derivatives up to d at parameter values u
Parameters
----------
u: float :term:`array_like` | int
If a float array (nu,), these are the parameter values at
which to compute points and derivatives.
If an int, specifies the number of parameter values (nu)
at which to evaluate the points and derivatives of the curve.
The points are equally spaced in parameter space.
d: int
The highest derivative to compute.
Returns
-------
float array (d+1,nu,3)
The coordinates of the points and the derivates up to the
order d at those points.
"""
if at.isInt(u):
u = at.uniformParamValues(u, self.knotu.val[0], self.knotu.val[-1])
else:
u = at.checkArray(u, (-1,), 'f', 'i')
# sanitize arguments for library call
ctrl = self.coords.astype(np.double)
knots = self.knotu.values().astype(np.double)
u = np.atleast_1d(u).astype(np.double)
d = int(d)
try:
pts = nurbs.curveDerivs(ctrl, knots, u, d)
if np.isnan(pts).any():
print("We got a NaN")
print(pts)
raise RuntimeError
except Exception:
raise RuntimeError("Some error occurred during the evaluation "
"of the Nurbs curve")
if pts.shape[-1] == 4:
pts = Coords4(pts)
# When using no weights, ctrl points are Coords4 normalized points,
# and the derivatives all have w=0: the points represent directions
# We just strip off the w=0.
# HOWEVER, if there are weights, not sure what to do.
# Points themselves could be just normalized and returned.
pts[0].normalize()
pts = Coords(pts[..., :3])
else:
pts = Coords(pts)
return pts
[docs] def frenet(self, u):
"""Compute Frenet vectors, curvature and torsion at parameter values u
Parameters
----------
u: float :term:`array_like` | int
If a float array (nu,), these are the parameter values at
which to compute points and derivatives.
If an int, specifies the number of parameter values (nu)
at which to evaluate the points and derivatives of the curve.
The points are equally spaced in parameter space.
Returns
-------
T: float array(nu,3)
Normalized tangent vectors (nu,3) at nu points.
N: float array(nu,3)
Normalized normal vectors (nu,3) at nu points.
B: float array(nu,3)
Normalized binormal vectors (nu,3) at nu points.
k: float array(nu,3)
Curvature of the curve (nu) at nu points.
t: float array(nu,3)
Torsion of the curve (nu) at nu points.
"""
derivs = self.derivs(u, 3)
return frenet(derivs[1], derivs[2], derivs[3])
[docs] def curvature(self, u, torsion=False):
"""Compute Frenet vectors, curvature and torsion at parameter values u
Parameters:
- `u`: either of:
- int: number of points (npts) at which to evaluate the points
and derivatives. The points will be equally spaced in parameter
space.
- float array (npts): parameter values at which to compute points
and derivatives.
- `torsion`: bool. If True, also returns the torsion in the curve.
If `torsion` is False (default), returns a float array with the
curvature at parameter values `u`.
If `torsion` is True, also returns a float array with the
torsion at parameter values `u`.
"""
T, N, B, k, t = self.frenet(u)
if torsion:
return k, t
else:
return k
[docs] def knotPoints(self, multiple=False):
"""Returns the points at the knot values.
If multiple is True, points are returned with their multiplicity.
The default is to return all points just once.
"""
if multiple:
val = self.knotu.knots
else:
val = self.knotu.val
return self.pointsAt(val)
[docs] def insertKnots(self, u):
"""Insert a set of knots in the Nurbs curve.
u is a vector with knot parameter values to be inserted into the
curve. The control points are adapted to keep the curve unchanged.
Returns:
A Nurbs curve equivalent with the original but with the
specified knot values inserted in the knot vector, and the control
points adapted.
"""
if self.closed:
raise ValueError("insertKnots currently does not work on "
"closed curves")
# sanitize arguments for library call
ctrl = self.coords.astype(np.double)
knots = self.knotu.values().astype(np.double)
u = np.asarray(u).astype(np.double)
newP, newU = nurbs.curveKnotRefine(ctrl, knots, u)
return NurbsCurve(newP, degree=self.degree, knots=newU,
closed=self.closed)
[docs] def requireKnots(self, val, mul):
"""Insert knots until the required multiplicity reached.
Inserts knot values only if they are currently not there or their
multiplicity is lower than the required one.
Parameters:
- `val`: list of float (nval): knot values required in the knot vector.
- `mul`: list of int (nval): multiplicities required for the knot values `u`.
Returns:
A Nurbs curve equivalent with the original but where the knot vector
is guaranteed to contain the values in `u` with at least the
corresponding multiplicity in `m`. If all requirements were already
fulfilled at the beginning, returns self.
"""
# get actual multiplicities
m = np.array([self.knotu.mult(ui) for ui in val])
# compute missing multiplicities
mul = mul - m
if (mul > 0).any():
# list of knots to insert
u = [[ui]*mi for ui, mi in zip(val, mul) if mi > 0]
return self.insertKnots(np.concatenate(u))
else:
return self
[docs] def subCurve(self, u1, u2):
"""Extract the subcurve between parameter values u1 and u2
Parameters:
- `u1`, `u2`: two parameter values (u1 < u2), delimiting the part of the
curve to extract. These values do not have to be knot values.
Returns a NurbsCurve containing only the part between u1 and u2.
"""
p = self.degree
# Make sure we have the knots
N = self.requireKnots([u1, u2], [p+1, p+1])
j1 = N.knotu.index(u1)
j2 = N.knotu.index(u2)
knots = KnotVector(val=N.knotu.val[j1:j2+1], mul=N.knotu.mul[j1:j2+1])
k1 = N.knotu.span(u1)
nctrl = knots.nknots() - p - 1
ctrl = N.coords[k1:k1+nctrl]
return NurbsCurve(control=ctrl, degree=p, knots=knots,
closed=self.closed)
[docs] def clamp(self):
"""Clamp the knot vector of the curve.
A clamped knot vector starts and ends with multiplicities p-1.
See also :meth:`isClamped`.
Returns self if the curve is already clamped, else returns an
equivalent curve with clamped knot vector.
Note: The use of unclamped knot vectors is deprecated.
This method is provided only as a convenient method to import
curves from legacy systems using unclamped knot vectors.
"""
if self.isClamped():
return self
else:
p = self.degree
u1, u2 = self.knotu.val[[p, -1-p]]
return self.subCurve(u1, u2)
[docs] def unclamp(self):
"""Unclamp the knot vector of the curve.
An unclamped knot vector starts and ends with multiplicities p-1.
See also :meth:`isClamped`.
Returns self if the curve is already clamped, else returns an
equivalent curve with clamped knot vector.
Note: The use of unclamped knot vectors is deprecated.
This method is provided as a convenient method to export
curves to legacy systems that only handle unclamped knot vectors.
"""
if self.isClamped():
from pyformex.lib.nurbs_e import curveUnclamp
P, U = curveUnclamp(self.coords, self.knotu.values())
return NurbsCurve(control=P, degree=self.degree, knots=U,
closed=self.closed)
else:
return self
[docs] def unblend(self):
"""Decomposes a curve in subsequent Bezier curves.
Returns an equivalent unblended Nurbs.
See also :meth:`toBezier`
"""
# sanitize arguments for library call
ctrl = self.coords
knots = self.knotu.values().astype(np.double)
X = nurbs.curveDecompose(ctrl, knots)
return NurbsCurve(X, degree=self.degree, blended=False)
# For compatibility
decompose = unblend
[docs] def toCurve(self, force_Bezier=False):
"""Convert a (nonrational) NurbsCurve to a BezierSpline or PolyLine.
This decomposes the curve in a chain of Bezier curves and converts
the chain to a BezierSpline or PolyLine.
This only works for nonrational NurbsCurves, as the
BezierSpline and PolyLine classes do not allow homogeneous
coordinates required for rational curves.
Returns a BezierSpline or PolyLine (if degree is 1) that is equivalent
with the NurbsCurve.
See also :meth:`unblend` which decomposes both rational and
nonrational NurbsCurves.
"""
if self.isRational():
raise ValueError("Can not convert a rational NURBS ro Bezier")
ctrl = self.coords
knots = self.knotu.values()
X = nurbs.curveDecompose(ctrl, knots)
X = Coords4(X).toCoords()
if self.degree > 1 or force_Bezier:
return curve.BezierSpline(control=X, degree=self.degree,
closed=self.closed)
else:
return curve.PolyLine(X, closed=self.closed)
[docs] def toBezier(self):
"""Convert a (nonrational) NurbsCurve to a BezierSpline.
This is equivalent with toCurve(force_Bezier=True) and returns
a BezierSpline in all cases.
"""
return self.toCurve(force_Bezier=True)
[docs] def removeKnot(self, u, m, tol=1.e-5):
"""Remove a knot from the knot vector of the Nurbs curve.
u: knot value to remove
m: how many times to remove (if negative, remove maximally)
Returns:
A Nurbs curve equivalent with the original but with a knot vector
where the specified value has been removed m times, if possible,
or else as many times as possible. The control points are adapted
accordingly.
"""
if self.closed:
raise ValueError("removeKnots currently does not work on "
"closed curves")
i = self.knotu.index(u)
if m < 0:
m = self.knotu.mul[i]
P = self.coords.astype(np.double)
Uv = self.knotu.val.astype(np.double)
Um = self.knotu.mul.astype(at.Int)
t, newP, newU = nurbs.curveKnotRemove(P, Uv, Um, i, m, tol)
if t > 0:
print(f"Removed the knot value {u} {t} times")
return NurbsCurve(newP, degree=self.degree, knots=newU,
closed=self.closed)
else:
print(f"Can not remove the knot value {u}")
return self
[docs] def removeAllKnots(self, tol=1.e-5):
"""Remove all removable knots
Parameters:
- `tol`: float: acceptable error (distance between old and new curve).
Returns an equivalent (if tol is small) NurbsCurve with all
extraneous knots removed.
"""
N = self
print(N)
while True:
print(N)
for u in N.knotu.val:
print(f"Removing {u}")
NN = N.removeKnot(u, m=-1, tol=tol)
if NN is N:
print("Can not remove")
continue
else:
break
if NN is N:
print("Done")
break
else:
print("Cycle")
N = NN
return N
blend = removeAllKnots
[docs] def elevateDegree(self, t=1):
"""Elevate the degree of the Nurbs curve.
t: how much to elevate the degree
Returns:
A Nurbs curve equivalent with the original but of a higher degree.
"""
if self.closed:
raise ValueError("elevateDegree currently does not work on "
"closed curves")
P = self.coords.astype(np.double)
U = self.knotu.values()
newP, newU, nh, mh = nurbs.curveDegreeElevate(P, U, t)
return NurbsCurve(newP, degree=self.degree+t, knots=newU,
closed=self.closed)
[docs] def reduceDegree(self, t=1):
"""Reduce the degree of the Nurbs curve.
t: how much to reduce the degree (max. = degree-1)
Returns:
A Nurbs curve approximating the original but of a lower degree.
"""
from pyformex.lib.nurbs_e import curveDegreeReduce
if self.closed:
raise ValueError("reduceDegree currently does not work on "
"closed curves")
if t >= self.degree:
raise ValueError(
"Can maximally reduce degree {self.degree-1} times")
N = self
while t > 0:
newP, newU, nh, mh, maxerr = curveDegreeReduce(N.coords, N.knots)
# newP,newU,nh,mh = nurbs.curveDegreeReduce(N.coords, N.knots)
N = NurbsCurve(newP, degree=self.degree-1, knots=newU,
closed=self.closed)
print(f"Reduced to degree {N.degree} with maxerr = {maxerr}")
t -= 1
return N
[docs] def projectPoint(self, P, eps1=1.e-5, eps2=1.e-5, maxit=20, nseed=20):
# TODO: This might be implemented in C for efficiency
"""Project a given point on the Nurbs curve.
This can also be used to determine the parameter value of a point
lying on the curve.
Parameters:
- `P`: Coords-like (npts,3): one or more points is space.
Returns a tuple (u,X):
- `u`: float: parameter value of the base point X of the projection
of P on the NurbsCurve.
- `X`: Coords (3,): the base point of the projection of P
on the NurbsCurve.
The algorithm is based on the from The Nurbs Book.
"""
P = at.checkArray(P, (3,), 'f', 'i')
# Determine start value from best match of nseed+1 points
umin, umax = self.knotu.val[[0, -1]]
u = at.uniformParamValues(nseed+1, umin, umax)
pts = self.pointsAt(u)
i = pts.distanceFromPoint(P).argmin()
u0, P0 = u[i], pts[i]
# Apply Newton's method to minimize distance
i = 0
ui = u0
while i < maxit:
i += 1
C = self.derivs([ui], 2)
C0, C1, C2 = C[:, 0]
CP = (C0-P)
CPxCP = np.dot(CP, CP)
C1xCP = np.dot(C1, CP)
C1xC1 = np.dot(C1, C1)
eps1sq = eps1*eps1
eps2sq = eps2*eps2
# Check convergence
chk1 = CPxCP <= eps1sq
chk2 = C1xCP / C1xC1 / CPxCP <= eps2sq
uj = ui - np.dot(C1, CP) / (np.dot(C2, CP) + np.dot(C1, C1))
# ensure that parameter stays in range
if self.closed:
while uj < umin:
uj += umax - umin
while uj > umax:
uj -= umax - umin
else:
if uj < umin:
uj = umin
if uj > umax:
uj = umax
# Check convergence
chk4 = (uj-ui)**2 * C1xC1 <= eps1sq
P0 = self.pointsAt([uj])[0]
if (chk1 or chk2) and chk4:
# Converged!
break
else:
# Prepare for next it
ui = uj
if i == maxit:
print(f"Convergence not reached after {maxit} iterations")
return u0, P0
[docs] def approx(self, ndiv=None, nseg=None, **kargs):
"""Return a PolyLine approximation of the Nurbs curve
If no `nseg` is given, the curve is approximated by a PolyLine
through equidistant `ndiv+1` point in parameter space. These points
may be far from equidistant in Cartesian space.
If `nseg` is given, a second approximation is computed with `nseg`
straight segments of nearly equal length. The lengths are computed
based on the first approximation with `ndiv` segments.
"""
from pyformex.plugins.curve import PolyLine
if ndiv is None:
ndiv = self.N_approx
umin, umax = self.urange()
u = at.uniformParamValues(ndiv, umin, umax)
PL = PolyLine(self.pointsAt(u))
if nseg is not None:
u = PL.atLength(nseg)
PL = PolyLine(PL.pointsAt(u))
return PL
[docs] def actor(self, **kargs):
"""Graphical representation"""
from pyformex.opengl.actors import Actor
G = self.approx(ndiv=100).toFormex()
G.attrib(**self.attrib)
return Actor(G, **kargs)
[docs] def reverse(self):
"""Return the reversed Nurbs curve.
The reversed curve is geometrically identical, but start and en point
are interchanged and parameter values increase in the opposite direction.
"""
return NurbsCurve(control=self.coords[::-1], knots=self.knotu.reverse(),
degree=self.degree, closed=self.closed)
#######################################################
## NURBS Surface ##
[docs]class NurbsSurface(Geometry4):
"""A NURBS surface
The Nurbs surface is defined as a tensor product of NURBS curves in two
parametrical directions u and v. The control points form a grid of
(nctrlu,nctrlv) points. The other data are like those for a NURBS curve,
but need to be specified as a tuple for the (u,v) directions.
The knot values are only defined upon a multiplicative constant, equal to
the largest value. Sensible default values are constructed automatically
by a call to the :func:`genKnotVector` function.
If no knots are given and no degree is specified, the degree is set to
the number of control points - 1 if the curve is blended. If not blended,
the degree is not set larger than 3.
.. warning:: This is a class under development!
"""
def __init__(self, control, degree=(None, None), wts=None,
knots=(None, None), closed=(False, False),
blended=(True, True)):
"""Initialize the NurbsSurface.
"""
Geometry4.__init__(self)
self.closed = closed
control = Coords4(control)
if wts is not None:
control.deNormalize(wts.reshape(wts.shape[-1], 1))
for d in range(2):
nctrl = control.shape[1-d] # BEWARE! the order of the nodes
deg = degree[d]
kn = knots[d]
bl = blended[d]
cl = closed[d]
if deg is None:
if kn is None:
deg = nctrl-1
if not bl:
deg = min(deg, 3)
else:
deg = len(kn) - nctrl -1
if deg <= 0:
raise ValueError(
f"Length of knot vector ({len(knots)}) must be at"
f"least number of control points ({nctrl}) plus 2")
# make degree changeable
degree = list(degree)
degree[d] = deg
order = deg+1
if nctrl < order:
raise ValueError(
f"Number of control points ({nctrl}) must not be "
f"smaller than order ({order})")
if kn is None:
kn = genKnotVector(nctrl, deg, blended=bl, closed=cl).values()
else:
kn = np.asarray(kn).ravel()
nknots = kn.shape[0]
if nknots != nctrl+order:
raise ValueError(
f"Length of knot vector ({nknots}) must be equal to "
f"number of control points ({nctrl}) plus order ({order})")
if d == 0:
self.knotu = kn
else:
self.knotv = kn
self.coords = control
self.degree = degree
self.closed = closed
def order(self):
return (self.knotu.shape[0]-self.coords.shape[1],
self.knotv.shape[0]-self.coords.shape[0])
[docs] def urange(self):
"""Return the u-parameter range on which the curve is defined.
Returns a (2,) float array with the minimum and maximum parameter
value u for which the curve is defined.
"""
p = self.degree[0]
return [self.knotu[p], self.knotu[-1-p]]
[docs] def vrange(self):
"""Return the v-parameter range on which the curve is defined.
Returns a (2,) float array with the minimum and maximum parameter
value v for which the curve is defined.
"""
p = self.degree[1]
return [self.knotv[p], self.knotv[-1-p]]
[docs] def bbox(self):
"""Return the bounding box of the NURBS surface.
"""
return self.coords.toCoords().bbox()
[docs] def pointsAt(self, u):
"""Return the points on the Nurbs surface at given parametric values.
Parameters:
- `u`: (nu,2) shaped float array: `nu` parametric values (u,v) at which
a point is to be placed.
Returns (nu,3) shaped Coords with `nu` points at the specified
parametric values.
"""
ctrl = self.coords.astype(np.double)
U = self.knotv.astype(np.double)
V = self.knotu.astype(np.double)
u = np.asarray(u).astype(np.double)
try:
pts = nurbs.surfacePoints(ctrl, U, V, u)
if np.isnan(pts).any():
print("We got a NaN")
raise RuntimeError
except Exception:
raise RuntimeError(
"Some error occurred during the evaluation of the Nurbs "
"surface.\nPerhaps you are not using the compiled library?")
if pts.shape[-1] == 4:
pts = Coords4(pts).toCoords()
else:
pts = Coords(pts)
return pts
[docs] def derivs(self, u, m):
"""Return points and derivatives at given parametric values.
Parameters:
- `u`: (nu,2) shaped float array: `nu` parametric values (u,v) at which
the points and derivatives are evaluated.
- `m`: tuple of two int values (mu,mv). The points and derivatives up
to order mu in u direction and mv in v direction are returned.
Returns:
(nu+1,nv+1,nu,3) shaped Coords with `nu` points at the
specified parametric values. The slice (0,0,:,:) contains the
points.
"""
# sanitize arguments for library call
ctrl = self.coords.astype(np.double)
U = self.knotv.astype(np.double)
V = self.knotu.astype(np.double)
u = np.asarray(u).astype(np.double)
mu, mv = m
mu = int(mu)
mv = int(mv)
try:
pts = nurbs.surfaceDerivs(ctrl, U, V, u, mu, mv)
if np.isnan(pts).any():
print("We got a NaN")
raise RuntimeError
except Exception:
raise RuntimeError("Some error occurred during the evaluation "
"of the Nurbs surface")
if pts.shape[-1] == 4:
pts = Coords4(pts)
pts[0][0].normalize()
pts = Coords(pts[..., :3])
else:
pts = Coords(pts)
return pts
[docs] def approx(self, ndiv=None, **kargs):
"""Return a Quad4 Mesh approximation of the Nurbs surface
Parameters:
- `ndiv`: number of divisions of the parametric space.
"""
from pyformex.mesh import Mesh, quad4_els
if ndiv is None:
ndiv = self.N_approx
if at.isInt(ndiv):
ndiv = (ndiv, ndiv)
udiv, vdiv = ndiv
umin, umax = self.urange()
vmin, vmax = self.vrange()
u = at.uniformParamValues(udiv, umin, umax)
v = at.uniformParamValues(udiv, umin, umax)
uv = np.ones((udiv+1, vdiv+1, 2))
uv[:, :, 0] *= u
uv[:, :, 1] *= v.reshape(-1, 1)
coords = self.pointsAt(uv.reshape(-1, 2))
elems = quad4_els(udiv, vdiv)
return Mesh(coords, elems, eltype='quad4')
[docs] def actor(self, **kargs):
"""Graphical representation"""
from pyformex.opengl.actors import Actor
G = self.approx(ndiv=100)
G.attrib(**self.attrib)
return Actor(G, **kargs)
################################################################
[docs]def globalInterpolationCurve(Q, degree=3, strategy=0.5):
"""Create a global interpolation NurbsCurve.
Given an ordered set of points Q, the globalInterpolationCurve
is a NURBS curve of the given degree, passing through all the
points.
Returns:
A NurbsCurve through the given point set. The number of
control points is the same as the number of input points.
.. warning:: Currently there is the limitation that two consecutive
points should not coincide. If they do, a warning is shown and
the double points will be removed.
The procedure works by computing the control points that will
produce a NurbsCurve with the given points occurring at predefined
parameter values. The strategy to set this values uses a parameter
as exponent. Different values produce (slighly) different curves.
Typical values are:
0.0: equally spaced (not recommended)
0.5: centripetal (default, recommended)
1.0: chord length (often used)
"""
from pyformex.plugins.curve import PolyLine
# set the knot values at the points
# nc = Q.shape[0]
# n = nc-1
# chord length
d = PolyLine(Q).lengths()
if (d==0.0).any():
utils.warn("warn_nurbs_gic")
w = np.where(d!=0)[0]
Q = np.concatenate([Q[w], Q[-1:]], axis=0)
d = PolyLine(Q).lengths()
if (d==0.0).any():
raise ValueError("Double points in the data set are not allowed")
# apply strategy
d = d ** strategy
d = d.cumsum()
d /= d[-1]
u = np.concatenate([[0.], d])
U, A = nurbs.curveGlobalInterpolationMatrix(Q, u, degree)
P = np.linalg.solve(A, Q)
return NurbsCurve(P, knots=U, degree=degree)
[docs]def NurbsCircle(C=[0., 0., 0.], r=1.0, X=[1., 0., 0.], Y=[0., 1., 0.],
ths=0., the=360.):
"""Create a NurbsCurve representing a perfect circle or arc.
Parameters:
- `C`: float (3,): center of the circle
- `r`: float: radius
- `X`: unit vector in the plane of the circle
- 'Y': unit vector in the plane of the circle and perpendicular to `X`
- `ths`: start angle, measured from the X axis, coungerclockwise in X-Y plane
- `the`: end angle, measured from the X axis
Returns a NurbsCurve that is a perfect circle or arc.
"""
if the < ths:
the += 360.
theta = (the-ths)
# Get the number of arcs
narcs = int(np.ceil(theta/90.))
n = 2*narcs # n+1 control points
C, X, Y = (at.checkArray(x, (3,), 'f', 'i') for x in (C, X, Y))
dths = ths*at.DEG
dtheta = theta*at.DEG/narcs
w1 = np.cos(dtheta/2.) # base angle
# Initialize start values
P0 = C + r*np.cos(dths)*X + r*np.sin(dths)*Y
T0 = -np.sin(ths)*X + np.cos(ths)*Y
Pw = np.zeros((n+1, 4), dtype=at.Float)
Pw[0] = Coords4(P0)
index = 0
angle = ths*at.DEG
# create narcs segments
for i in range(1, narcs+1):
angle += dtheta
P2 = C + r*np.cos(angle)*X + r*np.sin(angle)*Y
Pw[index+2] = Coords4(P2)
T2 = -np.sin(angle)*X + np.cos(angle)*Y
P1, P1b = gt.intersectLineWithLine(P0, T0, P2, T2)
Pw[index+1] = Coords4(P1) * w1
Pw[index+1, 3] = w1
index += 2
if i < narcs:
P0, T0 = P2, T2
# Load the knot vector
j= 2*narcs+1
U = np.zeros((j+3,), dtype=at.Float)
for i in range(3):
U[i] = 0.
U[i+j] = 1.
if narcs == 2:
U[3] = U[4] = 0.5
elif narcs == 3:
U[3] = U[4] = 1./3.
U[5] = U[6] = 2./3.
elif narcs == 4:
U[3] = U[4] = 0.25
U[5] = U[6] = 0.5
U[7] = U[8] = 0.75
return NurbsCurve(control=Pw, degree=2, knots=U)
[docs]def toCoords4(x):
"""Convert cartesian coordinates to homogeneous
`x`: :class:`Coords`
Array with cartesian coordinates.
Returns a Coords4 object corresponding to the input cartesian coordinates.
"""
return Coords4(x)
Coords.toCoords4 = toCoords4
[docs]def pointsOnBezierCurve(P, u):
"""Compute points on a Bezier curve
Parameters:
P is an array with n+1 points defining a Bezier curve of degree n.
u is a vector with nu parameter values between 0 and 1.
Returns:
An array with the nu points of the Bezier curve corresponding with the
specified parametric values.
ERROR: currently u is a single paramtric value!
See also:
examples BezierCurve, Casteljau
"""
u = np.asarray(u).ravel()
n = P.shape[0]-1
return Coords.concatenate([
(nurbs.allBernstein(n, ui).reshape(1, -1, 1) * P).sum(axis=1)
for ui in u], axis=0)
[docs]def deCasteljau(P, u):
"""Compute points on a Bezier curve using deCasteljau algorithm
Parameters:
P is an array with n+1 points defining a Bezier curve of degree n.
u is a single parameter value between 0 and 1.
Returns:
A list with point sets obtained in the subsequent deCasteljau
approximations. The first one is the set of control points, the last one
is the point on the Bezier curve.
This function works with Coords as well as Coords4 points.
"""
n = P.shape[0]-1
C = [P]
for k in range(n):
Q = C[-1]
Q = (1.-u) * Q[:-1] + u * Q[1:]
C.append(Q)
return C
[docs]def splitBezierCurve(P, u):
"""Split a Bezier curve at parametric values
Parameters:
P is an array with n+1 points defining a Bezier curve of degree n.
u is a single parameter value between 0 and 1.
Returns two arrays of n+1 points, defining the Bezier curves of degree n
obtained by splitting the input curve at parametric value u. These results
can be used with the control argument of BezierSpline to create the
corresponding curve.
"""
C = deCasteljau(P, u)
L = np.stack([x[0] for x in C])
R = np.stack([x[-1] for x in C[::-1]])
return L, R
[docs]def frenet(d1, d2, d3=None):
"""Compute Frenet vectors, curvature and torsion.
This function computes Frenet vectors, curvature and torsion
from the provided first, second, and optional third derivatives
of curve. The derivatives can be obtained from
:func:`NurbsCurve.deriv`.
Curvature is computed as `abs| d1 x d2 | / |d1|**3`
Parameters
----------
d1: float :term:`array_like` (npts,3)
First derivative at `npts` points of a nurbs curve
d2: float :term:`array_like` (npts,3)
Second derivative at `npts` points of a nurbs curve
d3: float :term:`array_like` (npts,3) , optional
Third derivative at `npts` points of a nurbs curve
Returns
-------
T: float array(npts,3)
Normalized tangent vector to the curve at `npts` points.
N: float array(npts,3)
Normalized normal vector to the curve at `npts` points.
B: float array(npts,3)
Normalized binormal vector to the curve at `npts` points.
k: float array(npts,3)
Curvature of the curve at `npts` points.
t: float array(npts,3), optional
Torsion of the curve at `npts` points. This value is only returned
if `d3` was provided.
See Also
--------
NurbsCurve.frenet : the corresponding NurbsCurve method
NurbsCurve.deriv : computation of the derivatives of a NurbsCurve
"""
ld = at.length(d1)
# What to do when ld is 0? same as with k?
if ld.min() == 0.0:
print(f"ld is zero at {np.where(ld==0.0)[0]}")
e1 = d1 / ld.reshape(-1, 1)
e2 = d2 - at.dotpr(d2, e1).reshape(-1, 1)*e1
k = at.length(e2)
if k.min() == 0.0:
w = np.where(k==0.0)[0]
print(f"k is zero at {w}")
# where k = 0: set e2 to mean of previous and following
e2 /= k.reshape(-1, 1)
# e3 = normalize(ddd - dotpr(ddd,e1)*e1 - dotpr(ddd,e2)*e2)
e3 = np.cross(e1, e2)
# m = at.dotpr(np.cross(d1, d2), e3)
# print "m",m
m = np.cross(d1, d2)
k = at.length(m) / ld**3
if d3 is None:
return e1, e2, e3, k
# compute torsion
t = at.dotpr(d1, np.cross(d2, d3)) / at.dotpr(d1, d2)
return e1, e2, e3, k, t
### End