#
##
## SPDX-FileCopyrightText: © 2007-2023 Benedict Verhegghe <bverheg@gmail.com>
## SPDX-License-Identifier: GPL-3.0-or-later
##
## This file is part of pyFormex 3.4 (Thu Nov 16 18:07:39 CET 2023)
## pyFormex is a tool for generating, manipulating and transforming 3D
## geometrical models by sequences of mathematical operations.
## Home page: https://pyformex.org
## Project page: https://savannah.nongnu.org/projects/pyformex/
## Development: https://gitlab.com/bverheg/pyformex
## 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/.
##
"""Handling curves in pyFormex
This module defines classes and functions specialized for handling curves
(one-dimensional geometrical objects) in pyFormex. They can be straight lines,
polylines, higher order curves and collections thereof. In general,
the curves are 3D, but special cases may be created for handling plane curves.
The most important curve type classes defined in this module are:
- :class:`Curve`: a virtual base class for all curve type classes.
It can not be instantiated itself: always use one of the derived classes.
- :class:`BezierSpline`: curves consisting of a concatenation of Bezier
polynomes of degree 1 to 3.
- :class:`PolyLine`: curves consisting of a concatenation of straight segments.
- :class:`Arc`: circles and arcs
Another important curve type, :class:`~plugins.nurbs.NurbsCurve`, is defined
in the :mod:`plugins.nurbs` module.
"""
import math
import functools
import numpy as np
import pyformex as pf
from pyformex import arraytools as at
from pyformex import utils
from pyformex.coords import Coords
from pyformex.coordsys import CoordSys
from pyformex.geometry import Geometry
from pyformex.formex import Formex, connect
from pyformex.mesh import Mesh
from pyformex import geomtools as gt
from pyformex.varray import Varray
# TODO: remove this when we make 3.9 the minimum version
import sys
cache = functools.cache if sys.hexversion >= 0x03090000 else functools.lru_cache
_numpy_printoptions_ = {'precision': 3}
###########################################################################
##
## class Curve
##
#########################
[docs]class Curve(Geometry):
"""Base class for curve type classes.
This is a virtual class that can not be instantiated. It defines the common
definitions for all curve type subclasses. The subclasses define at least
the following attributes:
Attributes
----------
coords: :class:`~coords.Coords`
The coordinates of all the points needed to define the curve. These
are not necessarily points on the curve.
nparts: int
The number of parts comprising the curve. Subsequent parts of a curve
(e.g. straight segments of a polyline), have a local parameter with
values in the range 0..1. Any point on the curve can thus be identified
by a float number, where the integer part is the curve number and the
fractional part is the parameter value. See :meth:`localParam`.
closed: bool
Whether the curve is closed or not. A closed curve has coinciding
begin and end points, making it a closed loop. An open curve with
coinciding end points may look like a closed curve, but technically
it is still open, and its behavior may be different from that of a
closed curve.
"""
_exclude_members_ = ['nelems', '_select']
# Required implementation of abstract Geometry methods
# Keep undocumented as they are not relevant
def nelems(self):
return 1
def _select(self, sel, compact=False):
return self
# DEVS:
# Curve subclasses should override the following methods if the default
# is not suitable:
#
# - `sub_points(t,j)`: returns points at parameter value t,j
# - `sub_directions(t,j)`: returns direction at parameter value t,j
# - `pointsOn()`: the defining points placed on the curve
# - `parts(j,k)`:
def __init__(self):
if self.__class__ == Curve:
raise ValueError("Curve can not be instantiated, use a subclass")
super().__init__()
self.prop = None
@property
def ctype(self):
"""Return the curve type
Returns
-------
str
A oneline string with the class name and important parameters.
See Also
--------
report: return a full report about the curve
Examples
--------
>>> C = PolyLine(Coords('012'))
>>> print(C.ctype)
PolyLine: nparts=2, ncoords=3, open
>>> C = BezierSpline('012')
>>> print(C.ctype)
BezierSpline: degree=3, nparts=2, ncoords=7, open
>>> C = Arc(center=[0.,0.,0.], radius=1., angles=(-90., 90.))
>>> print(C.ctype)
Arc: nparts=1, ncoords=3, open
"""
s = f"{self.__class__.__name__}: "
if hasattr(self, 'degree') and not isinstance(self, PolyLine):
s += f"degree={self.degree}, "
s += f"nparts={self.nparts}, ncoords={self.coords.shape[0]}, "
s += 'closed' if self.closed else 'open'
return s
[docs] def report(self):
"""Return a report of the curve.
Returns
-------
str
A multiline string with the :meth:`ctype` and the full set
of control points. This is also what is printed by the
print statement.
See Also
--------
ctype: return a one-line string with the curve type
"""
return self.ctype + f"\n Control points:\n{self.coords}"
__str__ = report
# Methods to be defined in subclasses
[docs] def sub_points(self, t, j):
"""Return the points at parameter values t in part j
Parameters
----------
t: float or float array
One or more parameter values at which to evaluate the point
coordinates. Values are usually between 0.0 and 1.0.
j: int
Curve part index for which to find points.
Returns
-------
Coords:
The coordinates of the points on curve part `j` at parameter
values `t`.
"""
raise NotImplementedError
[docs] def sub_directions(self, t, j):
"""Return the directions at parameter values t in part j
Parameters
----------
t: float or float array
One or more parameter values at which to evaluate the curve's
direction vector. Values are usually between 0.0 and 1.0.
j: int
Curve part index for which to find directions.
Returns
-------
Coords:
The direction vector(s) at parameter values `t` in curve part `j`.
"""
raise NotImplementedError
def parts(self, i, j):
raise NotImplementedError
def lengths(self, t=(0., 1.), j=None):
raise NotImplementedError
# Other methods
[docs] def pointsOn(self):
"""Return the control points that are on the curve.
Returns
-------
Coords
"""
return self.coords
[docs] def pointsOff(self):
"""Return the control points that are off the curve.
Returns
-------
Coords
"""
return Coords()
[docs] def ncoords(self):
"""Return the total number of control points.
Returns
-------
int
"""
return self.coords.shape[0]
[docs] def endPoints(self):
"""Return start and end points of an open curve.
Returns
-------
Coords:
A Coords with two points (the begin and end of the open curve)
or None if the curve is closed.
"""
if self.closed:
return None
else:
return self.coords[[0, -1]]
[docs] def charLength(self):
"""Return a characteristic length of the curve.
This value is used in some methods to make input tolerances
dimensionless.
Returns
-------
float
The characteristic length of the curve. If the curve defines
a :meth:`lengths` method returning the lengths of its parts,
this is the mean part length.
Else, it is the :meth:`~coords.Coords.dsize` of the curve's
:meth:`~coords.Coords.bbox`.
Examples
--------
>>> PolyLine([[0.,0.,0.], [1.,0.,0.], [1.5,0.,0.]]).charLength()
0.75
"""
try:
return self.lengths().mean()
except Exception:
return self.bbox().dsize()
[docs] def localParam(self, u, disc=False):
"""Split global parameter value in part number and local parameter
Each point along a Curve can be identified by a single float value
varying from 0 at the begin point to `nparts` at the end point, where
`nparts` is the number of parts in the curve. The integer part of
the parameter is the part number, and the decimal part is the local
parameter for that part, varying from 0 to 1. Input values outside
the range [0..nparts] are allowed. Values < 0 return a negative local
parameter on part 0, values above nparts return a value > 1 on the
last part (nparts-1).
Parameters
----------
u: float :term:`array_like`
One or more global parameter values, usually in the range
0..nparts.
disc: bool
If True, values input values which are (very nearly) integers
will be reported twice: once in the previous part with local
parameter value 1., and once with the next part with local
parameter 0. This allows processing of discontinuities at
the part boundaries.
Returns
-------
j: int array
Array of the same length as u with the part numbers corresponding
to the parameters t. Values are in the range 0 to nparts-1.
t: float array
Array of the same length as u with the local parameter value in part
i corresponding to global parameter u. Values are in the range
[0.0, 1.0] if input was inside 0..nparts. It is however legal to
extend parameters before the start and after the end of the curve.
Examples
--------
>>> PL = PolyLine(Coords('012'))
>>> PL.nparts
2
>>> PL.localParam([0.0, 0.5, 1.0, 1.5, 2.0])
(array([0, 0, 1, 1, 1]), array([0. , 0.5, 0. , 0.5, 1. ]))
>>> PL.localParam([0.0, 0.5, 1.0, 1.5, 2.0], disc=True)
(array([0, 0, 0, 1, 1, 1]), array([0. , 0.5, 1. , 0. , 0.5, 1. ]))
>>> PL.localParam([-0.1, 1.000001, 1.999999, 2.1])
(array([0, 1, 1, 1]), array([-0.1, 0. , 1. , 1.1]))
>>> PL.localParam([-0.1, 1.000001, 1.999999, 2.1], disc=True)
(array([0, 0, 1, 1, 1]), array([-0.1, 1. , 0. , 1. , 1.1]))
"""
# Do not use np.asarray here! We change it, so need a copy!
t = np.array(u).astype(at.Float).ravel()
ti = np.floor(t).clip(min=0, max=self.nparts-1)
t -= ti
j = ti.astype(int)
if disc:
atol = 1.e-5
d = at.where_1d((t < atol) * (j > 0))
d1 = at.where_1d(((1.0-t) < atol) * (j < self.nparts-1))
l, l1 = len(d), len(d1)
if l > 0:
vj = j[d] - 1
tj = [1.] * l
else:
vj = tj = []
if l1 > 0:
vj1 = j[d1] + 1
tj1 = [0.] * l1
else:
vj1 = tj1 = []
if l + l1 > 0:
d = np.concatenate([d, d1])
vj = np.concatenate([vj, vj1])
tj = np.concatenate([tj, tj1])
t = np.insert(t, d, tj)
j = np.insert(j, d, vj)
return j, t
[docs] def pointsAt(self, u, return_position=False):
"""Return the points at parameter values t.
Parameters
----------
u: float :term:`array_like`
One or more global parameter values, usually in the range
0..nparts (see :meth:`localParam`)
return_position: bool, optional
If True, also returns the values of :meth:`localParam` for the
given `u`.
Returns
-------
coords: Coords
The coordinates of the points on the curve corresponding with the
parameter values u.
j, t:
The return values of :meth:`localParam` for the given u.
Only returned if return_position is True.
Examples
--------
>>> PL = PolyLine(Coords('012'))
>>> PL.pointsAt([0.5, 1.5])
Coords([[0.5, 0. , 0. ],
[1. , 0.5, 0. ]])
"""
j, t = self.localParam(u)
X = Coords.concatenate([self.sub_points(ti, ji)
for ti, ji in zip(t, j)])
if return_position:
return X, j, t
else:
return X
[docs] def directionsAt(self, u):
"""Return the directions at parameter values t.
Parameters
----------
u: float :term:`array_like`
One or more global parameter values, usually in the range
0..nparts (see :meth:`localParam`)
Returns
-------
:Coords
The unit tangent vectors to the curve at the points with
parameter values u.
Examples
--------
>>> PL = PolyLine(Coords('012'))
>>> PL.directionsAt([0.5, 1.5])
Coords([[1., 0., 0.],
[0., 1., 0.]])
"""
i, t = self.localParam(u)
return Coords.concatenate([self.sub_directions(tj, ij)
for tj, ij in zip(t, i)])
[docs] def approx(self, nseg=None, *, ndiv=None, chordal=0.01,
equidistant=False, npre=None, atl=None):
"""Approximate a Curve with a PolyLine
There are three strategies to define the positions of the PolyLine
approximation:
- `nseg`: specify the total number of segments along the curve,
- `ndiv`: specify the number of segments over each part of the curve,
- `chordal`: use an adaptive method limiting the chordal distance of
the curve to the PolyLine segments.
Parameters
----------
nseg: int
Total number of segments of the resulting PolyLine. The number
of returned parameter values is `nseg` if the curve is closed,
else `nseg+1`. If provided, the `nseg` method will be used and
`ndiv` and `chordal` are ignored.
ndiv: int
A single integer or a list of :attr:`nparts` integers.
If a single integer, each part of the curve is divided in
ndiv segments and the total number of segments is
``ndiv * self.nparts``. A list of integers specifies the number
of segments for each part, and the total is the sum of the list.
If provided (and `nseg` is not), `chordal` is ignored.
chordal: float
Maximum chordal error when using the chordal method
(i.e. neither nseg nor ndiv are provided).
The chordal error is the maximum chord to curve distance,
relative to the curve's :meth:`charLength`.
equidistant: bool
Only used with the `nseg` method: if True, the nseg points are
spread at (almost) equal distances along the curve, instead of
at equal parameter distances.
npre: int:
Only used if the `chordal` method is used or if `nseg` is used
and `equidistant` is True: the number of segments per part of
the curve (like `ndiv`) used in a pre-approximation.
If not provided, the default value is set to the degree of the
curve in case of the `chordal` method, and to 100 for the
equidistant `nseg` method (which uses the pre-approximation
to compute accurate curve lengths).
atl: float :term:`array_like`
Only used with the `chordal` method: a list of parameter values
that need to be included in the result. The list should contain
increasing values in the curve's parameter range. It will be used
as a start for the adaptive refining. If not provided, a default
is used assuring that the curve is properly approximated. If you
specify this yourself, you may end up with bad approximations due
to bad choice of the initial values.
Returns
-------
PolyLine
See Also
--------
atApprox: return the parameter values yielding the PolyLine points
Examples
--------
>>> C = BezierSpline([[1,0,0], [0,1,0], [-1,0,0]], degree=2)
>>> print(C.approx(4))
PolyLine: nparts=4, ncoords=5, open
Control points:
[[ 1. 0. 0. ]
[ 0.5 0.75 0. ]
[ 0. 1. 0. ]
[-0.5 0.75 0. ]
[-1. 0. 0. ]]
>>> print(C.approx(ndiv=2))
PolyLine: nparts=4, ncoords=5, open
Control points:
[[ 1. 0. 0. ]
[ 0.5 0.75 0. ]
[ 0. 1. 0. ]
[-0.5 0.75 0. ]
[-1. 0. 0. ]]
>>> print(C.approx(chordal=0.2))
PolyLine: nparts=4, ncoords=5, open
Control points:
[[ 1. 0. 0. ]
[ 0.5 0.75 0. ]
[ 0. 1. 0. ]
[-0.5 0.75 0. ]
[-1. 0. 0. ]]
"""
atl = self.atApprox(nseg, ndiv, chordal, equidistant, npre, atl)
return self.approxAt(atl)
def _at_approx(self, nseg=None, ndiv=None, equidistant=False, npre=None):
"""Implementation of nseg and ndiv methods for atApprox."""
if ndiv is not None:
if at.isInt(ndiv):
nseg = ndiv * self.nparts
ndiv = None
equidistant = False
if equidistant:
if npre is None:
npre = 100
S = self.approx(ndiv=npre)
atl = S.atLength(nseg) / npre
elif ndiv is not None:
atl = np.concatenate([[0.]] + [i + at.unitDivisor(n)[1:]
for i, n in enumerate(ndiv)])
else:
if nseg is None:
nseg = self.nparts
atl = np.arange(nseg+1) * float(self.nparts) / nseg
return atl
def _at_chordal(self, chordal, atl=None):
"""Implementation of chordal method for atApprox."""
# Create first approximation
if atl is None:
if hasattr(self, 'degree'):
ndiv = self.degree
else:
ndiv = 1
atl = self._at_approx(nseg=ndiv*self.nparts)
charlen = self.approxAt(atl).charLength()
# Split in handled and to-be-handled
atl, bt = list(atl[:1]), list(atl[1:])
# Handle segment by segment
#
# TODO: THIS SHOULD BE CHANGED:
# insert NO points of degree 1 (always correct)
# insert 1 point at 1/2 for degree 2 (current implementation)
# insert 2 points at 1/3 and 2/3 for degree 3
# etc...
#
while len(bt) > 0:
c0, c2 = atl[-1], bt[0]
if c2 > c0:
c1 = 0.5*(c0+c2)
X = self.pointsAt([c0, c1, c2])
XM = 0.5*(X[0]+X[2])
d = at.length(X[1]-XM) / charlen
if d >= chordal:
# need refinement: put new point in front of todo list
bt = [c1] + bt
continue
# move on
atl.append(bt.pop(0))
return atl
[docs] def atApprox(self, nseg=None, ndiv=None, chordal=0.02,
equidistant=False, npre=None, atl=None):
"""Return parameter values for approximating a Curve with a PolyLine.
The parameters are the same as for :meth:`approx`
Returns
-------
array
A list of parameter values that define the positions of
the point of the PolyLine approximation of the curve
using the same parameter values.
See Also
--------
approx: return a PolyLine approximation of a Curve.
Examples
--------
>>> PL = PolyLine([[0,0,0],[1,0,0],[1,1,0]])
>>> print(PL.atApprox(nseg=6))
[0. 0.333 0.667 1. 1.333 1.667 2. ]
>>> print(PL.atApprox(ndiv=3))
[0. 0.333 0.667 1. 1.333 1.667 2. ]
>>> print(PL.atApprox(ndiv=(2,4)))
[0. 0.5 1. 1.25 1.5 1.75 2. ]
>>> print(PL.atApprox(ndiv=(2,)))
[0. 0.5 1. ]
"""
if nseg or ndiv:
atl = self._at_approx(nseg, ndiv, equidistant, npre)
else:
atl = self._at_chordal(chordal, atl)
if self.closed and atl[0] == 0. and atl[-1] == float(self.nparts):
# Avoid having the endpoint twice
atl = atl[:-1]
return atl
[docs] def approxAt(self, u):
"""Create a PolyLine approximation with specified parameter values.
Parameters
----------
u: :term:`array_like`
A list of global parameter values for the curve, usually in the
curve's parameter range. Values outside the range will extend the
curve.
Returns
-------
PolyLine
A PolyLine connecting the points on the curve at the specified
parameter values.
See Also
--------
atApprox: convenient methods to compute the parameter values
approx: PolyLine approximation with parameter values from atApprox
Examples
--------
>>> C = BezierSpline([[1,0,0], [0,1,0], [-1,0,0]], degree=2)
>>> PL = C.approxAt([0.0, 1.0, 2.0])
>>> print(PL)
PolyLine: nparts=2, ncoords=3, open
Control points:
[[ 1. 0. 0.]
[ 0. 1. 0.]
[-1. 0. 0.]]
>>> C.approxAt([0.0, 0.5, 1.0]).coords
Coords([[1. , 0. , 0. ],
[0.5 , 0.75, 0. ],
[0. , 1. , 0. ]])
"""
X = self.pointsAt(u)
PL = PolyLine(X, closed=self.closed)
return PL.setProp(self.prop)
[docs] def length(self, u0=None, u1=None):
"""Return the length of the curve between two parameter values.
Parameters
----------
u0: float
Global parameter value from where to measure length. If not
provided, it is set to the start of the curve (0.).
u1: float
Global parameter value until where to measure length. If not
provided, it is set to the end of the curve (self.nparts).
Returns
-------
float
The length of the curve between the specified parameter values.
Without parameters, the full length of the curve.
Notes
-----
This is only available for curves that implement the 'lengths'
method.
Examples
--------
>>> C = BezierSpline('1234', degree=2)
>>> C.length()
3.23...
>>> C.length(u1=1.5)
1.61...
>>> C.length(0.5, 2.5)
1.95...
"""
if u0 is None and u1 is None:
return self.lengths().sum()
if u0 is None:
u0 = 0.
if u1 is None:
u1 = self.nparts
if u1 < u0:
return 0.
i, r = self.localParam(u0)
j, s = self.localParam(u1)
l0 = self.lengths(t=(r, 1.), j=i)
l1 = self.lengths(j=np.arange(i+1, j))
l2 = self.lengths(t=(0., s), j=j)
return np.concatenate([l0, l1, l2]).sum()
# TODO: compute correct results and add an algorithm to fix
# disc, nans, flips
[docs] def frenet(self, ndiv=None, nseg=None, chordal=0.01, upvector=None,
avgdir=True, compensate=False):
"""Return points and Frenet frame along the curve.
First, a PolyLine approximation for the curve is constructed, using the
:meth:`Curve.approx()` method with the arguments `ndiv`, `nseg`
and `chordal`.
Then Frenet frames are constructed with :meth:`PolyLine._movingFrenet`
using the remaining arguments.
The resulting PolyLine points and Frenet frames are returned.
The PolyLine polygon approximation,limits the accuracy. More accurate
results can be obtained by converting the Curve to a :class:`NurbsCurve`
and using its :meth:`plugins.nurbs.NurbsCurve.frenet` method.
That one however has currently no discontinuity smoothing
(discontinuous tangents at sharp corners, undetermined normals at zero
curvature, binormal flips).
Parameters
----------
upvector: float (3,) vector
A vector normal to the (tangent,normal) plane at the first point
of the curve. It defines the binormal at the first point.
If not provided it is set to the shortest distance through
the set of 10 first points.
avgdir: bool | array.
If True (default), the tangential vector is set to
the average direction of the two segments ending at a node.
If False, the tangent vectors will be those of the line segment
starting at the points.
The tangential vector can also be set by the user by specifying
an array with the matching number of vectors.
compensate: bool
If True, adds a compensation algorithm if the curve is closed.
For a closed curve the moving Frenet algorithm can be continued
back to the first point. If the resulting binormal does not
coincide with the starting one, some torsion is added to the end
portions of the curve to make the two binormals coincide.
This feature is off by default because it is currently experimental
and is likely to change in future.
It may also form the base for setting the starting as well as the
ending binormal.
Returns
-------
X: :class:`Coords` (npts,3)
The coordinates of `npts` points on the curve.
T: :class:`Coords` (npts,3)
The normalized tangent vectors to the curve at the points `X`.
N: :class:`Coords` (npts,3)
The normalized normal vectors to the curve at the points `X`.
B: :class:`Coords` (npts,3)
The normalized binormal vector to the curve at the points `X`.
Notes
-----
"""
PL = self.approx(ndiv=ndiv, nseg=nseg, chordal=chordal)
X = PL.coords
T, N, B = PL._movingFrenet(upvector=upvector, avgdir=avgdir,
compensate=compensate)
return X, T, N, B
# TODO: This should use more accurate frenet when it has smoothing
[docs] def position(self, geom, csys=None):
""" Position a Geometry object along a path.
Creates copies of geom along a curve, orienting it according to
the :meth:`frenet` frame.
Parameters
----------
geom: :class:`Geometry` | :class:`Coords`
The geometry to position
csys: :class:`CoordSys`
The coordinate system that will be positioned along the
frenet axes. If not provided, the global axes are used.
Returns
-------
list
A list of Geometry/Coords objects properly positioned and
oriented along the curve.
"""
X, T, N, B = self.frenet()
if csys:
geom = geom.fromCS(csys)
return [geom.fromCS(CoordSys(rot=np.row_stack([t, n, b]), trl=x))
for x, t, n, b in zip(X, T, N, B)]
# TODO: This should be removed in favor of a Mesh.sweep using position
[docs] def sweep(self, mesh, eltype=None, csys=None):
"""Sweep a mesh along the curve, creating an extrusion.
Copies of the input mesh are :meth:`position` -ed along the curve
and then :meth:`~mesh.Mesh.connect` -ing them into an extrusion.
Parameters
----------
mesh: :class:`Mesh`
The (usually planar) mesh that is to be swept along the curve.
It should be a Mesh or an object having a `toMesh` method to
transform it into a Mesh.
eltype: str
The name of the target element type of the returned Mesh.
If not provided, it is determined from extruding the `mesh`
eltype.
csys: :class:`CoordSys`
The coordinate system that will be positioned along the
:meth:`frenet` axes. If not provided, the global axes are used.
Returns
-------
Mesh
The Mesh obtained by sweeping the input mesh along the curve.
This involves :meth:`position` -ing copies of the input Mesh
along the curve,
The returned Mesh has double plexitude of the original.
If `path` is a closed Curve connect back to the first.
.. note:: Sweeping nonplanar objects and/or sweeping along very curly
curves may result in physically impossible geometries.
See Also
--------
sweep2
"""
loop = self.closed
mesh = mesh.toMesh()
seq = self.position(mesh.coords, csys)
return mesh.connect(seq, eltype=eltype, loop=loop)
# TODO: This should be merged with position if it is to be kept
# Better however to use frenet positioning
[docs] def position2(self, objects, normal=0, upvector=2, avgdir=True,
enddir=None):
""" Position a sequence of Coords objects along a path.
At each point of the curve, a copy of the Coords object is created, with
its origin in the curve's point, and its normal along the curve's direction.
In case of a PolyLine, directions are pointing to the next point by default.
If avgdir==True, average directions are taken at the intermediate points
avgdir can also be an array like sequence of shape (N,3) to explicitely set
the directions for ALL the points of the path
Missing end directions can explicitely be set by enddir, and are by default
taken along the last segment.
enddir is a list of 2 array like values of shape (3). one of the two can
also be an empty list.
If the curve is closed, endpoints are treated as any intermediate point,
and the user should normally not specify enddir.
The return value is a sequence of the repositioned Coords objects.
"""
points = self.coords
if isinstance(avgdir, bool):
if avgdir:
directions = self.avgDirections()
else:
directions = self.directions()
else:
directions = np.asarray(avgdir).reshape(len(avgdir), -1)
missing = points.shape[0] - directions.shape[0]
if missing == 1:
lastdir = (points[-1] - points[-2]).reshape(1, 3)
directions = np.concatenate([directions, lastdir], axis=0)
elif missing == 2:
lastdir = (points[-1] - points[-2]).reshape(1, 3)
firstdir = (points[1] - points[0]).reshape(1, 3)
directions = np.concatenate([firstdir, directions, lastdir], axis=0)
if enddir:
for i, j in enumerate([0, -1]):
if enddir[i]:
directions[j] = Coords(enddir[i])
directions = at.normalize(directions)
if at.isInt(normal):
normal = Coords(at.unitVector(normal))
if at.isInt(upvector):
upvector = Coords(at.unitVector(upvector))
sequence = [x.rotate(at.rotMatrix2(normal, d, upvector)).translate(p)
for x, d, p in zip(objects, directions, points)]
return sequence
# TODO: this should be merged into position2.
[docs] def sweep2(self, coords, origin=(0., 0., 0.), scale=None, **kargs):
""" Sweep a Coords object along a curve, returning a series of copies.
At each point of the curve a copy of the coords is created,
possibly scaled, and rotated to keep same orientation with
respect to the curve.
Parameters
----------
coords: Coords object
The Coords object to be copied, possibly scaled and positioned
at each point of the curve.
origin: float :term:`array_like` (3,)
The local origin in the Coords. This is the point that will
be positioned at the curve's points. It is also the center of
scaling if `scale` is provided.
scale: float :term:`array_like`, optional
If provided, it should have the shape (npts,3). For each point
of the curve, it specifies the three scaling factors to be used
in the three coordinate directions on the `coords` for the copy
that is to be plavced at that point.
**kargs: optional keyword arguments
Extra keyword arguments that are passed to :meth:`position2`.
Returns
-------
A sequence of the transformed Coords objects.
See Also
--------
sweep
"""
if scale is not None:
scale = at.checkArray(scale, shape=(self.ncoords(), 3),
kind='f', allow='i')
# translate input coords to origin
base = coords.translate(-Coords(origin))
# create the scaled copies
if scale is not None:
sequence = (base.scale(*sc) for sc in scale)
else:
sequence = (base, ) * self.ncoords()
# position the copies at the curve's points
sequence = self.position2(sequence, **kargs)
return sequence
# DEVS: we have an actor to draw curves with a higher accuracy
# than the default toMesh() would have
# (until we have better line3/line4 drawing
[docs] def actor(self, **kargs):
"""Create an actor to draw the Curve."""
G = self.approx().toMesh()
G.attrib(**self.attrib)
return G.actor(**kargs)
[docs] def toNurbs(self):
"""Convert a curve to a NurbsCurve.
Returns
-------
NurbsCurve
A :class:`plugins.nurbs.nurbscurve` equivalent with the
input Curve. This is currently only implemented for
BezierSpline and PolyLine.
"""
from pyformex.plugins.nurbs import NurbsCurve
if isinstance(self, (BezierSpline, PolyLine)):
return NurbsCurve(self.coords, degree=self.degree,
closed=self.closed, blended=False)
else:
raise ValueError(f"A curve of type {self.__class__.__name__}"
" can not (yet) be converted to NurbsCurve")
[docs] def setProp(self, prop=None):
"""Create or destroy the property number for the Curve.
A curve can have a single integer as property number.
If it is set, derived curves and approximations will inherit it.
Use this method to set or remove the property.
Parameters
----------
prop: int
The int value to set as the curve's prop number. If None,
the prop is removed from the Curve.
Returns
-------
self
The curve itself with the property set or removed.
"""
try:
self.prop = int(prop)
except Exception:
self.prop = None
return self
# TODO: remove in 3.3
# Deprecated 2022-09-28. Kept for compatibility/convenience
utils.deprecated_by('Curve.totalLength', 'Curve.length')
def totalLength(self, t0, t1):
return self.length(u0=t0, u1=t1)
# TODO: remove in 3.3
# Deprecated 2022-09-08. Kept for compatibility/convenience
utils.deprecated_by('Curve.approximate', 'Curve.approx')
def approximate(self, *args, **kargs):
return self.approx(*args, **kargs)
# TODO: remove in 3.3
# Deprecated 2022-09-08.
# Kept for extend parameter, until Curve.extend is implemented
utils.deprecated_by('Curve.subPoints', 'Curve.pointsAt(Curve.atApprox)')
def subPoints(self, div=10, extend=[0., 0.]):
C = self.extend(*extend)
return C.pointsAt(C.atApprox(ndiv=div))
##############################################################################
#
# BezierSpline
#
##############################################################################
[docs]@utils.pzf_register
class BezierSpline(Curve):
"""A class representing a Bezier spline curve of degree 1, 2, 3 or higher.
A BezierSpline of degree `d` is a continuous curve consisting of `nparts`
successive Bezier curves of the same degree. Successive means that the
end point of one curve is also the starting point of the next curve.
A Bezier curve of degree d is determined by d+1 control points,
of which the first and the last are on the curve (the endpoints),
and the intermediate d-1 points are not.
Since the end point of one part is the starting point of the next part,
a BezierSpline is described by ``ncontrol = d * nparts + 1`` control
points. The number of points on the curve is ``npoints = nparts + 1``.
The above holds for open curves. The BezierSpline class however
distinguishes between open and closed curves. In a closed curve,
the last point coincides with the first, and is not stored. If the
last point is needed, it is obtained by wrapping around back to the
first point. An open curve however may also have a last point coinciding
with the first, so that the curve looks closed, but technically it remains
an open curve.
The BezierSpline class provides ways to create a full set of
control points. Often the off-curve control points can be
generated automatically. The default degree (3) generates them from
the requirement that the curve should be smooth, i.e. have a
continuous tangent vector when moving from one part to the next.
Therefore there are basically two ways to create a BezierSpline:
by specifying all control points, or by specifying only the points
through which the curve should pass (and possibly the tangents at
those points). The difference is made clear by using either the
``control`` or the ``coords`` parameter. Because in most quick
application cases the use of the ``coords`` option is the likely
choice, that parameter is made the (only) positional one.
The ``coords`` and ``control`` parameters are mutually exclusive, but
at least one of them must be used.
Historically, there was an option to use both ``coords`` and ``control``
parameters, with the latter then only specifying the off-curve points,
but this use case is now prohibited. See Notes for how to work around it.
Parameters
----------
coords: :term:`coords_like` (npoints, 3), optional
A set of points through which the curve should pass. The number
of points should at least be 2. An interpolation BezierSpline
of the requested degree is then constructed. The result can be
tuned by other parameters (``tangents``, ``curl``).
This is the only parameter that is allowed as a positional parameter
It can not be combined with the ``control`` parameter. It can also
not be used for degrees > 3 (which are seldomly used).
control: :term:`coords_like` (ntrl, 3)
The complete set of control points that define the BezierSpline.
The number of points (nctrl) should be a multiple of degree, plus 1.
This parameter can not be used if ``coords`` was provided.
degree: int
The polynomial degree of the curve. The default value is 3.
For curves of degree 1, the specialized :class:`PolyLine` subclass
may be more appropriate. For curves of degree 4 or higher the use of a
:class:`~plugins.nurbs.NurbsCurve` may be prefered.
closed: bool
If True, a closed curve is created. If the distance from
the last point to the first is less than ``tol``, the last point
is removed and the last segment is created between the penultimate
point and the first point. Else, a segment is created between the
last point and the first.
tol: float, optional
Tolerance used in testing for end point coincidence when creating
a closed curve. Only used if `closed=True`. If the distance
between the end points is less than `tol`, they are considered
coincident. Else, an extra curve segment is created between the
last and the first point. A value `0.0` will force the creation
of a extra segment. A value `np.inf` will never create an extra
segment. If not provided, a default value
``1.e-5 * coords.dsize()`` is used. This value ensures expected
behavior in most use cases.
tangents: :term:`coords_like` (npts, 3) or (2, 3), optional
The imposed tangent vectors to the curve at the specified points
``coords``. Can only be used with degree 2 or 3.
The number of tangents should be equal to the number of points
(after possibly removing one point for a closed curve).
As a convenience, the tangents array may contain just two vectors
if only the end tangents need to be specified.
The provided vectors will get normalized, so their length is irrelevant.
If not provided, ``tangents`` is set equal to
``PolyLine(coords).avgDirections()``.
The tangents array can contain np.nan values for points were the
user does not want to prescribe a tangent direction. These will also
be filled in with values from ``PolyLine(coords).avgDirections()``
curl: float, optional
The curl parameter can be set to influence the curliness of the curve
between two subsequent curve endpoints. A value curl=0.0 results in
straight segments. The higher the value, the more the curve becomes
curled. Can only be used with degree 3.
kargs: deprecated keyword arguments
For compatibility of legacy code, the 'tangents' parameter may
also be called 'deriv'.
Returns
-------
BezierSpline
A BezierSpline of the requested degree, passing through all points
``coords``. If the degree is 3, or the degree is 2 and all points
are lying in the same plane, the curve will have the provided (or
computed) tangents at the given points. If the degree is 2 and the
points are not coplanar, the curve may have discontinuities in the
tangent at the points.
See Also
--------
:class:`PolyLine`: a BezierSpline of degree 1 with more specialized methods
:class:`~plugins.nurbs.NurbsCurve`: Industry standard curves with a finer
control of smoothness.
Notes
-----
The combined use of coords and control is no longer permitted.
For such cases one can use the arraytools.interleave function to combine
them into a single full set of control points. Thus, instead of::
BezierSpline(coords=Q, control=R, degree=2)
BezierSpline(coords=Q, control=R, degree=3)
use::
BezierSpline(control=at.interleave(Q, R[:,0]), degree=2)
BezierSpline(control=at.interleave(Q, R[:,0], R[:,1]), degree=3)
Examples
--------
>>> B = BezierSpline('0123')
>>> print(B)
BezierSpline: degree=3, nparts=3, ncoords=10, open
Control points:
[[ 0. 0. 0. ]
[ 0.316 -0.105 0. ]
[ 0.764 -0.236 0. ]
[ 1. 0. 0. ]
[ 1.236 0.236 0. ]
[ 1.236 0.764 0. ]
[ 1. 1. 0. ]
[ 0.764 1.236 0. ]
[ 0.316 1.105 0. ]
[ 0. 1. 0. ]]
>>> print(B.pointsOn())
[[0. 0. 0.]
[1. 0. 0.]
[1. 1. 0.]
[0. 1. 0.]]
>>> print(B.pointsOff())
[[[ 0.316 -0.105 0. ]
[ 0.764 -0.236 0. ]]
<BLANKLINE>
[[ 1.236 0.236 0. ]
[ 1.236 0.764 0. ]]
<BLANKLINE>
[[ 0.764 1.236 0. ]
[ 0.316 1.105 0. ]]]
>>> print(B.points(1))
[[1. 0. 0. ]
[1.236 0.236 0. ]
[1.236 0.764 0. ]
[1. 1. 0. ]]
>>> B = BezierSpline('0123', degree=1)
>>> print(B)
BezierSpline: degree=1, nparts=3, ncoords=4, open
Control points:
[[0. 0. 0.]
[1. 0. 0.]
[1. 1. 0.]
[0. 1. 0.]]
>>> B = BezierSpline('0123', degree=1, closed=True)
>>> print(B.ctype)
BezierSpline: degree=1, nparts=4, ncoords=4, closed
"""
def __init__(self, coords=None, *, control=None, degree=3, closed=False,
tol=None, tangents=None, curl=1/3, **kargs):
"""Create a BezierSpline curve."""
super().__init__()
if degree <= 0:
raise ValueError("Degree of BezierSpline should be > 0!")
if coords is None:
# control points are specified
control = Coords(control)
if control.ndim != 2:
raise ValueError("The control parameter should be a 2-dim array")
# TODO: check number of control points
# TODO: if the interpolate functions work with the extra point,
# we could move this below.
if closed:
if tol is None:
tol = 1.e-5 * control.dsize()
if at.length(control[0] - control[-1]) < tol:
control = control[:-1]
else:
# interpolate: control points are to be computed
coords = Coords(coords)
if coords.ndim != 2:
raise ValueError("Coords(npts,3) required.")
ncoords = coords.shape[0]
if ncoords < 2:
raise ValueError("Need at least two points to create a curve")
if closed:
if tol is None:
tol = 1.e-5 * coords.dsize()
if at.length(coords[0] - coords[-1]) < tol:
coords = coords[:-1]
if 'deriv' in kargs:
# Deprecated deriv but still accepting
utils.warn('depr_bezierspline_deriv')
tangents = kargs.pop('deriv')
if kargs:
raise ValueError(f"Unknown parameter(s): {list(kargs.keys())}")
if degree == 1:
control = coords
elif degree == 2:
control = _quadratic_bezier_interpolate(
coords, tangents, closed=closed)
elif degree == 3:
control = _cubic_bezier_interpolate(
coords, tangents, closed=closed, curl=curl)
else:
raise ValueError(
"The coords parameter can only be used with degree <=3")
self.degree = degree
self.coeffs = bezierPowerMatrix(degree)
self.coords = control
self.closed = closed
@property
def nparts(self):
"""The number of parts in the BezierSpline"""
n = self.coords.shape[0]
if self.closed or self.degree > 1:
return n // self.degree
else:
return n - 1
@property
def npoints(self):
"""The number of oncurve points in the BezierSpline
This is equal to the number of parts if the curve is closed;
else, it is one more.
"""
n = self.nparts
return n if self.closed else n + 1
[docs] def pointsOn(self):
"""Return the control points that are on the curve.
Returns
-------
:class:`~coords.Coords` (nparts+1, 3):
The coordinates of the nparts+1 points on the curve.
For a closed curve, the last point will be equal to the first.
"""
return self.coords[::self.degree]
[docs] def pointsOff(self):
"""Return the control points that are off the curve.
Returns
-------
:class:`~coords.Coords` (nparts, degree-1, 3):
The coordinates of the intermediary control points for the
nparts in dividual Bezier curves. For degree=1, an empty Coords
is returned.
"""
if self.degree > 1:
return self.coords[:-1].reshape(-1, self.degree, 3)[:, 1:]
else:
return Coords()
[docs] def points(self, j=None, k=None):
"""Return the point sequence defining parts j to k of the curve.
The returned points sequence can be used to create a curve
identical to the parts j:k of the curve.
Parameters
----------
j: int
The first (or only) part to return. The value should be in
the range 0..nparts. If not provided, all points are returned.
k: int, optional
One more than the last part to return. Its value should be in
the range 1..nparts+1. For an open curve, k should be > j. For
a closed curve, k <= j is allowed to take a points spanning
the first/last point.
If not provided, k is set to j+1, resulting in the points for
a single part.
Returns
-------
:class:`~coords.Coords` ((k-j)*degree+1, 3)
The sequence of points from self.coords that define parts j to k
of the curve.
See Also
--------
part_points: return an array of single part points
parts: return a curve identical to parts j:k
Examples
--------
>>> C = BezierSpline('1234', degree=2)
>>> print(C.coords)
[[ 1. 0. 0. ]
[ 1.25 0.75 0. ]
[ 1. 1. 0. ]
[ 0.5 1.5 0. ]
[ 0. 1. 0. ]
[-0.25 0.75 0. ]
[ 0. 0. 0. ]]
>>> print(C.points(0))
[[1. 0. 0. ]
[1.25 0.75 0. ]
[1. 1. 0. ]]
>>> print(C.points(1,3))
[[ 1. 1. 0. ]
[ 0.5 1.5 0. ]
[ 0. 1. 0. ]
[-0.25 0.75 0. ]
[ 0. 0. 0. ]]
Notice the difference with :meth:`part_points`:
>>> print(C.part_points([1,2]))
[[[ 1. 1. 0. ]
[ 0.5 1.5 0. ]
[ 0. 1. 0. ]]
<BLANKLINE>
[[ 0. 1. 0. ]
[-0.25 0.75 0. ]
[ 0. 0. 0. ]]]
Closed curve example:
>>> C = BezierSpline('1234', degree=2, closed=True)
>>> print(C.coords)
[[ 1. 0. 0. ]
[ 1.5 0.5 0. ]
[ 1. 1. 0. ]
[ 0.5 1.5 0. ]
[ 0. 1. 0. ]
[-0.5 0.5 0. ]
[ 0. 0. 0. ]
[ 0.5 -0.5 0. ]]
>>> print(C.points(0))
[[1. 0. 0. ]
[1.5 0.5 0. ]
[1. 1. 0. ]]
>>> print(C.points(3,1))
[[ 0. 0. 0. ]
[ 0.5 -0.5 0. ]
[ 1. 0. 0. ]
[ 1.5 0.5 0. ]
[ 1. 1. 0. ]]
"""
if j is None:
return self.coords
if k is None:
k = j + 1
if j < 0 or k < 0 or j > self.nparts or k > self.nparts:
raise ValueError(
f"j, k-1 should be in range(0,nparts) with nparts={self.nparts}")
degree = self.degree
start = degree * j
end = degree * k + 1
if self.closed:
npoints = self.ncoords()
if end > npoints:
end %= npoints
if k <= j or end == 1:
# this works whether the last point of a closed curve
# is stored or not.
# Note: this is faster than a roll + single take
end1 = degree * self.nparts
coords = Coords.concatenate([
self.coords[start:end1], self.coords[:end]])
else:
coords = self.coords[start:end]
else:
if k <= j:
raise ValueError("Open curves require k > j.")
coords = self.coords[start:end]
return coords
[docs] def split(self, split=None):
"""Split a curve into a list of partial curves
Parameters
----------
split: list(int)
A list of integer values specifying the point numbers
where the curve is to be split. As a convenience, a single int may
be given if the curve is to be split only at a single point,
or None, to split at all nodes.
Returns
-------
list
A list of open curves of the same type as the original. Together,
the curves cover the original curve.
Notes
-----
Only multipart curves can be split.
Splitting a closed curve at a single point creates a single open curve
with begin/end at the split point.
Examples
--------
>>> PL = PolyLine(Coords('0123'))
>>> for PLi in PL.split(): print(PLi)
PolyLine: nparts=1, ncoords=2, open
Control points:
[[0. 0. 0.]
[1. 0. 0.]]
PolyLine: nparts=1, ncoords=2, open
Control points:
[[1. 0. 0.]
[1. 1. 0.]]
PolyLine: nparts=1, ncoords=2, open
Control points:
[[1. 1. 0.]
[0. 1. 0.]]
"""
if not hasattr(self, 'parts'):
raise ValueError(
f"Curves of type {self.__class__.__name__} can not be split")
if split is None:
split = np.arange(1, self.nparts)
else:
if at.isInt(split):
split = [split]
split = np.unique(split)
if len(split) == 0:
return [self.copy()]
if self.closed:
if split[-1] != split[0]+self.nparts:
split = np.concatenate([split, [split[0]]])
else:
if split[0] != 0:
split = np.concatenate([[0], split])
if split[-1] != self.nparts:
split = np.concatenate([split, [self.nparts]])
return [self.parts(j, k) for j, k in zip(split[:-1], split[1:])]
[docs] def parts(self, j, k):
"""Return a curve containing only parts j to k.
Parameters
----------
j: int
The first part to include in the new curve. The value should be in
range(0,nparts).
k: int
One more than the last part to include.
Returns
-------
BezierSpline
An open BezierSpline of the same degree as the input curve,
containing only the parts j..k of it.
Examples
--------
>>> PL = PolyLine(Coords('0123'))
>>> print(PL.ctype)
PolyLine: nparts=3, ncoords=4, open
>>> print(PL.parts(1,3))
PolyLine: nparts=2, ncoords=3, open
Control points:
[[1. 0. 0.]
[1. 1. 0.]
[0. 1. 0.]]
>>> PLC = PL.close()
>>> print(PLC)
PolyLine: nparts=4, ncoords=4, closed
Control points:
[[0. 0. 0.]
[1. 0. 0.]
[1. 1. 0.]
[0. 1. 0.]]
>>> print(PLC.ctype)
PolyLine: nparts=4, ncoords=4, closed
>>> print(PLC.parts(3,1))
PolyLine: nparts=2, ncoords=3, open
Control points:
[[0. 1. 0.]
[0. 0. 0.]
[1. 0. 0.]]
>>> print(PLC.parts(0,1))
PolyLine: nparts=1, ncoords=2, open
Control points:
[[0. 0. 0.]
[1. 0. 0.]]
"""
return self.__class__(control=self.points(j, k), degree=self.degree)
[docs] def part_points(self, j=None):
"""Return the defining points for parts j.
Parameters
----------
j: int | int :term:`array_like`
The part number(s)for which to return the defining points.
If not provided, all parts are returned.
Returns
-------
:class:`~coords.Coords`
The coordinates of the points defining each of the parts j
of the curve. If j is a single int, the shape of the Coords
(degree+1, 3), else it is (len(j), degree+1, 3).
See Also
--------
points: return a single sequence of points for parts j to k
Examples
--------
>>> C = BezierSpline('1234', degree=2)
>>> print(C.coords)
[[ 1. 0. 0. ]
[ 1.25 0.75 0. ]
[ 1. 1. 0. ]
[ 0.5 1.5 0. ]
[ 0. 1. 0. ]
[-0.25 0.75 0. ]
[ 0. 0. 0. ]]
>>> print(C.part_points(0))
[[1. 0. 0. ]
[1.25 0.75 0. ]
[1. 1. 0. ]]
>>> print(C.part_points([2,0]))
[[[ 0. 1. 0. ]
[-0.25 0.75 0. ]
[ 0. 0. 0. ]]
<BLANKLINE>
[[ 1. 0. 0. ]
[ 1.25 0.75 0. ]
[ 1. 1. 0. ]]]
"""
if j is None:
j = np.arange(self.nparts)
if np.isscalar(j):
return self.points(j)
else:
j = np.asarray(j).reshape(-1) # force a 1-d array
return np.stack([self.points(i) for i in j])
[docs] def sub_points(self, t, j=None):
"""Return points on the curve at parameter values t in part(s) j.
Parameters
----------
t: float :term:`array_like`
Local parameter values (usually in the range 0.0..1.0) of
the points to return.
j: int or int :term:`array_like`
The part number(s)for which to return points.
If not provided, points on all parts are returned.
Returns
-------
:class:`~coords.Coords`
The coordinates of the points on the curve at local parameter
values t of the parts j. The shape of the Coords is (len(t), 3)
if j is a single int, else the shape is (len(j), len(t), 3).
Examples
--------
>>> C = BezierSpline('1234', degree=2)
>>> C.sub_points([0.0, 0.2, 0.5])
Coords([[[ 1. , 0. , 0. ],
[ 1.08 , 0.28 , 0. ],
[ 1.125, 0.625, 0. ]],
<BLANKLINE>
[[ 1. , 1. , 0. ],
[ 0.8 , 1.16 , 0. ],
[ 0.5 , 1.25 , 0. ]],
<BLANKLINE>
[[ 0. , 1. , 0. ],
[-0.08 , 0.88 , 0. ],
[-0.125, 0.625, 0. ]]])
>>> C.sub_points([0.0, 0.2, 0.5], 1)
Coords([[1. , 1. , 0. ],
[0.8 , 1.16, 0. ],
[0.5 , 1.25, 0. ]])
>>> C.sub_points([0.0, 0.2, 0.5], [1, 0])
Coords([[[1. , 1. , 0. ],
[0.8 , 1.16 , 0. ],
[0.5 , 1.25 , 0. ]],
<BLANKLINE>
[[1. , 0. , 0. ],
[1.08 , 0.28 , 0. ],
[1.125, 0.625, 0. ]]])
"""
t = np.asarray(t).reshape(-1)
P = self.part_points(j)
C = np.dot(self.coeffs, P)
if C.ndim > 2:
C = C.transpose(1, 0, 2)
U = [t**d for d in range(0, self.degree+1)]
U = np.column_stack(U)
X = np.dot(U, C)
if X.ndim > 2:
X = X.transpose(1, 0, 2)
return Coords(X)
[docs] def sub_directions(self, t, j=None):
"""Return the unit direction vectors at values t in part j.
Parameters
----------
t: float :term:`array_like`
Local parameter values (usually in the range 0.0..1.0) of
the points at which to find the direction vector.
j: int or int :term:`array_like`
The part number(s) for which to return directions.
If not provided, directions for t on all parts are returned.
Returns
-------
:class:`array`
The unit direction vectors of the curve at the points with local
parameter values t on the parts j. The shape of the array is
(len(t), 3) if j is a single int, else it is (len(j), len(t), 3).
Examples
--------
>>> C = BezierSpline('1234', degree=2)
>>> C.sub_directions([0.0, 0.5, 1.0], 1)
array([[-0.707, 0.707, 0. ],
[-1. , 0. , 0. ],
[-0.707, -0.707, 0. ]])
>>> C.sub_directions([0.0, 0.5, 1.0])
array([[[ 0.316, 0.949, 0. ],
[ 0. , 1. , 0. ],
[-0.707, 0.707, 0. ]],
<BLANKLINE>
[[-0.707, 0.707, 0. ],
[-1. , 0. , 0. ],
[-0.707, -0.707, 0. ]],
<BLANKLINE>
[[-0.707, -0.707, 0. ],
[ 0. , -1. , 0. ],
[ 0.316, -0.949, 0. ]]])
"""
t = np.asarray(t).reshape(-1)
P = self.part_points(j)
C = np.dot(self.coeffs, P)
if C.ndim > 2:
C = C.transpose(1, 0, 2)
U = [d*(t**(d-1)) if d >= 1 else np.zeros_like(t)
for d in range(0, self.degree+1)]
U = np.column_stack(U)
T = np.dot(U, C)
if T.ndim > 2:
T = T.transpose(1, 0, 2)
T = at.normalize(T)
return T
# TODO: This is nowhere used. Create an example?
# TODO: to be replaced with general frenet method
[docs] def sub_curvature(self, t, j=None):
"""Return the curvature at values t in part j.
Parameters
----------
t: float :term:`array_like`
Local parameter values (usually in the range 0.0..1.0) of
the points at which to find the curvature.
j: int or int :term:`array_like`
The part number(s) for which to return curvatures.
If not provided, curvatures for all parts are returned.
Returns
-------
:class:`array`
The curvature of the curve at the points with local
parameter values t on the parts j. The shape of the return array is
(len(t),) if j is a single int, else it is (len(j), len(t)).
Examples
--------
>>> C = BezierSpline('1234', degree=2)
>>> C.sub_curvature([0.0, 0.5, 1.0], 0)
array([0.253, 1. , 2.828])
>>> C.sub_curvature([0.0, 0.5, 1.0], 1)
array([0.707, 2. , 0.707])
>>> C.sub_curvature([0.0, 0.5, 1.0])
array([[0.253, 1. , 2.828],
[0.707, 2. , 0.707],
[2.828, 1. , 0.253]])
"""
t = np.asarray(t).reshape(-1)
P = self.part_points(j)
C = np.dot(self.coeffs, P)
if C.ndim > 2:
C = C.transpose(1, 0, 2)
U1 = [d*(t**(d-1)) if d >= 1 else np.zeros_like(t)
for d in range(0, self.degree+1)]
U1 = np.column_stack(U1)
T1 = np.dot(U1, C)
U2 = [d*(d-1)*(t**(d-2)) if d >=2 else np.zeros_like(t)
for d in range(0, self.degree+1)]
U2 = np.column_stack(U2)
T2 = np.dot(U2, C)
K = at.length(np.cross(T1, T2))/(at.length(T1)**3)
if T1.ndim > 2:
K = K.transpose(1, 0)
return K
[docs] def directions(self):
"""Return the direction vectors at the points on the curve"""
return self.directionsAt(np.arange(self.npoints))
def _length_intgrnd(self, t, j):
P = self.points(j)
C = np.dot(self.coeffs, P)
U = [d*(t**(d-1)) if d >= 1 else 0. for d in range(0, self.degree+1)]
U = np.asarray(U)
T = np.dot(U, C).reshape(3)
return at.length(T)
[docs] def lengths(self, t=(0., 1.), j=None):
"""Return the length of the parts of the curve.
Parameters
----------
t: tuple of float
The range of parameter values over which to integrate the length.
Default is 0.0..1.0, to obtaind the full part length.
j: int or int :term:`array_like`
The part number(s) for which to return lengths.
If not provided, lengths for all parts are returned.
Returns
-------
:class:`array`
The lengths of the parts j of the curve between parameter values t.
Examples
--------
>>> C = BezierSpline('1234', degree=2)
>>> C.lengths()
array([1.046, 1.148, 1.046])
>>> C.lengths((0.0, 0.5), [0,1])
array([0.64 , 0.574])
"""
try:
from scipy import integrate
except ImportError:
pf.warning("""..
**The **lengths** function is not available.
Most likely because 'python-scipy' is not installed on your system.""")
return
if j is None:
j = np.arange(self.nparts)
else:
j = np.asarray(j).reshape(-1)
L = [integrate.quad(self._length_intgrnd, t[0], t[1], args=(i,))[0]
for i in j]
return np.array(L)
[docs] def atLength(self, l, approx=20):
"""Return the parameter values at given relative curve length.
Parameters
----------
l: :term:`array_like`
The list of relative curve lengths (ranging from 0.0 to 1.0
over the full length of the curve).
As a convenience, a single integer value may be specified,
in which case the relative curve lengths are found by dividing
the interval [0.0,1.0] in the specified number of subintervals.
approx: int or None.
If not None, an approximate result is returned obtained by
approximating the curve first by a PolyLine with `approx`
number of line segments per curve segment.
This is currently the only implemented method, and specifying
None will fail.
Returns
-------
list
A list with the parameter values for the points at the specified
relative lengths.
Examples
--------
>>> C = BezierSpline('1234', degree=2)
>>> C.atLength([0.0, 1/3, 0.5, 0.75, 1.0])
array([0. , 1.025, 1.5 , 2.314, 3. ])
"""
if at.isInt(approx) and approx > 0:
P = self.approx(ndiv=approx)
return P.atLength(l) / approx # this is not recursive
else:
raise ValueError("approx should be int and > 0")
[docs] def insertPointsAt(self, t, return_indices=False):
"""Insert new points on the curve at parameter values t.
Parameters
----------
t: float :term:`array_like`
A list of global parameter values where the new points will be
inserted.
** Currently there is a maximum of one new point per segment. **
return_indices: bool
If True, returns the indices of the new points in the BezierSpline.
Returns
-------
BezierSpline
The BezierSpline with extra points inserted at the specified
parameter values.
indices: int array
The indices in the new curve's :meth:`pointsOn` of the inserted
oncurve points. Only returned if `return_indices` is True.
See Also
--------
PolyLine.insertPointsAt: allows multiple insertions on same segment
Examples
--------
>>> C = BezierSpline('012', degree=2)
>>> print(C)
BezierSpline: degree=2, nparts=2, ncoords=5, open
Control points:
[[ 0. 0. 0. ]
[ 0.75 -0.25 0. ]
[ 1. 0. 0. ]
[ 1.25 0.25 0. ]
[ 1. 1. 0. ]]
>>> C1 = C.insertPointsAt([0.5, 1.5])
>>> print(C1)
BezierSpline: degree=2, nparts=4, ncoords=9, open
Control points:
[[ 0. 0. 0. ]
[ 0.375 -0.125 0. ]
[ 0.625 -0.125 0. ]
[ 0.875 -0.125 0. ]
[ 1. 0. 0. ]
[ 1.125 0.125 0. ]
[ 1.125 0.375 0. ]
[ 1.125 0.625 0. ]
[ 1. 1. 0. ]]
>>> C1 = C.insertPointsAt([-0.5])
>>> print(C1)
BezierSpline: degree=2, nparts=3, ncoords=7, open
Control points:
[[-0.875 0.375 0. ]
[-0.375 0.125 0. ]
[ 0. 0. 0. ]
[ 0.75 -0.25 0. ]
[ 1. 0. 0. ]
[ 1.25 0.25 0. ]
[ 1. 1. 0. ]]
"""
t = np.asarray(t).ravel()
t.sort()
X = []
points = [] # points of left over parts
if return_indices:
ind = -np.ones(self.npoints, dtype=at.Int)
# Loop in descending order to avoid recomputing parameters
n = len(t)
all_j, all_u = self.localParam(t[::-1]) # reverse order!
k = self.nparts # last part
for j, u in zip(all_j, all_u):
P = self.points(j)
if j==k:
raise ValueError(
"Currently there is max. one split point per curve part")
# Push the tail beyond current part
if k > j+1:
points[0:1] = self.points(j+1, k).tolist()
L, R = splitBezier(P, u)
if u < 0.:
L, R = L[::-1], P
elif u > 1.:
L, R = P, R[::-1]
# Push the right part
points[0:1] = R.tolist()
# Save the new segment
X.insert(0, Coords.concatenate(points))
if return_indices:
n -= 1
if u < 0.:
ind = np.concatenate([[n], ind])
elif u > 1.:
ind = np.concatenate([ind, [n]])
else:
ind = np.concatenate([ind[:j+1], [n], ind[j+1:]])
# Save left part
points = L.tolist()
k = j
if k > 0:
points = self.points(0, k).tolist()[:-1]+points
X.insert(0, Coords.concatenate(points))
X = Coords.concatenate([X[0]] + [xi[1:] for xi in X[1:]])
C = self.__class__(control=X, degree=self.degree, closed=self.closed)
if return_indices:
ind = ind.tolist()
ind = [ind.index(i) for i in np.arange(len(t))]
return C, ind
else:
return C
[docs] def splitAt(self, t):
"""Split a BezierSpline at parameter values.
Parameters
----------
t: float :term:`array_like`
A list of global parameter values where the curve will be split.
For a BezierSpline there is currently a maximum of one split point
per segment. The PolyLine subclass however allows multiple split
points per segment.
Returns
-------
list(BezierSpline)
A list of len(t)+1 open BezierSplines of the same degree
as the input.
Examples
--------
>>> C = BezierSpline('012', degree=2)
>>> CL = C.splitAt([0.5, 1.5])
>>> for Ci in CL: print(Ci)
BezierSpline: degree=2, nparts=1, ncoords=3, open
Control points:
[[ 0. 0. 0. ]
[ 0.375 -0.125 0. ]
[ 0.625 -0.125 0. ]]
BezierSpline: degree=2, nparts=2, ncoords=5, open
Control points:
[[ 0.625 -0.125 0. ]
[ 0.875 -0.125 0. ]
[ 1. 0. 0. ]
[ 1.125 0.125 0. ]
[ 1.125 0.375 0. ]]
BezierSpline: degree=2, nparts=1, ncoords=3, open
Control points:
[[1.125 0.375 0. ]
[1.125 0.625 0. ]
[1. 1. 0. ]]
"""
C, t = self.insertPointsAt(t, return_indices=True)
return C.split(t)
[docs] def splitAtLength(self, L):
"""Split a BezierSpline at relative lenghts L.
This is a convenient shorthand for::
self.splitAt(self.atLength(L))
See Also
--------
splitAt: split a PolyLine at given parameter values
atLength: compute parameter values at given curve length
"""
return self.splitAt(self.atLength(L))
[docs] def extend(self, start=None, end=None):
"""Extend the curve beyond its endpoints.
Uses de Casteljau's algorithm to add an extra curve part before
the first and/or after the last part of the :class:`BezierSpline`.
Parameters
----------
start: float
Parameter values to extend the :class:`BezierSpline` at
the start. Values start with zero at the start of the curve and
run in the direction opposite to that of segment 0. The new
start point of the curve will thus become the point that would
be inserted by ``self.insertPointsAt([-start])``.
end: float
Parameter value to extend :class:`BezierSpline` at
the end. Values start with zero at the end of the curve and
run beyond thet point along the last segment. The new
start point of the curve will thus become the point that would
be inserted by ``self.insertPointsAt([self.nparts + end])``.
Returns
-------
BezierSpline
A Curve of same type and degree as the input, extended at
the start and/or the end.
Notes
-----
Only open curves can be extended.
See Also
--------
PolyLine.extend: allows a list of values for start/end
Examples
--------
>>> PL = PolyLine(Coords('012'))
>>> PL.extend(0.4, 0.5).coords
Coords([[-0.4, 0. , 0. ],
[ 0. , 0. , 0. ],
[ 1. , 0. , 0. ],
[ 1. , 1. , 0. ],
[ 1. , 1.5, 0. ]])
>>> C = BezierSpline('012', degree=1)
>>> C.extend(0.4, 0.5).coords
Coords([[-0.4, 0. , 0. ],
[ 0. , 0. , 0. ],
[ 1. , 0. , 0. ],
[ 1. , 1. , 0. ],
[ 1. , 1.5, 0. ]])
"""
t = []
if start is not None:
t.append(-start)
if end is not None:
t.append(self.nparts + end)
return self.insertPointsAt(t)
[docs] def close(self, tangents=False, tol=None):
"""Close a Curve
Creates a closed curve throught the oncurve points of the current,
and possibly with the same tangents.
Parameters
----------
tangents: bool
If True, the returned curve will have the same tangents
at all internal points.
tol: float
Tolerance for testing end point coincidence when creating
closed curves. This parameter is passed to :meth:`ipol`.
Returns
-------
BezierSpline | PolyLine
A closed variant of the input curve. If that is already closed,
just returns self.
"""
if self.closed:
return self
kargs = {
'coords': self.pointsOn(),
'degree': self.degree,
'closed': True,
'tol': tol,
}
if self. degree > 1 and tangents:
T = self.directions()
T[0] = T[-1] = at.normalize(0.5 * (T[0] + T[-1]))
kargs['tangents'] = T
return self.__class__(**kargs)
[docs] def reverse(self):
"""Return the same curve with the parameter direction reversed."""
control = at.reverseAxis(self.coords, axis=0)
return self.__class__(control=control, degree=self.degree,
closed=self.closed)
[docs] def roll(self, n):
"""Roll the parts of a closed curve.
This rolls the start vertex of the curve n positions further.
Parameters
----------
n: int
The number of vertex positions to roll over: part 0
then becomes part n.
Returns
-------
BezierSpline | PolyLine
The same curve as the input, but with the vertex numbers
rolled over n positions.
Notes
-----
This only works for a closed curve.
Examples
--------
>>> C = BezierSpline('012', degree=2, closed=True)
>>> print(C.coords)
[[ 0. 0. 0. ]
[ 0.293 -0.707 0. ]
[ 1. 0. 0. ]
[ 1.707 0.707 0. ]
[ 1. 1. 0. ]
[-0.707 1.707 0. ]]
>>> print(C.roll(1).coords)
[[ 1. 1. 0. ]
[-0.707 1.707 0. ]
[ 0. 0. 0. ]
[ 0.293 -0.707 0. ]
[ 1. 0. 0. ]
[ 1.707 0.707 0. ]]
"""
if not self.closed:
raise ValueError("""Can only roll a closed curve""")
return self.__class__(
control=np.roll(self.coords, n*self.degree, axis=0),
degree=self.degree, closed=True)
[docs] def toMesh(self):
"""Convert a BezierSpline to a Mesh.
Returns
-------
Mesh
A Mesh of eltype 'line2', 'line3' or 'line4', where each element
corresponds with a part of the BezierSpline. This gives an exact
representation of a BezierSpline of degree 1, 2, or 3 or a
PolyLine.
Notes
-----
Currently, only BezierSplines of degree <= 3 can be converted to a Mesh.
For higher degrees, create an approximation of degree <= 3 first.
Examples
--------
>>> M = PolyLine('0123').toMesh()
>>> print(M.coords)
[[0. 0. 0.]
[1. 0. 0.]
[1. 1. 0.]
[0. 1. 0.]]
>>> print(M.elems)
[[0 1]
[1 2]
[2 3]]
>>> M = BezierSpline('0123',degree=2,closed=True).toMesh()
>>> print(M.coords)
[[ 0. 0. 0. ]
[ 0.5 -0.25 0. ]
[ 1. 0. 0. ]
[ 1.25 0.5 0. ]
[ 1. 1. 0. ]
[ 0.5 1.25 0. ]
[ 0. 1. 0. ]
[-0.25 0.5 0. ]]
>>> print(M.elems)
[[0 1 2]
[2 3 4]
[4 5 6]
[6 7 0]]
"""
if self.degree > 3:
raise ValueError(
"BezierSpline of degree > 3 can not be converted to Mesh")
if self.degree == 1:
X = self.coords
else:
t = {2: (0., 0.5), 3: (0., 1/3, 2/3)}[self.degree]
X = [self.sub_points(t, j) for j in range(self.nparts)]
if not self.closed:
X.append(self.coords[-1:])
X = Coords.concatenate(X)
nplex = self.degree + 1
c1 = np.arange(0, self.degree*self.nparts, self.degree)
e = np.column_stack([c1+i for i in range(nplex)])
if self.closed:
e[-1, -1] = e[0, 0]
return Mesh(X, e, eltype=f"line{nplex}", prop=self.prop)
###################
## PZF interface ##
[docs] def pzf_dict(self):
kargs = super().pzf_dict()
kargs['control'] = kargs.pop('coords')
kargs[f'degree:i__{self.degree}'] = None
kargs[f'closed:b__{self.closed}'] = None
return kargs
###########################################################################
# Functions constructing BezierSpline interpolates
#
def _cubic_bezier_interpolate(Q, T=None, closed=False, curl=1/3):
"""Create a cubic BezierSpline passing through given points.
coords: Coords(npts, 3)
tangents: Coords(npts, 3) or (2,3)
closed: bool
curl:
"""
P = PolyLine(control=Q, closed=closed)
ncoords = P.coords.shape[0]
ampl = P.lengths().reshape(-1, 1)
if T is None:
T = P.avgDirections()
else:
deriv = at.checkArray(T, (-1, 3), 'f', 'i')
nderiv = deriv.shape[0]
if not (nderiv == ncoords or nderiv == 2):
raise ValueError(
f"Expected either all or the start and end tangents, "
f"but got {nderiv}")
if nderiv == ncoords:
T = deriv
else:
T = np.full(Q.shape, np.nan, dtype=at.Float)
T[0], T[-1] = deriv
undefined = np.isnan(T).any(axis=-1)
if undefined.any():
T[undefined] = P.avgDirections()[undefined]
if closed:
R1 = Q + T*curl*ampl
R2 = Q - T*curl*np.roll(ampl, 1, axis=0)
R2 = np.roll(R2, -1, axis=0)
segments = np.stack([Q, R1, R2], axis=1).reshape(-1, 3)
else:
R1 = Q[:-1] + T[:-1]*curl*ampl
R2 = Q[1:] - T[1:]*curl*ampl
segments = np.stack([Q[:-1], R1, R2], axis=1)
segments = Coords(segments.reshape(-1, 3))
segments = Coords.concatenate([segments, Q[-1]])
return segments
# return BezierSpline(control=segments, degree=3, closed=closed)
def _quadratic_bezier_interpolate(Q, T=None, *, closed=False):
"""Create a quadratic BezierSpline interpolating given points.
Parameters
----------
Q: :term:`coords_like` (npts, 3)
The points through which the quadratic BezierSpline should pass.
T: :term:`coords_like` (npts, 3), optional
The tangents to the curve at the points Q. The length of the
vectors is irrelevant. If not provided, it is set equal to
``PolyLine(Q).avgDirections()``.
Returns
-------
BezierSpline
A BezierSpline of the second degree, passing through all points Q,
and with tangents (approximately) equal to the tangents T.
Notes
-----
If the points Q are coplanar, a planar curve results, the tangents
are exactly T and the curve is geometrically smooth.
A nonplanar curve however may contain discontinuities in the tangent
directions. A :func:`cubicBezierInterpolate` on the other hand
guarantees smoothness even for nonplanar curves.
"""
def handle_special(Q0, Q1, T0, T1, t0, t1):
QQ = at.normalize(Q1 - Q0)
if np.allclose(T0, QQ) and np.allclose(T1, QQ):
# straight segment
return [0.5 * (Q0 + Q1)]
elif np.allclose(T0, T1):
# parallel tangents: inflection point in the middle
Qm = 0.5 * (Q0 + Q1)
Tm = 2*(Q1-Q0) - T1
P0, P1 = gt.intersectLineWithLine(Q0, T0, Qm, Tm, mode='pair')
R0 = 0.5 * (P0+P1)
P0, P1 = gt.intersectLineWithLine(Q1, T1, Qm, Tm, mode='pair')
R1 = 0.5 * (P0+P1)
return [R0, Qm, R1]
else:
gamma = 0.25 * at.length(Q1-Q0)
R0 = Q0 + gamma * T0
R1 = Q1 - gamma * T1
Qm = 0.5 * (R0 + R1)
return [R0, Qm, R1]
Q = at.checkArray(Q, (-1, 3), 'f', 'i')
ncoords = Q.shape[0]
if ncoords < 2:
raise ValueError("Need at least two points to create a curve")
if T is None:
T = PolyLine(Q, closed=closed).avgDirections()
else:
T = at.checkArray(T, Q.shape, 'f', 'i')
if closed:
Q0, T0 = Q, T
Q1, T1 = np.roll(Q, -1, axis=0), np.roll(T, -1, axis=0)
else:
Q0, T0 = Q[:-1], T[:-1]
Q1, T1 = Q[1:], T[1:]
t0, t1 = gt.intersectLineWithLine(Q0, T0, Q1, T1, mode='pair', times=True)
R = 0.5 * (Q0 + t0[:, np.newaxis]*T0 + Q1 + t1[:, np.newaxis]*T1)
ok = (t0 > 0.0) * (t1 < 0.0)
segments = []
n = Q.shape[0]
for i in range(n if closed else n-1):
segments.append(Q[i])
if ok[i]:
segments.append(R[i])
else:
j = i+1 if i+1 < n else 0
extra = handle_special(Q[i], Q[j], T[i], T[j], t0[i], t1[i])
segments.extend(extra)
if not closed:
segments.append(Q[-1])
segments = Coords.concatenate(segments)
return segments
# return BezierSpline(control=segments, degree=2, closed=closed)
##############################################################################
#
# PolyLine
#
##############################################################################
[docs]@utils.pzf_register
class PolyLine(BezierSpline):
"""A Curve consisting of a sequence of straight line segments.
This is implemented as a :class:`BezierSpline` of degree 1, and in many
cases one can just use that super class. The PolyLine class however
provides a lot of extra functionality, which is only available for
curves of degree 1.
Parameters
----------
coords: :term:`coords_like`
An (npts,3) shaped array with the coordinates of the subsequent
vertices of the PolyLine, or any other data accepted by the
:class:`Coords` initialization.
Each vertex is connected to the next to form a PolyLine segment.
For compatibility with the :class:`BezierSpline` class, this
argument can also be named `control`, and takes precedent if both
are used.
closed: bool, optional
If True, the PolyLine is closed by connecting the last vertex back
to the first. The closed PolyLine thus has `npts` segments.
The default (False) leaves the curve open with `npts-1` segments.
control: optional
This is an alias for the coords parameter. It exists only for
symmetry with the :class:`BezierSpline` class.
If specified, `control` overrides the value of `coords`.
Examples
--------
>>> P = PolyLine('0123')
>>> print(P)
PolyLine: nparts=3, ncoords=4, open
Control points:
[[0. 0. 0.]
[1. 0. 0.]
[1. 1. 0.]
[0. 1. 0.]]
>>> P.nparts
3
>>> P = PolyLine(P.coords, closed=True).nparts
>>> print(P)
4
"""
def __init__(self, control=None, *, closed=False, tol=None, **kargs):
"""Initialize a PolyLine"""
control = Coords(kargs.get('coords', control))
super().__init__(control=control, closed=closed, tol=tol, degree=1)
[docs] def vectors(self):
"""Return the vectors from each point to the next one.
Returns
-------
Coords (nparts,3)
A Coords array holding the vectors from each point of the
PolyLine to the next one. The number of vectors is equal to
:attr:`nparts`.
Examples
--------
>>> PolyLine('0123').vectors()
Coords([[ 1., 0., 0.],
[ 0., 1., 0.],
[-1., 0., 0.]])
"""
x = self.coords
if self.closed:
x = Coords.concatenate([x, x[0]])
return x[1:] - x[:-1]
[docs] def directions(self, return_doubles=False):
"""Returns unit vectors in the direction of the next point.
Parameters
----------
return_doubles: bool
If True, also returns an index of coincident points.
Returns
-------
dirs: array(npoints,3)
The unit vectors at all points in the direction of the next
point. If two subsequent points are identical, the first one gets
the direction of the previous segment. If more than two subsequent
points are equal, an invalid direction (NaN) is returned.
If the curve is not closed, the last direction is set equal to the
penultimate.
doubles: array
The indices of the points that coincide with their follower.
Only returned if return_doubles is True.
See Also
--------
avgDirections: the average direction of the segments ending at the point
Examples
--------
>>> PolyLine('0123').directions()
array([[ 1., 0., 0.],
[ 0., 1., 0.],
[-1., 0., 0.],
[-1., 0., 0.]])
>>> P = PolyLine('01.2')
>>> print(P)
PolyLine: nparts=3, ncoords=4, open
Control points:
[[0. 0. 0.]
[1. 0. 0.]
[1. 0. 0.]
[1. 1. 0.]]
>>> P.directions(return_doubles=True)
(array([[1., 0., 0.],
[1., 0., 0.],
[0., 1., 0.],
[0., 1., 0.]]), array([1]))
"""
d = at.normalize(self.vectors())
w = at.where_1d(np.isnan(d).any(axis=1))
d[w] = d[w-1]
if not self.closed:
d = np.concatenate([d, d[-1:]], axis=0)
if return_doubles:
return d, w
else:
return d
[docs] def avgDirections(self, return_indices=False):
"""Returns the average directions at points.
Parameters
----------
return_indices: bool
If True, also returns an index of the points where an
average was computed. This excludes the endpoints and
subsequent conincident points, where the returned direction
is the same as that from :meth:`directions`.
Returns
-------
dirs: array(npoints,3)
The average direction at all points. The returned direction is
the average of the direction from the preceding point to the
current, and the direction from the current to the next point.
If the curve is open, the end directions are set to mimic
a natural free end: the angle with the chord is half the angle
at the other end of the segment, and in opposite direction.
Where two subsequent points coincide, the average directions
are set equal to those of the segment ending in the first point
and the segment starting from the last point.
doubles: array
The indices of the points that coincide with their follower.
Only returned if return_doubles is True.
See Also
--------
directions: the direction from the point to the next point
Examples
--------
>>> PolyLine('0123').avgDirections()
array([[ 0.949, -0.316, 0. ],
[ 0.707, 0.707, 0. ],
[-0.707, 0.707, 0. ],
[-0.949, -0.316, 0. ]])
>>> P = PolyLine('012.3')
>>> print(P)
PolyLine: nparts=4, ncoords=5, open
Control points:
[[0. 0. 0.]
[1. 0. 0.]
[1. 1. 0.]
[1. 1. 0.]
[0. 1. 0.]]
>>> P.avgDirections(return_indices=True)
(array([[ 0.949, -0.316, 0. ],
[ 0.707, 0.707, 0. ],
[ 0. , 1. , 0. ],
[-1. , 0. , 0. ],
[-1. , 0. , 0. ]]), array([1]))
"""
d, w = self.directions(True)
w = np.concatenate([w, w+1])
if not self.closed:
w = np.concatenate([[0, self.nparts], w])
w = np.setdiff1d(np.arange(self.nparts), w)
d[w] = 0.5 * (d[w] + np.roll(d, 1, axis=0)[w])
if not self.closed:
# set free end condition
d[0] = 2 * d[0] - d[1]
d[-1] = 2 * d[-1] - d[-2]
d = at.normalize(d) # need to renormalize!
if return_indices:
return d, w
else:
return d
[docs] def lengths(self, t=(0., 1.), j=None):
"""Return the length of the parts of the PolyLine.
This is like :meth:`BezierSpline.lengths` but implemented
more efficiently for PolyLine
Examples
--------
>>> PolyLine([[0., 0., 0.], [0.4, 0., 0.], [0.4, 0.9, 0.]]).lengths()
array([0.4, 0.9])
"""
lens = at.length(self.vectors()) * (t[1]-t[0])
if j is not None:
lens = lens[j]
return lens
# TODO: this can be done for BezierSpline
[docs] def cumLengths(self):
"""Return the cumulative length of the curve for all vertices."""
return np.concatenate([[0.], self.lengths().cumsum()])
# TODO: this can be done for BezierSpline
[docs] def relLengths(self):
"""Return the relative length of the curve for all vertices."""
cumlen = self.cumLengths()
return cumlen / cumlen[-1]
[docs] def atLength(self, div):
"""Return the parameter values at given relative curve length.
Parameters
----------
div: :term:`array_like`
The list of relative curve lengths (ranging from 0.0 to 1.0
over the full length of the curve).
As a convenience, a single integer value may be specified,
in which case the relative curve lengths are found by dividing
the interval [0.0,1.0] in the specified number of subintervals.
Returns
-------
list
A list with the parameter values for the points at the specified
relative lengths.
Examples
--------
>>> P = PolyLine([[0., 0., 0.], [0.4, 0., 0.], [0.4, 0.9, 0.]])
>>> P.atLength([0., 0.25, 0.5, 0.75, 1.0])
array([0. , 0.812, 1.278, 1.639, 2. ])
"""
rlen = self.relLengths()
if at.isInt(div):
div = np.arange(div+1) / float(div)
else:
div = np.asarray(div)
z = rlen.searchsorted(div)
# we need interpolation
wi = np.where(z>0)[0]
zw = z[wi]
L0 = rlen[zw-1]
L1 = rlen[zw]
ai = zw + (div[wi] - L1) / (L1-L0)
atl = np.zeros(len(div))
atl[wi] = ai
return atl
[docs] def cosAngles(self):
"""Return the cosinus of the angles between subsequent segments.
Returns
-------
array
An array of floats in the range [-1.0..1.0]. The value
at index i is the cosinus of the angle between the segment i and
the segment i-1. The number of floats is always equal to the number
of points.
If the curve is open, the first value is the cosinus of the
angle between the last and the first segment, while the last value
is always equal to 1.0.
Where a curve has two subsequent coincident points, the value for the
the first point is 1.0. Where a curve has more than two
subsequent coincident points, NAN values will result.
Examples
--------
>>> C1 = PolyLine('01567')
>>> C2 = PolyLine('01567',closed=True)
>>> C3 = PolyLine('015674')
>>> print(C1.cosAngles())
[-0.707 0.707 0. 0. 1. ]
>>> print(C2.cosAngles())
[0. 0.707 0. 0. 0.707]
>>> print(C3.cosAngles())
[0. 0.707 0. 0. 0.707 1. ]
"""
d = self.directions()
return at.vectorPairCosAngle(np.roll(d, 1, axis=0), d)
# TODO: add a subdivide(ndiv) method, identical to self.approx(ndiv=ndiv)
def subdivide(self, ndiv):
return self.approx(ndiv=ndiv)
[docs] def insertPointsAt(self, t, return_indices=False):
"""Insert new points at parameter values t
Parameters
----------
t: float :term:`array_like`
A list of global parameter values where the new points will be
inserted. The values can be in any order, but will be sorted
before processing.
Usually values are in the range 0..nparts. However, for open
PolyLines values < 0. will result in points inserted
before the first point, and values > nparts are will result
in points beyond the end of the PolyLine. This is a convenient
way to extend a PolyLine in the direction of its end segments.
return_indices: bool
If True, returns the indices of the new points in the PolyLine.
Returns
-------
PolyLine
A PolyLine with the new points inserted. Note that the
parameter values of the existing points will have changed.
indices: int array
Only returned if `return_indices` is True: the indices of the
inserted points in the returned PolyLine.
Notes
-----
This is like :meth:`BezierSpline.insertPointsAt` but allows multiple
values on the same segment.
Examples
--------
>>> PL = PolyLine(Coords('012'))
>>> PL.insertPointsAt([-0.5, 1.5, 2.5, 3.5]).coords
Coords([[-0.5, 0. , 0. ],
[ 0. , 0. , 0. ],
[ 1. , 0. , 0. ],
[ 1. , 0.5, 0. ],
[ 1. , 1. , 0. ],
[ 1. , 1.5, 0. ],
[ 1. , 2.5, 0. ]])
"""
t = np.sort(t)
if self.closed:
if (t<0.).any() or (t>self.nparts).any():
raise ValueError("For a closed curve all parameter values"
"should be in the range 0..nparts")
X, i, t = self.pointsAt(t, return_position=True)
Xc = self.coords.copy()
if return_indices:
ind = -np.ones(len(Xc), dtype=at.Int)
# Loop in descending order to avoid recomputing parameters
for j in range(len(i))[::-1]:
Xi, ii, ti = X[j].reshape(-1, 3), i[j]+1, t[j]
if ti < 0.:
ii -= 1
elif ti > 1.:
ii += 1
Xc = Coords.concatenate([Xc[:ii], Xi, Xc[ii:]])
if return_indices:
ind = np.concatenate([ind[:ii], [j], ind[ii:]])
PL = PolyLine(Xc, closed=self.closed)
if return_indices:
ind = ind.tolist()
ind = [ind.index(i) for i in np.arange(len(i))]
return PL, ind
else:
return PL
[docs] def extend(self, start=[], end=[]):
"""Extend and open PolyLine beyond its endpoints.
Parameters
----------
start: float or float :term:`array_like`
One or more parameter values to extend the PolyLine at
the start. Parameter values start at point 0 and run in
the direction opposite to that of segment 0, with a value
1.0 being obtained at a distance equal to the length of
the first segment.
end: float or float :term:`array_like`
One or more parameter values to extend the PolyLine at
the end. Parameter values start at point :attr:`nparts` and run in
the direction opposite to that of the last segment , with a value
1.0 being obtained at a distance equal to the length of
the last segment.
Notes
-----
This is equivalent with using :meth:`insertPointsAt` with t
values given by `- start` and `self.nparts + end`.
Only open curves can be extended.
This is like :meth:`BezierSpline.extend` but allows multiple
values for start and/or end.
Examples
--------
>>> PL = PolyLine(Coords('012'))
>>> PL.extend(0.4, [0.3, 0.5]).coords
Coords([[-0.4, 0. , 0. ],
[ 0. , 0. , 0. ],
[ 1. , 0. , 0. ],
[ 1. , 1. , 0. ],
[ 1. , 1.3, 0. ],
[ 1. , 1.5, 0. ]])
"""
if self.closed:
raise ValueError("Can not extend a closed curve")
start = np.atleast_1d(start)
end = np.atleast_1d(end)
t = np.concatenate([-start.ravel(), self.nparts + end.ravel()])
return self.insertPointsAt(t)
[docs] def splitByAngle(self, cosangle=0.0, angle=None, angle_spec=at.DEG):
"""Split a PolyLine at points with high change of direction.
Parameters
----------
cosangle: float
Threshold value for the cosinus of the angle between subsequent
segment vectors. The curve is split at all points where the
value of :meth:`cosAngles` is (algebraicallly) lower than or equal
to this threshold value. The default value splits the curve
where the direction changes with more than 90
degrees. A value 1.0 will split the curve at all points. A value
of -1.0 will only split where the curve direction exactly reverses.
angle: float, optional
If provided, `cosangle` is computed from
``at.cosd(angle,angle_spec)```
angle_spec: float, optional
Units used for the `angle` parameter. This is the number of
radians for 1 unit.
Returns
-------
list
A list of PolyLine objects, where each PolyLine has a limited
change of direction at its vertices.
"""
if at.isFloat(angle):
cosangle = at.cosd(angle, angle_spec)
w = np.where(self.cosAngles() <= cosangle)[0]
return self.split(w)
def _compensate(self, end=0, cosangle=0.5):
"""_Compensate an end discontinuity over a piece of curve.
An end discontinuity in the curve is smeared out over a part of
the curve where no sharp angle occur with cosangle < 0.5
"""
cosa = self.cosAngles()
print("COSA", cosa)
L = self.lengths()
if end < 0:
L = L[::-1]
cosa = np.roll(cosa, -1)[::-1]
print(len(L), L)
for i in range(1, len(L)):
if cosa[i] <= cosangle:
break
print(f"HOLDING AT i={i}")
L = L[:i]
print(len(L), L)
L = L[::-1].cumsum()
comp = L / L[-1]
comp = comp[::-1]
print("COMP", comp)
return comp
def _movingFrenet(self, upvector=None, avgdir=True, compensate=False):
"""Return a Frenet frame along the curve.
..note : The recommended way to use this method is through the
:meth:`frenet` method.
The Frenet frame consists of a system of three orthogonal vectors:
the tangent (T), the normal (N) and the binormal (B).
These vectors define a coordinate system that re-orients while walking
along the polyline.
The _movingFrenet method tries to minimize the twist angle.
Parameters:
- `upvector`: (3,) vector: a vector normal to the (tangent,normal)
plane at the first point of the curve. It defines the binormal at
the first point. If not specified it is set to the shorted distance
through the set of 10 first points.
- `avgdir`: bool or array.
If True (default), the tangential vector is set to
the average direction of the two segments ending at a node.
If False, the tangent vectors will be those of the line segment
starting at the points.
The tangential vector can also be set by the user by specifying
an array with the matching number of vectors.
- `compensate`: bool: If True, adds a compensation algorithm if the
curve is closed. For a closed curve the moving Frenet
algorithm can be continued back to the first point. If the resulting
binormial does not coincide with the starting one, some torsion is
added to the end portions of the curve to make the two binormals
coincide.
This feature is off by default because it is currently experimental
and is likely to change in future.
It may also form the base for setting the starting as well as the
ending binormal.
Returns:
- `T`: normalized tangent vector to the curve at `npts` points
- `N`: normalized normal vector to the curve at `npts` points
- `B`: normalized binormal vector to the curve at `npts` points
"""
if isinstance(avgdir, np.ndarray):
T = at.checkArray(avgdir, (self.ncoords(), 3), 'f')
elif avgdir:
T = self.avgDirections()
else:
T = self.directions()
B = np.zeros(T.shape)
if upvector is None:
upvector = gt.smallestDirection(self.coords[:10])
B[-1] = at.normalize(upvector)
for i, t in enumerate(T):
B[i] = np.cross(t, np.cross(B[i-1], t))
if np.isnan(B[i]).any():
# if we can not compute binormal, keep previous
B[i] = B[i-1]
T = at.normalize(T)
B = at.normalize(B)
if self.closed and compensate:
from pyformex.gui.draw import drawVectors
print(len(T))
print(T[0], B[0])
print(T[-1], B[-1])
P0 = self.coords[0]
if avgdir:
T0 = T[0]
else:
T0 = T[-1]
B0 = np.cross(T0, np.cross(B[-1], T0))
N0 = np.cross(B0, T0)
drawVectors(P0, T0, size=2., nolight=True, color='magenta')
drawVectors(P0, N0, size=2., nolight=True, color='yellow')
drawVectors(P0, B0, size=2., nolight=True, color='cyan')
print("DIFF", B[0], B0)
if at.length(np.cross(B[0], B0)) < 1.e-5:
print("NO NEED FOR COMPENSATION")
else:
PM, BM = gt.intersectionLinesPWP(P0, T[0], P0, T0, mode='pair')
PM = PM[0]
BM = BM[0]
if at.length(BM) < 1.e-5:
print("NORMAL PLANES COINCIDE")
BM = (B0 + B[0])/2
print(f"COMPENSATED B0: {BM}")
print("Compensate at start")
print(BM, B[0])
a = at.vectorPairAngle(B[0], BM)
print(f"ANGLE to compensate: {a}")
sa = np.sign(at.projection(np.cross(B[0], BM), T[0]))
print(f"Sign of angle: {sa}")
a *= sa
torsion = a * self._compensate(0)
print("COMPENSATION ANGLE", torsion)
for i in range(len(torsion)):
B[i] = Coords(B[i]).rotate(torsion[i], axis=T[i])
print("Compensate at end")
print(BM, B0)
a = at.vectorPairAngle(B0, BM)
print(f"ANGLE to compensate: {a}")
sa = np.sign(at.projection(np.cross(BM, B0), T[-1]))
print(f"Sign of angle: {sa}")
a *= sa
torsion = a * self._compensate(-1)
print("COMPENSATION ANGLE", torsion)
for i in range(len(torsion)):
B[-i] = Coords(B[-i]).rotate(torsion[i], axis=T[-i])
N = np.cross(B, T)
return T, N, B
[docs] def cutWithPlane(self, p, n, side=''):
"""Return the parts of the PolyLine at one or both sides of a plane.
Cuts a PolyLine with a plane and returns the resulting polylines at
either side of the plane or both.
Parameters
----------
p: :term:`array_like` (3,)
A point in the cutting plane.
n: :term:`array_like` (3,)
The normal vector to the cutting plane.
side: str ('' | '+' | '-')
Specifies which side of the plane should be returned.
If an empty string (default), both sides are returned.
If '+' or '-', only the part at the positive, resp. negative
side of the plane (as defined by its normal `n`) is returned.
Returns
-------
PLpos: list of PolyLine
A list of the resulting polylines at the positive side of the plane.
Not returned if side=='-'.
PLneg: list of PolyLine
A list of the resulting polylines at the negative side of the plane.
Not returned if side=='+'.
"""
n = np.asarray(n)
p = np.asarray(p)
d = self.coords.distanceFromPlane(p, n)
t = d > 0.0
cut = t != np.roll(t, -1)
if not self.closed:
cut = cut[:-1]
w = np.where(cut)[0] # segments where side switches
res = [[], []]
i = 0 # first point to include in next part
if t[0]:
sid = 0 # start at '+' side
else:
sid = 1 # start at '-' side
Q = Coords() # new point from previous cutting (None at start)
for j in w:
pts = self.coords.take(range(j, j+2), axis=0, mode='wrap')
P = gt.intersectSegmentWithPlane(pts, p, n, mode='pair')
x = Coords.concatenate([Q, self.coords[i:j+1], P])
res[sid].append(PolyLine(x))
sid = 1-sid
i = j+1
Q = P
# Remaining points
x = Coords.concatenate([Q, self.coords[i:]])
if not self.closed:
# append the remainder as an extra PolyLine
res[sid].append(PolyLine(x))
else:
# append remaining points to the first
if len(res[sid]) > 0:
x = Coords.concatenate([x, res[sid][0].coords])
res[sid][0] = PolyLine(x)
else:
res[sid].append(PolyLine(x))
if len(res[sid]) == 1 and len(res[1-sid]) == 0:
res[sid][0].closed = True
# Do not use side in '+-', because '' in '+-' returns True
if side in ['+', '-']:
return res['+-'.index(side)]
else:
return res
[docs] def append(self, PL, fuse=True, smart=False, **kargs):
"""Concatenate two open PolyLines.
This combines two open PolyLines into a single one. Closed PolyLines
cannot be concatenated.
Parameters
----------
PL: PolyLine
An open PolyLine, to be appended at the end of the current.
fuse: bool
If True (default), the last point of `self` and the first point
of `PL` will be fused to a single point if they are close. Extra
parameters may be added to tune the fuse operation. See the
:meth:`Coords.fuse` method. The `ppb` parameter is not allowed.
smart: bool
If True, `PL` will be connected to `self` by the
endpoint that is closest to the last point of self, thus possibly
reverting the sense of PL.
Returns
-------
PolyLine
The concatenation of the polylines self and PL.
Notes
-----
The same result (with the default parameter values) can also be
achieved using operator syntax: ``PolyLine1 + PolyLine2``.
See Also
--------
concatenate: concatenate a list of PolyLine's
"""
if self.closed or PL.closed:
raise RuntimeError("Closed PolyLines cannot be concatenated.")
if smart:
d = PL.coords[[0, -1]].distanceFromPoint(self.coords[-1])
if d[1] < d[0]:
PL = PL.reverse()
X = PL.coords
if fuse:
x = Coords.concatenate([self.coords[-1], X[0]])
x, e = x.fuse(ppb=3) # !!! YES ! > 2 !!!
if e[0] == e[1]:
X = X[1:]
return PolyLine(Coords.concatenate([self.coords, X]))
# allow syntax PL1 + PL2
__add__ = append
[docs] @staticmethod
def concatenate(PLlist, **kargs):
"""Concatenate a list of :class:`PolyLine` objects.
Parameters
----------
PLlist: list of PolyLine.
The list of PolyLine's to concatenate
**kargs:
Other keyword arguments like in :meth:`append`.
Returns
-------
PolyLine
The concatenation of all the polylines in `PLlist`.
"""
PL = PLlist[0]
for pl in PLlist[1:]:
PL = PL.append(pl, **kargs)
return PL
[docs] def refine(self, maxlen):
"""Insert extra points in the PolyLine to reduce the segment length.
Parameters
----------
maxlen: float
The maximum length of the segments. The value is
relative to the total curve :meth:`length`.
Returns
-------
PolyLine
A PolyLine which is geometrically equivalent to the
input PolyLine but has no segment longer than `maxlen`
times the total curve :meth:`length`.
"""
maxlen *= self.length()
ndiv = np.ceil(self.lengths() / maxlen).astype(at.Int)
return self.approx(ndiv=ndiv)
[docs] def vertexReductionDP(self, tol, maxlen=None, keep=[0, -1]):
"""Douglas-Peucker vertex reduction.
Finds out which points of the PolyLine can be removed while
keeping a required accuracy.
Parameters
----------
tol: float
Required accuracy of the result. For any subpart of the PolyLine,
if the distance of all its vertices to the line connecting its
endpoints is smaller than `tol`, the internal points are removed.
Returns
-------
bool array
A bool array flagging the vertices to be kept in the reduced form.
See Also
--------
coarsen: returns the reduced PolyLine
"""
x = self.coords
n = len(x)
_keep = np.zeros(n, dtype=bool)
_keep[keep] = 1
def decimate(i, j):
"""Recursive vertex decimation.
This will remove all the vertices in the segment i-j that
are closer than tol to any chord.
"""
# Find point farthest from line through endpoints
d = x[i+1:j].distanceFromLine(x[i], x[j]-x[i])
k = np.argmax(d)
dmax = d[k]
k += i+1
if dmax > tol:
# Keep the farthest vertex, split the polyline at that vertex
# and recursively decimate the two parts
_keep[k] = 1
if k > i+1:
decimate(i, k)
if k < j-1:
decimate(k, j)
def reanimate(i, j):
"""Recursive vertex reanimation.
This will revive vertices in the segment i-j if the
segment is longer than maxlen.
"""
if j > i+1 and at.length(x[i]-x[j]) > maxlen:
# too long: revive a point
d = at.length(x[i+1:j]-x[i])
w = np.where(d>maxlen)[0]
if len(w) > 0:
k = w[0] + i+1
_keep[k] = 1
reanimate(k+1, j)
# Compute vertices to keep
decimate(0, n-1)
# Reanimate some vertices if maxlen specified
if maxlen is not None:
w = np.where(_keep)[0]
for i, j in zip(w[:-1], w[1:]):
reanimate(i, j)
return _keep
[docs] def coarsen(self, tol=0.01, maxlen=None, keep=[0, -1]):
""" Reduce the number of points of the PolyLine.
Parameters
----------
tol: float
Maximum relative distance from the chord of the segment for
vertices to be removed. The value is relative to the total curve
length.
keep: int :term:`array_like`
List of vertices to keep during the coarsening process.
(Not implemented yet !! NEED TO CHECK THIS).
Returns
-------
Polyline
A coarsened version of the PolyLine.
"""
atol = tol*self.length()
if maxlen:
maxlen *= self.length()
keep = self.vertexReductionDP(tol=atol, maxlen=maxlen, keep=keep)
return PolyLine(self.coords[keep], closed=self.closed)
###################
## PZF interface ##
[docs] def pzf_dict(self):
"""Return the data to save in PZF format."""
kargs = super().pzf_dict()
kargs.pop('degree:i__1', None) # no need to store the degree
return kargs
##############################################################################
#
#
[docs]class Line(PolyLine):
"""A Line is a straight segment between two points.
Line is implemented as a :class:PolyLine with exactly two points.
Parameters
----------
coords: :term:`array_like` (2,3)
The coordinates of begin and end point of the line.
Examples
--------
>>> L = Line([[0.,0.,0.], [0.,1.,0.]])
>>> print(L)
Line: nparts=1, ncoords=2, open
Control points:
[[0. 0. 0.]
[0. 1. 0.]]
"""
def __init__(self, coords):
"""Initialize the Line."""
super().__init__(coords)
if self.coords.shape[0] != 2:
raise ValueError(
f"Expected exactly two points, got {coords.shape[0]}")
##############################################################################
[docs]class Contour(Curve):
"""A class for storing a contour.
The Contour class stores a continuous (often closed, 2D) curve which
consists of a sequence of strokes, each stroke being a Bezier curve.
Generally the degree of the Bezier curves is in the range 1..3, but
higher degrees are accepted. Each stroke is thus defined by 2, 3 or 4
(or more) points. Each strokes starts at the endpoint of the previous
stroke. If the last point of the last stroke and the first point of the
first stroke are the same point, the Contour is closed.
The Contour is defined by a list of points and a Varray of element
connectivity. This format is well suited to store contours of scalable
fonts (the contours are the normally 2D curves).
Parameters
----------
coords: :term:`coords_like` (npoints, 3)
The coordinates of all points used in the definitions of the strokes.
elems: :term:`varray_like`
A Varray defining the strokes as a list of point indices. Each row
should start with the same point that ended the previous stroke.
Examples
--------
>>> X = Coords('0123')
>>> C = Contour(X, [(0,1), (1,2,3,0)])
>>> print(C)
Contour: nparts=2, ncoords=4, closed
Control points:
[[0. 0. 0.]
[1. 0. 0.]
[1. 1. 0.]
[0. 1. 0.]]
Strokes: Varray (nrows=2, width=2..4)
[0 1]
[1 2 3 0]
>>> print(C.elems)
Varray (nrows=2, width=2..4)
[0 1]
[1 2 3 0]
>>> print(C.nparts)
2
>>> print(C.stroke(1))
BezierSpline: degree=3, nparts=1, ncoords=4, open
Control points:
[[1. 0. 0.]
[1. 1. 0.]
[0. 1. 0.]
[0. 0. 0.]]
>>> print(C.parts(1,2))
Contour: nparts=1, ncoords=4, open
Control points:
[[0. 0. 0.]
[1. 0. 0.]
[1. 1. 0.]
[0. 1. 0.]]
Strokes: Varray (nrows=1, width=4..4)
[1 2 3 0]
"""
def __init__(self, coords, elems):
super().__init__()
self.prop = None
self.coords = Coords(at.checkArray(coords, (-1, 3), 'f'))
self.elems = Varray(elems)
wmin, wmax = self.elems.width
if wmin < 2 or wmax > 4:
raise ValueError(f"Contour: row length of elems should be"
f" in the range [2..4], got [{wmin}..{wmax}]")
first = self.elems.col(0)
last = self.elems.col(-1)
if (first[1:] != last[:-1]).any():
raise ValueError("elems do not form a continuous contour")
self.nparts = self.elems.shape[0]
self.closed = self.elems[0][0] == self.elems[-1][-1]
def __str__(self):
return super().__str__() + f"\n Strokes: {self.elems}"
[docs] def pointsOn(self):
ind = self.elems.col(0)
if not self.closed:
ind = np.concatenate([ind, [self.elems[-1][-1]]])
return self.coords[ind]
[docs] def pointsOff(self):
ind = np.unique(np.concatenate([r[1:-1] for r in self.elems]))
return self.coords[ind]
[docs] def ncoords(self):
return self.coords.shape[0]
[docs] def endPoints(self):
"""Return start and end points of the curve.
Returns a Coords with two points, or None if the curve is closed.
"""
if self.closed:
return None
else:
return self.coords[[0, -1]]
[docs] def points(self, i):
"""Returns the points defining part j of the curve."""
return self.coords[self.elems[i]]
[docs] def compact(self):
"""Compact the data, removing unused points"""
pass # TODO: needs to be implemented
[docs] def parts(self, j, k):
"""Return a Contour only containing parts j to k"""
# TODO: this should compact the data
return Contour(self.coords, self.elems.select(range(j, k)))
[docs] def stroke(self, i):
"""Return curve for part i"""
X = self.points(i)
degree = len(X) - 1
if len(X) == 1:
return PolyLine(X)
else:
return BezierSpline(control=X, degree=degree)
def split(self):
return [self.stroke(i) for i in range(self.nparts)]
[docs] def sub_points(self, t, j):
"""Return the points at values t in part j
t can be an array of parameter values, j is a single segment number.
"""
j = int(j)
t = np.asarray(t).reshape(-1, 1)
return self.stroke(j).sub_points(t, 0)
[docs] def sub_directions(self, t, j):
"""Return the directions at values t in part j
t can be an array of parameter values, j is a single segment number.
"""
j = int(j)
t = np.asarray(t).reshape(-1, 1)
return self.stroke(j).sub_points(t, 0)
def lengths(self):
return [self.stroke(i).length() for i in range(self.nparts)]
def atChordal(self, chordal=0.01, atl=None):
# Define specialized preapproximation
if at is None:
atl = self.atApprox(ndiv=self.elems.lengths)
return Curve.atChordal(self, chordal, atl)
[docs] def toMesh(self):
"""Convert the Contour to a Mesh."""
return [self.stroke(i).toMesh() for i in range(self.nparts)]
###########################################################################
# DEPRECATED 2022-08-24
class CardinalSpline(BezierSpline):
def __init__(self, coords, tension=0.0, closed=False, **kargs):
utils.warn('depr_cardinalspline')
super().__init__(coords, curl=(1.-tension)/3., closed=closed)
##############################################################################
# DEPRECATED 2022-11-08
[docs]class NaturalSpline(Curve):
"""A class representing a natural spline.
The use of this class is deprecated. For a closed curve or an open curve
with endzerocurv=True, BezierSpline gives a good approximation.
For an open curve with endzerocurv=False, a NurbsCurve obtained with
nurbs.globalInterpolationCurve will do fine.
"""
def __init__(self, coords, closed=False, endzerocurv=False):
"""Create a natural spline through the given points.
coords specifies the coordinates of a set of points. A natural spline
is constructed through this points.
closed specifies whether the curve is closed or not.
endzerocurv specifies the end conditions for an open curve.
If True, the end curvature will forced to be zero. The default is
to use maximal continuity (up to the third derivative) between
the first two splines. The value may be set to a tuple of two
values to specify different end conditions for both ends.
This argument is ignored for a closed curve.
"""
utils.warn('depr_naturlspline')
super().__init__()
coords = Coords(coords)
if closed:
coords = Coords.concatenate([coords, coords[:1]])
self.nparts = coords.shape[0] - 1
self.closed = closed
if not closed:
if endzerocurv in [False, True]:
self.endzerocurv = (endzerocurv, endzerocurv)
else:
self.endzerocurv = endzerocurv
self.coords = coords
self.compute_coefficients()
def _set_coords(self, coords):
C = self.copy()
C._set_coords_inplace(coords)
C.compute_coefficients()
return C
def compute_coefficients(self):
n = self.nparts
M = np.zeros([4*n, 4*n])
B = np.zeros([4*n, 3])
# constant submatrix
m = np.array([[0., 0., 0., 1., 0., 0., 0., 0.],
[1., 1., 1., 1., 0., 0., 0., 0.],
[3., 2., 1., 0., 0., 0., -1., 0.],
[6., 2., 0., 0., 0., -2., 0., 0.]])
for i in range(n-1):
f = 4*i
M[f:f+4, f:f+8] = m
B[f:f+2] = self.coords[i:i+2]
# the last spline passes through the last 2 points
f = 4*(n-1)
M[f:f+2, f:f+4] = m[:2, :4]
B[f:f+2] = self.coords[-2:]
# add the appropriate end constrains
if self.closed:
# first and second derivatives at starting and last point
# (that are actually the same point) are the same
M[f+2, f:f+4] = m[2, :4]
M[f+2, 0:4] = m[2, 4:]
M[f+3, f:f+4] = m[3, :4]
M[f+3, 0:4] = m[3, 4:]
else:
if self.endzerocurv[0]:
# second derivatives at start is zero
M[f+2, 0:4] = m[3, 4:]
else:
# third derivative is the same between the first 2 splines
M[f+2, [0, 4]] = np.array([6., -6.])
if self.endzerocurv[1]:
# second derivatives at end is zero
M[f+3, f:f+4] = m[3, :4]
else:
# third derivative is the same between the last 2 splines
M[f+3, [f-4, f]] = np.array([6., -6.])
# calculate the coefficients
C = np.linalg.solve(M, B)
self.coeffs = np.array(C).reshape(-1, 4, 3)
[docs] def sub_points(self, t, j):
C = self.coeffs[j]
U = np.column_stack([t**3., t**2., t, np.ones_like(t)])
X = np.dot(U, C)
return X
##############################################################################
## circles and arcs
[docs]def circle():
"""Create a BezierSpline approximation of a circle.
Returns
-------
BezierSpline
A closed BezierSpline through 8 points lying on a circle x,y plane,
with its center at (0,0,0) and having a radius 1.
Notes
-----
The result can easily be scaled, translated or rotated to create other
circles. It is adequate for drawing circles, though it doesn't exactly
represent a circle.
See Also
--------
Arc: create exact representation of circles and arcs.
"""
pts = Formex([1.0, 0.0, 0.0]).rosette(8, 45.).coords.reshape(-1, 3)
return BezierSpline(pts, curl=0.375058, closed=True)
[docs]class Arc(Curve):
"""A class representing a circular arc.
The arc can be specified in one of three ways:
- by specifying 3 points on the arc: the begin point,
an intermediate point and the end point. Use the p3 argument to use
this method.
- by specifying the center and the begin and end points: use the cbe
argument.
- by the general method specifying the center, radius, normal, and
begin and end angles of the arc.
Parameters
----------
p3: :term:`coords_like` (3,3)
The coordinates of three subsequent points on the arc: the begin
point, an intermediate point and the end point. The three points
should obviously not be colinear, with one exception: if the begin
and end points coincide, a full circle is created, and the `normal`
argument is used to help with orienting the circle's plane.
If `p3` is provided, all other arguments but `normal` are disregarded.
cbe: :term:`coords_like` (3,3)
The coordinates of the center and the begin and end points of the arc.
The three points should not be colinear. Even then, the problem has
always two solutions, depending on the choice of the positive normal
on the plane of the three points. Those two solutions form a full
circle. The chosen solution is the one that corresponds with a positive
normal pointing to the same side of the plane as the specified `normal`
vector. If `cbe` method is used (and no `p3` is provided), all other
arguments but `normal` are disregarded.
center: :term:`coords_like` (3,)
The center point of the arc.
radius: float
The radius of the arc.
normal: :term:`coords_like` (3,)
The normal on the plane of the arc. The arc is constructed in the
x,y plane and then the z-axis is rotated to the specified normal
direction.
angles: (float, float)
The start and end angles of the arc, by default in degrees.
angle_spec: float
A multiplier that turns the angles into radians. The default
expects the angles to be degrees.
Examples
--------
Three ways to construct a full unit circle in the x,y plane:
>>> A = Arc(center=[0.,0.,0.], radius=1., angles=(0., 360.))
>>> B = Arc(cbe=[[0.,0.,0.], [1.,0.,0.], [1.,0.,0.]])
>>> C = Arc(p3=[[1.,0.,0.], [-1.,0.,0.], [1.,0.,0.]])
>>> print(A,B,C)
Arc
Center [0. 0. 0.], Radius 1., Normal [0. 0. 1.]
Angles=(0.0, 360.0)
P0=[1. 0. 0.]; P1=[-1. 0. 0.]; P2=[ 1. -0. 0.]
Arc
Center [0. 0. 0.], Radius 1., Normal [0. 0. 1.]
Angles=(0.0, 360.0)
P0=[1. 0. 0.]; P1=[-1. 0. 0.]; P2=[1. 0. 0.]
Arc
Center [0. 0. 0.], Radius 1., Normal [0. 0. 1.]
Angles=(0.0, 360.0)
P0=[1. 0. 0.]; P1=[-1. 0. 0.]; P2=[1. 0. 0.]
Three ways to constructing the right half of that circle:
>>> A = Arc(center=[0.,0.,0.], radius=1., angles=(-90., 90.))
>>> B = Arc(cbe=[[0.,0.,0.], [0.,-1.,0.], [0.,1.,0.]])
>>> C = Arc(p3=[[0.,-1.,0.], [1.,0.,0.], [0.,1.,0.]])
>>> print(A,B,C)
Arc
Center [0. 0. 0.], Radius 1., Normal [0. 0. 1.]
Angles=(270.0, 450.0)
P0=[-0. -1. 0.]; P1=[ 1. -0. 0.]; P2=[0. 1. 0.]
Arc
Center [0. 0. 0.], Radius 1., Normal [0. 0. 1.]
Angles=(270.0, 450.0)
P0=[ 0. -1. 0.]; P1=[ 1. -0. 0.]; P2=[0. 1. 0.]
Arc
Center [0. 0. 0.], Radius 1., Normal [0. 0. 1.]
Angles=(270.0, 450.0)
P0=[ 0. -1. 0.]; P1=[1. 0. 0.]; P2=[0. 1. 0.]
"""
def __init__(self, *, p3=None, cbe=None, center=(0., 0., 0.), radius=1.,
normal=(0., 0., 1.), angles=(0., 360.), angle_spec=at.DEG):
"""Initialize the arc."""
super().__init__()
self.nparts = 1
self.closed = False # TODO: derive from angles
if p3 is not None:
# 3 points on the arc
points = Coords(p3)
if points.shape != (3, 3):
raise ValueError("Expected 3 points for p3")
if np.allclose(points[0], points[2]):
# first and last point are coincident
# we have a full circle with normal given by argument
radius = at.length(points[1] - points[0]) / 2
center = (points[0] + points[1]) / 2
else:
r, c, n = gt.triangleCircumCircle(points.reshape(1, 3, 3))
if np.isnan(r).any():
raise ValueError(
"The three points are colinear and the first "
"and last point do not coincide")
radius, center, normal = r[0], c[0], n[0]
angles = gt.rotationAngle(
Coords([1., 0., 0.]).rotate(at.rotMatrix2([0., 0., 1.], normal)),
points[[0, 2]], normal, angle_spec)
elif cbe is not None:
# center, begin, end
points = Coords(cbe)
if points.shape != (3, 3):
raise ValueError("Expected 3 points for cbe")
center = points[0].copy()
v = points[1:] - center
radius = at.length(v[0])
n = at.normalize(np.cross(v[0], v[1]))
if np.isnan(n).any():
# cbe points are colinear: try to use normal
n = at.orthog(normal, v[0])
if np.isnan(n).any():
n = at.any_perp(v[0])
else:
if normal is not None:
normal = at.checkArray(normal, shape=(3,))
if n @ normal < 0.:
# flip the normal
n = -n
normal = n
angles = gt.rotationAngle(
Coords([1., 0., 0.]).rotate(
at.rotMatrix2([0., 0., 1.], normal)), v, normal, angle_spec)
points[0] = points[1] # move beginpoint to first position
else:
# general method
center = np.asarray(center).reshape(3)
radius = float(radius)
normal = np.asarray(normal).reshape(3)
angles = (float(angles[0]), float(angles[1]))
self.center = center
self.radius = radius
self.normal = at.normalize(normal)
self.angles = _fix_angles(*angles)
if p3 is not None:
pass
elif cbe is not None:
points[1] = self.sub_points(np.array([0.5]), 0)[0]
else:
points = self.sub_points(np.array([0.0, 0.5, 1.0]), 0)
self.coords = points
@property
def points(self, j=None, k=None):
return self.coords
def __repr__(self):
return f"Arc(p3={self.coords.tolist()})"
def __str__(self):
return f"""\
Arc
Center {self.center}, Radius {_g(self.radius)}, Normal {self.normal}
Angles={self.angles}
P0={self.points[0]}; P1={self.points[1]}; P2={self.points[2]}
"""
[docs] def sub_points(self, t, j):
a = t*(self.angles[-1]-self.angles[0])
X = Coords(np.column_stack([at.cosd(a), at.sind(a), np.zeros_like(a)]))
X = X.scale(self.radius).rotate(self.angles[0]).rotate(
at.rotMatrix2([0., 0., 1.], self.normal)).translate(self.center)
return X
[docs] def sub_directions(self, t, j):
a = t*(self.angles[-1]-self.angles[0])
X = Coords(np.column_stack([-at.sind(a), at.cosd(a), np.zeros_like(a)]))
X = X.rotate(self.angles[0]).rotate(
at.rotMatrix2([0., 0., 1.], self.normal))
return X
[docs] def approx(self, ndiv=None, chordal=0.001):
"""Return a PolyLine approximation of the Arc.
Approximates the Arc by a sequence of inscribed straight line
segments.
If `ndiv` is specified, the arc is divided in precisely `ndiv`
segments.
If `ndiv` is not given, the number of segments is determined
from the chordal distance tolerance. It will guarantee that the
distance of any point of the arc to the chordal approximation
is less or equal than `chordal` times the radius of the arc.
"""
if ndiv is None:
phi = 2. * np.arccos(1.-chordal)
rng = abs(self.angles[1] - self.angles[0]) * at.DEG
ndiv = int(np.ceil(rng/phi))
# print(ndiv)
return Curve.approx(self, ndiv=ndiv)
[docs]def arc2points(x0, x1, R, pos='-'):
"""Create an arc between two points
Given two points x0 and x1, this constructs an arc with radius R
through these points. The two points should have the same z-value.
The arc will be in a plane parallel with the x-y plane and wind
positively around the z-axis when moving along the arc from x0 to x1.
If pos == '-', the center of the arc will be at the left when going
along the chord from x0 to x1, creating an arc smaller than a half-circle.
If pos == '+', the center of the arc will be at the right when going
along the chord from x0 to x1, creating an arc larger than a half-circle.
If R is too small, an exception is raised.
"""
if x0[2] != x1[2]:
raise ValueError("The points should b in a plane // xy plane")
xm = (x0+x1) / 2 # center of points
xd = (x0-x1) / 2 # half length vector
do = at.normalize([xd[1], -xd[0], 0]) # direction of center
xl = np.dot(xd, xd) # square length
if R**2 < xl:
raise ValueError(f"The radius should at least be {np.sqrt(xl)}")
dd = np.sqrt(R**2 - xl) # distance of center to xm
if pos == '+':
xc = xm - dd * do # Center is to the left when going from x0 to x1
else:
xc = xm + dd * do
xx = [1., 0., 0.]
xz = [0., 0., 1.]
angles = gt.rotationAngle([xx, xx], [x0-xc, x1-xc], m=[xz, xz])
if dir == '-':
angles = reversed(angles)
xc[2] = x0[2]
return Arc(center=xc, radius=R, angles=angles)
# TODO: implement this
# class Spiral(Curve):
# """A class representing a spiral curve."""
# def __init__(self, turns=2.0, nparts=100, rfunc=None):
# Curve.__init__(self)
# # TODO: implement the rfunc
# if rfunc is None:
# rfunc = lambda x: x
# self.coords = Coords([0., 0., 0.]).replic(npoints+1).hypercylindrical()
# self.nparts = nparts
# self.closed = False
##############################################################################
# Other functions
[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 splitBezier(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.
This works for u < 0 and u > 1, to extend the curve.
If u < 0, the left part is returned reverse, if u
"""
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
def _g(value, precision=None):
"""Format a float in g format but add a trailing dot if None
>>> print(f"{1.27:g} {1.00:g}")
1.27 1
>>> print(f"{_g(1.27)} {_g(1.00)}")
1.27 1.
"""
s = f"{value:g}"
if '.' not in s:
s += '.'
return s
def _fix_angles(angle0, angle1):
"""Sanitize angles
Given two angles in degrees, this makes sure that
- angle1 > angle0
- angle0 in range [0, 360]
- angle1 in range ]0, 720]
Returns
-------
angle0: float
The first angle, in the range [0, 360[
angle1: float
The second angle, in the range ]0, 720[ and > angle0.
Examples
--------
>>> _fix_angles(360,720)
(0.0, 360.0)
>>> _fix_angles(750,10)
(30.0, 370.0)
>>> _fix_angles(-30,-100)
(330.0, 620.0)
"""
angle0 %= 360.
angle1 %= 360.
if angle1 <= angle0:
angle1 += 360
return angle0, angle1
# Keep binomial as an alias for compatibilty
binomial = math.comb
@cache
def binomialCoeffs(p):
"""Compute all binomial coefficients for a given degree p.
Parameters
----------
p: int
Degree of the binomial
Returns
-------
array:
An array of p+1 float values.
Notes
-----
The results of this function are cached to allow multiple calls without
the need for recomputing.
Examples
--------
>>> print(binomialCoeffs(4))
[1 4 6 4 1]
"""
return np.array([binomial(p, i) for i in range(p+1)])
@cache
def bezierPowerMatrix(p):
"""Compute the Bezier to power curve transformation matrix for degree p.
Bezier curve representations can be converted to power curve representations
using the coefficients in this matrix.
Returns a (p+1,p+1) shaped array with a zero upper triangular part.
For efficiency reasons, the computed values are stored in the module,
in a dict with p as key. This allows easy and fast lookup of already
computed values.
Notes
-----
The results of this function are cached to allow multiple calls without
the need for recomputing.
Examples
--------
>>> print(bezierPowerMatrix(3))
[[ 1. 0. 0. 0.]
[-3. 3. 0. 0.]
[ 3. -6. 3. 0.]
[-1. 3. -3. 1.]]
"""
M = np.zeros((p+1, p+1))
# Set corner elements
M[0, 0] = M[p, p] = 1.0
M[p, 0] = -1.0 if p % 2 else 1.0
# Compute first column, last row and diagonal
sign = -1.0
for i in range(1, p):
M[i, i] = binomialCoeffs(p)[i]
M[i, 0] = M[p, p-i] = sign*M[i, i]
sign = -sign
# Compute remaining elements
k1 = (p+1)//2
pk = p-1
for k in range(1, k1):
sign = -1.0
for j in range(k+1, pk+1):
M[j, k] = M[pk, p-j] = \
sign * binomialCoeffs(p)[k] * binomialCoeffs(p-k)[j-k]
sign = -sign
pk -= 1
return M
# End