From 3fcb2ce62b6e632eb4ad19b4929577af4679c1d3 Mon Sep 17 00:00:00 2001 From: DENEL Bertrand Date: Mon, 8 Jun 2026 19:18:40 -0500 Subject: [PATCH 1/2] Draft --- docs/geos_mesh_docs/stats.rst | 9 ++ docs/geos_mesh_docs/utils.rst | 9 ++ .../generic_processing_tools.rst | 9 -- docs/geos_processing_docs/pre_processing.rst | 8 -- docs/mesh-doctor.rst | 11 +++ .../mesh/stats}/CellTypeCounterEnhanced.py | 2 +- .../src/geos/mesh/utils}/SplitMesh.py | 35 ++++--- geos-mesh/tests/conftest.py | 4 + .../tests/data/hexs3_tets36_pyrs18.vtu | 0 .../tests/data/quads2_tris4.vtu | 0 .../tests/test_CellTypeCounterEnhanced.py | 2 +- .../tests/test_SplitMesh.py | 87 ++++++++++++++++- .../pre_processing/MeshQualityEnhanced.py | 2 +- geos-processing/tests/conftest.py | 4 +- .../plugins/generic_processing/PVSplitMesh.py | 2 +- .../plugins/qc/PVCellTypeCounterEnhanced.py | 2 +- .../geos/mesh_doctor/actions/refineMesh.py | 84 ++++++++++++++++ .../src/geos/mesh_doctor/parsing/__init__.py | 1 + .../mesh_doctor/parsing/refineMeshParsing.py | 95 +++++++++++++++++++ mesh-doctor/src/geos/mesh_doctor/register.py | 2 +- mesh-doctor/tests/test_refineMesh.py | 71 ++++++++++++++ 21 files changed, 397 insertions(+), 42 deletions(-) rename {geos-processing/src/geos/processing/pre_processing => geos-mesh/src/geos/mesh/stats}/CellTypeCounterEnhanced.py (98%) rename {geos-processing/src/geos/processing/generic_processing_tools => geos-mesh/src/geos/mesh/utils}/SplitMesh.py (94%) rename {geos-processing => geos-mesh}/tests/data/hexs3_tets36_pyrs18.vtu (100%) rename {geos-processing => geos-mesh}/tests/data/quads2_tris4.vtu (100%) rename {geos-processing => geos-mesh}/tests/test_CellTypeCounterEnhanced.py (96%) rename {geos-processing => geos-mesh}/tests/test_SplitMesh.py (78%) create mode 100644 mesh-doctor/src/geos/mesh_doctor/actions/refineMesh.py create mode 100644 mesh-doctor/src/geos/mesh_doctor/parsing/refineMeshParsing.py create mode 100644 mesh-doctor/tests/test_refineMesh.py diff --git a/docs/geos_mesh_docs/stats.rst b/docs/geos_mesh_docs/stats.rst index d5d06b2c..97c1ad1f 100644 --- a/docs/geos_mesh_docs/stats.rst +++ b/docs/geos_mesh_docs/stats.rst @@ -11,3 +11,12 @@ geos.mesh.stats.meshQualityMetricHelpers module :members: :undoc-members: :show-inheritance: + + +geos.mesh.stats.CellTypeCounterEnhanced module +---------------------------------------------- + +.. automodule:: geos.mesh.stats.CellTypeCounterEnhanced + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/geos_mesh_docs/utils.rst b/docs/geos_mesh_docs/utils.rst index 7c15213c..d865607e 100644 --- a/docs/geos_mesh_docs/utils.rst +++ b/docs/geos_mesh_docs/utils.rst @@ -57,3 +57,12 @@ geos.mesh.utils.pyvistaTools module :members: :undoc-members: :show-inheritance: + + +geos.mesh.utils.SplitMesh module +----------------------------------------------- + +.. automodule:: geos.mesh.utils.SplitMesh + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/geos_processing_docs/generic_processing_tools.rst b/docs/geos_processing_docs/generic_processing_tools.rst index dcbede23..cb6bd6db 100644 --- a/docs/geos_processing_docs/generic_processing_tools.rst +++ b/docs/geos_processing_docs/generic_processing_tools.rst @@ -55,12 +55,3 @@ MergeBlockEnhanced :members: :undoc-members: :show-inheritance: - - -SplitMesh -------------------------- - -.. automodule:: geos.processing.generic_processing_tools.SplitMesh - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/geos_processing_docs/pre_processing.rst b/docs/geos_processing_docs/pre_processing.rst index beab97d4..6bfb6227 100644 --- a/docs/geos_processing_docs/pre_processing.rst +++ b/docs/geos_processing_docs/pre_processing.rst @@ -3,14 +3,6 @@ Pre-processing filters The `pre_processing` module of `geos-processing` package contains filters to pre-process meshes for GEOS simulation. -CellTypeCounterEnhanced filter -------------------------------------------------------------- - -.. automodule:: geos.processing.pre_processing.CellTypeCounterEnhanced - :members: - :undoc-members: - :show-inheritance: - MeshQualityEnhanced filter --------------------------------------------------------- diff --git a/docs/mesh-doctor.rst b/docs/mesh-doctor.rst index 2ff03f0e..06a33045 100644 --- a/docs/mesh-doctor.rst +++ b/docs/mesh-doctor.rst @@ -134,6 +134,17 @@ The ``generateGlobalIds`` can generate `global ids` for the imported ``vtk`` mes .. command-output:: mesh-doctor generateGlobalIds --help :shell: +``refineMesh`` +"""""""""""""" + +The ``refineMesh`` module refines a mesh by splitting each cell into smaller cells of the same type using edge midpoints +(hexahedron -> 8, tetrahedron -> 8, pyramid -> 6 pyramids + 4 tetrahedra, triangle -> 4, quad -> 4). +Shared edges reuse the same midpoint, so the refined mesh stays conformal, including across 2D faces coincident with 3D cell faces. +The refinement can be applied several times with the ``--iterations`` option. + +.. command-output:: mesh-doctor refineMesh --help + :shell: + ``nonConformal`` """""""""""""""" diff --git a/geos-processing/src/geos/processing/pre_processing/CellTypeCounterEnhanced.py b/geos-mesh/src/geos/mesh/stats/CellTypeCounterEnhanced.py similarity index 98% rename from geos-processing/src/geos/processing/pre_processing/CellTypeCounterEnhanced.py rename to geos-mesh/src/geos/mesh/stats/CellTypeCounterEnhanced.py index 2e0eddcc..facc8371 100644 --- a/geos-processing/src/geos/processing/pre_processing/CellTypeCounterEnhanced.py +++ b/geos-mesh/src/geos/mesh/stats/CellTypeCounterEnhanced.py @@ -20,7 +20,7 @@ .. code-block:: python - from geos.processing.pre_processing.CellTypeCounterEnhanced import CellTypeCounterEnhanced + from geos.mesh.stats.CellTypeCounterEnhanced import CellTypeCounterEnhanced # Filter inputs inputMesh: vtkUnstructuredGrid diff --git a/geos-processing/src/geos/processing/generic_processing_tools/SplitMesh.py b/geos-mesh/src/geos/mesh/utils/SplitMesh.py similarity index 94% rename from geos-processing/src/geos/processing/generic_processing_tools/SplitMesh.py rename to geos-mesh/src/geos/mesh/utils/SplitMesh.py index 51afc7f5..2147459f 100644 --- a/geos-processing/src/geos/processing/generic_processing_tools/SplitMesh.py +++ b/geos-mesh/src/geos/mesh/utils/SplitMesh.py @@ -8,18 +8,19 @@ from vtkmodules.vtkCommonCore import vtkPoints, vtkIdTypeArray, vtkDataArray from vtkmodules.vtkCommonDataModel import ( vtkUnstructuredGrid, vtkCellArray, vtkCellData, vtkCell, vtkCellTypes, - VTK_TRIANGLE, VTK_QUAD, VTK_TETRA, VTK_HEXAHEDRON, VTK_PYRAMID, VTK_WEDGE, - VTK_POLYHEDRON, VTK_POLYGON ) + VTK_TRIANGLE, VTK_QUAD, VTK_TETRA, VTK_HEXAHEDRON, VTK_PYRAMID, VTK_WEDGE ) from vtkmodules.util.numpy_support import numpy_to_vtk, vtk_to_numpy from geos.utils.Logger import ( getLogger, Logger, CountVerbosityHandler, isHandlerInLogger, getLoggerHandlerType ) -from geos.processing.pre_processing.CellTypeCounterEnhanced import CellTypeCounterEnhanced +from geos.mesh.stats.CellTypeCounterEnhanced import CellTypeCounterEnhanced from geos.mesh.model.CellTypeCounts import CellTypeCounts __doc__ = """ SplitMesh module is a vtk filter that splits cells of a mesh composed of tetrahedra, pyramids, hexahedra, triangles, and quads. -.. Warning:: Current implementation only supports meshes composed of either polygons or polyhedra, not both together. +Polygons (2D) and polyhedra (3D) may be mixed in the same mesh: edge midpoints are +shared through an internal cache, so a 2D cell coincident with a 3D cell face splits +conformally with that face. Wedges are not supported. Filter input and output types are vtkUnstructuredGrid. @@ -27,7 +28,7 @@ .. code-block:: python - from geos.processing.generic_processing_tools.SplitMesh import SplitMesh + from geos.mesh.utils.SplitMesh import SplitMesh # Filter inputs inputMesh: vtkUnstructuredGrid @@ -74,6 +75,9 @@ def __init__( self, inputMesh: vtkUnstructuredGrid, speHandler: bool = False ) - self.cellTypes: list[ int ] self.speHandler: bool = speHandler self.handler: None | logging.Handler = None + # Cache of edge midpoints keyed on (min_pt_id, max_pt_id) so that + # shared edges across adjacent cells reuse the same point ID. + self.m_edgeMidpointCache: dict[ tuple[ int, int ], int ] = {} # Logger self.logger: Logger @@ -133,20 +137,14 @@ def applyFilter( self: Self ) -> None: if counts.getTypeCount( VTK_WEDGE ) != 0: raise TypeError( "Input mesh contains wedges that are not currently supported." ) - nbPolygon: int = counts.getTypeCount( VTK_POLYGON ) - nbPolyhedra: int = counts.getTypeCount( VTK_POLYHEDRON ) - # Current implementation only supports meshes composed of either polygons or polyhedra - if nbPolyhedra * nbPolygon != 0: - raise TypeError( - "Input mesh is composed of both polygons and polyhedra, but it must contains only one of the two." ) - nbTet: int = counts.getTypeCount( VTK_TETRA ) # will divide into 8 tets nbPyr: int = counts.getTypeCount( VTK_PYRAMID ) # will divide into 6 pyramids and 4 tets so 10 new cells nbHex: int = counts.getTypeCount( VTK_HEXAHEDRON ) # will divide into 8 hexes nbTriangles: int = counts.getTypeCount( VTK_TRIANGLE ) # will divide into 4 triangles nbQuad: int = counts.getTypeCount( VTK_QUAD ) # will divide into 4 quads - nbNewPoints: int = 0 - nbNewPoints = nbHex * 19 + nbTet * 6 + nbPyr * 9 if nbPolyhedra > 0 else nbTriangles * 3 + nbQuad * 5 + # Upper bound: shared edges (e.g. between a 3D cell and its 2D face) produce one midpoint + # not two, thanks to the edge midpoint cache. The actual count will be <= this. + nbNewPoints: int = nbHex * 19 + nbTet * 6 + nbPyr * 9 + nbTriangles * 3 + nbQuad * 5 nbNewCells: int = nbHex * 8 + nbTet * 8 + nbPyr * 10 + nbTriangles * 4 + nbQuad * 4 self.points = vtkPoints() @@ -159,6 +157,7 @@ def applyFilter( self: Self ) -> None: self.originalId.SetName( "OriginalID" ) self.originalId.Allocate( nbNewCells ) self.cellTypes = [] + self.m_edgeMidpointCache = {} # Define cell type to splitting method mapping splitMethods = { @@ -233,10 +232,16 @@ def _addMidPoint( self: Self, ptA: int, ptB: int ) -> int: Returns: int: inserted point Id """ + key: tuple[ int, int ] = ( min( ptA, ptB ), max( ptA, ptB ) ) + cached = self.m_edgeMidpointCache.get( key ) + if cached is not None: + return cached ptACoor: npt.NDArray[ np.float64 ] = np.array( self.points.GetPoint( ptA ) ) ptBCoor: npt.NDArray[ np.float64 ] = np.array( self.points.GetPoint( ptB ) ) center: npt.NDArray[ np.float64 ] = ( ptACoor + ptBCoor ) / 2. - return self.points.InsertNextPoint( center[ 0 ], center[ 1 ], center[ 2 ] ) + newId: int = self.points.InsertNextPoint( center[ 0 ], center[ 1 ], center[ 2 ] ) + self.m_edgeMidpointCache[ key ] = newId + return newId def _splitTetrahedron( self: Self, cell: vtkCell, index: int ) -> None: r"""Split a tetrahedron. diff --git a/geos-mesh/tests/conftest.py b/geos-mesh/tests/conftest.py index 23366ef0..bb1498fd 100644 --- a/geos-mesh/tests/conftest.py +++ b/geos-mesh/tests/conftest.py @@ -162,6 +162,10 @@ def _get_dataset( datasetType: str ) -> vtkMultiBlockDataSet | vtkPolyData | vtk vtkFilename = "data/singlePhasePoromechanics_FaultModel_well_seq/extractAndMergeWell1.vtu" elif datasetType == "extractAndMergeVolumeWell1": vtkFilename = "data/singlePhasePoromechanics_FaultModel_well_seq/extractAndMergeVolumeWell1.vtm" + elif datasetType == "quads2_tris4": + vtkFilename = "data/quads2_tris4.vtu" + elif datasetType == "hexs3_tets36_pyrs18": + vtkFilename = "data/hexs3_tets36_pyrs18.vtu" datapath: str = str( Path( __file__ ).parent / vtkFilename ) reader.SetFileName( datapath ) diff --git a/geos-processing/tests/data/hexs3_tets36_pyrs18.vtu b/geos-mesh/tests/data/hexs3_tets36_pyrs18.vtu similarity index 100% rename from geos-processing/tests/data/hexs3_tets36_pyrs18.vtu rename to geos-mesh/tests/data/hexs3_tets36_pyrs18.vtu diff --git a/geos-processing/tests/data/quads2_tris4.vtu b/geos-mesh/tests/data/quads2_tris4.vtu similarity index 100% rename from geos-processing/tests/data/quads2_tris4.vtu rename to geos-mesh/tests/data/quads2_tris4.vtu diff --git a/geos-processing/tests/test_CellTypeCounterEnhanced.py b/geos-mesh/tests/test_CellTypeCounterEnhanced.py similarity index 96% rename from geos-processing/tests/test_CellTypeCounterEnhanced.py rename to geos-mesh/tests/test_CellTypeCounterEnhanced.py index 409054eb..9288b342 100644 --- a/geos-processing/tests/test_CellTypeCounterEnhanced.py +++ b/geos-mesh/tests/test_CellTypeCounterEnhanced.py @@ -6,7 +6,7 @@ import pytest from typing import Any -from geos.processing.pre_processing.CellTypeCounterEnhanced import CellTypeCounterEnhanced +from geos.mesh.stats.CellTypeCounterEnhanced import CellTypeCounterEnhanced from geos.mesh.model.CellTypeCounts import CellTypeCounts from vtkmodules.vtkCommonDataModel import ( vtkUnstructuredGrid, vtkCellTypes, vtkCell, VTK_TRIANGLE, VTK_QUAD, diff --git a/geos-processing/tests/test_SplitMesh.py b/geos-mesh/tests/test_SplitMesh.py similarity index 78% rename from geos-processing/tests/test_SplitMesh.py rename to geos-mesh/tests/test_SplitMesh.py index c024cb7a..29e14906 100644 --- a/geos-processing/tests/test_SplitMesh.py +++ b/geos-mesh/tests/test_SplitMesh.py @@ -12,8 +12,8 @@ VTK_QUAD, VTK_TETRA, VTK_HEXAHEDRON, VTK_PYRAMID ) from vtkmodules.vtkCommonCore import vtkPoints, vtkIdList, vtkDataArray -from geos.mesh.utils.genericHelpers import createSingleCellMesh -from geos.processing.generic_processing_tools.SplitMesh import SplitMesh +from geos.mesh.utils.genericHelpers import createMultiCellMesh, createSingleCellMesh +from geos.mesh.utils.SplitMesh import SplitMesh ############################################################### # create single tetra mesh # @@ -296,3 +296,86 @@ def test_splitMesh( nbArrayInput: int = cellDataInput.GetNumberOfArrays() nbArraySplitted: int = cellData.GetNumberOfArrays() assert nbArraySplitted == nbArrayInput + 1, f"Number of arrays should be { nbArrayInput + 1 }." + + +@pytest.mark.parametrize( "cellType,coords", [ + ( VTK_HEXAHEDRON, [ + np.array( [ [ 0., 0., 0. ], [ 1., 0., 0. ], [ 1., 1., 0. ], [ 0., 1., 0. ], [ 0., 0., 1. ], [ 1., 0., 1. ], + [ 1., 1., 1. ], [ 0., 1., 1. ] ] ), + np.array( [ [ 1., 0., 0. ], [ 2., 0., 0. ], [ 2., 1., 0. ], [ 1., 1., 0. ], [ 1., 0., 1. ], [ 2., 0., 1. ], + [ 2., 1., 1. ], [ 1., 1., 1. ] ] ), + ] ), + ( VTK_TETRA, [ + np.array( [ [ 0., 0., 0. ], [ 1., 0., 0. ], [ 0., 1., 0. ], [ 0., 0., 1. ] ] ), + np.array( [ [ 1., 0., 0. ], [ 1., 1., 0. ], [ 0., 1., 0. ], [ 0., 0., 1. ] ] ), + ] ), + ( VTK_TRIANGLE, [ + np.array( [ [ 0., 0., 0. ], [ 1., 0., 0. ], [ 0., 1., 0. ] ] ), + np.array( [ [ 1., 0., 0. ], [ 1., 1., 0. ], [ 0., 1., 0. ] ] ), + ] ), + ( VTK_QUAD, [ + np.array( [ [ 0., 0., 0. ], [ 1., 0., 0. ], [ 1., 1., 0. ], [ 0., 1., 0. ] ] ), + np.array( [ [ 1., 0., 0. ], [ 2., 0., 0. ], [ 2., 1., 0. ], [ 1., 1., 0. ] ] ), + ] ), +] ) +def test_splitMeshFaceConformity( cellType: int, coords: list[ npt.NDArray[ np.float64 ] ] ) -> None: + """Two adjacent cells sharing a face must produce topologically conforming children. + + After splitting, no two distinct point IDs should occupy the same coordinates. + If the edge-midpoint cache works correctly, shared-edge midpoints are inserted + once and reused, so unique coordinates == number of points in the output. + """ + cellTypeName: str = vtkCellTypes.GetClassNameFromTypeId( cellType ) + mesh: vtkUnstructuredGrid = createMultiCellMesh( [ cellType, cellType ], coords, sharePoints=True ) + + splitFilter: SplitMesh = SplitMesh( mesh ) + splitFilter.applyFilter() + output: vtkUnstructuredGrid = splitFilter.getOutput() + + pts: npt.NDArray[ np.float64 ] = vtk_to_numpy( output.GetPoints().GetData() ) + uniquePts = np.unique( pts, axis=0 ) + + assert len( uniquePts ) == output.GetNumberOfPoints(), ( + f"{ cellTypeName }: found { output.GetNumberOfPoints() - len( uniquePts ) } duplicate coincident points after " + f"splitting -- shared-edge midpoints are not being reused." ) + + +def test_splitMeshHybrid2D3DConformity() -> None: + """A 3D cell with an explicit 2D face cell must split conformally. + + Build a single tetrahedron (pts 0-3) whose bottom face (pts 0,1,2) is also + represented as an explicit triangle cell (as fracture or boundary faces are + in GEOS meshes). After splitting, the 2D children must share the same point + IDs as the corresponding face children of the 3D split -- no duplicate + coincident points, and the output has the correct cell counts. + """ + pts = np.array( [ [ 0., 0., 0. ], [ 1., 0., 0. ], [ 0., 1., 0. ], [ 0., 0., 1. ] ], dtype=np.float64 ) + + mesh: vtkUnstructuredGrid = vtkUnstructuredGrid() + points = vtkPoints() + for p in pts: + points.InsertNextPoint( p[ 0 ], p[ 1 ], p[ 2 ] ) + mesh.SetPoints( points ) + + tetIds = vtkIdList() + for i in [ 0, 1, 2, 3 ]: + tetIds.InsertNextId( i ) + mesh.InsertNextCell( VTK_TETRA, tetIds ) + + triIds = vtkIdList() + for i in [ 0, 1, 2 ]: # bottom face of the tet + triIds.InsertNextId( i ) + mesh.InsertNextCell( VTK_TRIANGLE, triIds ) + + splitFilter: SplitMesh = SplitMesh( mesh ) + splitFilter.applyFilter() + output: vtkUnstructuredGrid = splitFilter.getOutput() + + # 1 tet -> 8 tets, 1 tri -> 4 tris + assert output.GetNumberOfCells() == 12, f"Expected 12 cells, got { output.GetNumberOfCells() }." + + outPts: npt.NDArray[ np.float64 ] = vtk_to_numpy( output.GetPoints().GetData() ) + uniquePts = np.unique( outPts, axis=0 ) + assert len( uniquePts ) == output.GetNumberOfPoints(), ( + f"Found { output.GetNumberOfPoints() - len( uniquePts ) } duplicate coincident points -- " + f"3D/2D shared-face edges are not sharing midpoint IDs." ) diff --git a/geos-processing/src/geos/processing/pre_processing/MeshQualityEnhanced.py b/geos-processing/src/geos/processing/pre_processing/MeshQualityEnhanced.py index 6690fbb3..aa85114e 100644 --- a/geos-processing/src/geos/processing/pre_processing/MeshQualityEnhanced.py +++ b/geos-processing/src/geos/processing/pre_processing/MeshQualityEnhanced.py @@ -16,7 +16,7 @@ VTK_POLYHEDRON ) from vtkmodules.util.numpy_support import vtk_to_numpy, numpy_to_vtk -from geos.processing.pre_processing.CellTypeCounterEnhanced import CellTypeCounterEnhanced +from geos.mesh.stats.CellTypeCounterEnhanced import CellTypeCounterEnhanced from geos.mesh.model.CellTypeCounts import CellTypeCounts from geos.mesh.model.QualityMetricSummary import ( QualityMetricSummary, StatTypes ) from geos.mesh.utils.arrayHelpers import getAttributesWithNumberOfComponents diff --git a/geos-processing/tests/conftest.py b/geos-processing/tests/conftest.py index faf8474f..b1497ed7 100644 --- a/geos-processing/tests/conftest.py +++ b/geos-processing/tests/conftest.py @@ -58,9 +58,9 @@ def _get_dataset( datasetType: str ) -> vtkMultiBlockDataSet | vtkPolyData | vtk vtkFilename = "geos-mesh/tests/data/hasFault.vtu" # Small useful meshes elif datasetType == "quads2_tris4": - vtkFilename = "geos-processing/tests/data/quads2_tris4.vtu" + vtkFilename = "geos-mesh/tests/data/quads2_tris4.vtu" elif datasetType == "hexs3_tets36_pyrs18": - vtkFilename = "geos-processing/tests/data/hexs3_tets36_pyrs18.vtu" + vtkFilename = "geos-mesh/tests/data/hexs3_tets36_pyrs18.vtu" elif datasetType == "meshtet1": vtkFilename = "geos-processing/tests/data/mesh1.vtu" elif datasetType == "meshtet1b": diff --git a/geos-pv/src/geos/pv/plugins/generic_processing/PVSplitMesh.py b/geos-pv/src/geos/pv/plugins/generic_processing/PVSplitMesh.py index 7a3b15d5..eec3bb4c 100644 --- a/geos-pv/src/geos/pv/plugins/generic_processing/PVSplitMesh.py +++ b/geos-pv/src/geos/pv/plugins/generic_processing/PVSplitMesh.py @@ -21,7 +21,7 @@ update_paths() -from geos.processing.generic_processing_tools.SplitMesh import SplitMesh +from geos.mesh.utils.SplitMesh import SplitMesh from geos.utils.Logger import isHandlerInLogger from geos.pv.utils.details import ( SISOFilter, FilterCategory ) diff --git a/geos-pv/src/geos/pv/plugins/qc/PVCellTypeCounterEnhanced.py b/geos-pv/src/geos/pv/plugins/qc/PVCellTypeCounterEnhanced.py index c46c429a..8cb22a19 100644 --- a/geos-pv/src/geos/pv/plugins/qc/PVCellTypeCounterEnhanced.py +++ b/geos-pv/src/geos/pv/plugins/qc/PVCellTypeCounterEnhanced.py @@ -24,7 +24,7 @@ update_paths() -from geos.processing.pre_processing.CellTypeCounterEnhanced import CellTypeCounterEnhanced +from geos.mesh.stats.CellTypeCounterEnhanced import CellTypeCounterEnhanced from geos.mesh.model.CellTypeCounts import CellTypeCounts from geos.pv.utils.details import FilterCategory from geos.utils.Logger import isHandlerInLogger diff --git a/mesh-doctor/src/geos/mesh_doctor/actions/refineMesh.py b/mesh-doctor/src/geos/mesh_doctor/actions/refineMesh.py new file mode 100644 index 00000000..c02138a7 --- /dev/null +++ b/mesh-doctor/src/geos/mesh_doctor/actions/refineMesh.py @@ -0,0 +1,84 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright 2023-2024 TotalEnergies. +"""Refine a VTU mesh by repeatedly applying edge-midpoint cell splitting.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from geos.mesh.io.vtkIO import VtkOutput, readUnstructuredGrid, writeMesh +from geos.mesh.utils.SplitMesh import SplitMesh +from geos.mesh_doctor.parsing.cliParsing import setupLogger + + +@dataclass( frozen=True ) +class Options: + """Options for mesh refinement. + + Attributes: + outputFile: VTK output file configuration. + iterations: Number of times to apply the split filter. Each iteration + multiplies the cell count by 4 (2D) or 8 (3D hexahedra/tetrahedra). + """ + outputFile: VtkOutput + iterations: int + + +@dataclass( frozen=True ) +class Result: + """Result of mesh refinement. + + Attributes: + inputNumPoints: Number of points in the input mesh. + inputNumCells: Number of cells in the input mesh. + outputNumPoints: Number of points in the refined mesh. + outputNumCells: Number of cells in the refined mesh. + iterations: Number of refinement iterations applied. + """ + inputNumPoints: int + inputNumCells: int + outputNumPoints: int + outputNumCells: int + iterations: int + + +def action( vtuInputFile: str, options: Options ) -> Result: + """Refine a mesh by splitting each cell using edge midpoints. + + Each iteration splits every cell into smaller cells of the same type: + hexahedra -> 8, tetrahedra -> 8, pyramids -> 10, triangles -> 4, quads -> 4. + Cell data arrays are propagated to the child cells. + + Args: + vtuInputFile: Path to the input VTU mesh file. + options: Refinement options (output path and iteration count). + + Returns: + Statistics comparing the input and output mesh. + + Raises: + TypeError: If the mesh contains unsupported cell types (e.g. wedges). + RuntimeError: If refinement fails for any reason. + """ + setupLogger.info( f"Reading mesh from \"{vtuInputFile}\"." ) + mesh = readUnstructuredGrid( vtuInputFile ) + inputNumPoints = mesh.GetNumberOfPoints() + inputNumCells = mesh.GetNumberOfCells() + + current = mesh + for i in range( options.iterations ): + setupLogger.info( f"Refinement iteration {i + 1}/{options.iterations}." ) + splitFilter = SplitMesh( current ) + splitFilter.applyFilter() + current = splitFilter.getOutput() + + setupLogger.info( f"Writing refined mesh to \"{options.outputFile.output}\"." ) + writeMesh( current, options.outputFile ) + + return Result( + inputNumPoints=inputNumPoints, + inputNumCells=inputNumCells, + outputNumPoints=current.GetNumberOfPoints(), + outputNumCells=current.GetNumberOfCells(), + iterations=options.iterations, + ) diff --git a/mesh-doctor/src/geos/mesh_doctor/parsing/__init__.py b/mesh-doctor/src/geos/mesh_doctor/parsing/__init__.py index f25f20ec..24738719 100644 --- a/mesh-doctor/src/geos/mesh_doctor/parsing/__init__.py +++ b/mesh-doctor/src/geos/mesh_doctor/parsing/__init__.py @@ -20,6 +20,7 @@ CHECK_INTERNAL_TAGS = "checkInternalTags" EULER = "euler" CONVERT_MD2SG = "convertMD2SG" +REFINE_MESH = "refineMesh" @dataclass( frozen=True ) diff --git a/mesh-doctor/src/geos/mesh_doctor/parsing/refineMeshParsing.py b/mesh-doctor/src/geos/mesh_doctor/parsing/refineMeshParsing.py new file mode 100644 index 00000000..6f07af47 --- /dev/null +++ b/mesh-doctor/src/geos/mesh_doctor/parsing/refineMeshParsing.py @@ -0,0 +1,95 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright 2023-2024 TotalEnergies. +"""Command line parsing for the refineMesh action.""" + +from __future__ import annotations + +import argparse +from typing import Any + +from geos.mesh_doctor.actions.refineMesh import Options, Result +from geos.mesh_doctor.parsing import REFINE_MESH +from geos.mesh_doctor.parsing.cliParsing import setupLogger, addVtuInputFileArgument +from geos.mesh_doctor.parsing import vtkOutputParsing + +__ITERATIONS = "iterations" +__ITERATIONS_DEFAULT = 1 + + +def fillSubparser( subparsers: argparse._SubParsersAction[ Any ] ) -> None: + """Fill the argument parser for the refineMesh action. + + Args: + subparsers: The subparsers action to add the parser to. + """ + p = subparsers.add_parser( + REFINE_MESH, + help="Refine a mesh by splitting each cell using edge midpoints.", + description="""\ +Refine a VTU mesh by repeatedly splitting each cell into smaller cells of the same type. + +Splitting rules (one iteration): + hexahedron -> 8 hexahedra + tetrahedron -> 8 tetrahedra + pyramid -> 6 pyramids + 4 tetrahedra + triangle -> 4 triangles + quad -> 4 quads + +Cell data arrays are propagated to child cells. The OriginalID array +records which input cell each output cell was split from. + +Examples: + mesh-doctor refineMesh -i mesh.vtu --output refined.vtu + mesh-doctor refineMesh -i mesh.vtu --output refined.vtu --iterations 2 +""", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + addVtuInputFileArgument( p ) + vtkOutputParsing.fillVtkOutputSubparser( p ) + p.add_argument( + "--" + __ITERATIONS, + type=int, + default=__ITERATIONS_DEFAULT, + metavar="N", + help=f"[int]: Number of refinement iterations. Each iteration multiplies the cell count " + f"by 4 (2D) or 8 (3D). Default: {__ITERATIONS_DEFAULT}.", + ) + + +def convert( parsedOptions: dict[ str, Any ] ) -> Options: + """Convert parsed command-line options to an Options object. + + Args: + parsedOptions: Dictionary of parsed command-line options. + + Returns: + Options for the refineMesh action. + """ + iterations: int = parsedOptions.get( __ITERATIONS, __ITERATIONS_DEFAULT ) + if iterations < 1: + raise ValueError( f"--{__ITERATIONS} must be >= 1, got {iterations}." ) + return Options( + outputFile=vtkOutputParsing.convert( parsedOptions ), + iterations=iterations, + ) + + +def displayResults( options: Options, result: Result ) -> None: + """Display the results of mesh refinement. + + Args: + options: The options used for refinement. + result: The result of the refinement action. + """ + setupLogger.results( "=" * 60 ) + setupLogger.results( "REFINE MESH" ) + setupLogger.results( "=" * 60 ) + setupLogger.results( f"Iterations applied : {result.iterations}" ) + setupLogger.results( f"Input points : {result.inputNumPoints:,}" ) + setupLogger.results( f"Input cells : {result.inputNumCells:,}" ) + setupLogger.results( f"Output points : {result.outputNumPoints:,}" ) + setupLogger.results( f"Output cells : {result.outputNumCells:,}" ) + cellFactor = result.outputNumCells / result.inputNumCells if result.inputNumCells else 0 + setupLogger.results( f"Cell count factor : {cellFactor:.1f}x" ) + setupLogger.results( f"Output written to : {options.outputFile.output}" ) + setupLogger.results( "=" * 60 ) diff --git a/mesh-doctor/src/geos/mesh_doctor/register.py b/mesh-doctor/src/geos/mesh_doctor/register.py index 41d96a60..b8a83f6e 100644 --- a/mesh-doctor/src/geos/mesh_doctor/register.py +++ b/mesh-doctor/src/geos/mesh_doctor/register.py @@ -59,7 +59,7 @@ def registerParsingActions( parsing.FIX_ELEMENTS_ORDERINGS, parsing.GENERATE_CUBE, parsing.GENERATE_FRACTURES, parsing.GENERATE_GLOBAL_IDS, parsing.MAIN_CHECKS, parsing.NON_CONFORMAL, parsing.SELF_INTERSECTING_ELEMENTS, parsing.SUPPORTED_ELEMENTS, parsing.ORPHAN_2D, - parsing.CHECK_INTERNAL_TAGS, parsing.EULER, parsing.CONVERT_MD2SG ): + parsing.CHECK_INTERNAL_TAGS, parsing.EULER, parsing.CONVERT_MD2SG, parsing.REFINE_MESH ): __HELPERS[ actionName ] = actionName __ACTIONS[ actionName ] = actionName diff --git a/mesh-doctor/tests/test_refineMesh.py b/mesh-doctor/tests/test_refineMesh.py new file mode 100644 index 00000000..2e7c6ab2 --- /dev/null +++ b/mesh-doctor/tests/test_refineMesh.py @@ -0,0 +1,71 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright 2023-2024 TotalEnergies. +"""Tests for the refineMesh action and its CLI parsing.""" + +from __future__ import annotations + +import numpy as np +import pytest +from pathlib import Path + +from vtkmodules.vtkCommonDataModel import VTK_HEXAHEDRON + +from geos.mesh.io.vtkIO import VtkOutput, readUnstructuredGrid, writeMesh +from geos.mesh.utils.genericHelpers import createMultiCellMesh +from geos.mesh_doctor.actions.refineMesh import Options, action +from geos.mesh_doctor.parsing import refineMeshParsing + + +def __writeTwoHexMesh( path: Path ) -> None: + """Write a conformal two-hexahedra mesh sharing a face to ``path``.""" + c0 = np.array( [ [ 0., 0., 0. ], [ 1., 0., 0. ], [ 1., 1., 0. ], [ 0., 1., 0. ], [ 0., 0., 1. ], [ 1., 0., 1. ], + [ 1., 1., 1. ], [ 0., 1., 1. ] ] ) + c1 = np.array( [ [ 1., 0., 0. ], [ 2., 0., 0. ], [ 2., 1., 0. ], [ 1., 1., 0. ], [ 1., 0., 1. ], [ 2., 0., 1. ], + [ 2., 1., 1. ], [ 1., 1., 1. ] ] ) + mesh = createMultiCellMesh( [ VTK_HEXAHEDRON, VTK_HEXAHEDRON ], [ c0, c1 ], sharePoints=True ) + writeMesh( mesh, VtkOutput( output=str( path ), isDataModeBinary=True ), canOverwrite=True ) + + +def test_refineMeshSingleIteration( tmp_path: Path ) -> None: + """One iteration splits each hex into 8 and keeps the mesh conformal.""" + inputFile = tmp_path / "in.vtu" + outputFile = tmp_path / "out.vtu" + __writeTwoHexMesh( inputFile ) + + options = Options( outputFile=VtkOutput( output=str( outputFile ), isDataModeBinary=True ), iterations=1 ) + result = action( str( inputFile ), options ) + + assert result.inputNumCells == 2 + assert result.outputNumCells == 2 * 8 + assert result.iterations == 1 + assert outputFile.exists() + + # 4x2x2 conformal hex grid -> (5,3,3) = 45 unique points, no duplicates. + out = readUnstructuredGrid( str( outputFile ) ) + assert out.GetNumberOfCells() == 16 + assert out.GetNumberOfPoints() == 45 + + +def test_refineMeshTwoIterationsConformal( tmp_path: Path ) -> None: + """Two iterations multiply cells by 8 each time and stay conformal.""" + inputFile = tmp_path / "in.vtu" + outputFile = tmp_path / "out.vtu" + __writeTwoHexMesh( inputFile ) + + options = Options( outputFile=VtkOutput( output=str( outputFile ), isDataModeBinary=True ), iterations=2 ) + result = action( str( inputFile ), options ) + + assert result.outputNumCells == 2 * 8 * 8 + # 8x4x4 conformal hex grid -> (9,5,5) = 225 unique points. + out = readUnstructuredGrid( str( outputFile ) ) + assert out.GetNumberOfPoints() == 225 + from vtkmodules.util.numpy_support import vtk_to_numpy + pts = vtk_to_numpy( out.GetPoints().GetData() ) + assert len( np.unique( pts, axis=0 ) ) == out.GetNumberOfPoints(), "Refined mesh has duplicate coincident points." + + +def test_refineMeshConvertRejectsNonPositiveIterations() -> None: + """The CLI converter rejects iteration counts below 1.""" + parsed = { "output": "out.vtu", "data_mode": "binary", "iterations": 0 } + with pytest.raises( ValueError ): + refineMeshParsing.convert( parsed ) From b2221220e747ddd2edc19887d8c4e1bd1f776b8d Mon Sep 17 00:00:00 2001 From: Randolph Settgast Date: Fri, 5 Jun 2026 21:24:30 -0700 Subject: [PATCH 2/2] fix label checks to read current label status --- .github/workflows/python-package.yml | 42 ++++++++++++++++++++++------ 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index f533a136..af2bda2c 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -140,40 +140,66 @@ jobs: check_integration_label: runs-on: ubuntu-latest needs: [build] + permissions: + pull-requests: read outputs: has_geos_integration_label: ${{ steps.set-label.outputs.has_label }} steps: - name: Check if PR has '${{ env.LABEL_TEST_GEOS_INTEGRATION }}' label id: set-label + # Fetch labels live from the GitHub REST API rather than reading + # github.event.pull_request.labels. The event payload is a snapshot + # frozen at the time the workflow was first triggered, so labels + # added after that first run are invisible to re-runs. + env: + GITHUB_TOKEN: ${{ github.token }} + REQUIRED_LABEL: ${{ env.LABEL_TEST_GEOS_INTEGRATION }} run: | echo "Checking for label..." LABEL_FOUND=false - LABELS='${{ toJson(github.event.pull_request.labels.*.name) }}' + PR_JSON=$(curl --fail --silent --show-error \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${GITHUB_TOKEN}" \ + "https://api.github.com/repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}") + LABELS=$(echo "${PR_JSON}" | jq -crM '[.labels[].name]') echo "PR Labels: $LABELS" - if echo "$LABELS" | grep -q "${{ env.LABEL_TEST_GEOS_INTEGRATION }}"; then + if echo "$LABELS" | jq -e --arg label "${REQUIRED_LABEL}" 'index($label) != null' > /dev/null; then LABEL_FOUND=true - echo "Label '${{ env.LABEL_TEST_GEOS_INTEGRATION }}' found" + echo "Label '${REQUIRED_LABEL}' found" fi - echo "has_label=$LABEL_FOUND" >> $GITHUB_OUTPUT + echo "has_label=$LABEL_FOUND" >> "$GITHUB_OUTPUT" check_force_integration_label: runs-on: ubuntu-latest # needs: [build] + permissions: + pull-requests: read outputs: has_geos_integration_force_label: ${{ steps.set-label.outputs.has_label }} steps: - name: Check if PR has '${{ env.LABEL_FORCE_GEOS_INTEGRATION }}' label id: set-label + # Fetch labels live from the GitHub REST API rather than reading + # github.event.pull_request.labels. The event payload is a snapshot + # frozen at the time the workflow was first triggered, so labels + # added after that first run are invisible to re-runs. + env: + GITHUB_TOKEN: ${{ github.token }} + REQUIRED_LABEL: ${{ env.LABEL_FORCE_GEOS_INTEGRATION }} run: | echo "Checking for label..." LABEL_FOUND=false - LABELS='${{ toJson(github.event.pull_request.labels.*.name) }}' + PR_JSON=$(curl --fail --silent --show-error \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${GITHUB_TOKEN}" \ + "https://api.github.com/repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}") + LABELS=$(echo "${PR_JSON}" | jq -crM '[.labels[].name]') echo "PR Labels: $LABELS" - if echo "$LABELS" | grep -q "${{ env.LABEL_FORCE_GEOS_INTEGRATION }}"; then + if echo "$LABELS" | jq -e --arg label "${REQUIRED_LABEL}" 'index($label) != null' > /dev/null; then LABEL_FOUND=true - echo "Label '${{ env.LABEL_FORCE_GEOS_INTEGRATION }}' found" + echo "Label '${REQUIRED_LABEL}' found" fi - echo "has_label=$LABEL_FOUND" >> $GITHUB_OUTPUT + echo "has_label=$LABEL_FOUND" >> "$GITHUB_OUTPUT" # Step 3: Check if GEOS integration is required based on changed files check_geos_integration_required: