Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .mypy.ini
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
[mypy]
python_version = 3.10
exclude = /(build|dist|\.eggs)/

# strict = true
warn_unreachable = True
Expand Down
644 changes: 644 additions & 0 deletions mesh-doctor/tests/helpers.py

Large diffs are not rendered by default.

28 changes: 27 additions & 1 deletion mesh-doctor/tests/test_collocatedNodes.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright 2023-2024 TotalEnergies.
# SPDX-FileContributor: Thomas Gazolla, Alexandre Benedicto
# SPDX-FileContributor: Thomas Gazolla, Alexandre Benedicto, Jacques Franc
import pytest
from typing import Iterator, Tuple
from vtkmodules.vtkCommonCore import vtkPoints
from vtkmodules.vtkCommonDataModel import vtkCellArray, vtkTetra, vtkUnstructuredGrid, VTK_TETRA
from geos.mesh_doctor.actions.collocatedNodes import Options, meshAction
from .helpers import build_mesh_with_collocated_nodes


def getPoints() -> Iterator[ Tuple[ vtkPoints, int ] ]:
Expand Down Expand Up @@ -70,3 +71,28 @@ def test_wrongSupportElements() -> None:
assert len( result.nodesBuckets ) == 0
assert len( result.wrongSupportElements ) == 1
assert result.wrongSupportElements[ 0 ] == 0


def test_collocated_shared_face_nodes() -> None:
"""Two hexes with duplicated shared-face nodes: 4 buckets with exact pair indices.

``build_mesh_with_collocated_nodes()`` has 16 points instead of 12.
The four shared-face nodes on the x=1 plane are duplicated in place:

original │ duplicate
──────────┼───────────
1 │ 8
2 │ 11
5 │ 12
6 │ 15

Each bucket is a tuple ``(first_inserted_id, rejected_duplicate_id)``.
"""
mesh = build_mesh_with_collocated_nodes()
result = meshAction( mesh, Options( tolerance=1.e-6 ) )

assert len( result.wrongSupportElements ) == 0
assert len( result.nodesBuckets ) == 4

buckets = sorted( result.nodesBuckets )
assert buckets == [ ( 1, 8 ), ( 2, 11 ), ( 5, 12 ), ( 6, 15 ) ]
32 changes: 31 additions & 1 deletion mesh-doctor/tests/test_elementVolumes.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright 2023-2024 TotalEnergies.
# SPDX-FileContributor: Thomas Gazolla, Alexandre Benedicto
# SPDX-FileContributor: Thomas Gazolla, Alexandre Benedicto, Jacques Franc
import numpy
from vtkmodules.vtkCommonCore import vtkPoints
from vtkmodules.vtkCommonDataModel import VTK_TETRA, vtkCellArray, vtkTetra, vtkUnstructuredGrid
from geos.mesh_doctor.actions.elementVolumes import Options, meshAction
from .helpers import build_mesh_with_negative_volume


def test_simpleTet() -> None:
Expand Down Expand Up @@ -41,3 +42,32 @@ def test_simpleTet() -> None:
result = meshAction( mesh, Options( minVolume=0. ) )

assert len( result.elementVolumes ) == 0


def test_negative_volume_hex_detected() -> None:
"""Hexahedron with top/bottom node groups swapped has negative signed volume.

``build_mesh_with_negative_volume()`` contains two hexes:
- cell 0: correctly oriented unit cube → positive volume (+1.0)
- cell 1: top and bottom face groups swapped → negative volume (−1.0)

With ``minVolume=0`` only cells whose volume ≤ minVolume are reported.
Cell 1 (volume ≈ −1.0) must appear; cell 0 must not.
"""
mesh = build_mesh_with_negative_volume()
result = meshAction( mesh, Options( minVolume=0.0 ) )

assert len( result.elementVolumes ) == 1
assert result.elementVolumes[ 0 ][ 0 ] == 1 # second cell
assert result.elementVolumes[ 0 ][ 1 ] < 0.0 # negative volume


def test_positive_volume_hex_not_reported() -> None:
"""The correctly oriented hex in the same mesh is not flagged."""
mesh = build_mesh_with_negative_volume()

