TriMe++: a multi-threaded software library for fast generation of large-scale, adaptive 2D triangle meshes for intricate geometric shapes using Delaunay triangulation.
By Jiayin Lu 12, Chris H. Rycroft 1
Please consider citing our paper.
I am very interested to hear from users of the software, so if you find this useful or have any questions, please email me at jiayinlu19960224work@gmail.com.
-
1.1. Directory structure
1.2. Method
1.3. Related programs
1.4. Citation and paper
-
3.1. Compilation: Linux / Mac OS / Windows with Cygwin
3.2. Run your own program
3.3. Run a basic example: built-in shape primitives, mesh adaptivity control, parallel threads control
3.4. Understand the output files: vertices, triangles, edges, boundaries, mesh quality measures
3.5. Visualize mesh
3.6. Customization:
TriMe++ is especially suited for the fast generation of large-scale, adaptive and high-quality triangle mesh for complicated shapes in 2D.
TriMe++ implements three iterative meshing algorithms: DistMesh [1], Centroidal Voronoi Diagram [2] meshing, and a hybrid method of the two.
In each meshing iteration, the multi-threaded version of Voro++ [3] is used to obtain fast and efficient generation of the Delaunay triangulation.
Multi-threaded parallel computing is implemented throughout the meshing procedure via OpenMP [4].
TriMe
: Root-directory
Code
config.mk
: File specifying C++ compilation and installation settings
src_voro
: Sub-directory with source code files of the multi-threaded Voro++ program
src_TriMe
: Sub-directory with source code files of the TriMe++ program
Examples
: Sub-directory with example files of meshing on geometry shapes making use of the library
Visualization
: Sub-directory with a simple python script to visualize the mesh based on the output filesDocumentation
README.md
: Documentation and quick start guide
docs
: Sub-directory with figures for the README.md file
TriMe++ is built around several C++ classes that follows a clearly structured meshing pipeline. Throughout the meshing workflow, we utilize OpenMP for multi-threaded parallel computations.
During initialization, the shape_2d
class reads the geometry input and generates a signed distance field
using a grid-based data structure to represent the shape. The sizing_2d
class subsequently produces
adaptive element sizing and density fields for the mesh. It utilizes an adaptive quad-tree data structure,
enabling efficient refinement of sizing and density values in areas with complex geometries.
In the meshing procedure, the parallel_meshing_2d
class iteratively improves point positions in the mesh.
In each meshing iteration, we employ the multi-threaded Voro++ to facilitate fast and efficient parallel computation
in generating the Delaunay triangulation of the points. Users have the option to select from three meshing
algorithms, the DistMesh algorithm in the mesh_alg_2d_dm
class, the Centroidal Voronoi Diagram meshing
algorithm in the mesh_alg_2d_cvd
class, and a hybrid method of the two in the mesh_alg_2d_hybrid
class.
We use the multi-threaded version of Voro++ [3] in the meshing algorithms.
TriMe++ is released as a free software.
If you plan to publish an academic paper using this software, please consider citing the following publications:
Lu, Jiayin, Rycroft, Chris. (2023). TriMe++: Multi-threaded triangular meshing in two dimensions [ArXiv]
Abstract
Geometry meshing gives discrete representation of continuous geometry shapes. It has important applications in computer graphics and in scientific computing, especially for numerical simlations using the finite element method. In some large-scale simulations, high-resolution geometry meshes using hundreds of thousands of points may be needed. In this paper, we present TriMe++, a multi-threaded software library designed for generating adaptive 2D meshes for intricate geometric shapes using Delaunay triangulation. Multi-threaded parallel computing is implemented throughout the meshing procedure, making it especially suitable for fast generation of large-scale meshes. Three iterative meshing algorithms are provided to users: DistMesh, Centroidal Voronoi Diagram meshing, and a hybrid method of the two. We test the performance of TriMe++. We compare the three meshing methods, and show that the hybrid method has the advantages of the other two, while avoiding the disadvantages. We demonstrate that the software library achieves significant speed up in time with parallel computing when generating a large-scale mesh of
$10^6$ points. We also show that TriMe++ is able to handle extremely complicated geometry shapes and generates adaptive meshes of high quality.
Further improvements of TriMe++ will be made and documented in the Code updates section.
The code is written in ANSI C++, and compiles on many system architectures. The package contains the C++ source code and example files. On Linux, Mac OS, and Windows (using Cygwin), the compilation and installed can be carried out using GNU Make.
To begin, the user should review the file config.mk
in the top level
directory, to make sure that the compilation and installation settings are
appropriate for their system.
The current compiler flags are for Mac. For a Linux system, you should change the compiler flags in
config.mk
to:cc=gcc
cxx=g++
Then type make
in the command line in the Example
directory will compile the static
library and examples. And we now see executable example files in the directory.
Suppose you write your own program using TriMe++ in /Example
directory, called my_program.cc
. To compile the program, you simply need to make a few changes to /Example/Makefile
:
Add
my_program
to the lineexecs
, separated by a space with other programs:execs = my_program other_program1 other_program2 ...
Add the following similar to other example programs:
my_program: my_program.cc $(objs) $(cxx) $(cflags) $(iflags) $(lflags) -o $@ $^ -lvoro++ -ltrime++
Then, in the /Example
directory command line type make
, you shall see your executable my_program
, and you can run it by typing ./my_program
.
In the /Examples
directory, let's first look at the most basic example file, primitive_shape_meshing.cc
. This file does meshing on built-in primitive shapes provided by the library: rectangle, circles, triangles.
To run its meshing, we simply need to run the corresponding executable file by typing ./primitive_shape_meshing
in the command line.
Let's now look at the code in detail in primitive_shape_meshing.cc
.
On the top, use #include "parallel_meshing_2d.hh"
to import the TriMe++ code.
In the main code, these few lines at the beginning of the code specify:
-
num_t
: The number of parallel threads to use.Best efficient performance is reached when the number of threads
$\leq$ the number of physical cores in the computer.When the number of threads is more than that, we can usually still get improvement in computation time, just less efficient.
It is recommended that the number of threads is never more than twice the number of physical cores.
-
method_ind
: The meshing algorithm to use.$0$ DistMesh;$1$ CVD;$2$ Hybrid. -
Ntotal
: The total number of meshing points specified. At meshing termination, we employed a procedure to delete problematic points from the mesh. Therefore, the final mesh number of points is$\leq$ Ntotal
. -
K
: Adaptivity of mesh.$0$ Uniform;$>1$ Adaptive; LargerK
increases the adaptivity of mesh. -
output_interval
: File output frequency.$0$ No output;$-1$ Only output at termination;$-2$ Output initial and final triangulation dataAny positive integer, e.g.
$10$ , represents outputting every$10$ triangulations during the meshig procedure.
Here, we are using
//Specify number of parallel threads
int num_t=4;
//Method index, 0 Distmesh; 1 CVD; 2 Hybrid
int method_ind=2;
//Number of vertices in the mesh
int Ntotal=5000;
//0:uniform sizing field
double K=0.1;
//File output frequency. 0 no output; -1 output at termination; 10, every 10 triangulations output
int output_interval=-1;
The next few lines create a directory /Examples/case_name_base
to store the output files.
//File output name prefix
char case_name_base[256];
sprintf(case_name_base,"triangle_mesh_N_%d_K_%g",Ntotal,K);
//Create directory to store file outputs
mkdir(case_name_base,S_IRWXU|S_IRWXG|S_IROTH|S_IXOTH);
Next, we need to create a container_2d
class object from Multi-threaded Voro++, in order to use it for Delaunay triangulation in the meshing later.
//------------------1.Create container----------------------
int cnx=sqrt(Ntotal/3.3); int cny=cnx;
container_2d con(0.0,1.0,0.0,1.0,cnx,cny,false,false,16,num_t);
ax,bx,ay,by
(the first four parameters): Computation domain
We first need to define the computation domain, which is a rectangle [ax,bx]x[ay,by]
. The computation domain should completely cover and be slightly larger than the mesh.
For good performance and simplicity, we recommend to construct a square computation domain, such that the mesh lies in the domain center, and the maximum dimension of the mesh (i.e. max(length, width)) takes up
In the code above, the computation domain is initiated as a sqaure
cnx,cny
: Domain grid dimensions
The computation domain is divided into a grid structure in Voro++. Here, we need to define the number of grid cells in each of the x- and y-dimensions (i.e. number of columns and rows).
We recommend the above setting, int cnx=sqrt(Ntotal/3.3); int cny=cnx;
. Here, since we use a square domain, the grid dimensions can simply be cny=cnx
. Furthermore, the calculation of cnx
represents that approximately
periodic in x?
periodic in y?
The next two parameters are boolean variables specifying whether the computation domain is periodic in each of the x- and y-dimensions. Here, we simply specify false
for both.
memory initialization per grid
Next, we need to specify a number, which initiate memory per grid.
num_t
: Number of parallel threads
Last, we need to specify the number of parallel threads to use for parallel computation of the Voronoi diagram in the Delaunay triangulation.
A few built-in primitive shapes are provided in the library. They are rectangle, circle and triangle:
shape_2d_rectangle(container_2d &con, int num_t, double x0, double x1, double y0, double y1)
: A rectangular shape defined on the domain$[x_0,x_1]\times[y_0,y_1]$ .
shape_2d_circle(container_2d &con, int num_t, double r, double x0, double y0)
: A circular shape with radius$r$ centered at$(x_0,y_0)$ .
shape_2d_triangle(container_2d &con, int num_t, double x0, double y0, double x1, double y1, double x2, double y2)
: A triangle defined by three vertices,$(x_0,y_0)$ ,$(x_1,y_1)$ and$(x_2,y_2)$ .
In the syntax of the primitive shapes creation above, we need to pass in the Voro++ container_2d
object created, and pass in the number of parallel threads to use for calculating the Voronoi diagram in the Delaunay triangulation.
In the following code, we create a triangle shape defined by the three vertices
//-------------------2.Create shape------------------------
//A circle with radius 0.3 centered at (0.5,0.5)
//shape_2d_circle shp(con,num_t,0.3,0.5,0.5);
//A rectangle on domain [0.1,0.9]x[0.3,0.7]
//shape_2d_rectangle shp(con,num_t,0.1, 0.9, 0.3, 0.7);
//A triangle defined by the three vertices (0.1,0.1),(0.3,0.8) and (0.8,0.5)
shape_2d_triangle shp(con,num_t,0.1,0.1,0.3,0.8,0.8,0.5);
The plot here shows three example meshes using the different primitive shapes and adaptivity paramemters.
Next, we generate automatically the adaptive sizing field for meshing, based on the shape defined and the adaptivity parameter K
.
//-------------------3.Create adaptive sizing field------------------
sizing_2d_automatic size_field(&shp,K);
We then create the parallel_meshing_2d object for the meshing procedure later. It takes in as parameters:
- the Voro++
container_2d
object for Delaunay triangulation, - the
shape_2d
object that defines the geometry to mesh, - the adaptive sizing field
size_field
that defines the relative triangle element sizes in the mesh, - the number of parallel threads to use in the meshing algorithm
num_t
, - the frequency of file outputs
output_interval
, - the output file names prefix
case_name_base
.
//------------------4.Create parallel_meshing_2d object----------------------
//Create the pm2d object
parallel_meshing_2d pm2d(&con, &shp, &size_field, num_t, output_interval, case_name_base);
We then initialize meshing points.
//Optional: set seed for rand() for generating randome points in pt_init
srand(10);
//Initialize meshing points
pm2d.pt_init(Ntotal);
Afterwards, we start the iterative meshing procedure, depending on the meshing algorithm chosen.
For a chosen meshing algorithm, the code first creates an object mesh_method
that implements the meshing algorithm. It reads in the pm2d
object to obtain all the information of meshing specifications, such as the shape, adaptivity and number of threads to use.
The pm2d
then calls pm2d.meshing(&mesh_method)
to do the iterative meshing using that chosen method.
//======================Parallel meshing=====================
if(method_ind==0){ //DistMesh
mesh_alg_2d_dm mesh_method(&pm2d);
pm2d.meshing(&mesh_method);
}
else if(method_ind==1){ //CVD
mesh_alg_2d_cvd mesh_method(&pm2d);
pm2d.meshing(&mesh_method);
}
else if(method_ind==2){ //Hybrid
mesh_alg_2d_hybrid mesh_method(&pm2d);
pm2d.meshing(&mesh_method);
}
Let's now look at the output files from running ./primitive_shape_meshing
. They are in the directory /Example/triangle_mesh_N_5000_K_0.1
.
In TriMe++, each point is associated with an ID pid
and x and y coordinates (px, py)
.
point:
pid, (px, py)
A triangle is associated with an ID tid
, the three point IDs of its three vertices, (vid0, vid1, vid2)
, and two quality measures, aspect ratio α
and edge ratio β
.
triangle:
tid, (vid0, vid1, vid2), (α, β)
The aspect ratio
where
The filename prefix is fp="triangle_mesh_N_5000_K_0.1"
. The next description phrase ti
describe the meshing iteration outputted: fp_ti_=fp_1_
is the initial triangulation; fp_10_
is the triangulation of the
The rest of the description phrases in the filenames desribes the data being outputted. Suppose we have
Points
fp_ti_xy_id.txt
: Each row format is[x y]
. The particle ID and coordinates. The particle ID[0,1,...,N-1]
is implicity implied by the line number. The first line corresponds to the coordinates of point$0$ , and the tenth line corresponds to the coordinates of point$9$ .
Edges
fp_ti_tria_bar_ids.txt
: Each row format is[vid0 vid1]
, the point IDs corresponding to the end points of each edge in the triangulation. The triangulation edges are unique and non-overlapping in the output.
fp_ti_tria_bar_coords.txt
: The coordinates of the end points for each triangulation edge. Each edge consists of two rows, each row is the coordiate of one of the end poins:
[x0 y0]
[x1 y1]
An empty line then follows, separating the current edge output and the next edge output.
Triangles
fp_ti_tria_vertex_ids.txt
: Each row format is[tid vid0 vid1 vid2]
, the triangle ID followed by the three particle IDs of its three vertices.
fp_ti_tria_vertex_coords.txt
: Each row format is[tid x0 y0 x1 y1 x2 y2]
, the triangle ID followed by the three particle coordinates of its three vertices. At termination, the triangle vertices coordinates are outputted in CCW order.
Mesh quality
fp_ti_tria_quality_stat_ar.txt
: This file consists of a single line, listing the aspect ratios$\alpha_i$ of each triangle$i$ , separated by space. The format is:[α0 α1 α2 ... αM]
.
fp_ti_tria_quality_stat_er.txt
: This file consists of a single line, listing the edge ratios$\beta_i$ of each triangle$i$ , separated by space. The format is:[β0 β1 β2 ... βM]
.
fp_tria_quality_stat_overall.txt
: This file outputs the overall mesh quality at the corresponding outputted triangulation iterations. Each row consists of$12$ numbers, they are (in order):
- (0) the triangulation iteration number,
- (1) the number of vertices,
- (2) the number of triangles,
- (3) the maximum
$\alpha$ , (4) the maximum$\beta$ ,- (5) the median
$\alpha$ , (6) the median$\beta$ ,- (7) the mean
$\alpha$ , (8) the mean$\beta$ ,- (9) the
$\frac{1}{2}$ -mean of$\alpha$ , (10) the$\frac{1}{2}$ -mean of$\beta$ ,- (11) the standard deviation of
$\alpha$ , (12) the standard deviation of$\beta$ .
The
A simple Python program to visulize your mesh is provided in Visualization/visualize_mesh.py
. Simply modify the path to output files as directed in the script, and run the script to view the mesh.
If you have Gnuplot
program installed, then you can go into your output files folder, and run the following gnuplot commands to view the mesh:
eps=0.01
plot [-eps:1+eps] [-eps:1+eps] '..._tria_bar_coords.txt' w l, '..._xy_id.txt' u 1:2 w p pt 7 ps 0.1
Sometimes we may get a better performance by setting different numbers of parallel threads for setting up and for the actual meshing. To do so, we can follow the implementation provided in primitive_shape_meshing_change_num_threads.cc
. The code structure is similar to the basic example. Here are the few differences we need to make:
Firstly, we specify two different numbers of parallel threads to use, for setting up and for the actual meshing.
//Specify number of parallel threads for setting up and the actual meshing
int num_t_setup=4;
int num_t_mesh=8;
In creating the container, shape, sizing field, and the parallel_meshing_2d class object, we use num_t_setup
.
Then, in the parallel meshing part, we call mesh_method.change_number_thread(num_t_mesh);
before the actual meshing, to properly switch to using num_t_mesh
threads. For example, for the DistMesh meshing method,
mesh_alg_2d_dm mesh_method(&pm2d);
//Call this function to properly switch to using num_t_mesh threads for meshing
mesh_method.change_number_thread(num_t_mesh);
pm2d.meshing(&mesh_method);
In the basic example above, the (triangle) element sizing field was calculated automatically, based on the mesh gradation parameter sizing_2d_automatic
sizing field object. A possible customization is that users may define their own sizing field function.
A sizing field function gives the relative sizes of the triangles. For example, a sizing field of
An example implementation is provided in the file primitive_shape_meshing_sizing_function.cc
. Notice that we first need to create our own sizing field class sizing_2d_func
, which is derived from the base class sizing_2d
. In sizing_2d_func
, we can define our own sizing field function in double getSizingVal(double x, double y)
.
/**
* @brief A class representing a user-defined sizing function in 2D.
*
* This class derives from the base class `sizing_2d` and provides
* user-defined implementations for
* computing sizing and density values at a given point.
*/
class sizing_2d_func : public sizing_2d {
public:
/**
* @brief Constructor for the `sizing_2d_func` class.
*
* @param shp_ A pointer to the underlying shape_2d object.
*/
sizing_2d_func(shape_2d *shp_) : sizing_2d(shp_){}
/**
* @brief Destructor for the `sizing_2d_func` class.
*/
~sizing_2d_func(){};
/**
* @brief Computes the sizing value at a given point (x, y).
*
* This function implements the user-defined sizing function.
*
* @param x The x-coordinate of the point.
* @param y The y-coordinate of the point.
* @return The computed sizing value at the point (x, y).
*/
double getSizingVal(double x, double y){
return 1.0+3*x+3*y;
};
};
The execution code in int main()
are the same as previous, except that we use sizing_2d_func size_field(&shp);
for the user-defined sizing field, instead of the automatic sizing field sizing_2d_automatic size_field(&shp,K);
.
The example code generates the following mesh, where the triangles close to the origin are the smallest, and gradually grows larger towards
We can perform boolean operations on shapes. Suppose
Union:
$\quad d_{A\cup B}(x,y)=\min (d_{A}(x,y), d_{B}(x,y))$ Difference:
$\quad d_{A\backslash B}(x,y)=\max (d_{A}(x,y), -d_{B}(x,y))$ Intersection:
$\quad d_{A\cap B}(x,y)=\max (d_{A}(x,y), d_{B}(x,y))$
These operations are implemented in the code as shape_2d
derived class objects that we can create, and they are shape_2d_union
, shape_2d_difference
and shape_2d_intersection
, respectively.
An example implementation is provided in the file primitive_shape_meshing_boolean_sdf.cc
. The final mesh looks as follows:
To create this shape, we first create four circles with some overlapping regions. And we do intersections for pairs of circles, to obtain the four leafy shapes.
//A. Intersection
shape_2d_circle cdl(con,num_t,0.15,0.4,0.4);
shape_2d_circle cdr(con,num_t,0.15,0.6,0.4);
shape_2d_circle cul(con,num_t,0.15,0.4,0.6);
shape_2d_circle cur(con,num_t,0.15,0.6,0.6);
shape_2d_intersection leafd(con,num_t,&cdl,&cdr);
shape_2d_intersection leafl(con,num_t,&cdl,&cul);
shape_2d_intersection leafu(con,num_t,&cul,&cur);
shape_2d_intersection leafr(con,num_t,&cdr,&cur);
Next, we create a circle in the middle, and perform union of all the shapes created.
//B. Union
shape_2d_circle cm(con,num_t,0.1,0.5,0.5);
shape_2d_union m0(con,num_t,&leafd,&leafl);
shape_2d_union m1(con,num_t,&m0,&leafu);
shape_2d_union m2(con,num_t,&m1,&leafr);
shape_2d_union m3(con,num_t,&m2,&cm);
Lastly, we create a large square, and take the difference of the square and the previous union shape.
//C. Difference
shape_2d_rectangle rec(con,num_t,0.1,0.9,0.1,0.9);
shape_2d_difference shp_final(con,num_t,&rec,&m3);
We can also define our own SDF. The example code is provided in primitive_shape_meshing_own_sdf.cc
. The code generates mesh for a six-pointed hexagram star shape with a hollow circle in the middle -- like a flower:
Let's see how to obtain our hexagram star shape. Firstly, we need to create our own SDF class, which is a derived class from the base class shape_2d
.
/**
* @brief A class representing a user-defined signed distance function in 2D.
* Here, we construct a SDF for a hexagram star shape.
* The code of the SDF is adapted from https://iquilezles.org/articles/distfunctions2d/.
*
* This class derives from the base class `shape_2d` and provides user-defined implementations for
* computing signed distance value at a given point.
*/
class shape_2d_mySDF : public shape_2d{...}
For the class constructor, it needs to take in the parameters container_2d &con_
and int num_t_
, since they are needed for the initialization of the shape_2d
class. The rest of the input parameters are user-created and based on the shape one defines. Here, we require input parameters (centerX_, centerY_)
where the hexagram centers, and radius
defining the radius of the circumscribed circle of the hexagram. In the constructor, get_geometryGrid();
needs to be called, to initialize and create an underlying geometry grid required for the meshing code. The geometry grid categorize each grid cell as inside the shape, outside the shape, or on the shape boundary, significantly speed up the meshing procedure.
public:
/**
* Constructor for shape_2d_mySDF, a shape given by user-defined signed distance field.
*
* @param con_ The container_2d object.
* @param num_t_ Number of parallel threads.
* @param centerX_ Center of the hexagram shape, x-coordinate.
* @param centerY_ Center of the hexagram shape, y-coordinate.
* @param radius_ Radius of the circumscribed circle.
*/
shape_2d_mySDF(container_2d &con_, int num_t_,double centerX_, double centerY_, double radius_)
:shape_2d(con_,num_t_), centerX(centerX_), centerY(centerY_), radius(radius_)
{
get_geometryGrid();
}
/**
* Destructor for shape_2d_mySDF.
* Frees any resources allocated by the object.
*/
~shape_2d_mySDF(){};
double centerX; //Center of the hexagram shape, x-coordinate.
double centerY; //Center of the hexagram shape, y-coordinate.
double radius; //Radius of the circumscribed circle.
Next, we can define our own SDF in the function double sdf(double x, double y)
. The function format must be such that it takes in the coordinates of a point,
/**
* Signed distance function calculation of a hexagram star shape.
* Code adapted from https://iquilezles.org/articles/distfunctions2d/.
*
* @param x The x-coordinate.
* @param y The y-coordinate.
* @return The signed distance of the point (x, y) to the shape.
* Negative values indicate inside, positive values indicate outside.
*/
double sdf(double x, double y){
double kx=-0.5;
double ky=0.86602540378;
double kz=0.57735026919;
double kw=1.73205080757;
//Translate (x,y) to be centered around the origin, since the hexagram SDF code is centered at the origin.
x-=centerX;
y-=centerY;
double px=abs(x);
double py=abs(y);
double fac1=min((kx*px+ky*py),0.0)*2.0;
double fac2=min((ky*px+kx*py),0.0)*2.0;
px-=fac1*kx;
py-=fac1*ky;
px-=fac2*ky;
py-=fac2*kx;
double fac3=min(max(px,radius*kz),radius*kw);
px-=fac3;
py-=radius;
double d=sqrt(px*px+py*py);
if(py<0.0){
d=-d;
}
return d;
};
Next, in int main()
implementation, we can create our shape_2d_mySDF
shape. Furthermore, we create a circular shape by using the built-in primitive shape_2d_circle
. And we perform boolean difference of the hexagram and the circle to obtain our final flower shape.
//User-defined SDF shape
shape_2d_mySDF myShp(con,num_t,0.5,0.5,0.2);
//Circular shape
shape_2d_circle cir(con,num_t,0.1,0.5,0.5);
//Boolean difference of the two shapes
shape_2d_difference shp(con,num_t,&myShp,&cir);
We can also define our shape using the contour line segments of the shape. The line segments are stored in a variable std::vector<std::vector<double>> boundaries
.
- Number of shape boundaries : A shape can have multiple shape boundaries.
If the shape has one boundary, the line segments of that boundary are stored in a vector, e.g.
boundary1
, and then further stored in the variableboundaries
. That is,boundaries[0]
is that boundary vector.
If the shape has multiple boundaries, each boundary has a vector that stores its line segments, e.g.
boundary1
,boundary2
, etc. These boundary vectors are further stored in the variableboundaries
. That is,boundaries[0]
is the vector of the first boundary,boundaries[1]
is the vector of the second boundary, and so on.
- Orientation:
A boundary enclosing the shape interior is defined as a sequence of points in clockwise order, forming a closed loop with the same end point
[x0,y0]
. For example,boundary1
can be defined as:boundary1 = [x0,y0, x1,y1, x2,y2, x3,y3, x0,y0]
, where the interior of the loop is the interior of the shape.
If the shape has holes, the hole boundary is inputted as a sequence of points in counter-clockwise order, again forming a close loop. In this case, the interior of the loop is the exterior of the shape.
An example is provided in custom_poker_meshing.cc
. Here is the mesh it produces:
Here, we have one boundary. First, we have boundaryx
and boundaryy
storing the for
loop, we store the coordinates in the boundary vector boundary1
, in the format boundary1 = [x0,y0, x1,y1, ..., x0,y0]
. We also keep a count of the number of boundary line segments bdry_ct
.
Then, we store boundary1
into the viariable boundaries
.
//Define a shape from custom contour line segments
double boundaryx[117]={ 0.585 , ... , 0.585};
double boundaryy[117]={ 0.0284, ... , 0.0284};
std::vector<std::vector<double>> boundaries;
std::vector<double> boundary1;
int bdry_ct=0;
for(int i=116;i>=0;i--){
boundaryx[i]=boundaryx[i];
boundaryy[i]=boundaryy[i];
boundary1.push_back(boundaryx[i]);
boundary1.push_back(boundaryy[i]);
bdry_ct++;
}
boundaries.push_back(boundary1);
In this code, we also distinguish a few different parallel number of threads of use. Note that this is optional - for simplicity, one can as well just use the same number of parallel threads throughout the code.
num_t_meshing
is the number of threads to use during the actually meshing procedure. num_t_setup
is the number of threads to use when we are setting up the shape, container, and sizing field, etc, before the actual meshing. num_t_setup_cshp
is the number of threads to use specifically for the custom shape creation. If there are only a small number of boundary line segments bdry_ct
, then we may set num_t_setup_cshp=1
, to avoid parallel overhead. But when there are a large number of boundary line segments, then we may set num_t_setup_cshp=num_t_setup
. Here, an arbitrary threshold of
int num_t_meshing=2*physical_core;
int num_t_setup=physical_core;
//for custom_shape_2d: if the boundary contour line segments count is small, we use serial code nt=1 to avoid parallel overhead
int num_t_setup_cshp=1;
if(bdry_ct>200){
num_t_setup_cshp=num_t_setup;
}
In the shape creation, we can create an shape_2d_contour_lines
for our custom shape defined by the shape boundaries. Let's first take a look at parts of the constructor of the class below. We see that it is a derived class from the base class shape_2d
, therefore, it needs to take as input parameters container_2d &con_
and int num_t_
, which are used to initiate the base class shape_2d
. Furthermore, an input parameter int num_t_cshp_
is needed to create the custom shape. The variable std::vector<std::vector<double>> boundaries_
is also required, which has the shape boundaries information. Lastly, a boolean variable bool normalize_model_
is required, which indicates whether to normalize the model. If true, the model will be translated to fit and centered into the container domain, and the model will be scaled. Suppose the container domain size is [ax, bx] x [ay, by]
, with side length Lx = bx-ax
, Ly = by-ay
. The model will be scaled so that its longest dimension fits in [ax+0.1*Lx, ax+0.9*Lx] x [ay+0.1*Ly, ay+0.9*Ly]
.
/**
* Constructor for shape_2d_contour_lines, a custom shape defined by user-input contour line segments.
*
* @param con_ The container_2d object.
* @param num_t_ Number of parallel threads.
* @param num_t_cshp_ Number of parallel threads for the custom shape.
* @param boundaries_ A vector of vectors representing the contour line segments of the shape.
* @param normalize_model_ Flag indicating whether to normalize the model.
*/
shape_2d_contour_lines(container_2d &con_, int num_t_, int num_t_cshp_, std::vector<std::vector<double>> boundaries_, bool normalize_model_):shape_2d(con_,num_t_),
cshp(custom_shape_2d(boundaries_, con_.ax, con_.bx, con_.ay, con_.by, normalize_model_,num_t_cshp_))
{...}
Based on the constructor description, we can create our custom shape as follows. We first feed in the boundaries
to the shape_2d_contour_lines
object, and require it to normalize the model. We also make a hollow circule inside the shape.
//final test shape, complicated poker shape
bool normalize_model=true;
shape_2d_contour_lines shp1(con,num_t_setup,num_t_setup_cshp,boundaries,normalize_model);
shape_2d_circle shp2(con,num_t_setup,0.1,0.5,0.7);
shape_2d_difference shp(con,num_t_setup,&shp1,&shp2);
The following code are similar. Except that, since we want to use a different number of parallel threads for the meshing, we use the function mesh_method.change_number_thread(num_t_meshing)
to properly set the number of threads in the code before doing the actual meshing.
if(method_ind==0){
mesh_alg_2d_dm mesh_method(&pm2d);
mesh_method.change_number_thread(num_t_meshing);
pm2d.meshing(&mesh_method);
}
else if(method_ind==1){
mesh_alg_2d_cvd mesh_method(&pm2d);
mesh_method.change_number_thread(num_t_meshing);
pm2d.meshing(&mesh_method);
}
else if(method_ind==2){
mesh_alg_2d_hybrid mesh_method(&pm2d);
mesh_method.change_number_thread(num_t_meshing);
pm2d.meshing(&mesh_method);
}
Another example is meshing on the North America geography map:
The mesh is generated using the file North_America_map_meshing.cc
. To obtain the shape, we first read in the contour line segments in the file north_america_single_bdry_xy.txt
, and store them in the variable boundaries
. Each line in north_america_single_bdry_xy.txt
gives a boundary of the shape, in the format: [x0 y0 x1 y1 ... xk yk ... x0 y0]
After we define the shape from the contour line segments, we follow the similar procedure as before to obtain the mesh.
A few comments:
-
The time it takes to initialize the shape is relatively long here. This is because, in creating the shape, we build an underlying geometry grid, where each grid cell is categorized as inner/boundary/outer grid. In this process, each grid cell need to computer their center signed distance, which is expensive, since we have a highly detailed geometry. The calculation will be improved in the near future. We will implement a quad-tree structure for the geometry grid, to avoid computing the signed distance for every grid cell.
-
The mesh has good quality overall. In this testing, we obtain
$19851$ vertices and$35972$ triangles, with median aspect and edge ratios to be$1.03383$ and$1.2194$ , respectively. We see some meshing inaccuracy along the shape boundary, up to the resolution of the local triangle sizes.
We can also add a list of fixed points into the mesh. These points stays at the some positions throughout the meshing procedure. An example implementation is provided based on the custom poker shape meshing code, in custom_poker_add_fixed_pts_meshing.cc
.
Once we created the parallel_meshing_2d pm2d
class object, we add the fixed points in. As shown in the following, we first create a list of the fixed point coordinates, with format [x0 y0 x1 y1 ...]
. To add them into the mesh,
One option is to call
pm2d.add_fixed_points_normailze(Nfixed, &shp1, fixed_pt_list)
, which perform the same normalization (scale + translation) on the fixted points as theshape_2d
objectshp1
. Note that only shapes built from shape contour line segments,shape_2d_contour_lines
class objects, has the normalization object at initialization. Therefore, this option to normalize the fixed points is only relevant forshape_2d_contour_lines
shape objects.
Another option is to use
pm2d.add_fixed_points(Nfixed, fixed_pt_list)
, which simple adds the points as they are into the mesh as fixed points.
//Add fixed points into the mesh
int Nfixed=10; //Number of fixed points
//Coordinates of the fixed points stored into a list
double *fixed_pt_list=new double[2*Nfixed];
double fixed_pt_x[Nfixed]={0.2,0.3,0.4,0.2,0.2,0.5,0.5,0.7,0.7,0.7};
double fixed_pt_y[Nfixed]={0.2,0.3,0.4,0.3,0.4,0.6,0.1,0.4,0.3,0.2};
//The fixed point list has format [x0, y0, x1, y1, ...]
for(int i=0;i<Nfixed;i++){
fixed_pt_list[2*i]=fixed_pt_x[i];
fixed_pt_list[2*i+1]=fixed_pt_y[i];
}
//Option 1: Use the same normalization (scale + translation) on the fixed points as in shp1
pm2d.add_fixed_points_normailze(Nfixed, &shp1, fixed_pt_list);
//Option 2: Simple add in the points as they are as fixed points
//pm2d.add_fixed_points(Nfixed, fixed_pt_list);
After adding fixed points, we can initialize meshing points as follows. Notice that here, the total number of meshing points, Ntotal
equals to the sum of the number of fixed points and the number of unfixed points.
pm2d.pt_init(Ntotal); //Ntotal=Nfix+Nmove: total number of fixed and unfixed points in the mesh
For detailed performance analysis, please refer to Sec. 6 and 7 of the paper. Here, we provide brief summaries of two cases:
A few aspects we test are:
-
Parallel performance. We measure the wall clock time
$t_p$ using a variable number of$p$ threads, and calculate the parallel efficiency using$$T_e(p) =\frac{t_1}{p \cdot t_p}$$ which measures the effective slowdown from the hypothetically perfect parallel scaling. -
Optimal point scheme. The above tests are repeated using two different point schemes:
I. All ``Ntotal`` points are initialized prior to meshing. II. A small fraction of points are first initialized, and during the meshing iterations, additional points are inserted to refine the mesh, until ``Ntotal`` points are reached.
We compare the timing performance and mesh quality of the point schemes.
-
Mesh quality. We provide mesh quality statistics from a typical run using the preferred point scheme.
-
System size. Using the preferred point scheme, we vary the number of meshing points
Ntotal
, and examine the parallel performance of the code as system size varies.
Our tests are done on a Ubuntu Linux computer with 256 GB of memory and dual Intel Xeon E5-2650L v4 processors with 14 low-power cores using a 1.7 GHz base clock speed. Since the computer has
Here is a schematic plot of an example mesh with Ntotal
Uniform meshing of a square represents a situation where both the geometry and the sizing field of the mesh are simple. In general, we found that for simple and convex shapes (e.g. rectangles, circles) where points can easily move around without obstruction, and when the element sizing field varies gradually, there is no need to use the point addition scheme.
Instead, initializing all points to start meshing gives better performance.
This can be done by modifying the parameter
const double pt_init_frac=1.0
in file/src_TriMe/config.hh
.
Furthermore, we recommend to use CVD meshing or hybrid meshing in these situations.
Here, we show performance statistics (parallel time, mesh quality) with Ntotal
Plot (a) shows that parallel computation significantly speed up the meshing time. Indeed, the serial codes run close to
Plot (b) shows the corresponding parallel efficiency. As expected, CVD meshing has very high parallel efficiency close to
Plot (c) shows the median aspect ratios of the meshes against the number of meshing iterations from a typical run. We see all methods produce overall very high quality meshes at termination.
Plot (d) shows the maximum aspect ratio, corresponding to the worst triangles in the meshes. We see the ratios are reasonable for all three methods, meaning all triangles are of high quality and close to equilateral triangles.
The plot here shows the parallel efficiency as the number of points increases. As expected, CVD meshing has the best efficiency, and the efficiency of DistMesh is the lowest. Furthermore, we see a gradually increasing trend of parallel efficiency for the three meshing methods from
This is the case illustrated in the custom poker example. For performance test, we mesh it with Ntotal
Adaptive meshing on the custom shape represents a case where the geometry and the sizing field are complicated. In general, we found that when either geometry or sizing field is complicated, it is recommended to use:
Point addition scheme, where we initialize
pt_init_frac
ofNtotal
points to start meshing. And we add points during meshing from time to time to refine regions of the mesh that need points the most.This can be done by modifying the parameter
const double pt_init_frac=0.2
in file/src_TriMe/config.hh
.Reason: According to the highly non-uniform density field, points in the mesh require more movements and adjustments. However, since the geometry is complicated, points cannot move around freely without obstruction. Using the point addition scheme effectively refines regions of the mesh that need points most during the meshing iterations, and leads to much better shaped triangles conforming to the density field.
Furthermore, we recommend to use hybrid meshing in these situations.
Here, we show performance statistics (parallel time, mesh quality) with Ntotal
Plot (a) shows parallelization significantly speed up the computation time. Furthermore, we see that CVD meshing is the most expensive in serial code, while DistMesh and hybrid meshing takes similar time. This is reasonable, since the Voronoi cell centroid calculation is more expensive than the force balancing procedure in DistMesh. And because hybrid meshing uses mostly the cheaper DistMesh iterations, its computation time is similar to that of DistMesh, and hybrid also tends to terminate stably like CVD. Using
Plot (b) shows the corresponding parallel efficiency. As expected, CVD meshing has high parallel efficiency, DistMesh the lowest, and hybrid in between. In this case, hybrid meshing has only a few CVD iterations at the end, so its efficiency is similar to that of DistMesh.
Plot (c) shows the median aspect ratio. The peaks in the plots correspond to when points are added into the mesh according to the point addition scheme. All three meshing methods achieve very good median ratios at termination.
Plot (d) shows the maximum aspect ratios of the worst triangles. DistMesh typically has highest maximum aspect ratios, corresponding to flatter shaped triangles in the mesh. In comparison, CVD meshing has better shaped triangles and lower maximum aspect ratio. Since hybrid meshing uses CVD iterations as the end as refinement steps, it also has lower maximum aspect ratio similar to that of CVD meshing.
As shown in the plot, similar to the uniform meshing of sqaure case, we observe similar trend of a gradual increase in parallel efficiency as the system size increases from
This research was supported by a grant from the United States-Israel Binational Science Foundation (BSF), Jerusalem, Israel through grant number 2018/170. C. H. Rycroft was partially supported by the Applied Mathematics Program of the U.S. DOE Office of Advanced Scientific Computing Research under contract number DE-AC02-05CH11231.
[1] DistMesh
-
P.-O. Persson, G. Strang, A simple mesh generator in matlab, SIAM Review 46 (2) (June 2004) 329–345. [website]
-
P.-O. Persson, Mesh generation for implicit geometries, Ph.D. thesis, Massachusetts Institute of Technology (2005).
[2] Centroidal Voronoi Diagram meshing
-
Q. Du, V. Faber, M. Gunzburger, Centroidal Voronoi tessellations: Applications and algorithms, SIAM Review 41 (4) (1999) 637–676.
-
Q. Du, D. Wang, Tetrahedral mesh generation and optimization based on centroidal voronoi tessellations, International Journal on Numerical Methods in Engineering 56 (9) (2003) 1355–1373.
[3] Multi-threaded Voro++
-
Chris H. Rycroft, "Voro++: A three-dimensional Voronoi cell library in C++", Chaos 19, 041111 (2009). [website]
-
Lu, Jiayin, Lazar, Emanuel, and Rycroft, Chris. (2023). "An extension to Voro++ for multithreaded computation of Voronoi cells". Computer Physics Communications. 291. 108832 (2023). [website]
[4] OpenMP
- L. Dagum, R. Menon, OpenMP: an industry standard API for shared-memory pro- gramming, IEEE Computational Science and Engineering 5 (1) (1998) 46–55. doi: 10.1109/99.660313. [website]