From 61dd176f1f9e58c3a864695bdd051e68d300484c Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Mon, 2 Mar 2026 11:19:49 +1100 Subject: [PATCH 1/3] Fix inverted hexahedral cell faces in 3D StructuredQuadBox visualization PETSc DMPlex returns hex vertices with bottom-face winding opposite to VTK convention (CCW vs CW from above). Apply permutation [0,3,2,1,4,5,6,7] to swap vertices 1 and 3 on the bottom face when converting to meshio/VTK format. Closes #40 Underworld development team with AI support from Claude Code --- src/underworld3/visualisation/visualisation.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/underworld3/visualisation/visualisation.py b/src/underworld3/visualisation/visualisation.py index dff644125..42203feee 100644 --- a/src/underworld3/visualisation/visualisation.py +++ b/src/underworld3/visualisation/visualisation.py @@ -109,6 +109,14 @@ def mesh_to_pv_mesh(mesh, jupyter_backend=None): cell_points = mesh.dm.getTransitiveClosure(cell_id)[0][-cell_num_points:] cell_points_list.append(cell_points - pStart) + # PETSc DMPlex hexahedra have bottom-face vertices wound opposite to VTK convention. + # PETSc: (0,0,0)→(0,1,0)→(1,1,0)→(1,0,0) (CCW from above) + # VTK: (0,0,0)→(1,0,0)→(1,1,0)→(0,1,0) (CW from above) + # Fix by swapping vertices 1 and 3 on the bottom face: [0,3,2,1,4,5,6,7] + if not mesh.dm.isSimplex() and mesh.dim == 3: + hex_reorder = [0, 3, 2, 1, 4, 5, 6, 7] + cell_points_list = [pts[hex_reorder] for pts in cell_points_list] + try: import meshio From 9bccb80ffd879d59847752ce77da1676916e197c Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Mon, 2 Mar 2026 14:00:35 +1100 Subject: [PATCH 2/3] Also fix inverted tetrahedra orientation for 3D simplex meshes PETSc DMPlex uses left-handed orientation for tetrahedra; VTK expects right-handed. Swap vertices 1 and 2 ([0,2,1,3]) to correct this. Without correct orientation, face normals point inward which can cause shading artifacts, incorrect surface clipping, and back-face culling issues. Verified: all 3D cell types (hex + tet) now produce positive Jacobian determinant; 2D meshes (quad + triangle) are unaffected. Underworld development team with AI support from Claude Code --- .../visualisation/visualisation.py | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/underworld3/visualisation/visualisation.py b/src/underworld3/visualisation/visualisation.py index 42203feee..9b222ed5e 100644 --- a/src/underworld3/visualisation/visualisation.py +++ b/src/underworld3/visualisation/visualisation.py @@ -109,13 +109,21 @@ def mesh_to_pv_mesh(mesh, jupyter_backend=None): cell_points = mesh.dm.getTransitiveClosure(cell_id)[0][-cell_num_points:] cell_points_list.append(cell_points - pStart) - # PETSc DMPlex hexahedra have bottom-face vertices wound opposite to VTK convention. - # PETSc: (0,0,0)→(0,1,0)→(1,1,0)→(1,0,0) (CCW from above) - # VTK: (0,0,0)→(1,0,0)→(1,1,0)→(0,1,0) (CW from above) - # Fix by swapping vertices 1 and 3 on the bottom face: [0,3,2,1,4,5,6,7] - if not mesh.dm.isSimplex() and mesh.dim == 3: - hex_reorder = [0, 3, 2, 1, 4, 5, 6, 7] - cell_points_list = [pts[hex_reorder] for pts in cell_points_list] + # PETSc DMPlex uses opposite winding to VTK for 3D cells. + # Reorder vertices to match VTK convention (right-handed orientation) + # so that face normals, shading, clipping, and back-face culling work correctly. + # + # Hexahedra: PETSc winds bottom face CCW from above, VTK expects CW from above. + # Fix: swap vertices 1 and 3 on the bottom face → [0,3,2,1,4,5,6,7] + # Tetrahedra: PETSc uses left-handed orientation, VTK expects right-handed. + # Fix: swap vertices 1 and 2 → [0,2,1,3] + if mesh.dim == 3: + if mesh.dm.isSimplex(): + tet_reorder = [0, 2, 1, 3] + cell_points_list = [pts[tet_reorder] for pts in cell_points_list] + else: + hex_reorder = [0, 3, 2, 1, 4, 5, 6, 7] + cell_points_list = [pts[hex_reorder] for pts in cell_points_list] try: import meshio From d973186b8e709227eaaaf23e104f2d4bf7eb36d0 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Mon, 2 Mar 2026 15:16:14 +1100 Subject: [PATCH 3/3] Add regression test for 3D cell VTK orientation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests that hex and tet cells have positive Jacobian determinant after the PETSc→VTK vertex reordering, and that 2D meshes are unaffected. Underworld development team with AI support from Claude Code --- tests/test_0055_mesh_vtk_orientation.py | 97 +++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 tests/test_0055_mesh_vtk_orientation.py diff --git a/tests/test_0055_mesh_vtk_orientation.py b/tests/test_0055_mesh_vtk_orientation.py new file mode 100644 index 000000000..55331ad99 --- /dev/null +++ b/tests/test_0055_mesh_vtk_orientation.py @@ -0,0 +1,97 @@ +"""Regression test for 3D mesh cell orientation in VTK/meshio output. + +PETSc DMPlex uses opposite vertex winding to VTK for 3D cells. +mesh_to_pv_mesh() must reorder vertices so that cell Jacobians are +positive (right-handed orientation), otherwise face normals, shading, +clipping, and back-face culling break. + +See: https://github.com/underworldcode/underworld3/issues/40 +""" + +import pytest +import numpy as np +import underworld3 as uw + +pytestmark = [pytest.mark.level_1, pytest.mark.tier_a] + + +def _tet_jacobian_sign(verts): + """Signed volume of tetrahedron. Positive = VTK right-handed.""" + e01 = verts[1] - verts[0] + e02 = verts[2] - verts[0] + e03 = verts[3] - verts[0] + return np.dot(np.cross(e01, e02), e03) + + +def _hex_jacobian_sign(verts): + """Trilinear Jacobian sign at vertex 0. Positive = VTK convention.""" + e01 = verts[1] - verts[0] + e03 = verts[3] - verts[0] + e04 = verts[4] - verts[0] + return np.dot(np.cross(e01, e03), e04) + + +def _get_viz_cell_points(mesh): + """Replicate the cell extraction and reordering from mesh_to_pv_mesh.""" + cStart, cEnd = mesh.dm.getHeightStratum(0) + pStart, _ = mesh.dm.getDepthStratum(0) + n = mesh.element.entities[mesh.dim] + + cell_points_list = [] + for cell_id in range(cStart, cEnd): + pts = mesh.dm.getTransitiveClosure(cell_id)[0][-n:] - pStart + cell_points_list.append(pts) + + if mesh.dim == 3: + if mesh.dm.isSimplex(): + reorder = [0, 2, 1, 3] + else: + reorder = [0, 3, 2, 1, 4, 5, 6, 7] + cell_points_list = [pts[reorder] for pts in cell_points_list] + + return cell_points_list + + +def test_hex_orientation_3d(): + mesh = uw.meshing.StructuredQuadBox( + minCoords=(0.0, 0.0, 0.0), + maxCoords=(1.0, 1.0, 1.0), + elementRes=(2, 2, 2), + ) + coords = np.asarray(mesh.X.coords, dtype=np.double) + cell_points_list = _get_viz_cell_points(mesh) + + for i, pts in enumerate(cell_points_list): + det = _hex_jacobian_sign(coords[pts]) + assert det > 0, f"Hex cell {i} has negative Jacobian ({det})" + + +def test_tet_orientation_3d(): + mesh = uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0, 0.0), + maxCoords=(1.0, 1.0, 1.0), + cellSize=0.5, + ) + coords = np.asarray(mesh.X.coords, dtype=np.double) + cell_points_list = _get_viz_cell_points(mesh) + + for i, pts in enumerate(cell_points_list): + det = _tet_jacobian_sign(coords[pts]) + assert det > 0, f"Tet cell {i} has negative Jacobian ({det})" + + +def test_2d_unaffected(): + """Verify 2D meshes are not modified by the 3D reordering.""" + for mesh in [ + uw.meshing.StructuredQuadBox(elementRes=(3, 3)), + uw.meshing.UnstructuredSimplexBox(), + ]: + coords = np.asarray(mesh.X.coords, dtype=np.double) + cell_points_list = _get_viz_cell_points(mesh) + + for i, pts in enumerate(cell_points_list): + verts = coords[pts] + e01 = verts[1] - verts[0] + e02 = verts[2] - verts[0] + cross = e01[0] * e02[1] - e01[1] * e02[0] + assert cross > 0, f"2D cell {i} has wrong winding ({cross})"