# With threshold just below the correct cell volume (+1.0) nothing is reported
result = meshAction( mesh, Options( minVolume=-0.5 ) )

flagged_ids = { entry[ 0 ] for entry in result.elementVolumes }
assert 0 not in flagged_ids # cell 0 is valid, volume ≈ +1.0
74 changes: 74 additions & 0 deletions mesh-doctor/tests/test_euler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright 2023-2026 TotalEnergies.
# SPDX-FileContributor: Jacques Franc
import pytest
from vtkmodules.vtkFiltersCore import vtkFeatureEdges
from vtkmodules.vtkFiltersGeometry import vtkGeometryFilter
from geos.mesh_doctor.actions.euler import Options, meshAction
from .helpers import build_volume_with_non_manifold_edge, build_surface_with_non_manifold_edge

_OPTS = Options()


def test_volume_non_manifold_edge_detected() -> None:
"""Bowtie of two hexes sharing only one edge has a non-manifold boundary.

``build_volume_with_non_manifold_edge()`` produces two hexahedra that share
only the edge (1,1,0)→(1,1,1). On the extracted boundary surface that edge
is touched by four faces (two from each hex), making it non-manifold.

Expected topology (V=14, E=23, F=12, C=2):
solidEulerCharacteristic = 14 - 23 + 12 - 2 = 1
numNonManifoldEdges = 1
numConnectedComponents = 1 (hexes share 2 points → same component)
"""
result = meshAction( build_volume_with_non_manifold_edge(), _OPTS )

assert result.numNonManifoldEdges == 1
assert result.solidEulerCharacteristic == 1
assert result.numConnectedComponents == 1
assert result.numBoundaryEdges == 0


def test_volume_non_manifold_topology_counts() -> None:
"""Exact V/E/F/C counts for the bowtie configuration."""
result = meshAction( build_volume_with_non_manifold_edge(), _OPTS )

assert result.numVertices == 14
assert result.numEdges == 23
assert result.numFaces == 12
assert result.numCells == 2


def test_surface_non_manifold_edge_detected() -> None:
"""Three quads sharing one edge form a non-manifold surface.

``build_surface_with_non_manifold_edge()`` returns a vtkUnstructuredGrid of
3 VTK_QUAD cells. Edge (1,0,0)→(1,1,0) is shared by all three faces.

``euler.meshAction`` requires 3D cells and raises ``RuntimeError`` on a
pure surface mesh. The non-manifold edge is verified directly via
``vtkFeatureEdges`` — the same filter ``euler.py`` uses internally.
"""
ugrid = build_surface_with_non_manifold_edge()

# euler.meshAction cannot handle a mesh with no 3D cells
with pytest.raises( RuntimeError ):
meshAction( ugrid, _OPTS )

# Convert to polydata and run the same vtkFeatureEdges query euler.py uses
gf = vtkGeometryFilter()
gf.SetInputData( ugrid )
gf.Update()
surface = gf.GetOutput()

fe = vtkFeatureEdges()
fe.SetInputData( surface )
fe.BoundaryEdgesOff()
fe.ManifoldEdgesOff()
fe.NonManifoldEdgesOn()
fe.FeatureEdgesOff()
fe.Update()

# The edge (1,0,0)→(1,1,0) is shared by 3 quads → 1 non-manifold edge segment
assert fe.GetOutput().GetNumberOfCells() > 0
69 changes: 69 additions & 0 deletions mesh-doctor/tests/test_orphan.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright 2023-2026 TotalEnergies.
# SPDX-FileContributor: Jacques Franc
from geos.mesh_doctor.actions.orphan2d import Options, meshAction
from .helpers import ( build_hex_mesh, build_hex_mesh_with_fractures, build_tetra_mesh_with_fractures,
build_mesh_with_orphan_2d_cells )

_OPTS = Options( orphanVtkOutput=None, cleanVtkOutput=None )


