Terrain meshing#

Section author: Dominik Kern (TU Bergakademie Freiberg)

Here we show different tools to mesh a minimum example of a terrain, given by raster data. These are

terrain

Gmsh#

If the relief is given as relief.grd, then we need to convert it first (file names are hard coded) python -m ogstools.msh2vtu.examples.howto_mesh_terrain.grd2stl.

Running python -m ogstools.msh2vtu.examples.howto_mesh_terrain.pyvista_mesh reads in relief.stl and meshes the volume between the relief and a z-coordinate specified in the script. In addition it creates physical groups for all bounding surfaces.

gmsh

#!/usr/bin/env python3
"""
Created on Thu Sep 23 10:22:22 2021

@author: dominik

This script is derived from the gmsh-demo   terrain_stl.py
Requirements: relief.stl (possibly converted from relief.grd)
It is assumed that curves and points of read-in boundary are ordered.

TODO
    - N-E-S-W instead of front/left/...
    - use transfinite curves/surfaces/volumes to create a hex-mesh
    - make class for SIDES
    - try to detect side points automatically from stl
"""
import math
import sys
from pathlib import Path

import gmsh
import numpy as np

dim1 = 1
dim2 = 2
dim3 = 3
EPS = 1e-6  # for collinearity check,
MIN_SIZE = 0.1  # minimum element size
MAX_SIZE = 1.0  # maximum elemenz size

# side definitions (straight lines), points chosen outside to prevent
# collocation
X0 = 0
X1 = 10
DX = 1
Y0 = 0
Y1 = 10
DY = 1
Z = 7  # bottom_level

side_points = {
    "front": {"p1": np.array([X1, Y0 - DY]), "p2": np.array([X1, Y1 + DY])},
    "right": {"p1": np.array([X0 - DX, Y1]), "p2": np.array([X1 + DX, Y1])},
    "back": {"p1": np.array([X0, Y0 - DY]), "p2": np.array([X0, Y1 + DY])},
    "left": {"p1": np.array([X0 - DX, Y0]), "p2": np.array([X1 + DX, Y0])},
}  # a line is defined by two points: p1 (x,y), p2 (x,y)

side_surface_ids: dict[str, list[int]] = {
    "front": [],
    "right": [],
    "back": [],
    "left": [],
}


def collinear2D(p0, p1, p2):  #
    x1, y1 = p0[0] - p1[0], p0[1] - p1[1]
    x2, y2 = p2[0] - p0[0], p2[1] - p0[1]
    CROSS_PRODUCT = x1 * y2 - x2 * y1
    print(f"p0-p1=[{x1}, {y1}]")
    print(f"p2-p0=[{x2}, {y2}]")
    print(CROSS_PRODUCT)
    return abs(CROSS_PRODUCT) < EPS


def on_line2D(xyz, guess):
    p0 = np.array([xyz[0], xyz[1]])
    print(p0)
    points = side_points[guess]
    if collinear2D(p0, points["p1"], points["p2"]):
        return guess
    for side, points in side_points.items():
        print(side)
        if collinear2D(p0, points["p1"], points["p2"]):
            return side
    print("point " + str(p0) + " not on a given side")
    return "NO SIDE FOUND"


gmsh.initialize(sys.argv)  # use finalize to unload from memory

# load an STL surface
path = Path(__file__).parent
gmsh.merge(path / "relief.stl")

# classify the surface mesh according to given angle, and create discrete model
# entities (surfaces, curves and points) accordingly; curveAngle forces bounding
# curves to be split on sharp corners
gmsh.model.mesh.classifySurfaces(
    math.pi, curveAngle=0.0 * math.pi
)  # angle, boundary = True, forReparametrization = False, curveAngle = pi
# angle=pi, selects the surface as one, no matter what angles are between the
# STL-patches
# curveAngle=0 selects each STL line segment as curve, even if they continue in
# the same direction

# create a geometry for the discrete curves and surfaces
gmsh.model.mesh.createGeometry()

