# -*- coding: utf-8 -*-
"""
This module implements computational geometry algorithms, needed for other modules.
All the examples assume that the modules `numpy` and `matplotlib.pyplot` were imported as `np`
and `plt`, respectively.
Classes
-------
.. autosummary::
:nosignatures:
:toctree: classes/
Mesh
TriMesh
Polygon
Functions
---------
.. autosummary::
:toctree: functions/
is_collinear
squared_distance
distance_matrix
_polygon_area
"""
from abc import ABC, abstractmethod
from math import sqrt, isclose, pi
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.tri import Triangulation
from grains.utils import parse_kwargs
[docs]class Mesh(ABC):
"""Data structure for a general mesh.
This class by no means wants to provide intricate functionalities and does not strive to be
efficient at all. Its purpose is to give some useful features the project relies on.
The two main entities in the mesh are the `vertices` and the `cells`. They are expected to be
passed by the user, so reading from various mesh files is not implemented. This keeps the
class simple, requires few package dependencies and keeps the class focused as there are
powerful tools to convert among mesh formats (see e.g. `meshio
<https://github.com/nschloe/meshio>`_).
Parameters
----------
vertices : ndarray
2D numpy array with 2 columns, each row corresponding to a vertex, and the two columns
giving the Cartesian coordinates of the vertex.
cells : ndarray
Cell-vertex connectivities in a 2D numpy array, in which each row corresponds to a
cell and the columns are the vertices of the cells. It is assumed that all the cells
have the same number of vertices.
See Also
--------
~TriMesh.change_vertex_numbering
Notes
-----
Although not necessary, it is highly recommended that the local vertex numbering in the
cells are the same, either clockwise or counter-clockwise. Some methods, such as
:meth:`get_boundary` even requires it. If you are not sure whether the cells you provide
have a consistent numbering, it is better to renumber them by calling the
:meth:`~TriMesh.change_vertex_numbering` method.
"""
# @abstractmethod
[docs] def __init__(self, vertices, cells):
"""
.. todo::
Error checking
"""
self.vertices = vertices
self.cells = cells
self.vertex_sets = {}
self.cell_sets = {}
self.field = {'name': None, 'values': None} # field attached to the mesh
[docs] def create_cell_set(self, name, cells):
"""Forms a group from a set of cells.
Parameters
----------
name : str
Name of the cell set.
cells : list
List of cells to be added to the set.
Returns
-------
None
"""
self.cell_sets[name] = cells
[docs] def create_vertex_set(self, name, vertices):
"""Forms a group from a set of vertices.
Parameters
----------
name : str
Name of the vertex set.
vertices : list
List of vertices to be added to the set.
"""
self.vertex_sets[name] = vertices
[docs] def associate_field(self, vertex_values, name='field'):
"""Associates a scalar, vector or tensor field to the nodes.
Only one field can be present at a time. If you want to use a new field, call this method
again with the new field values, which will replace the previous ones.
Parameters
----------
vertex_values : ndarray
Field values at the nodes.
name : str, optional
Name of the field. If not given, it will be 'field'.
Returns
-------
None
"""
if Mesh._isvector(vertex_values): # store as a column vector
vertex_values = np.reshape(vertex_values, (len(vertex_values), 1))
elif not Mesh._ismatrix(vertex_values):
raise ValueError('Vertex values are expected to be given in a vector or in a matrix.')
self.field = {'name': name, 'values': vertex_values}
[docs] def get_edges(self):
"""Constructs edge-cell connectivities of the mesh.
The cells of the mesh do not necessarily have to have a consistent vertex numbering.
Returns
-------
edges : dict
The keys of the returned dictionary are 2-tuples, representing the two vertices of
the edges, while the values are the list of cells containing a particular edge.
Notes
-----
We traverse through the cells of the mesh, and within each cell the edges. The edges are
stored as new entries in a dictionary if they are not already stored. Checking if a key
exists in a dictionary is performed in O(1). The number of edges in a cell is independent
of the mesh density. Therefore, the time complexity of the algorithm is O(N), where N is
the number of cells in the mesh.
See Also
--------
get_boundary
Examples
--------
We show an example for a triangular mesh (as the :class:`Mesh` class is abstract).
>>> mesh = TriMesh(np.array([[0, 0], [1, 0], [2, 0], [0, 2], [0, 1], [1, 1]]),
... np.array([[0, 1, 5], [4, 5, 3], [5, 4, 0], [2, 5, 1]]))
>>> edges = mesh.get_edges()
>>> edges # doctest: +NORMALIZE_WHITESPACE
{(0, 1): [0], (1, 5): [0, 3], (5, 0): [0, 2], (4, 5): [1, 2], (5, 3): [1],
(3, 4): [1], (4, 0): [2], (2, 5): [3], (1, 2): [3]}
>>> mesh.plot(cell_labels=True, vertex_labels=True)
>>> plt.show()
"""
edges = {}
n_cell_edge = np.size(self.cells, 1)
for cell, cell_edges in enumerate(self.cells):
for i in np.arange(n_cell_edge):
edge = tuple(np.roll(cell_edges, -i)[0:2])
edge_reverse_orientation = edge[::-1]
if edge in edges:
edges[edge].append(cell)
elif edge_reverse_orientation in edges:
edges[edge_reverse_orientation].append(cell)
else:
edges[edge] = [cell]
if any(len(i) not in {1, 2} for i in edges.values()):
raise Exception('Each edge must have either 1 or 2 neighboring cells.')
return edges
[docs] def get_boundary(self):
"""Extracts the boundary of the mesh.
It is expected that all the cells have the same orientation, i.e. the cell vertices are
consistently numbered (either clockwise or counter-clockwise). See the constructor for
details.
Returns
-------
boundary_vertices : ndarray
Ordered 1D ndarray of vertices, the boundary vertices of the mesh.
boundary_edges : dict
The keys of the returned dictionary are 2-tuples, representing the two vertices of
the boundary edges, while the values are the list of cells containing a particular
boundary edge. The dictionary is ordered: the consecutive keys represent the
consecutive boundary edges. Although a boundary edge is part of a single cell, that
cell is given in a list so as to maintain the same format as the one used in the
:meth:`get_edges` method.
Notes
-----
The reason why consistent cell vertex numbering is demanded is because in that case the
boundary edges are oriented in such a way that the second vertex of a boundary edge is
the first vertex of the boundary edge it connects to.
Examples
--------
Let us consider the same example mesh as the one described in the :meth:`get_edges` method.
>>> mesh = TriMesh(np.array([[0, 0], [1, 0], [2, 0], [0, 2], [0, 1], [1, 1]]),
... np.array([[0, 1, 5], [4, 5, 3], [5, 4, 0], [2, 5, 1]]))
We extract the boundary of that mesh using
>>> bnd_vertices, bnd_edges = mesh.get_boundary()
>>> bnd_vertices
array([0, 1, 2, 5, 3, 4])
>>> bnd_edges
{(0, 1): [0], (1, 2): [3], (2, 5): [3], (5, 3): [1], (3, 4): [1], (4, 0): [2]}
"""
# All edges in the mesh
edges = self.get_edges()
# Consider only those edges that are on the boundary
boundary_edges = {edge: cells for edge, cells in edges.items() if len(cells) == 1}
boundary_edges = np.array(list(boundary_edges.keys()))
# Preallocate the arrays holding the boundary edges and the boundary vertices
n_boundary_edge = np.size(boundary_edges, 0)
n_boundary_vertex = n_boundary_edge + 1
boundary_edges_interlaced = np.empty((n_boundary_edge, 2), dtype=int)
boundary_vertices = np.empty(n_boundary_vertex, dtype=int)
# Start the processing with the first boundary edge and its two vertices
boundary_edges_interlaced[0, :] = boundary_edges[0, :]
boundary_vertices[0:2] = boundary_edges_interlaced[0, :]
# Find the consecutive boundary edges and their vertices
for i in np.arange(1, n_boundary_edge):
# The next boundary edge starts with the last boundary vertex we found
next_boundary_edge_idx = np.where(boundary_edges[:, 0] == boundary_vertices[i])[0]
next_boundary_edge = boundary_edges[next_boundary_edge_idx, :]
# The next boundary vertex is the end point of this new boundary edge
next_boundary_vertex = next_boundary_edge[0, 1]
# Update the already found consecutive boundary edges and vertices
boundary_edges_interlaced[i, :] = next_boundary_edge
boundary_vertices[i+1] = next_boundary_vertex
# The last vertex is the same as the first one: keep only one
boundary_vertices = boundary_vertices[:-1]
# Bring the boundary edges to the same format as the set of all edges
boundary_edges = {tuple(edge): edges[tuple(edge)] for edge in boundary_edges_interlaced}
return boundary_vertices, boundary_edges
[docs] @staticmethod
def _isvector(array):
"""Decides whether the input is a vector.
Parameters
----------
array : ndarray
Numpy array to be checked.
Returns
-------
bool
True if the input is a 1D array or if it is a column or row vector. Otherwise, False.
See Also
--------
_ismatrix
"""
shape = np.shape(array)
if len(shape) == 1:
return True
elif len(shape) == 2 and shape[0] == 1 or shape[1] == 1:
return True
else:
return False
[docs] @staticmethod
def _ismatrix(array):
"""Decides whether the input is a matrix.
Parameters
----------
array : ndarray
Numpy array to be checked.
Returns
-------
bool
True if the input is a 2D array. Otherwise, False.
See Also
--------
_isvector
"""
shape = np.shape(array)
return len(shape) == 2
[docs]class TriMesh(Mesh):
"""Unstructured triangular mesh.
Vertices and cells are both stored as numpy arrays. This makes the simple mesh manipulations
easy and provides interoperability with the whole scientific Python stack.
Parameters
----------
vertices : ndarray
2D numpy array with 2 columns, each row corresponding to a vertex, and the two columns
giving the Cartesian coordinates of the vertex.
cells : ndarray
Cell-vertex connectivities in a 2D numpy array, in which each row corresponds to a
cell and the columns are the vertices of the cells. It is assumed that all the cells
have the same number of vertices.
"""
# Default settings for plotting the mesh
plot_options = {'ax': None, # Axes object the mesh is plotted
'cell_sets': True, 'vertex_sets': True, # plot groups (if any)
'cell_legends': False, 'vertex_legends': False, # show group legends
'cell_labels': False, 'vertex_labels': False # show labels
}
[docs] def __init__(self, vertices, cells):
super().__init__(vertices, cells)
[docs] def cell_set_to_mesh(self, cell_set):
"""Creates a mesh from a cell set.
The cell orientation is preserved. I.e. if the cells had a consistent orientation
(clockwise or counter-clockwise), the cells of the new mesh inherit this property.
Parameters
----------
cell_set : str
Name of the cell set being used to construct the new mesh. The cell set must be
present in the :code:`cell_sets` member variable of the current mesh object.
Returns
-------
TriMesh
A new :class:`TriMesh` object, based on the selected cell set of the original mesh.
Notes
-----
The implementation is based on https://stackoverflow.com/a/13572640/4892892.
Examples
--------
>>> mesh = TriMesh(np.array([[0, 0], [1, 0], [0, 1], [1, 1]]),
... np.array([[0, 1, 2], [1, 3, 2]]))
>>> mesh.create_cell_set('set', [1])
>>> new_mesh = mesh.cell_set_to_mesh('set')
>>> new_mesh.cells # note that the vertices have been relabelled
array([[0, 2, 1]])
>>> new_mesh.vertices
array([[1, 0],
[0, 1],
[1, 1]])
>>> new_mesh.plot(cell_labels=True, vertex_labels=True)
>>> plt.show()
"""
# Cells and vertices in the given cell set
cells = self.cells[self.cell_sets[cell_set]]
vertices = np.unique(cells)
# Indices where the vertices can be found in the cell-vertex connectivity matrix
cell_vertex_flattened = np.digitize(cells.ravel(), vertices, right=True)
# These indices, at the same time, give the new cell labels
# (because the vertices are renumbered as 0, 1, ...)
cells_renumbered = cell_vertex_flattened.reshape(cells.shape)
return TriMesh(self.vertices[vertices], cells_renumbered)
[docs] def change_vertex_numbering(self, orientation, inplace=False):
"""Changes cell vertex numbering.
Parameters
----------
orientation : {'ccw', 'cw'}
Vertex numbering within a cell, either `'ccw'` (counter-clockwise, default) or
`'cw'` (clock-wise).
inplace : bool, optional
If True, the vertex ordering is updated in the mesh object. The default is False.
Returns
-------
reordered_cells : ndarray
Same format as the :code:`cells` member variable, with the requested vertex ordering.
Notes
-----
Supposed to be used with planar P1 or Q1 finite s.
Examples
--------
>>> mesh = TriMesh(np.array([[1, 1], [3, 5], [7,3]]), np.array([0, 1, 2]))
>>> mesh.change_vertex_numbering('ccw')
array([[2, 1, 0]])
"""
if np.ndim(self.cells) == 1: # single cell, given as a vector
self.cells = self.cells.reshape((1, np.size(self.cells))) # reshape to a matrix
if np.size(self.cells, 1) < 3:
raise Exception('Cells must have at least 3 vertices.')
reordered_cells = np.empty_like(self.cells, dtype=int)
for i, cell_vertices in enumerate(self.cells):
coordinates = self.vertices[cell_vertices]
signed_area = _polygon_area(list(coordinates[:, 0]), list(coordinates[:, 1]))
if (signed_area < 0 and orientation == 'ccw') or \
(signed_area > 0 and orientation == 'cw'):
cell_vertices = cell_vertices[::-1]
reordered_cells[i] = cell_vertices
if inplace:
self.cells = reordered_cells
return reordered_cells
[docs] def scale(self, factor, inplace=False):
"""Scales the geometry by modifying the coordinates of the vertices.
Parameters
----------
factor : float
Each vertex coordinate is multiplied by this non-negative number.
inplace : bool, optional
If True, the vertex positions are updated in the mesh object. The default is False.
Returns
-------
None.
"""
scaled_vertices = factor*self.vertices
if inplace:
self.vertices = scaled_vertices
return scaled_vertices
[docs] def rotate(self, angle, point=(0, 0), inplace=False):
"""Rotates a 2D mesh about a given point by a given angle.
Parameters
----------
angle : float
Angle of rotation, in radians.
point : list or tuple, optional
Coordinates of the point about which the mesh is rotated. If not given, it is the origin.
inplace : bool, optional
If True, the vertex positions are updated in the mesh object. The default is False.
Returns
-------
rotated_vertices : ndarray
Same format as the :code:`vertices` member variable, with the requested rotation.
Notes
-----
Rotating a point :math:`P` given by its coordinates in the global coordinate system as
:math:`P(x,y)` around a point :math:`A(x,y)` by an angle :math:`\\alpha` is done as follows.
1. The coordinates of :math:`P` in the local coordinate system, the origin of which is
:math:`A`, is expressed as
.. math::
P(x',y') = P(x,y) - A(x,y).
2. The rotation is performed in the local coordinate system as :math:`P'(x',y') = RP(x',y')`,
where :math:`R` is the rotation matrix:
.. math::
R = \\begin{pmatrix}
\\cos\\alpha & -\\sin\\alpha \\\\
\\sin\\alpha & \\hphantom{-}\\cos\\alpha
\\end{pmatrix}.
3. The rotated point :math:`P'` is expressed in the original (global) coordinate system:
.. math::
P'(x,y) = P'(x',y') + A(x,y).
"""
rotation_matrix = np.array(
[[np.cos(angle), -np.sin(angle)], [np.sin(angle), np.cos(angle)]])
rotated_vertices = np.empty_like(self.vertices)
for i, vertex in enumerate(self.vertices):
vertex_local = vertex - point
rotated_vertex_local = rotation_matrix.dot(vertex_local)
rotated_vertex = rotated_vertex_local + point
rotated_vertices[i] = rotated_vertex
if inplace:
self.vertices = rotated_vertices
return rotated_vertices
[docs] def cell_area(self, cell):
"""Computes the area of a cell.
Parameters
----------
cell : int
Cell label.
Returns
-------
area : float
Area of the cell.
See Also
--------
cell_set_area
"""
vertices = self.cells[cell]
coordinates = self.vertices[vertices]
return _polygon_area(list(coordinates[:, 0]), list(coordinates[:, 1]))
[docs] def cell_set_area(self, cell_set):
"""Computes the area of a cell set.
Parameters
----------
cell_set : str
Name of the cell set.
Returns
-------
area : float
Area of the cell set.
See Also
--------
cell_area
"""
area = 0
for cell in self.cell_sets[cell_set]:
area += self.cell_area(cell)
return area
[docs] def plot(self, *args, **kwargs):
"""Plots the mesh.
Parameters
----------
ax : matplotlib.axes.Axes, optional
The `Axes` instance the mesh resides in. The default is None, in which case a new
`Axes` within a new figure is created.
Other Parameters
----------------
cell_sets, vertex_sets : bool, optional
If True, the cell/vertex sets (if exist) are highlighted in random colors.
The default is True.
cell_legends, vertex_legends : bool, optional
If True, cell/vertex set legends are shown. The default is False. For many sets,
it is recommended to leave these options as False, otherwise the plotting becomes
very slow.
cell_labels, vertex_labels : bool, optional
If True, cell/vertex labels are shown. The default is False. Recommended to be left
False in case of many cells/vertices. Cell labels are positioned in the centroids
of the cells.
args, kwargs : optional
Additional arguments and keyword arguments to be specified. Those arguments are the
ones supported by :meth:`matplotlib.axes.Axes.plot`.
Returns
-------
None
Notes
-----
If you do not want to plot the cells, only the vertices, pass the :code:`'.'` option, e.g.:
.. code-block:: python
mesh.plot('k.')
to plot the vertices in black. Here, :code:`mesh` is a `TriMesh` object.
Examples
--------
A sample mesh is constructed by creating uniformly randomly distributed points on the
rectangular domain [-1, 1] x [1, 2]. These points will constitute the vertices of the
mesh, while its cells are the Delaunay triangles on the vertices.
.. plot::
>>> from grains.geometry import TriMesh
>>> msh = TriMesh(*TriMesh.sample_mesh(1))
The cells are drawn in greeen, in 3 points of line width, and the vertices of the mesh
are shown in blue.
>>> msh.plot('go-', linewidth=3, markerfacecolor='b', vertex_labels=True)
>>> plt.show()
Notes
-----
The plotting is done by calling :func:`~matplotlib.pyplot.triplot`, which internally
makes a deep copy of the triangles. This increases the memory usage in case of many
elements.
"""
method_options, matplotlib_options = parse_kwargs(kwargs, TriMesh.plot_options)
# Plot the cells
ax = method_options['ax'] if method_options['ax'] else plt.figure().add_subplot()
ax.set_aspect('equal')
ax.triplot(self.vertices[:, 0], self.vertices[:, 1], self.cells, *args,
**matplotlib_options)
# Show element labels, if requested
if method_options['cell_labels']:
for label, elem in enumerate(self.cells):
x, y = np.mean(self.vertices[elem], axis=0)
ax.text(x, y, str(label))
if method_options['vertex_labels']:
for label, vertex in enumerate(self.vertices):
x, y = vertex
ax.text(x, y, str(label))
# Plot the cell sets, vertex sets, and show their labels in the legend, if requested
if method_options['cell_sets']:
for name, cells in self.cell_sets.items():
cells_in_set = self.cells[cells]
triangles = ax.triplot(self.vertices[:, 0], self.vertices[:, 1], cells_in_set, *args,
color=np.random.random((3, )), **matplotlib_options)[0]
if method_options['cell_legends']:
n_cell_set = len(self.cell_sets)
triangles.set_label(name)
ax.legend(loc='lower left', bbox_to_anchor=(0.0, 1.01), ncol=n_cell_set,
frameon=False)
if method_options['vertex_sets']:
for name, vertices in self.vertex_sets.items():
markercolor = np.random.random((3, ))
vertices_in_set = self.vertices[vertices]
points = ax.plot(vertices_in_set[:, 0], vertices_in_set[:, 1], marker='.',
markerfacecolor=markercolor, markeredgecolor=markercolor,
markersize=10, linestyle='None')[0]
if method_options['vertex_legends']:
n_vertex_set = len(self.vertex_sets)
points.set_label(name)
ax.legend(loc='lower left', bbox_to_anchor=(0.0, 1.01), ncol=n_vertex_set,
frameon=False)
[docs] def plot_field(self, component, *args, show_mesh=True, **kwargs):
"""Plots a field on the mesh.
The aim of this method is to support basic post-processing for finite element visualization.
Only the basic contour plot type is available. For vector or tensor fields, the
components to be plotted must be chosen. For faster and more comprehensive plotting
capabilities, turn to well-established scientific visualization software,
such as `ParaView <https://www.paraview.org>`_ or `Mayavi
<http://docs.enthought.com/mayavi/mayavi/>`_. Another limitation of the :meth:`plot_field`
method is that field values are assumed to be associated to the vertices of the mesh,
which restricts us to :math:`P1` Lagrange elements.
Parameters
----------
component : int
Positive integer, the selected component of the field to be plotted. Components are
indexed from 0.
show_mesh : bool, optional
If True, the underlying mesh is shown. The default is True.
ax : matplotlib.axes.Axes, optional
The `Axes` instance the plot resides in. The default is None, in which case a new
`Axes` within a new figure is created.
Other Parameters
----------------
See them described in the :meth:`plot` method.
Returns
-------
None
See Also
--------
plot
Examples
--------
The following example considers the same type of mesh as in the example shown for
:meth:`plot`.
>>> msh = TriMesh(*TriMesh.sample_mesh(1))
We pretend that the field is an analytical function, evaluated at the vertices.
>>> field = lambda x, y: 1 - (x + y**2) * np.sign(x)
>>> field = field(msh.vertices[:, 0], msh.vertices[:, 1])
We associate this field to the mesh and plot it with and without the mesh
>>> msh.associate_field(field, 'analytical field')
>>> _, (ax1, ax2) = plt.subplots(1, 2)
>>> msh.plot_field(0, 'bo-', ax=ax1, linewidth=1, markerfacecolor='k')
>>> msh.plot_field(0, ax=ax2, show_mesh=False)
>>> plt.show()
"""
if self.field['values'] is None:
raise Exception('Field is not associated to the mesh.')
field = self.field['values']
n_component = np.shape(field)[1]
if component not in range(n_component):
raise ValueError('Component must be in [0, {0}].'.format(n_component-1))
method_options, _ = parse_kwargs(kwargs, TriMesh.plot_options)
ax = method_options['ax'] if method_options['ax'] else plt.figure().add_subplot()
ax.set_aspect('equal')
ax.tricontourf(self.vertices[:, 0], self.vertices[:, 1], field[:, component], 1000)
if show_mesh:
self.plot(*args, **kwargs)
[docs] def write_inp(self, filename):
"""Writes the mesh to an Abaqus .inp file.
Parameters
----------
filename : str
Path to the mesh file.
Returns
-------
None
"""
# Node coordinates
inp_file = '*NODE\n'
for global_node_num, coordinate in enumerate(self.vertices, 1):
# TODO: avoid reallocation by temporary strings as done below
inp_file += str(global_node_num) + ', ' + str(list(coordinate))[1:-1] + '\n'
# Cell-vertex connectivities
element_type = 'CPS3' # 3-node linear plane stress element; can be changed in Abaqus
inp_file += '*ELEMENT, TYPE=' + element_type + '\n'
global_elem_num = 0
for elem_vertices in self.cells:
global_elem_num += 1
# TODO: avoid reallocation by temporary strings as done below
inp_file += str(global_elem_num) + ', ' + str(list(elem_vertices+1))[1:-1] + '\n'
# Element groups
element_sets = '' # Grow a new string to avoid extensive reallocation
for group_name, elements in self.cell_sets.items():
element_set = '' # grow a new string to avoid extensive reallocation
# Header
element_set += '\n*ELSET, ELSET=' + group_name + '\n'
# Elements belonging to this group
for i, element in enumerate(elements+1, 1):
element_set += str(element) + ', '
if i % 16 == 0: # Abaqus allows at most 16 entries per line
element_set += '\n'
element_sets += element_set
inp_file += element_sets
# Node groups
node_sets = '' # Grow a new string to avoid extensive reallocation
for group_name, nodes in self.vertex_sets.items():
node_set = '' # grow a new string to avoid extensive reallocation
# Header
node_set += '\n*NSET, NSET=' + group_name + '\n'
# Nodes belonging to this group
for i, node in enumerate(nodes + 1, 1):
node_set += str(node) + ', '
if i % 16 == 0: # Abaqus allows at most 16 entries per line
node_set += '\n'
node_sets += node_set
inp_file += node_sets
# Write to file
with open(filename, 'w') as target:
target.write(inp_file)
[docs] @staticmethod
def sample_mesh(sample, param=100):
"""Provides sample meshes.
Parameters
----------
sample : int
Integer, giving the sample mesh to be considered. Possibilities:
param :
Parameters to the sample meshes. Possibilities:
Returns
-------
nodes : ndarray
2D numpy array with 2 columns, each row corresponding to a vertex, and the two columns
giving the Cartesian coordinates of the vertices.
cells : ndarray
Cell-vertex connectivity in a 2D numpy array, in which each row corresponds to a
cell and the columns are the vertices of the cells. It is assumed that all the
cells have the same number of vertices.
"""
if sample == 1:
n_vertex = param
a, b, c, d = (-1, 1, 1, 2)
x_vertices = (b-a) * np.random.rand(n_vertex) + a
y_vertices = (d-c) * np.random.rand(n_vertex) + c
vertices = np.stack((x_vertices, y_vertices), axis=1)
cells = Triangulation(x_vertices, y_vertices).triangles
elif sample == 2:
vertices = np.array([[]])
cells = np.array([[]])
else:
raise ValueError('Sample not available.')
return vertices, cells
[docs]def _polygon_area(x, y):
"""Computes the signed area of a non-self-intersecting, possibly concave, polygon.
Directly taken from http://rosettacode.org/wiki/Shoelace_formula_for_polygonal_area#Python
Parameters
----------
x, y : list
Coordinates of the consecutive vertices of the polygon.
Returns
-------
float
Area of the polygon.
Warnings
--------
If numpy vectors are passed as inputs, the resulting area is incorrect! WHY?
Notes
-----
The code is not optimized for speed and for numerical stability. Intended to be used to compute
the area of finite element cells, in which case the numerical stability is not an issue
(unless the cell is degenerate). As this function is called possibly as many times as the
number of cells in the mesh, no input checking is performed.
Examples
--------
>>> _polygon_area([0, 1, 1], [0, 0, 1])
0.5
"""
return 1/2*(sum(i*j for i, j in zip(x, y[1:]+y[:1])) -
sum(i*j for i, j in zip(x[1:]+x[:1], y)))
[docs]class Polygon:
"""Represents a polygon.
This class works as expected as long as the given polygon is `simple`, i.e. it is not
self-intersecting and does not contain holes.
A simple class that has `numpy` and `matplotlib` (for the visualization) as the only
dependencies. It does not want to provide extensive functionalities (for those, check out
`Shapely <https://github.com/Toblerity/Shapely>`_). The polygon is represented by its
vertices, given in a consecutive order.
Parameters
----------
vertices : ndarray
2D numpy array with 2 columns, each row corresponding to a vertex, and the two columns
giving the Cartesian coordinates of the vertex.
Raises
------
Exception
If all the vertices of the polygon lie along the same line.
If the polygon is not given in R^2.
ValueError
If the polygon does not have at least 3 vertices.
Examples
--------
Try to give a "polygon", in which all vertices are collinear
>>> poly = Polygon(np.array([[0, 0], [1, 1], [2, 2]])) # doctest: +IGNORE_EXCEPTION_DETAIL
Traceback (most recent call last):
...
Exception: All vertices are collinear. Not a valid polygon.
Now we give a valid polygon:
>>> pentagon = Polygon(np.array([[2, 1], [0, 0], [0.5, 3], [-1, 4], [3, 5]]))
Use Python's `print` function to display basic information about a polygon:
>>> print(pentagon)
A non-convex polygon with 5 vertices, oriented clockwise.
"""
# Default settings for plotting the polygon
plot_options = {'ax': None, # Axes object the mesh is plotted
'show_axes': True, # show the coordinate axes
'vertex_labels': False # show the vertex numbers
}
[docs] def __init__(self, vertices):
n_vertex, dimension = np.shape(vertices)
if dimension != 2:
raise Exception('Polygon vertices must lie on a plane')
if n_vertex < 3:
raise ValueError('A polygon consists of at least 3 vertices.')
if is_collinear(vertices):
raise Exception('All vertices are collinear. Not a valid polygon.')
self.vertices = vertices
self.n_vertex = n_vertex
[docs] def area(self):
"""Signed area of the polygon.
The signed area is computed by the shoelace formula [1]_
.. math::
A = \\frac{1}{2}\sum\limits_{i=1}^N (x_i y_{i+1} - x_{i+1} y_i)
Returns
-------
float
Signed area.
References
----------
.. [1] http://paulbourke.net/geometry/polygonmesh/centroid.pdf
Examples
--------
>>> poly = Polygon(np.array([[0, 0], [1, 0], [1, 1], [-1, 1]]))
>>> poly.area()
1.5
>>> poly = Polygon(np.array([[-1, 1], [1, 1], [1, 0], [0, 0]]))
>>> poly.area()
-1.5
"""
vertex_idx = np.arange(-1, self.n_vertex) # close the polygon
x = self.vertices[vertex_idx, 0]
y = self.vertices[vertex_idx, 1]
return 1/2*sum(x[i]*y[i+1] - x[i+1]*y[i] for i in range(self.n_vertex))
[docs] def centroid(self):
"""Centroid of the polygon.
The centroid is computed according to the following formula [2]_
.. math::
C_x = \\frac{1}{6A}\sum\limits_{i=1}^N (x_i + x_{i+1})(x_i y_{i+1} - x_{i+1} y_i)
C_y = \\frac{1}{6A}\sum\limits_{i=1}^N (y_i + y_{i+1})(x_i y_{i+1} - x_{i+1} y_i)
where :math:`A` is the signed area determined by the :meth:`area` method and :math:`x_i,
y_i` are the vertex coordinates with :math:`x_{N+1}=x_1` and :math:`y_{N+1}=y_1`.
Returns
-------
tuple
2-tuple, the coordinates of the centroid.
References
----------
.. [2] http://paulbourke.net/geometry/polygonmesh/centroid.pdf
Examples
--------
>>> poly = Polygon(np.array([[0, 0], [0, 1], [1, 1], [1, 0]]))
>>> poly.centroid()
(0.5, 0.5)
>>> poly = Polygon(np.array([[2, 1], [0, 0], [0.5, 3], [-1, 4], [3, 5]]))
>>> poly.centroid() # doctest: +ELLIPSIS
(1.254..., 2.807...)
"""
vertex_idx = np.arange(-1, self.n_vertex) # close the polygon
x = self.vertices[vertex_idx, 0]
y = self.vertices[vertex_idx, 1]
a = self.area()
c_x = sum((x[i] + x[i+1]) * (x[i]*y[i+1] - x[i+1]*y[i]) for i in range(self.n_vertex))
c_y = sum((y[i] + y[i+1]) * (x[i]*y[i+1] - x[i+1]*y[i]) for i in range(self.n_vertex))
return 1/(6*a)*c_x, 1/(6*a)*c_y
[docs] def diameter(self, definition='set'):
"""Diameter of the polygon.
Multiple definitions are supported for the diameter:
- `Diameter of a set`. The polygon is considered as a set :math:`A` of points comprised
of the polygon vertices. Let :math:`(X,d)` be a metric space. The diameter of the set is
defined as
.. math::
\mathrm{diam}(A) = \sup\{ d(x,y)\ |\ x, y \in A\}.
:label: setDiameter
Here, the Euclidean metric is used.
- `Equivalent diameter`. Diameter of the circle of the same area as that of the polygon.
Parameters
----------
definition : {'set', 'equivalent'}, optional
The default is 'set'.
Returns
-------
float
Diameter of the polygon, based on the chosen definition.
Notes
-----
When :code:`definition` is 'set', computing the diameter by :math:numref:`setDiameter` is
`equivalent <https://cs.stackexchange.com/questions/23646/why-are-the-two-farthest-points
-vertices-of-the-convex-hull>`_ to determining the distance of the furthest points in the
convex hull of :math:`A`. Therefore, the diameter will always be the distance between two
points on the convex hull of :math:`A`. Then for each vertex of the hull finding which
other hull vertex is farthest away from it, the `rotating caliper` algorithm can be used.
Our brute-force method is simpler as it needs neither the convex hull nor the rotating
caliper algorithm: all the pairwise distances among the polygon vertices are computed and
the largest one is chosen. Pair of points a maximum distance apart. Since the polygons in
our applications do not have that many vertices, this simplistic approach is a viable
alternative.
Examples
--------
>>> poly = Polygon(np.array([[2, 5], [0, 1], [4, 3], [4, 5]]))
>>> poly.diameter('set') # doctest: +ELLIPSIS
5.6568542...
>>> poly.diameter('equivalent') # doctest: +ELLIPSIS
3.1915382...
>>> poly = Polygon(np.array([[2, 1], [3, -4], [-1, -1], [-4, -2], [-3, 0]]))
>>> poly.diameter('set') # doctest: +ELLIPSIS
7.2801098...
>>> poly.diameter('equivalent') # doctest: +ELLIPSIS
4.2967398...
"""
if definition == 'set':
return sqrt(np.max(distance_matrix(self.vertices)))
elif definition == 'equivalent':
return sqrt(4*abs(self.area())/pi)
else:
raise ValueError('Diameter definition unknown. Choose between "set" or "equivalent".')
[docs] def orientation(self):
"""Orientation of the polygon.
Returns
-------
str
'cw' if the polygon has clockwise orientation, 'ccw' if counter-clockwise.
"""
cross_product = np.cross(self.vertices[1] - self.vertices[0],
self.vertices[2] - self.vertices[1])
return 'ccw' if cross_product > 0 else 'cw'
[docs] def is_convex(self):
"""Decides whether the polygon is convex.
Returns
-------
bool
True if the polygon is convex. False otherwise.
Notes
-----
The algorithm works by checking if all pairs of consecutive edges in the polygon are
either all clockwise or all counter-clockwise oriented. This method is valid only for
simple polygons. The implementation follows `this code
<https://github.com/crm416/point-location/blob/01b5c0f2105237e7108730d0e0db6213c0aadfbf/
geo/shapes.py#L168>`_, extended for the case when two consecutive edges are collinear.
If the polygon was not simple, a more complicated algorithm would be needed, see e.g.
`here <https://stackoverflow.com/a/45372025/4892892>`_.
Examples
--------
A triangle is always convex:
>>> poly = Polygon(np.array([[1, 1], [0, 1], [0, 0]]))
>>> poly.is_convex()
True
Let us define a concave deltoid:
>>> poly = Polygon(np.array([[-1, -1], [0, 1], [1, -1], [0, 5]]))
>>> poly.is_convex()
False
Give a polygon that has two collinear edges:
>>> poly = Polygon(np.array([[0.5, 0], [1, 0], [1, 1], [0, 1], [0, 0]]))
>>> poly.is_convex()
True
"""
target = None # pairwise orientation of the edges, True if counter-clockwise
target_set = False # True if the truth value of `target` has been set
for i in range(self.n_vertex): # for every consecutive three vertices
A = self.vertices[i]
B = self.vertices[(i + 1) % self.n_vertex]
C = self.vertices[(i + 2) % self.n_vertex]
cross_product = np.cross(B - A, C - B) # the orientation is implied from it
if not isclose(cross_product, 0): # otherwise the orientation is not accurate
is_ccw = cross_product > 0
if not target_set:
target = is_ccw
target_set = True
else:
if is_ccw != target: # not all pairs of edges have the same orientation
return False
return True
[docs] def plot(self, *args, **kwargs):
"""Plots the polygon.
Parameters
----------
ax : matplotlib.axes.Axes, optional
The `Axes` instance the polygon resides in. The default is None, in which case a new
`Axes` within a new figure is created.
Other Parameters
----------------
show_axes : bool, optional
If True, the coordinate system is shown. The default is True.
vertex_labels : bool, optional
If True, vertex labels are shown. The default is False.
args, kwargs : optional
Additional arguments and keyword arguments to be specified. Those arguments are the
ones supported by :meth:`matplotlib.axes.Axes.plot`.
Returns
-------
None
Examples
--------
Consider the pentagon used in the example of the constructor. Plot it in black with
red diamond symbols representing its vertices. Moreover, display the vertex numbers
and do not show the coordinate system.
>>> pentagon = Polygon(np.array([[2, 1], [0, 0], [0.5, 3], [-1, 4], [3, 5]]))
>>> pentagon.plot('k-d', vertex_labels=True, markerfacecolor='r', show_axes=False)
>>> plt.show()
"""
method_options, matplotlib_options = parse_kwargs(kwargs, Polygon.plot_options)
ax = method_options['ax'] if method_options['ax'] else plt.figure().add_subplot()
ax.set_aspect('equal')
vertex_idx = np.arange(-1, self.n_vertex) # close the polygon
ax.plot(self.vertices[vertex_idx, 0], self.vertices[vertex_idx, 1],
*args, **matplotlib_options)
if method_options['vertex_labels']: # show the vertex numbers
for label, vertex in enumerate(self.vertices, 1):
x, y = vertex
ax.text(x, y, str(label))
if not method_options['show_axes']: # do not show the axes
ax.set_axis_off()
def __str__(self):
convexity = ' convex' if self.is_convex() else ' non-convex'
orientation = ' clockwise' if self.orientation() == 'cw' else ' counter-clockwise'
return 'A' + convexity + ' polygon with ' + str(self.n_vertex) + \
' vertices, oriented' + orientation + '.'
[docs]def is_collinear(points, tol=None):
"""Decides whether a set of points is collinear.
Works in any dimensions.
Parameters
----------
points : ndarray
2D numpy array with N columns, each row corresponding to a point, and the N columns
giving the Cartesian coordinates of the point.
tol : float, optional
Tolerance value passed to numpy's `matrix_rank` function. This tolerance gives the
threshold below which SVD values are considered zero.
Returns
-------
bool
True for collinear points.
See Also
--------
:func:`numpy.linalg.matrix_rank`
Notes
-----
The algorithm for three points is from `Tim Davis <https://nl.mathworks.com/matlabcentral/
discussions/b-loren/127448-loren-on-the-art-of-matlab-collinearity/21239#reply_21239>`_.
Examples
--------
Two points are always collinear
>>> is_collinear(np.array([[1, 0], [1, 5]]))
True
Three points in 3D which are supposed to be collinear (returns false due to numerical error)
>>> is_collinear(np.array([[0, 0, 0], [1, 1, 1], [5, 5, 5]]), tol=0)
False
The previous example with looser tolerance
>>> is_collinear(np.array([[0, 0, 0], [1, 1, 1], [5, 5, 5]]), tol=1e-14)
True
"""
return np.linalg.matrix_rank(points - points[0, :], tol=tol) < 2
[docs]def squared_distance(x, y):
"""Squared Euclidean distance between two points.
For points :math:`x(x_1, ..., x_n)` and :math:`y(y_1, ... y_n)` the following metric is computed
.. math::
\sum\limits_{i=1}^n (x_i - y_i)^2
Parameters
----------
x, y : ndarray
1D numpy array, containing the coordinates of the two points.
Returns
-------
float
Squared Euclidean distance.
See Also
--------
distance_matrix
Examples
--------
>>> squared_distance(np.array([0, 0, 0]), np.array([1, 1, 1]))
3.0
"""
return float(((x - y) ** 2).sum())
[docs]def distance_matrix(points):
"""A symmetric square matrix, containing the pairwise squared Euclidean distances among points.
Parameters
----------
points : ndarray
2D numpy array with 2 columns, each row corresponding to a point, and the two columns
giving the Cartesian coordinates of the points.
Returns
-------
dm : ndarray
Distance matrix.
See Also
--------
squared_distance
Examples
--------
>>> points = np.array([[1, 1], [3, 0], [-1, -1]])
>>> distance_matrix(points)
array([[ 0., 5., 8.],
[ 5., 0., 17.],
[ 8., 17., 0.]])
"""
dm = np.asarray([[squared_distance(p1, p2) for p2 in points] for p1 in points])
np.fill_diagonal(dm, 0.0)
return dm