Source code for polygons

#
##
##  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/.
##

"""Polygon meshes.

This module defines the Polygons class, which can be used to describe
discrete geometrical models consisting of polygons.
"""
import numpy as np

from pyformex import utils
from pyformex import arraytools as at
from pyformex import geomtools as gt
from pyformex.coords import Coords
from pyformex.mesh import Mesh
from pyformex.varray import Varray
from pyformex.geometry import Geometry
from pyformex.elements import Elems, ElementType

# These are here for the read/write functions
# from pyformex.path import Path

__all__ = ['Polygons', 'nodalVSum']


def table_func(key):
    @property
    def wrapper(self):
        if key not in self._memory:
            self.compute_tables()
        return self._memory[key]
    return wrapper

##############################################################

[docs]@utils.pzf_register class Polygons(Geometry): """Polygons is a discrete geometrical model consisting of polygons. The Polygons class is implemented in a similar manner as the :class:`Mesh` and :class`TriSurface` classes: the coordinates of all the vertices are collected in a single :class:`Coords` array, and the 'elements' (polygons) are defined using indices into that array. While the :class:`Mesh` and :class`TriSurface` classes store the elements in an :class:`Elems` array (requiring a fixed plexitude for all elements), the Polygons class uses a :class:`Varray` so that the polygons can have a variable number of vertices. Parameters ---------- coords: :term:`coords_like` A 2-dim :class:`~coords.Coords` (or data to initalize it) with the coordinates of all the vertices used to define the polygons. elems: :class:`~varray.Varray` A :class:`~varray.Varray` (or data to initalize it) with the indices of the vertices that define each of the polygons. All values in elems should be in the range 0 <= value < len(coords). prop: int :term:`array_like`, optional 1-dim int array with non-negative element property numbers. If provided, :meth:`setProp` will be called to assign the specified properties. Examples -------- A Polygons with a triangle and a square. >>> P = Polygons(Coords('0123'), [[0,1,2], [0,1,2,3]]) >>> print(P.report()) Polygons: nnodes: 4, nelems: 2, nplex: min 3, max 4, eltype: polygon BBox: [0. 0. 0.], [1. 1. 0.] Size: [1. 1. 0.] Coords: [[0. 0. 0.] [1. 0. 0.] [1. 1. 0.] [0. 1. 0.]] Elems: Varray (nrows=2, width=3..4) [0 1 2] [0 1 2 3] """ def __init__(self, coords=None, elems=None, *, prop=None, check=True): """Initialize a new Mesh.""" Geometry.__init__(self) if coords is None or elems is None: if coords is None and elems is None: # Create an empty Polygons object self.coords = Coords() self.elems = Varray() self.prop = None return else: raise ValueError("Both coords and elems need to be specified") if not isinstance(coords, Coords): coords = Coords(coords) if coords.ndim != 2: raise ValueError( f"\nExpected 2D coordinate array, got {coords.ndim}") if not isinstance(elems, Varray): elems = Varray(elems) if elems.size > 0 and ( elems.data.max() >= coords.shape[0] or elems.data.min() < 0): raise ValueError( "\nInvalid connectivity data: " "some node number(s) not in coords array " f"(min={elems.data.min()}, max={elems.data.max()}, " f"ncoords={coords.shape[0]}") self.coords = coords self.elems = elems self.setProp(prop) if check: self.check() self._memory = {}
[docs] def check(self): """Check that all polygons have at least length 3""" if (self.elems.lengths < 3).any(): raise ValueError( "Invalid Polygons: Some elements have less than 3 vertices")
def _set_coords(self, coords): """Replace the current coords with new ones. Parameters ---------- coords: Coords A Coords with same shape as self.coords. Returns ------- Mesh A Mesh (or subclass) instance with same connectivity, eltype and properties as the current, but with possible changes in the coordinates of the nodes. """ if isinstance(coords, Coords) and coords.shape == self.coords.shape: M = self.__class__(coords, self.elems, prop=self.prop) M.attrib(**self.attrib) return M else: raise ValueError( f"Invalid reinitialization of {self.__class__} coords") @property def eltype(self): """Return the element type of the Polygons. Returns ------- str Always 'polygon' """ return 'polygon'
[docs] def elName(self): # TODO: deprecate this in favor of self.eltype.name? """Return the element name of the Polygons. Returns ------- str Always 'polygon' """ return 'polygon'
# @property
[docs] def level(self): """Return the level of dimensionality of the polygons. Returns ------- int Always 2. """ return 2
@property def shape(self): """Return the shape of the :attr:`elems` Varray.""" return self.elems.shape # @property
[docs] def nelems(self): """Return the number of polygons. This is the number of 'rows' in the :attr:`elems` Varray. """ return self.elems.shape[0]
# @property
[docs] def nplex(self): """Return the plexitude of the polygon elements. Always returns None, as there is no fixed plexitude of the polygons. """ return None
@property def plex(self): """Return the plexitude of each of the elements""" return self.elems.lengths # @property
[docs] def ncoords(self): """Return the number of points used to define the polygons. This is the first dimension of the :attr:`coords` array. Notes ----- This may be different from the total number of vertices in all the polygons, as polygons may share some vertices. See also size: The total number of vertices in all the polygons. """ return self.coords.shape[0]
@property def size(self): """Return the total number of polygon vertices. This is the total number of entries in the :attr:`elems` Varray. """ return self.elems.size
[docs] def info(self): """Return short info about the Mesh. Returns ------- str A string with info about the shape of the :attr:`~mesh.Mesh.coords` and :attr:`elems` attributes. """ return "coords" + str(self.coords.shape) + "; elems" + str(self.elems.shape)
[docs] def report(self, full=True): # TODO: We need an option here to let numpy print the full tables. """Create a report on the Mesh shape and size. The report always contains the number of nodes, number of elements, plexitude, dimensionality, element type, bbox and size. If full==True(default), it also contains the nodal coordinate list and element connectivity table. Because the latter can be rather bulky, they can be switched off. Note ---- NumPy normally limits the printed output. You will have to change numpy settings to actually print the full arrays. """ bb = self.bbox() s = (f"{self.__class__.__name__}: " f"nnodes: {self.ncoords()}, nelems: {self.nelems()}, ") if self.nelems() > 0: s += (f"nplex: min {self.plex.min()}, max {self.plex.max()}, " f"eltype: {self.elName()}" f"\n BBox: {bb[0]}, {bb[1]}" f"\n Size: {bb[1]-bb[0]}") # if self.level() == 2: # s += f"\n Area: {self.area()}" if full: s += '\n' + at.stringar(" Coords: ", self.coords) + \ '\n' + at.stringar(" Elems: ", self.elems) return s
def __str__(self): """Format a Mesh in a string. This creates a detailed string representation of a Mesh, containing the report() and the lists of nodes and elements. """ return self.report(False)
[docs] def shallowCopy(self, prop=None): """Return a shallow copy. Parameters ---------- prop: int :term:`array_like`, optional 1-dim int array with non-negative element property numbers. Returns ------- Polygons A shallow copy of the Mesh, using the same data arrays for ``coords`` and ``elems``. If ``prop`` was provided, the new Mesh can have other property numbers. This is a convenient method to use the same Mesh with different property attributes. """ if prop is None: prop = self.prop return self.__class__(self.coords, self.elems, prop=prop)
# NB: It does not make sense putting compact=True here as default, # since _select is normally used via select, which has compact=False def _select(self, selected, compact=False): """Return a Mesh only holding the selected elements. This is the low level select method. The normal user interface is via the :meth:`select` method. """ selected = at.checkArray1D(selected) M = self.__class__(self.coords, self.elems[selected]) if self.prop is not None: M.setProp(self.prop[selected]) # if compact: # M = M.compact() return M ########################################## ## Allow drawing ## def actor(self, **kargs): if self.nelems() == 0: return None from pyformex.opengl.drawable import Actor return Actor(self, **kargs)
[docs] @classmethod @utils.memoize def triangleSelector(clas, n): """Return a selector to get triangle fan elements from polygons. Examples -------- >>> Polygons.triangleSelector(5) array([[0, 1, 2], [0, 2, 3], [0, 3, 4]]) """ i0 = np.zeros(n-2, dtype=at.Int) i1 = np.arange(1, n-1, dtype=at.Int) i2 = i1+1 return np.column_stack([i0, i1, i2])
[docs] @classmethod @utils.memoize def edgeSelector(clas, n): """Return a selector to get edge elements from polygons. Examples -------- >>> Polygons.edgeSelector(5) array([[0, 1], [1, 2], [2, 3], [3, 4], [4, 0]]) """ i0 = np.arange(n) i1 = np.roll(i0, -1) return np.column_stack([i0, i1])
[docs] def triangles(self, layout='fan'): """Return an Elems with the triangles of the polygons layout = 'fan' | 'strip' | 'edglen' TODO: only 'fan' is implemented! """ sels = [self.__class__.triangleSelector(l) for l in self.elems.lengths] elems = np.row_stack([e[s] for e, s in zip(self.elems, sels)]) return Elems(elems, eltype='tri3')
[docs] def edges(self, layout='fan'): """Return an Elems with the edges of the polygons layout = 'fan' | 'strip' | 'edglen' TODO: only 'fan' is implemented! """ sels = [self.__class__.edgeSelector(l) for l in self.elems.lengths] elems = np.row_stack([e[s] for e, s in zip(self.elems, sels)]) return Elems(elems, eltype='line2')
def compute_tables(self): faces = self.elems edges = np.row_stack([ faces.data[i:j][Polygons.edgeSelector(j-i)] for i, j in faces.rowlimits()]) uniq, uniqid = at.uniqueRowsIndex(edges, permutations='all') self._memory['f_v'] = faces self._memory['f_e'] = Varray(uniqid, ind=faces.ind) self._memory['e_v'] = Varray(edges[uniq]) self._memory['e_f'] = self._memory['f_e'].inverse() self._memory['v_f'] = self._memory['f_v'].inverse() self._memory['v_e'] = self._memory['e_v'].inverse() f_v = table_func('f_v') f_e = table_func('f_e') e_v = table_func('e_v') e_f = table_func('e_f') v_f = table_func('v_f') v_e = table_func('v_e') def isManifold(self): return self.e_f.maxwidth == 2 def isClosedManifold(self): return self.e_f.maxwidth == self.e_f.minwidth == 2 def isOrientable(self): return self.e_f.maxwidth == self.e_f.minwidth == 2
[docs] def print_tables(self): """Print all the tables""" print("Connection tables") print(f"f_v (faces to vertices) = {self.f_v}") print(f"f_e (faces to edges) = {self.f_e}") print(f"e_v (edges to vertices) = {self.e_v}") print(f"e_f (edges to faces) = {self.e_f}") print(f"v_f (vertices to faces) = {self.v_f}") print(f"v_e (vertices to edges) = {self.v_e}")
@property def vertices(self): """Return all vertices of all polygons. Returns ------- Coords The coordinates of all vertices of all polygons, in the order of the :attr:`elems` data. Examples -------- >>> P = Polygons(Coords('0123'), [[0,1,2], [0,1,2,3]]) >>> P.vertices Coords([[0., 0., 0.], [1., 0., 0.], [1., 1., 0.], [0., 0., 0.], [1., 0., 0.], [1., 1., 0.], [0., 1., 0.]]) """ return self.coords[self.elems.data]
[docs] def vectors(self): """Return vectors along all edges of all polygons. Returns ------- Coords The vectors along all the edges of all polygons, in the order of the :attr:`elems` data. The vectors point from each vertex to the next vertex in the polygon. Examples -------- >>> P = Polygons(Coords('0123'), [[0,1,2], [0,1,2,3]]) >>> P.vectors() Coords([[ 1., 0., 0.], [ 0., 1., 0.], [-1., -1., 0.], [ 1., 0., 0.], [ 0., 1., 0.], [-1., 0., 0.], [ 0., -1., 0.]]) """ ni = self.elems return self.coords[ni.roll(-1).data] - self.coords[ni.data]
[docs] @utils.memoize def vectorPairs(self): """Compute vector pairs along the edges at each vertex of the polygons. Returns ------- vec1: float array (nel, nplex, 3) The vectors from each vertex to the previous vertex in the polygon. vec2: float array (nel, nplex, 3) The vectors from each vertex to the next vertex in the polygon. Examples -------- >>> P = Polygons(Coords('0123'), [[0,1,2], [0,1,2,3]]) >>> v1, v2 = P.vectorPairs() >>> print(v1) [[-1. -1. 0.] [ 1. 0. 0.] [ 0. 1. 0.] [ 0. -1. 0.] [ 1. 0. 0.] [ 0. 1. 0.] [-1. 0. 0.]] >>> print(v2) [[ 1. 0. 0.] [ 0. 1. 0.] [-1. -1. 0.] [ 1. 0. 0.] [ 0. 1. 0.] [-1. 0. 0.] [ 0. -1. 0.]] """ # This is an alternate implementation # ni = Varray(np.arange(self.elems.size), self.elems.ind) # nj = ni.roll(1).data # v2 = self.vectors() # v1 = v2[ni.roll(1).data] ni = self.elems v1 = self.coords[ni.data] - self.coords[ni.roll(1).data] v2 = self.coords[ni.roll(-1).data] - self.coords[ni.data] return (v1, v2)
@property @utils.memoize def vnormals(self): """Return normals at vertices of polygons. Returns ------- normals: float array (self.size,3) The unit normals on the two edges ending at each vertex. Examples -------- >>> P = Polygons(Coords('0123'), [[0,1,2], [0,1,2,3]]) >>> n = P.vnormals >>> print(n.round(2)+0.) [[0. 0. 1.] [0. 0. 1.] [0. 0. 1.] [0. 0. 1.] [0. 0. 1.] [0. 0. 1.] [0. 0. 1.]] """ v1, v2 = self.vectorPairs() vnormals = at.vectorPairNormals(v1, v2) # check for nans (colinear vector pairs) w = np.where(np.isnan(vnormals).any(axis=-1))[0] if len(w) > 0: # replace nans with mean for polygon v = self.elems.where(w)[:, 0] for r, p in zip(w, v): vnormals[r] = np.nanmean( vnormals[self.elems.rowslice(p)], axis=0) return vnormals @property @utils.memoize def angles(self): """Compute internal angles at vertices of polygons. Returns ------- angles: float array (nel, nplex) The internal angles made by the two polygon edges at the vertex. Examples -------- >>> P = Polygons(Coords('0123'), [[0,1,2], [0,1,2,3]]) >>> a = P.angles >>> print(a) [45. 90. 45. 90. 90. 90. 90.] """ v1, v2 = self.vectorPairs() return 180. - at.vectorPairAngle(v1, v2) @property @utils.memoize def fnormals(self): """Compute mean face normals of polygons. Returns ------- fnormals: float array (self.nelems, 3) For each polygon, the mean of the normals at all its vertices. Examples -------- >>> P = Polygons(Coords('0123'), [[0,1,2], [0,1,2,3], [2,1,0]]) >>> f = P.fnormals >>> print(f) [[ 0. 0. 1.] [ 0. 0. 1.] [ 0. 0. -1.]] """ n = self.vnormals f = np.empty((self.nelems(), 3), dtype=at.Float) for i in range(self.nelems()): f[i] = n[self.elems.rowslice(i)].mean(axis=0) return f
[docs] def avg_normals(self, weights='angle', full=False, treshold=None): """Compute averaged normals at the nodes of a Polygons. Parameters ---------- coords: float :term:`array_like` (ncoords, 3) Array with the coordinates of the nodes elems: int :term:`array_like` (nelems, nplex, 3) Definitions of the polygons in terms of the nodes. All polygons should have the same plexitude. weights: float :term:`array_like` | 'angle' | None Weights to apply to the polygon normals at a node during averaging. The default 'angle' will weigh the contribution of the polygons by the angle their edges make at the node. Custom values should be an array with shape (nelems, nplex). Specifying None will result in giving the same weight to all normals. full: bool, optional If False (default), unique averaged normals at the nodes are returned. If True, the averaged normals are returned for each vertex of each polygon. This is mainly intended for rendering purposes. treshold: float, optional Only used with ``full=True``. If provided, element vertex normals making an angle with the averaged normal having a cosinus smaller than treshold, will be returned as the original normal. This allows the rendering of feature edges. Returns ------- normals: float array (ncoords, 3) The unit normals at the nodes, obtained by (weighted) averaging the normals on the polygons attached to that node. The default ``full=False`` returns an array with shape (ncoords, 3). With ``full=True``, an array with shape (nelems, nplex, 3) is returned. Examples -------- This example is the surface of a unit cube. Notice that the average normals come out nicely symmetric, even without weights, because all polygons have the same angles at the nodes. >>> from pyformex.simple import Cube >>> M = Cube() >>> P = Polygons(M.coords, M.elems) >>> print(P.avg_normals()) [[-0.5774 -0.5774 -0.5774] [ 0.5774 -0.5774 -0.5774] [ 0.5774 0.5774 -0.5774] [-0.5774 0.5774 -0.5774] [-0.5774 -0.5774 0.5774] [ 0.5774 -0.5774 0.5774] [ 0.5774 0.5774 0.5774] [-0.5774 0.5774 0.5774]] >>> print(P.avg_normals(weights=None)) [[-0.5774 -0.5774 -0.5774] [ 0.5774 -0.5774 -0.5774] [ 0.5774 0.5774 -0.5774] [-0.5774 0.5774 -0.5774] [-0.5774 -0.5774 0.5774] [ 0.5774 -0.5774 0.5774] [ 0.5774 0.5774 0.5774] [-0.5774 0.5774 0.5774]] """ normals = self.vnormals if weights == 'angle': weights = self.angles if weights is not None: normals *= weights[..., np.newaxis] normals, cnt = nodalVSum(normals, self.elems, self.coords.shape[0]) # No need to take average, since we are going to normalize anyway normals = at.normalize(normals) # if not atnodes: # normals = normals[elems] # if treshold is not None: # fnormals = at.normalize(fnormals.reshape(-1,3)) # normals = normals.reshape(-1,3) # cosa = at.vectorPairCosAngle(normals, fnormals) # w = np.where(cosa<treshold)[0] # normals[w, :] = fnormals[w, :] # normals = normals.reshape(elems.shape[0], 3, 3) return normals
@property @utils.memoize def anormals(self): return self.avg_normals() @property def fanormals(self): return self.anormals[self.elems.data] def centroids(self): return Coords.concatenate([ self.coords[r].mean(axis=0) for r in self.elems]) ############################################## ## add, merge, fuse, compact def __add__(self, other): """Return the sum of two Polygons. The sum of the Meshes is simply the concatenation thereof. It allows us to write simple expressions as M1+M2 to concatenate the Meshes M1 and M2. Both meshes should be of the same plexitude and have the same eltype. The result will be of the same class as self (either a Mesh or a subclass thereof). """ return self.concatenate([self, other])
[docs] @classmethod def concatenate(clas, polys): """Concatenate a list of Polygons. Parameters ---------- polys: list of Polygons A list of Polygons instance to be concatenated to a single one. Notes ----- The concatenation itself does not fuse the vertices that happen to be (nearly) conincident. You may want to call the :meth:`fuse` method. If any of the Polygons has property numbers, the resulting Polygons will inherit the properties. In that case, any elements from Polygons without properties will be assigned property 0. If all input objects are without properties, so will be the result. Examples -------- >>> M0 = Mesh(eltype='quad4') >>> P0 = Polygons(M0.coords, M0.elems) >>> P1 = Polygons(M0.coords.trl(0, 1.), [[0,1,2],[0,2,3]]) >>> P = Polygons.concatenate([P0,P1]) >>> print(P.coords) [[0. 0. 0.] [1. 0. 0.] [1. 1. 0.] [0. 1. 0.] [1. 0. 0.] [2. 0. 0.] [2. 1. 0.] [1. 1. 0.]] >>> print(P.elems) Varray (nrows=3, width=3..4) [0 1 2 3] [4 5 6] [4 6 7] >>> P = P.fuse() >>> print(P.coords) [[0. 0. 0.] [0. 1. 0.] [1. 0. 0.] [1. 1. 0.] [2. 0. 0.] [2. 1. 0.]] >>> print(P.elems) Varray (nrows=3, width=3..4) [0 2 3 1] [2 4 5] [2 5 3] """ coords = Coords.concatenate([p.coords for p in polys]) offset = at.cumsum0([p.coords.shape[0] for p in polys]) ndata = at.cumsum0([p.elems.size for p in polys]) elems = Varray.concatenate([p.elems for p in polys]) for i in range(1, len(polys)): elems.data[ndata[i]:ndata[i+1]] += offset[i] if all([p.prop is None for p in polys]): # There are no props prop = None else: # Keep the available props prop = np.concatenate([p.prop if p.prop is not None else np.zeros(p.nelems(), dtype=at.Int) for p in polys]) P = clas(coords, elems, prop=prop) return P
[docs] def fuse(self, nodes=None, **kargs): """Fuse the nodes of a Polygons. Nodes that are within the tolerance limits of each other are merged into a single node. Parameters ---------- nodes: int :term:`array_like`, optional A list of node numbers. If provided, only these nodes will be involved in the fuse operation. **kargs: Extra arguments for tuning the fuse operation are passed to the :meth:`coords.Coords:fuse` method. Examples: see :meth:`concatenate` """ if nodes is None: coords, index = self.coords.fuse(**kargs) else: keep = at.complement(nodes, self.ncoords()) coords, fusindex = self.coords[nodes].fuse(**kargs) coords = Coords.concatenate([self.coords[keep], coords]) index = -np.ones(self.ncoords(), dtype=at.Int) index[keep] = np.arange(len(keep), dtype=at.Int) index[nodes] = len(keep) + fusindex elems = Varray(index[self.elems.data], ind=self.elems.ind) return self.__class__(coords, elems, prop=self.prop)
[docs] def compact(self): """Remove unconnected nodes and renumber the Polygons. Returns ------- Polygons The input object, where any unconnected nodes have been removed and the nodes are renumbered to a compacter scheme. Notes ----- This changes the object in-place. If the node-numbering has been changed, the object will have an attribute 'oldnumbers' which is an int array giving the old node number for in the position of the new node number. Examples -------- >>> x = Coords([[i] for i in np.arange(5)]) >>> P = Polygons(x, [[0,1,2],[2,3,1,0]]) >>> print(P.coords) [[0. 0. 0.] [1. 0. 0.] [2. 0. 0.] [3. 0. 0.] [4. 0. 0.]] >>> P1 = P.compact() >>> print(P1.coords) [[0. 0. 0.] [1. 0. 0.] [2. 0. 0.] [3. 0. 0.]] >>> print(P1.elems) Varray (nrows=2, width=3..4) [0 1 2] [2 3 1 0] >>> P1 is P True >>> print(P.oldnumbers) None >>> P = Polygons(x, [[4,1,2],[2,3,1,4]]) >>> P.compact() <pyformex.polygons.Polygons object at ...> >>> print(P.coords) [[1. 0. 0.] [2. 0. 0.] [3. 0. 0.] [4. 0. 0.]] >>> print(P.elems) Varray (nrows=2, width=3..4) [3 0 1] [1 2 0 3] >>> print(P.oldnumbers) [1 2 3 4] """ old = np.unique(self.elems.data) nmax = old.max() + 1 if old.min() == 0 and nmax == old.size: # We have compact elems. if len(self.coords) > nmax: self.coords = self.coords[:nmax] self.oldnumbers = None else: # Renumber the nodes new = at.inverseUniqueIndex(old) self.elems = Varray(new[self.elems.data], ind=self.elems.ind) self.coords = self.coords[old] self.oldnumbers = old return self
def _reduce(self, mplex): """Reduce the Polygons to a specified maximum plexitude. Notes ----- This is a low level function. Users shoudl invoke it through :meth:`reduce` or :meth:`split`. Returns ------- dict A dictionary where the keys are plexitudes and the values are the faces having that plexitude. See Also -------- reduce: reduce the maximum plexitude of the polygons split: split the Polygons into Mesh objects. """ # TODO: inherit the props def split_max(): """Split the polygons with highest plexitude""" for e in gt.split_polygon(self.coords, elems.pop(max(elems))): nplex = e.shape[-1] if nplex in elems: elems[nplex] = np.concatenate([elems[nplex], e], axis=0) else: elems[nplex] = e elems = self.elems.split() if len(elems) > 0 and elems[-1].shape[1] > mplex: elems = dict((e.shape[1], e) for e in elems) while max(elems) > mplex: split_max() elems = [elems[nplex] for nplex in sorted(elems.keys())] return elems
[docs] def reduce(self, mplex): """Reduce the Polygons to a specified maximum plexitude. Parameters ---------- mplex: int The maximal plexitude of the output polygons. Thus, with mplex=3 only triangles will results; mplex=4 will yield triangles and quads. Returns ------- Polygons A Polygons where all of the polygons with more than mplex vertices have been split into smaller ones. Notes ----- Splitting a polygon is done along the shortest diagonal. See Also -------- split: split (and optionally reduce) the Polygons into Mesh objects. Examples -------- >>> x = Coords([[i] for i in np.arange(5)]) >>> P = Polygons(x, [[0,1,2],[0,1,2,3],[0,1,2,3,4]]) >>> print(P.reduce(4).elems) Varray (nrows=4, width=3..4) [0 1 2] [0 1 2] [0 1 2 3] [2 3 4 0] >>> print(P.reduce(3).elems) Varray (nrows=6, width=3..3) [0 1 2] [0 1 2] [0 1 2] [2 3 4] [2 3 0] [4 0 2] >>> """ if self.elems.maxwidth <= mplex: # nothing to be done return self else: return Polygons(self.coords, Varray.fromArrays(self._reduce(mplex)))
[docs] def split(self, mplex=None): """Split the Polygons into Meshes of fixed plexitude Parameters ---------- mplex: int, optional The maximal plexitude of the resulting Meshes. Thus, with mplex=3 only triangles will results; mplex=4 will yield triangles and quads. If needed, polygons will be split up to be smaller that the maximum plexitude. If not provided, the original plexitudes are kept. Returns ------- list of Mesh A list of Mesh objects with plexitude >= 3. The eltype of the Mesh objects is Tri3, Quad4 or Poly# for plexitudes > 4. All the Mesh objects use the same coords object. The list is sorted in order of increasing plexitude. Notes ----- While reducing and splitting the Polygons can also be achieved with ``self.reduce(mplex).split()``, using the mplex argument here is slightly faster. See Also -------- reduce: reduce the maximum plexitude of the polygons """ if mplex is None or mplex >= self.elems.maxwidth: # no need to reduce polygons elems = self.elems.split() else: elems = self._reduce(mplex) return [Mesh(self.coords, e, eltype=ElementType.polygon(e.shape[1])) for e in elems]
[docs] def toSurface(self, method='reduce'): """Convert the Polygons to a TriSurface Parameters ---------- method: str The method to use to convert polygons into triangles. One of: - 'reduce': use the :meth:`reduce` method, splitting the polygons along the shortest diagonals. This is the default. - 'fan': split the polygons into a fan of triangles with apex at the first point. This corresponds to hoe the polygons are rendered. - 'prune': simply removes all non-triangle polygons. Notes ----- Currently, the 'reduce' method does not retain the 'prop' values. """ from pyformex.trisurface import TriSurface prop = self.prop if method == 'fan': elems = self.triangles('fan') if prop is not None: prop = np.repeat(prop, self.elems.lengths-2) elif method == 'prune': elems = self.elems.split()[0] ok = self.plex == 3 if prop is not None: prop = prop[ok] else: elems = self.reduce(3).elems return TriSurface(self.coords, elems, prop=prop)
################### ## PZF interface ##
[docs] def pzf_dict(self): kargs = Geometry.pzf_dict(self) kargs['elems'] = self.elems.data kargs['ind'] = self.elems.ind return kargs
@classmethod def pzf_load(clas, coords, elems, ind, **kargs): return clas(coords, Varray(elems, ind), **kargs)
########## IO functions ########### def _install_writePOLYGONS(): """Install a method to write to PGF file""" def writePolygons(self, F, name=None, sep=None): """Write a Polygons. Parameters ---------- F: :class:`Polygons` The object to be written. name: str See :meth:`writeGeometry` sep: str See :meth:`writeGeometry` Notes ----- This writes a header line with these attributes and arguments: objtype, ncoords, nelems, size, props(True/False), eltype, normals(True/False), color, sep, name. This is followed by the array data for: coords, elems.data, elems.ind, prop, normals, color """ objtype = F.__class__.__name__ if not isinstance(F, Polygons): raise ValueError( f"Invalid object type {type(F)}, expected Polygons") if sep is None: sep = self.sep hasprop = F.prop is not None hasnorm = hasattr(F, 'normals') and \ isinstance(F.normals, np.ndarray) and \ F.normals.shape == (F.size, 3) color = None colormap = None Fc = F.attrib['color'] if Fc is not None: if isinstance(Fc, str): color = Fc else: try: Fc = at.checkArray(Fc, kind='f') colormap = None colorshape = Fc.shape except Exception: Fc = at.checkArray(Fc, kind='i') colormap = 'default' colorshape = Fc.shape + (3,) if colorshape == (3,): color = tuple(Fc) elif colorshape == (F.nelems(), 3): color = 'element' elif colorshape == (F.size, 3): color = 'vertex' else: raise ValueError(f"Incorrect color shape: {str(colorshape)}") head = ( # parentheses to allow string continuation f"# objtype='{objtype}'; " f"ncoords={F.ncoords()}; nelems={F.nelems()}; size={F.size}; " f"props={hasprop}; normals={hasnorm}; " f"color={repr(color)}; sep='{sep}'" ) if name: head += f"; name='{name}'" if F.elName(): head += f"; eltype='{F.elName()}'" if colormap: head += f"; colormap='{colormap}'" self.writeline(head) self.writeData(F.coords, sep) self.writeData(F.elems.data, sep) self.writeData(F.elems.ind, sep) if hasprop: self.writeData(F.prop, sep) if hasnorm: self.writeData(F.normals, sep) if color == 'element' or color == 'vertex': self.writeData(Fc, sep) for field in F.fields: fld = F.fields[field] self.writeline(f"# field='{fld.fldname}'; fldtype='{fld.fldtype}'; " f"shape={repr(fld.data.shape)}; sep='{sep}'") self.writeData(fld.data, sep) def readPolygons(self, ncoords, nelems, size, props, sep): """Read a Polygons from a pyFormex geometry file. The following arrays are read from the file: - a coordinate array with `ncoords` points, - a Varray data array of length `size` - a Varray ind array with `nelems+1` elements - if present, a property number array for `nelems` elements. Returns the Mesh constructed from these data, or a subclass if an objtype is specified. """ ndim = 3 x = at.readArray(self.fil, at.Float, (ncoords, ndim), sep=sep) e = at.readArray(self.fil, at.Int, (size,), sep=sep) i = at.readArray(self.fil, at.Int, (nelems+1,), sep=sep) if props: p = at.readArray(self.fil, at.Int, (nelems,), sep=sep) else: p = None M = Polygons(x, Varray(e, ind=i), prop=p) return M from pyformex import geomfile geomfile.GeometryFile.writePolygons = writePolygons geomfile.GeometryFile.readPolygons = readPolygons _install_writePOLYGONS()
[docs]def nodalVSum(val, elems, nnod=None): """Compute the nodal sum of values defined at polygon vertices. This is like :func:`arraytools.nodalSum`, but where elems is defined as a Varray and val contains the value in order of that Varray. Parameters ---------- val: float array (nsize, nval) Defines nval values at elems.nsize vertices. elems: Varray The node indices of nelems polygons. nnod: int The number of nodes. This should be higher than the maximum value in elems. If not specified, it will be set to the highest value in elems + 1. Returns ------- sum: float ndarray (nnod, nval) The sum of all the values at the same node. cnt: int ndarray (nnod) The number of values summed at each node. """ nmax = elems.data.max() if nnod is None: nnod = nmax + 1 if nnod <= nmax: raise ValueError(f"nnod should at least be {nmax+1}") # create return arrays nval = val.shape[-1] sum = np.zeros((nnod, nval), dtype=np.float32) cnt = np.zeros((nnod,), dtype=np.int32) for i in range(elems.nrows): ind = elems.rowslice(i) nodes = elems.data[ind] sum[nodes] += val[ind] cnt[nodes] += 1 return sum, cnt
# End