# retrieve the surface, its boundary curves and corner points
top_surfaces = gmsh.model.getEntities(2)  # discrete surface
if len(top_surfaces) == 1:
    top_surface = top_surfaces[0]
else:
    print("More than one top surface detected.")
    gmsh.finalize()
    sys.exit()

top_curves = gmsh.model.getEntities(1)  # discrete surface
top_points = gmsh.model.getEntities(0)  # discrete surface

# create geometric entities to form one volume below the terrain surface
bottom_point_ids = []
side_curve_ids = []  # vertical lines
for top_point in top_points:
    xyz = gmsh.model.getValue(0, top_point[1], [])  # get x,y,z coordinates
    bottom_point_id = gmsh.model.geo.addPoint(xyz[0], xyz[1], Z)
    bottom_point_ids.append(bottom_point_id)
    side_curve_id = gmsh.model.geo.addLine(bottom_point_id, top_point[1])
    side_curve_ids.append(side_curve_id)
gmsh.model.geo.synchronize()

Nc = len(top_curves)
bottom_curve_ids = []  # horizontal lines

guessed_side = "left"
for ci, top_curve in enumerate(top_curves):
    cip1 = (ci + 1) % Nc  # switch to next and from last to first (cycle)
    # get x,y,z coordinates
    xyz_i = gmsh.model.getValue(0, bottom_point_ids[ci], [])
    xyz_ip1 = gmsh.model.getValue(
        0, bottom_point_ids[cip1], []
    )  # get x,y,z coordinates
    xyz = 0.5 * (xyz_i + xyz_ip1)  # midpoint
    detected_side = on_line2D(xyz, guessed_side)
    if detected_side not in side_points:
        print("Geometry error")
        gmsh.finalize()
        sys.exit()
    else:
        guessed_side = detected_side  # next guess

    bottom_curve_id = gmsh.model.geo.addLine(
        bottom_point_ids[ci], bottom_point_ids[cip1]
    )
    bottom_curve_ids.append(bottom_curve_id)
    side_ll = gmsh.model.geo.addCurveLoop(
        [
            bottom_curve_id,
            side_curve_ids[cip1],
            -top_curve[1],
            -side_curve_ids[ci],
        ]
    )
    side_surface_id = gmsh.model.geo.addPlaneSurface([side_ll])
    side_surface_ids[detected_side].append(side_surface_id)

bottom_ll = gmsh.model.geo.addCurveLoop(bottom_curve_ids)
bottom_surface_id = gmsh.model.geo.addPlaneSurface([bottom_ll])

all_side_ids = []
for surface_ids in side_surface_ids.values():
    all_side_ids += surface_ids

surface_loop1 = gmsh.model.geo.addSurfaceLoop(
    [top_surface[1]] + all_side_ids + [bottom_surface_id]
)
volume1 = gmsh.model.geo.addVolume([surface_loop1])

gmsh.model.geo.synchronize()

# physical groups
Top_Surface = gmsh.model.addPhysicalGroup(dim2, [top_surface[1]])
gmsh.model.setPhysicalName(dim2, Top_Surface, "top")

Bottom_Surface = gmsh.model.addPhysicalGroup(dim2, [bottom_surface_id])
gmsh.model.setPhysicalName(dim2, Bottom_Surface, "bottom")

for side in side_surface_ids:
    Side_Surface = gmsh.model.addPhysicalGroup(dim2, side_surface_ids[side])
    gmsh.model.setPhysicalName(dim2, Side_Surface, side)

Volume1 = gmsh.model.addPhysicalGroup(dim3, [volume1])
gmsh.model.setPhysicalName(dim3, Volume1, "volume")

# meshing

gmsh.option.setNumber("Mesh.MeshSizeMin", MIN_SIZE)
gmsh.option.setNumber("Mesh.MeshSizeMax", MAX_SIZE)
gmsh.model.mesh.generate(3)
gmsh.write("gmsh_terrain.msh")

# gmsh.fltk.run()   # GUI
gmsh.finalize()  # to remove all objects from memory

OGS Utilities#

