MeshGlyph — Unstructured Mesh Visualization¶
This notebook demonstrates cleopatra.glyphs.gridded.mesh_glyph.MeshGlyph, a class for visualizing
UGRID-style unstructured mesh data using matplotlib.
Features covered:
- Building a triangular mesh and plotting face-centered data
- Node-centered (interpolated contour) plotting
- Wireframe / outline rendering
- Mixed-element meshes (triangles + quads)
- Overlaying data on a wireframe
- Generating a realistic river-channel mesh
import matplotlib.pyplot as plt
import matplotlib.tri as mtri
import numpy as np
from cleopatra.glyphs.gridded.mesh_glyph import MeshGlyph
from cleopatra.styling.params import Contour, DataStyle
1. Simple Triangular Mesh — Face-Centered Data¶
Create a small triangular mesh from random points using Delaunay triangulation, then color each face by its centroid elevation.
rng = np.random.default_rng(42)
n_points = 80
node_x = rng.uniform(0, 10, n_points)
node_y = rng.uniform(0, 8, n_points)
# matplotlib's Triangulation computes Delaunay internally
tri = mtri.Triangulation(node_x, node_y)
face_nodes = tri.triangles # (n_faces, 3)
# Compute a synthetic "water depth" at each face centroid
cx = node_x[face_nodes].mean(axis=1)
cy = node_y[face_nodes].mean(axis=1)
face_depth = np.sin(cx * 0.6) * np.cos(cy * 0.5) + 1.5
mg = MeshGlyph(node_x, node_y, face_nodes)
print(f"Mesh: {mg.n_nodes} nodes, {mg.n_faces} faces")
Mesh: 80 nodes, 145 faces
fig, ax = mg.plot(
face_depth,
location="face",
cmap="RdYlBu_r",
title="Face-Centered Water Depth",
edgecolor="grey",
figsize=(10, 7),
)
ax.set_xlabel("x [m]")
ax.set_ylabel("y [m]")
plt.tight_layout()
plt.show()
2. Node-Centered Data — Smooth Contours¶
When data is defined at nodes rather than faces, MeshGlyph.plot uses
tricontourf for smooth interpolated contours.
# Synthetic elevation field at each node
node_elevation = np.sin(node_x * 0.5) * np.cos(node_y * 0.4) * 3.0 + 5.0
fig, ax = mg.plot(node_elevation, location="node", cmap="terrain", title="Node-Centered Elevation (Contour Fill)", figsize=(10, 7), contour=Contour(levels=15))
ax.set_xlabel("x [m]")
ax.set_ylabel("y [m]")
plt.tight_layout()
plt.show()
<Figure size 640x480 with 0 Axes>
3. Wireframe Outline¶
plot_outline renders the mesh edges as a lightweight wireframe, useful for
inspecting mesh quality or overlaying on top of data plots.
fig, ax = mg.plot_outline(color="steelblue", linewidth=0.5, figsize=(10, 7))
ax.plot(node_x, node_y, "k.", markersize=3)
ax.set_title("Mesh Wireframe with Node Locations")
ax.set_xlabel("x [m]")
ax.set_ylabel("y [m]")
plt.tight_layout()
plt.show()
<Figure size 640x480 with 0 Axes>
4. Overlay — Data + Wireframe on the Same Axes¶
Pass an existing ax to combine a data plot with a wireframe overlay.
fig, ax = mg.plot(
face_depth,
location="face",
cmap="Blues",
colorbar=True,
title="Water Depth with Wireframe Overlay",
figsize=(10, 7),
)
# Overlay the wireframe on the same axes
mg.plot_outline(ax=ax, color="black", linewidth=0.3)
ax.set_xlabel("x [m]")
ax.set_ylabel("y [m]")
plt.tight_layout()
plt.show()
<Figure size 640x480 with 0 Axes>
5. Mixed-Element Mesh (Triangles + Quads)¶
Many real-world UGRID meshes combine quadrilateral and triangular elements.
MeshGlyph handles these via fan triangulation — quads are decomposed into 2
triangles internally. Use fill_value=-1 to pad rows for faces with fewer nodes.
# Build a structured quad grid (3x2 cells) with triangular transitions
#
# 6---7---8---9
# | | | / |
# 3---4---5 |
# | | | \ |
# 0---1---2--10
#
mixed_x = np.array([0, 1, 2, 0, 1, 2, 0, 1, 2, 3, 3], dtype=float)
mixed_y = np.array([0, 0, 0, 1, 1, 1, 2, 2, 2, 2, 0], dtype=float)
# 4 quads + 2 triangles — padded with -1
face_conn = np.array(
[
[0, 1, 4, 3], # quad
[1, 2, 5, 4], # quad
[3, 4, 7, 6], # quad
[4, 5, 8, 7], # quad
[2, 10, 5, -1], # triangle (bottom-right)
[5, 10, 9, -1], # triangle (right side)
]
)
face_values = np.array([1.0, 2.0, 3.0, 4.0, 2.5, 3.5])
mg_mixed = MeshGlyph(mixed_x, mixed_y, face_conn, fill_value=-1)
print(f"Mixed mesh: {mg_mixed.n_nodes} nodes, {mg_mixed.n_faces} faces")
print(f"Nodes per face: {mg_mixed.nodes_per_face}")
Mixed mesh: 11 nodes, 6 faces Nodes per face: [4 4 4 4 3 3]
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
# Left: face data
mg_mixed.plot(
face_values,
location="face",
ax=axes[0],
cmap="coolwarm",
edgecolor="black",
title="Mixed Mesh — Face Data",
)
axes[0].set_xlabel("x")
axes[0].set_ylabel("y")
# Right: wireframe only
mg_mixed.plot_outline(ax=axes[1], color="darkgreen", linewidth=1.5)
for i, (x, y) in enumerate(zip(mixed_x, mixed_y)):
axes[1].annotate(
str(i),
(x, y),
fontsize=9,
ha="center",
va="bottom",
fontweight="bold",
color="darkgreen",
)
axes[1].set_title("Mixed Mesh — Wireframe with Node IDs")
axes[1].set_xlabel("x")
axes[1].set_ylabel("y")
plt.tight_layout()
plt.show()
/tmp/ipykernel_2601/3050513185.py:31: UserWarning: This figure includes Axes that are not compatible with tight_layout, so results might be incorrect. plt.tight_layout()
6. Realistic Example — River Channel Bathymetry¶
Generate a curved river-channel mesh using Delaunay triangulation on a set of points that follow a sinusoidal centreline. The face data represents simulated bed elevation.
rng = np.random.default_rng(123)
# River centreline: sinusoidal path
s = np.linspace(0, 4 * np.pi, 200)
cx_line = s * 50 / (4 * np.pi) # 0..50 m along x
cy_line = 4 * np.sin(s) # sinusoidal meander
# Scatter points around the centreline with a channel half-width of ~3 m
n_river = 600
idx = rng.integers(0, len(s), n_river)
offsets = rng.normal(0, 1.2, n_river)
rx = cx_line[idx] + rng.normal(0, 0.5, n_river)
ry = cy_line[idx] + offsets
# Triangulate using matplotlib (Delaunay)
tri_river = mtri.Triangulation(rx, ry)
faces_river = tri_river.triangles
# Compute face-centroid bed elevation: deeper in the centre, higher at banks
fcx = rx[faces_river].mean(axis=1)
fcy = ry[faces_river].mean(axis=1)
dist_from_centre = np.abs(fcy - 4 * np.sin(fcx * 4 * np.pi / 50))
bed_elevation = (
-2.0
+ 1.5 * (dist_from_centre / dist_from_centre.max())
+ rng.normal(0, 0.1, len(fcx))
)
mg_river = MeshGlyph(rx, ry, faces_river)
print(f"River mesh: {mg_river.n_nodes} nodes, {mg_river.n_faces} faces")
River mesh: 600 nodes, 1184 faces
fig, axes = plt.subplots(2, 1, figsize=(14, 10))
# Top: bed elevation with face colouring
mg_river.plot(
bed_elevation,
location="face",
ax=axes[0],
cmap="YlOrBr_r",
vmin=-2.5,
vmax=0.0,
title="River Channel — Bed Elevation (face-centered)",
)
axes[0].set_xlabel("Along-channel distance [m]")
axes[0].set_ylabel("Cross-channel [m]")
# Bottom: node-centered velocity magnitude (synthetic)
node_vel = np.exp(-0.3 * np.abs(ry - 4 * np.sin(rx * 4 * np.pi / 50)))
mg_river.plot(node_vel, location="node", ax=axes[1], cmap="plasma", title="River Channel — Velocity Magnitude (node-centered contour)", contour=Contour(levels=20))
mg_river.plot_outline(ax=axes[1], color="white", linewidth=0.15)
axes[1].set_xlabel("Along-channel distance [m]")
axes[1].set_ylabel("Cross-channel [m]")
plt.tight_layout()
plt.show()
/tmp/ipykernel_2601/2632828612.py:23: UserWarning: This figure includes Axes that are not compatible with tight_layout, so results might be incorrect. plt.tight_layout()
7. Explicit Edge Connectivity — Wireframe with Pre-built Edges¶
When edge-node connectivity is available (common in UGRID NetCDF files),
pass it to MeshGlyph for faster vectorized wireframe rendering.
# Build a simple structured quad grid with explicit edges
nx, ny = 6, 4
gx, gy = np.meshgrid(np.arange(nx, dtype=float), np.arange(ny, dtype=float))
gx = gx.ravel()
gy = gy.ravel()
# Build quad faces: each cell (i,j) -> [i*nx+j, i*nx+j+1, (i+1)*nx+j+1, (i+1)*nx+j]
quads = []
for i in range(ny - 1):
for j in range(nx - 1):
n0 = i * nx + j
quads.append([n0, n0 + 1, n0 + nx + 1, n0 + nx])
quad_faces = np.array(quads)
# Build explicit edges (horizontal + vertical)
edges = []
for i in range(ny):
for j in range(nx - 1):
edges.append([i * nx + j, i * nx + j + 1])
for i in range(ny - 1):
for j in range(nx):
edges.append([i * nx + j, (i + 1) * nx + j])
edge_arr = np.array(edges)
mg_quad = MeshGlyph(gx, gy, quad_faces, edge_node_connectivity=edge_arr)
print(
f"Quad grid: {mg_quad.n_nodes} nodes, {mg_quad.n_faces} faces, {mg_quad.n_edges} edges"
)
Quad grid: 24 nodes, 15 faces, 38 edges
quad_data = np.sin(gx[quad_faces].mean(axis=1)) * np.cos(gy[quad_faces].mean(axis=1))
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
mg_quad.plot(
quad_data,
location="face",
ax=axes[0],
cmap="PiYG",
edgecolor="black",
title="Quad Grid — Face Data",
)
axes[0].set_xlabel("x")
axes[0].set_ylabel("y")
mg_quad.plot_outline(ax=axes[1], color="navy", linewidth=1.0)
axes[1].set_title("Quad Grid — Wireframe (explicit edges)")
axes[1].set_xlabel("x")
axes[1].set_ylabel("y")
plt.tight_layout()
plt.show()
/tmp/ipykernel_2601/959532724.py:21: UserWarning: This figure includes Axes that are not compatible with tight_layout, so results might be incorrect. plt.tight_layout()
8. Side-by-Side Comparison — Face vs Node¶
Compare the two rendering modes on the same mesh and data to see the difference between flat face colouring and smooth node interpolation.
fig, axes = plt.subplots(1, 2, figsize=(14, 6))
# Face-centered (flat colours per triangle)
mg.plot(
face_depth,
location="face",
ax=axes[0],
cmap="RdYlBu_r",
edgecolor="lightgrey",
title="Face-Centered (tripcolor)",
)
axes[0].set_xlabel("x [m]")
axes[0].set_ylabel("y [m]")
# Node-centered (smooth contour interpolation)
mg.plot(node_elevation, location="node", ax=axes[1], cmap="RdYlBu_r", title="Node-Centered (tricontourf)", contour=Contour(levels=15))
axes[1].set_xlabel("x [m]")
axes[1].set_ylabel("y [m]")
plt.tight_layout()
plt.show()
/tmp/ipykernel_2601/751375181.py:20: UserWarning: This figure includes Axes that are not compatible with tight_layout, so results might be incorrect. plt.tight_layout()
9. Relief shading (hillshade) for terrain meshes¶
A triangulated terrain with a wide elevation range reads poorly from colour alone. Passing hillshade
(True or an options dict) with location='node' blends the triangle-normal relief into the surface, so it
reads by form. Hillshade needs the per-node elevation (the surface geometry), so it applies to
node-centered data; face-centered data raises a clear error.
Build a structured quad terrain mesh with a wide-range node elevation (a steep ridge over a gentle plain).
n = 20
gx, gy = np.meshgrid(np.linspace(0, 10, n), np.linspace(0, 10, n))
node_x, node_y = gx.ravel(), gy.ravel()
elevation = (
40
+ 250 * np.exp(-(((node_x - 7) / 2) ** 2 + ((node_y - 5) / 3) ** 2))
+ 8 * np.sin(node_x) * np.cos(node_y)
)
faces = np.array(
[
[j * n + i, j * n + i + 1, j * n + i + n + 1, j * n + i + n]
for j in range(n - 1)
for i in range(n - 1)
]
)
elevation.min(), elevation.max()
(np.float64(32.02726620840118), np.float64(288.7624001162125))
mg = MeshGlyph(node_x, node_y, faces)
fig, ax = mg.plot(elevation, location='node', cmap='terrain', title='Terrain mesh with hillshade', data_style=DataStyle(hillshade={'vert_exag': 3}))
Each triangle is coloured by its elevation and relief-shaded by its normal, so ridges and slopes are legible
and the colorbar still reads the elevation scale. This is the same hillshade option as ArrayGlyph, using
the mesh (shade_faces) geometry instead of the raster grid.
Data-style presets: style= and apply_style()¶
Pick a preset by name instead of a colormap. style= works at construction or plot() time; apply_style(name) restyles an existing glyph in place (reusing the last-plotted mesh data), glyph.style reads it back, and the preset is sticky/clearable. A continuous preset composes with hillshade; a categorical preset (e.g. flow_direction_d8) draws a discrete legend.
mg = MeshGlyph(node_x, node_y, faces)
mg.plot(elevation, location='node', title="style='topography'", data_style=DataStyle(style='topography'))
print('style:', mg.style)
mg.apply_style('elevation') # restyle in place, reusing the data
print('style after apply_style:', mg.style)
style: topography style after apply_style: elevation