Skip to content

Commit b59ff7f

Browse files
bd713DENEL Bertrand
andauthored
feat: add a mesh‑doctor action to compute a mesh’s Euler characteristic (#239)
* changed from surface to solid euler --------- Co-authored-by: DENEL Bertrand <bertrand.denel@total.com>
1 parent 1b0f4df commit b59ff7f

6 files changed

Lines changed: 592 additions & 5 deletions

File tree

.github/workflows/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ This document explains the Continuous Integration (CI) setup for the geosPythonP
66

77
The CI system consists of two main workflows:
88

9-
1. **`python-package.yml`** - Tests all Python packages individually
9+
1. **`python-package.yml`** - Tests all Python packages individually,
1010
2. **`test_geos_integration.yml`** - Tests integration with the GEOS simulation framework
1111

1212
## Workflow 1: Python Package Testing (`python-package.yml`)
@@ -485,4 +485,4 @@ When adding new Python packages or modifying existing ones:
485485
## References
486486

487487
- [GEOS Repository](https://github.com/GEOS-DEV/GEOS)
488-
- [GEOS Documentation](https://geosx-geosx.readthedocs-hosted.com/)
488+
- [GEOS Documentation](https://geosx-geosx.readthedocs-hosted.com/)
Lines changed: 382 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,382 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
# SPDX-FileCopyrightText: Copyright 2023-2024 TotalEnergies.
3+
"""Compute Solid Euler Characteristic for mesh files (3D elements only)."""
4+
5+
from dataclasses import dataclass
6+
import vtk
7+
from tqdm import tqdm
8+
9+
from geos.mesh_doctor.parsing.cliParsing import setupLogger
10+
from geos.mesh.io.vtkIO import readUnstructuredGrid
11+
12+
13+
@dataclass( frozen=True )
14+
class Options:
15+
"""Options for Euler characteristic computation."""
16+
pass
17+
18+
19+
@dataclass( frozen=True )
20+
class Result:
21+
"""Result of solid Euler characteristic computation.
22+
23+
Attributes:
24+
numVertices: Number of vertices (V) in 3D mesh
25+
numEdges: Number of unique edges (E) in 3D mesh
26+
numFaces: Number of unique faces (F) in 3D mesh
27+
numCells: Number of 3D cells (C)
28+
solidEulerCharacteristic: Solid Euler characteristic (chi = V - E + F - C)
29+
num3dCells: Number of 3D volumetric cells in input
30+
num2dCells: Number of 2D surface cells in input (ignored)
31+
numOtherCells: Number of other cells in input
32+
numBoundaryEdges: Number of boundary edges on surface
33+
numNonManifoldEdges: Number of non-manifold edges on surface
34+
numConnectedComponents: Number of disconnected 3D mesh regions
35+
"""
36+
numVertices: int
37+
numEdges: int
38+
numFaces: int
39+
numCells: int
40+
solidEulerCharacteristic: int
41+
num3dCells: int
42+
num2dCells: int
43+
numOtherCells: int
44+
numBoundaryEdges: int
45+
numNonManifoldEdges: int
46+
numConnectedComponents: int
47+
48+
49+
def __countConnectedComponents( mesh: vtk.vtkUnstructuredGrid ) -> int:
50+
"""Count number of disconnected mesh components.
51+
52+
Args:
53+
mesh: Input unstructured grid
54+
55+
Returns:
56+
Number of disconnected regions
57+
"""
58+
setupLogger.info( "Checking for disconnected 3D components..." )
59+
60+
connectivity = vtk.vtkConnectivityFilter()
61+
connectivity.SetInputData( mesh )
62+
connectivity.SetExtractionModeToAllRegions()
63+
connectivity.ColorRegionsOn()
64+
connectivity.Update()
65+
66+
numRegions = connectivity.GetNumberOfExtractedRegions()
67+
68+
setupLogger.info( f"Found {numRegions} disconnected 3D component(s)" )
69+
70+
return numRegions
71+
72+
73+
def __filter3dElements( mesh: vtk.vtkUnstructuredGrid ) -> tuple[ vtk.vtkUnstructuredGrid, int, int, int, bool ]:
74+
"""Filter only 3D volumetric elements from unstructured grid.
75+
76+
Removes 2D faces, 1D edges, and 0D vertices.
77+
78+
Args:
79+
mesh: Input unstructured grid
80+
81+
Returns:
82+
Tuple of (filtered_mesh, n_3d, n_2d, n_other, has_3d)
83+
"""
84+
# Classify cells by dimension
85+
cell3dIds = []
86+
n3d = 0
87+
n2d = 0
88+
nOther = 0
89+
90+
setupLogger.info( "Classifying cell types..." )
91+
for i in tqdm( range( mesh.GetNumberOfCells() ), desc="Scanning cells" ):
92+
cell = mesh.GetCell( i )
93+
cellDim = cell.GetCellDimension()
94+
95+
if cellDim == 3:
96+
cell3dIds.append( i )
97+
n3d += 1
98+
elif cellDim == 2:
99+
n2d += 1
100+
else:
101+
nOther += 1
102+
103+
setupLogger.info( "Cell type breakdown:" )
104+
setupLogger.info( f" 3D volumetric cells: {n3d}" )
105+
setupLogger.info( f" 2D surface cells: {n2d}" )
106+
setupLogger.info( f" Other cells: {nOther}" )
107+
108+
# Check if we have 3D cells
109+
has3d = n3d > 0
110+
111+
if not has3d:
112+
setupLogger.warning( "No 3D volumetric elements found!" )
113+
setupLogger.warning( "This appears to be a pure surface mesh." )
114+
setupLogger.warning( "Cannot compute solid Euler characteristic." )
115+
return mesh, n3d, n2d, nOther, False
116+
117+
if n2d > 0:
118+
setupLogger.info( f"Filtering out {n2d} 2D boundary cells..." )
119+
setupLogger.info( f"Using only {n3d} volumetric elements" )
120+
121+
# Extract only 3D cells using vtkExtractCells
122+
idList = vtk.vtkIdList()
123+
for cellId in cell3dIds:
124+
idList.InsertNextId( cellId )
125+
126+
extractor = vtk.vtkExtractCells()
127+
extractor.SetInputData( mesh )
128+
extractor.SetCellList( idList )
129+
extractor.Update()
130+
131+
filteredMesh = extractor.GetOutput()
132+
133+
return filteredMesh, n3d, n2d, nOther, has3d
134+
135+
136+
def __countUniqueEdgesAndFaces( mesh: vtk.vtkUnstructuredGrid ) -> tuple[ int, int ]:
137+
"""Count unique edges and faces in 3D mesh (NumPy optimized).
138+
139+
Args:
140+
mesh: 3D unstructured grid
141+
142+
Returns:
143+
Tuple of (num_edges, num_faces)
144+
"""
145+
setupLogger.info( "Counting unique edges and faces in 3D mesh..." )
146+
147+
try:
148+
import numpy as np
149+
use_numpy = True
150+
except ImportError:
151+
use_numpy = False
152+
153+
numCells = mesh.GetNumberOfCells()
154+
155+
if use_numpy:
156+
# Use numpy for faster operations
157+
edge_list = []
158+
face_list = []
159+
160+
for i in tqdm( range( numCells ), desc="Processing cells", mininterval=1.0 ):
161+
162+
cell = mesh.GetCell( i )
163+
164+
# Edges
165+
numEdges = cell.GetNumberOfEdges()
166+
for edge_idx in range( numEdges ):
167+
edge = cell.GetEdge( edge_idx )
168+
p0 = edge.GetPointId( 0 )
169+
p1 = edge.GetPointId( 1 )
170+
edge_list.append( ( min( p0, p1 ), max( p0, p1 ) ) )
171+
172+
# Faces
173+
numFaces = cell.GetNumberOfFaces()
174+
for face_idx in range( numFaces ):
175+
face = cell.GetFace( face_idx )
176+
num_pts = face.GetNumberOfPoints()
177+
point_ids = tuple( sorted( [ face.GetPointId( j ) for j in range( num_pts ) ] ) )
178+
face_list.append( point_ids )
179+
180+
# Use numpy unique for deduplication (faster than set)
181+
setupLogger.info( " Deduplicating edges and faces..." )
182+
183+
# For edges: convert to array and use unique
184+
edge_array = np.array( edge_list, dtype=np.int64 )
185+
unique_edges = np.unique( edge_array, axis=0 )
186+
num_edges = len( unique_edges )
187+
188+
# For faces: use set (numpy doesn't handle variable-length well)
189+
num_faces = len( set( face_list ) )
190+
191+
else:
192+
# Fallback to optimized set-based approach
193+
edge_set = set()
194+
face_set = set()
195+
196+
for i in tqdm( range( numCells ), desc="Processing cells", mininterval=0.5 ):
197+
cell = mesh.GetCell( i )
198+
199+
numEdges = cell.GetNumberOfEdges()
200+
for edge_idx in range( numEdges ):
201+
edge = cell.GetEdge( edge_idx )
202+
p0 = edge.GetPointId( 0 )
203+
p1 = edge.GetPointId( 1 )
204+
edge_set.add( ( min( p0, p1 ), max( p0, p1 ) ) )
205+
206+
numFaces = cell.GetNumberOfFaces()
207+
for face_idx in range( numFaces ):
208+
face = cell.GetFace( face_idx )
209+
num_pts = face.GetNumberOfPoints()
210+
face_set.add( tuple( sorted( [ face.GetPointId( j ) for j in range( num_pts ) ] ) ) )
211+
212+
num_edges = len( edge_set )
213+
num_faces = len( face_set )
214+
215+
setupLogger.info( f" Unique edges: {num_edges:,}" )
216+
setupLogger.info( f" Unique faces: {num_faces:,}" )
217+
218+
return num_edges, num_faces
219+
220+
221+
def __extractSurface( mesh: vtk.vtkUnstructuredGrid ) -> vtk.vtkPolyData:
222+
"""Extract surface from unstructured grid (3D elements only).
223+
224+
Args:
225+
mesh: Input unstructured grid (3D elements)
226+
227+
Returns:
228+
Surface as polydata
229+
"""
230+
setupLogger.info( "Extracting boundary surface from 3D elements..." )
231+
surfaceFilter = vtk.vtkDataSetSurfaceFilter()
232+
surfaceFilter.SetInputData( mesh )
233+
surfaceFilter.Update()
234+
return surfaceFilter.GetOutput()
235+
236+
237+
def __checkMeshQuality( surface: vtk.vtkPolyData ) -> tuple[ int, int ]:
238+
"""Check for boundary edges and non-manifold features.
239+
240+
Args:
241+
surface: Surface mesh
242+
243+
Returns:
244+
Tuple of (boundary_edges, non_manifold_edges)
245+
"""
246+
setupLogger.info( "Checking mesh quality..." )
247+
248+
# Count boundary edges
249+
featureEdgesBoundary = vtk.vtkFeatureEdges()
250+
featureEdgesBoundary.SetInputData( surface )
251+
featureEdgesBoundary.BoundaryEdgesOn()
252+
featureEdgesBoundary.ManifoldEdgesOff()
253+
featureEdgesBoundary.NonManifoldEdgesOff()
254+
featureEdgesBoundary.FeatureEdgesOff()
255+
featureEdgesBoundary.Update()
256+
boundaryEdges = featureEdgesBoundary.GetOutput().GetNumberOfCells()
257+
258+
# Count non-manifold edges
259+
featureEdgesNm = vtk.vtkFeatureEdges()
260+
featureEdgesNm.SetInputData( surface )
261+
featureEdgesNm.BoundaryEdgesOff()
262+
featureEdgesNm.ManifoldEdgesOff()
263+
featureEdgesNm.NonManifoldEdgesOn()
264+
featureEdgesNm.FeatureEdgesOff()
265+
featureEdgesNm.Update()
266+
nonManifoldEdges = featureEdgesNm.GetOutput().GetNumberOfCells()
267+
268+
return boundaryEdges, nonManifoldEdges
269+
270+
271+
def meshAction( mesh: vtk.vtkUnstructuredGrid, options: Options ) -> Result:
272+
"""Compute solid Euler characteristic for a mesh.
273+
274+
Only considers 3D volumetric elements. Computes chi_solid = V - E + F - C.
275+
276+
Args:
277+
mesh: Input unstructured grid
278+
options: Computation options
279+
280+
Returns:
281+
Result with solid Euler characteristic and topology information
282+
"""
283+
setupLogger.info( "Starting solid Euler characteristic computation..." )
284+
setupLogger.info( f"Input mesh: {mesh.GetNumberOfPoints()} points, {mesh.GetNumberOfCells()} cells" )
285+
286+
# Filter to 3D elements only
287+
mesh3d, n3d, n2d, nOther, has3d = __filter3dElements( mesh )
288+
289+
if not has3d:
290+
raise RuntimeError( "Cannot compute solid Euler - no 3D cells found" )
291+
292+
# Count connected components
293+
numComponents = __countConnectedComponents( mesh3d )
294+
295+
# Get basic counts
296+
V = mesh3d.GetNumberOfPoints()
297+
C = mesh3d.GetNumberOfCells()
298+
299+
# Count unique edges and faces in 3D mesh
300+
E, F = __countUniqueEdgesAndFaces( mesh3d )
301+
302+
setupLogger.info( "Solid mesh topology:" )
303+
setupLogger.info( f" Vertices (V): {V:,}" )
304+
setupLogger.info( f" Edges (E): {E:,}" )
305+
setupLogger.info( f" Faces (F): {F:,}" )
306+
setupLogger.info( f" Cells (C): {C:,}" )
307+
308+
# Calculate solid Euler characteristic
309+
solidEuler = V - E + F - C
310+
311+
setupLogger.info( f"Solid Euler characteristic (chi = V - E + F - C): {solidEuler}" )
312+
313+
# Interpret result
314+
setupLogger.info( "Topology interpretation:" )
315+
setupLogger.info( f" 3D connected components: {numComponents}" )
316+
317+
if numComponents == 1:
318+
if solidEuler == 1:
319+
setupLogger.info( " chi = 1: Contractible (solid ball topology)" )
320+
setupLogger.info( " VALID simple 3D region for simulation" )
321+
elif solidEuler == 0:
322+
setupLogger.warning( " chi = 0: Solid torus (has through-hole)" )
323+
setupLogger.warning( " Verify this matches your domain geometry" )
324+
elif solidEuler == 2:
325+
setupLogger.warning( " chi = 2: Hollow shell or internal cavity topology" )
326+
setupLogger.warning( " Expected chi = 1 for simple solid ball" )
327+
setupLogger.warning( " This suggests internal void or nested structure" )
328+
setupLogger.warning( " 3D cells ARE connected (verified above) - verify intended" )
329+
else:
330+
setupLogger.warning( f" chi = {solidEuler}: Unusual topology" )
331+
setupLogger.warning( " Verify mesh integrity" )
332+
else:
333+
setupLogger.error( f" Mesh has {numComponents} disconnected 3D components!" )
334+
setupLogger.error( " This is NOT suitable for simulation without fixing" )
335+
336+
# Check mesh quality
337+
surface = __extractSurface( mesh3d )
338+
boundaryEdges, nonManifoldEdges = __checkMeshQuality( surface )
339+
340+
setupLogger.info( "Mesh quality:" )
341+
setupLogger.info( f" Boundary edges: {boundaryEdges:,}" )
342+
setupLogger.info( f" Non-manifold edges: {nonManifoldEdges:,}" )
343+
344+
# Final validation
345+
if numComponents == 1 and boundaryEdges == 0 and nonManifoldEdges == 0:
346+
if solidEuler == 1:
347+
setupLogger.info( " Perfect: single connected volume, simple topology - READY!" )
348+
else:
349+
setupLogger.warning( f" Connected volume but chi = {solidEuler}" )
350+
setupLogger.warning( " Verify internal features are intended" )
351+
elif numComponents > 1:
352+
setupLogger.error( " Multiple disconnected regions - INVALID!" )
353+
elif boundaryEdges > 0:
354+
setupLogger.error( " Open surface detected - INVALID!" )
355+
elif nonManifoldEdges > 0:
356+
setupLogger.error( " Non-manifold geometry detected - INVALID!" )
357+
358+
return Result( numVertices=V,
359+
numEdges=E,
360+
numFaces=F,
361+
numCells=C,
362+
solidEulerCharacteristic=solidEuler,
363+
num3dCells=n3d,
364+
num2dCells=n2d,
365+
numOtherCells=nOther,
366+
numBoundaryEdges=boundaryEdges,
367+
numNonManifoldEdges=nonManifoldEdges,
368+
numConnectedComponents=numComponents )
369+
370+
371+
def action( vtuInputFile: str, options: Options ) -> Result:
372+
"""Compute solid Euler characteristic for a VTU file.
373+
374+
Args:
375+
vtuInputFile: Path to input VTU file
376+
options: Computation options
377+
378+
Returns:
379+
Result with solid Euler characteristic and topology information
380+
"""
381+
mesh = readUnstructuredGrid( vtuInputFile )
382+
return meshAction( mesh, options )

0 commit comments

Comments
 (0)