def test_dedicated_orphan_cell() -> None:
"""Mesh with one matched and one orphaned 2D quad is correctly classified.

``build_mesh_with_orphan_2d_cells()`` contains:
- 1 hex (3D)
- 1 quad that IS the bottom face of the hex → matched
- 1 quad at z=2 that is NOT any face of the hex → orphan, cell index 2
- 1 quad at z=[0,1] that is diagonal to hex and is NOT any face of the hex → orphan, cell index 3
"""
result = meshAction( build_mesh_with_orphan_2d_cells(), _OPTS )

assert result.total2dCells == 3
assert result.total3dCells == 1
assert result.matched2dCells == 1
assert result.orphaned2dCells == 2
assert result.orphaned2dIndices == [ 2, 3 ]


def test_clean_hex_has_no_orphans() -> None:
"""A pure volumetric hex mesh (no 2D cells) reports zero orphans."""
result = meshAction( build_hex_mesh( 4, 4, 4 ), _OPTS )

assert result.total2dCells == 0
assert result.orphaned2dCells == 0


def test_hex_fracture_quads_are_orphans() -> None:
"""Fracture quads appended with MergePointsOff have disjoint point indices.

``build_hex_mesh_with_fractures`` combines the volume and fracture sub-meshes
via ``vtkAppendFilter(MergePointsOff)``. Because the fracture quad node IDs
differ from those of the hex faces (even at the same coordinates),
``orphan2d.meshAction`` cannot match them → all fracture quads are orphans.

5×5×5 grid with 1 fracture plane and default trim margins (offx=offy=1)
produces 4 fracture quads, all orphaned.
"""
mesh = build_hex_mesh_with_fractures( 5, 5, 5, nfrac=1 )
result = meshAction( mesh, _OPTS )

assert result.total3dCells == 64
assert result.total2dCells == 4
assert result.matched2dCells == 0
assert result.orphaned2dCells == 4


def test_tetra_fracture_triangles_are_orphans() -> None:
"""Same MergePointsOff behaviour applies to tetra meshes with tri fractures.

5×5×5 Delaunay tetra mesh with 1 fracture plane produces 8 triangles,
all orphaned.
"""
mesh = build_tetra_mesh_with_fractures( 5, 5, 5, nfrac=1 )
result = meshAction( mesh, _OPTS )

assert result.total3dCells > 0
assert result.total2dCells == 8
assert result.matched2dCells == 0
assert result.orphaned2dCells == 8
23 changes: 22 additions & 1 deletion mesh-doctor/tests/test_selfIntersectingElements.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright 2023-2024 TotalEnergies.
# SPDX-FileContributor: Thomas Gazolla, Alexandre Benedicto
# SPDX-FileContributor: Thomas Gazolla, Alexandre Benedicto, Jacques Franc
from vtkmodules.vtkCommonCore import vtkPoints
from vtkmodules.vtkCommonDataModel import vtkCellArray, vtkHexahedron, vtkUnstructuredGrid, VTK_HEXAHEDRON
from geos.mesh_doctor.actions.selfIntersectingElements import Options, meshAction
from .helpers import build_mesh_with_negative_volume


def test_jumbledHex() -> None:
Expand Down Expand Up @@ -43,3 +44,23 @@ def test_jumbledHex() -> None:

assert len( result.invalidCellIds[ "intersectingFacesElements" ] ) == 1
assert result.invalidCellIds[ "intersectingFacesElements" ][ 0 ] == 0


def test_inverted_hex_orientation_from_gen_mesh() -> None:
"""Inverted hex (top/bottom node groups swapped) is caught by orientation checks.

``build_mesh_with_negative_volume()`` contains two hexes:
- cell 0: correctly oriented unit cube
- cell 1: top and bottom face groups swapped → non-convex + wrong face orientation

``vtkCellValidator`` flags cell 1 via:
- ``nonConvexElements`` (concavity caused by the inversion)
- ``facesOrientedIncorrectlyElements`` (inward-pointing normals)

Cell 0 must NOT appear in either of those categories.
"""
mesh = build_mesh_with_negative_volume()
result = meshAction( mesh, Options( minDistance=0. ) )

assert result.invalidCellIds[ "nonConvexElements" ] == [ 1 ]
assert result.invalidCellIds[ "facesOrientedIncorrectlyElements" ] == [ 1 ]
Loading
Loading