OGS comes with some utilities for meshing. Required are raster files (.grd) and a file specifying their sequence (here layer_file_list). The following commands create a 2D mesh, extrude it to 3D-wedge elements and then fit in a structured hex mesh.

generateStructuredMesh -e tri --lx 10 --nx 20 --ly 10 --ny 20 -o tri2d_mesh.vtu
createLayeredMeshFromRasters -i tri2d_mesh.vtu -o wedge3d_mesh.vtu -r layer_file_list
Vtu2Grid -i wedge3d_mesh.vtu -o hex3d_mesh.vtu -x 0.4

ogstools

PyVista#

PyVista is mainly made for visualization, but brings some meshing functionality. It has a grid reader to read relief.grd and a meshio-interface to write to relief.vtu. Running python -m ogstools.msh2vtu.examples.howto_mesh_terrain.pyvista_mesh generates a structured mesh on the relief and extrudes it downwards to 3D.

pyvista

#!/usr/bin/env python3
"""
Created on Wed Sep 22 11:31:50 2021

@author: dominik

generate structured grid

TODO
    add physical group as cell data
"""

import numpy as np
import pyvista as pv
from PVGeo.grids import SurferGridReader

#   read file
filename = "relief.grd"
dem = SurferGridReader().apply(filename)

relief = dem.warp_by_scalar(scale_factor=1)
relief.plot(cmap="terrain")  #

# create meshgrid
z_spacing = np.array([1, 2, 3])

xx = np.repeat(relief.x, len(z_spacing), axis=-1)
yy = np.repeat(relief.y, len(z_spacing), axis=-1)
z_offset = np.cumsum(z_spacing).reshape((1, 1, -1))
# since the top z-coordinates (relief) repeat, we must subtract the z-offset
# for each layer
zz = np.repeat(relief.z, len(z_spacing), axis=-1) - z_offset

mesh = pv.StructuredGrid(xx, yy, zz)
mesh["Elevation"] = zz.ravel(order="F")  # flatten nested array to 1D-array

# List of camera position, focal point, and view up, either vector or string
# e.g. "xy"
# cpos = [(1826736.796308761, 5655837.275274233, 4676.8405505181745),
# (1821066.1790519988, 5649248.765538796, 943.0995128226014),
# (-0.2797856225380979, -0.27966946337594883, 0.9184252809434081)]

# as "Elevation is the only data field it gets plotted
mesh.plot(show_edges=True)  # lighting=False

# mesh.save("terrain.vtk")
pv.save_meshio("pv_terrain.vtu", mesh)

TetGen#

TetGen creates a 3D mesh from a 2D mesh on a closed surface. This surface may be either read from stl-files or PyVista-data. Currently the pyvista-tetgen basic example (tetgen_example.py) has been prepared for terrain meshing (tetgen_mesh.py), but is not finished yet.

#!/usr/bin/env python3
"""
Created on Thu Sep 23 09:29:51 2021

@author: dominik

TetGen starts from a closed triangulated surface mesh.
So one needs to find a way to create such a surface (pyvista).

FAVORIZED STRATEGY:
    - create a pv.Box, adjust its top surface z=z_top to DEM z=z(x,y)

ALTERNATIVES
    - create complete Polydata (vertices, faces) for DEM and remaining sides
    - generate DEM for all sides and merge them
    - clip a volume
"""
import pyvista as pv
import tetgen

pv.set_plot_theme("document")

box = pv.Box(bounds=(-1.0, 1.0, -1.0, 1.0, -1.0, 1.0), level=1, quads=False)
# box.plot(show_edges=True, opacity=0.4)

tet = tetgen.TetGen(box)
tet.tetrahedralize(order=1, mindihedral=20, minratio=1.5)
grid = tet.grid
# grid.plot(show_edges=True, opacity=0.4)

# plot mesh quality
cell_qual = grid.compute_cell_quality()["CellQuality"]
grid.plot(
    scalars=cell_qual,
    scalar_bar_args={"title": "Quality"},
    cmap="bwr",
    clim=[0, 1],
    flip_scalars=True,
    show_edges=True,
)