Skip to content

Commit 71ce949

Browse files
authored
PhysicsNeMo-Mesh: Adds new capability of Polyhedra triangulation in PyVista/VTU I/O (#1469)
* Refactor from_pyvista to use celltypes for face and volume cell counting - Updated the handling of UnstructuredGrid meshes to utilize the celltypes array instead of cells_dict, improving compatibility with variable-length polyhedra. - Adjusted error messages and test cases to reflect the new attribute usage. - Renamed tessellation references to triangulation for clarity in the context of mesh conversion. * format * Add changelog. * line break * Update from_pyvista function to enforce UnstructuredGrid type check for 3D meshes, improving error handling and clarity in mesh validation. * Add methods for graph extraction in Mesh class - Implemented `to_edge_graph`, `to_dual_graph`, and `to_point_cloud` methods to facilitate the extraction of edge graphs, dual graphs, and point clouds from the Mesh class. - Each method includes detailed docstrings with examples for better usability. - Added comprehensive unit tests to validate the functionality of the new methods, ensuring correct edge and dual graph generation from various mesh configurations. * Enhance from_pyvista function to improve cell data handling and native dimension detection - Updated the logic for passing cell data to ensure compatibility with varying manifold dimensions and native mesh dimensions. - Improved the detection of native dimensions by adding checks for line types, enhancing the robustness of the function. - Clarified comments to better explain the conditions under which cell data can be passed through. * formatting * Ruff fixes * Add force_copy parameter to from_pyvista function for memory management - Introduced a new `force_copy` parameter to control whether point and cell arrays are copied, allowing the returned Mesh to own its memory independently of the source PyVista mesh. - Updated documentation to clarify the implications of this parameter on memory sharing and data mutation. - Enhanced the logic for determining manifold dimensions and improved error handling for ambiguous mesh types. * format * Update dependencies in pyproject.toml and enhance vtk import in io_pyvista.py - Updated matplotlib to version 3.10.8 and pyvista to version 0.47.0 in the mesh-extras section of pyproject.toml. - Added vtk version requirement to the _build_dual_graph_edges function in io_pyvista.py, improving module import handling with importlib for better compatibility. * Add detailed docstrings for graph extraction tests - Enhanced test classes for edge graph, dual graph, and point cloud extraction with comprehensive docstrings explaining their functionality and verification processes. - Improved clarity on the expected behavior and data handling in the tests for better maintainability and understanding. * Updates uv.lock after TOML update * Refactor Mesh initialization to also work when deserializing point clouds from memmaps. * formatting * Refactor Mesh initialization to handle non-dict Mappings for point, cell, and global data. This change ensures compatibility with PyVista DataSetAttributes by converting them to plain dictionaries before assignment.
1 parent dc2acf5 commit 71ce949

File tree

10 files changed

+2116
-517
lines changed

10 files changed

+2116
-517
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212

1313
- Adds GLOBE model (`physicsnemo.experimental.models.globe.model.GLOBE`)
1414
- Adds GLOBE AirFRANS example case (`examples/cfd/external_aerodynamics/globe/airfrans`)
15+
- PhysicsNeMo-Mesh now supports conversion from PyVista/VTK/VTU meshes that may
16+
contain polyhedral cells.
17+
- In PhysicsNeMo-Mesh, adds `Mesh.to_point_cloud()`, `.to_edge_graph()`, and
18+
`.to_dual_graph()` methods. These allow Mesh conversion to 0D point clouds, 1D
19+
edge graphs, and 1D dual graphs, respectively, when connectivity information
20+
is not needed.
1521

1622
### Changed
1723

physicsnemo/mesh/io/io_pyvista.py

Lines changed: 366 additions & 111 deletions
Large diffs are not rendered by default.

physicsnemo/mesh/mesh.py

Lines changed: 178 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -225,47 +225,80 @@ def __init__(
225225
*,
226226
_cache: TensorDict | None = None,
227227
) -> None:
228-
### Assign tensorclass fields
229-
if cells is None:
230-
cells = torch.zeros(0, 1, dtype=torch.long, device=points.device)
231228
self.points = points
232-
self.cells = cells
233-
234-
# For data fields, convert inputs to TensorDicts if needed
235-
if isinstance(point_data, TensorDict):
236-
point_data.batch_size = torch.Size(
237-
[self.n_points]
238-
) # Ensure shape-compatible
229+
self.cells = cells # type: ignore[assignment] # normalized by __post_init__
230+
# The tensorclass setter silently drops entries from non-dict Mappings
231+
# (e.g. PyVista DataSetAttributes). Wrapping with dict() converts any
232+
# Mapping to a plain dict that the setter handles correctly.
233+
self.point_data = ( # type: ignore[assignment] # normalized by __post_init__
234+
dict(point_data)
235+
if point_data is not None and not isinstance(point_data, TensorDict)
236+
else point_data
237+
)
238+
self.cell_data = ( # type: ignore[assignment] # normalized by __post_init__ (coerced to TensorDict)
239+
dict(cell_data)
240+
if cell_data is not None and not isinstance(cell_data, TensorDict)
241+
else cell_data
242+
)
243+
self.global_data = ( # type: ignore[assignment] # normalized by __post_init__
244+
dict(global_data)
245+
if global_data is not None and not isinstance(global_data, TensorDict)
246+
else global_data
247+
)
248+
self._cache = _cache # type: ignore[assignment] # normalized by __post_init__
249+
# tensorclass only auto-calls __post_init__ from the *generated* __init__
250+
# (same semantics as dataclasses). Since we define a custom __init__,
251+
# we must call it explicitly. During load(), tensorclass calls it
252+
# automatically, so __post_init__ is the single source of truth for
253+
# defaults, coercions, and validation.
254+
self.__post_init__()
255+
256+
def __post_init__(self):
257+
"""Normalize fields and validate invariants.
258+
259+
Called automatically during ``load()`` by tensorclass, and explicitly
260+
from ``__init__`` during normal construction. This is the single source
261+
of truth for all default values, type coercions, and shape validation.
262+
"""
263+
### cells: default empty-cells sentinel for point clouds
264+
# The tensordict memmap format does not persist tensors with 0 elements,
265+
# so this also restores cells after deserialization.
266+
if self.cells is None:
267+
self.cells = torch.zeros(0, 1, dtype=torch.long, device=self.points.device)
268+
269+
### point_data: coerce dict -> TensorDict and enforce batch_size
270+
if isinstance(self.point_data, TensorDict):
271+
self.point_data.batch_size = torch.Size([self.n_points])
239272
else:
240-
point_data = TensorDict(
241-
{} if point_data is None else dict(point_data),
273+
self.point_data = TensorDict(
274+
{} if self.point_data is None else dict(self.point_data),
242275
batch_size=torch.Size([self.n_points]),
243276
device=self.points.device,
244277
)
245-
self.point_data = point_data
246278

247-
if isinstance(cell_data, TensorDict):
248-
cell_data.batch_size = torch.Size([self.n_cells]) # Ensure shape-compatible
279+
### cell_data: coerce dict -> TensorDict and enforce batch_size
280+
if isinstance(self.cell_data, TensorDict):
281+
self.cell_data.batch_size = torch.Size([self.n_cells])
249282
else:
250-
cell_data = TensorDict(
251-
{} if cell_data is None else dict(cell_data),
283+
self.cell_data = TensorDict(
284+
{} if self.cell_data is None else dict(self.cell_data),
252285
batch_size=torch.Size([self.n_cells]),
253286
device=self.cells.device,
254287
)
255-
self.cell_data = cell_data
256288

257-
if isinstance(global_data, TensorDict):
258-
global_data.batch_size = torch.Size([]) # Ensure shape-compatible
289+
### global_data: coerce dict -> TensorDict and enforce batch_size
290+
if isinstance(self.global_data, TensorDict):
291+
self.global_data.batch_size = torch.Size([])
259292
else:
260-
global_data = TensorDict(
261-
{} if global_data is None else dict(global_data),
293+
self.global_data = TensorDict(
294+
{} if self.global_data is None else dict(self.global_data),
262295
batch_size=torch.Size([]),
263296
device=self.points.device,
264297
)
265-
self.global_data = global_data
266298

267-
if _cache is None:
268-
_cache = TensorDict(
299+
### _cache: default empty cache structure
300+
if self._cache is None:
301+
self._cache = TensorDict(
269302
{
270303
"cell": TensorDict(
271304
{}, batch_size=[self.n_cells], device=self.points.device
@@ -277,7 +310,6 @@ def __init__(
277310
batch_size=[],
278311
device=self.points.device,
279312
)
280-
self._cache = _cache
281313

282314
### Validate shapes and dtypes
283315
if not torch.compiler.is_compiling():
@@ -1506,6 +1538,126 @@ def get_boundary_mesh(
15061538
target_counts="boundary",
15071539
)
15081540

1541+
def to_edge_graph(self) -> "Mesh":
1542+
r"""Return a 1D Mesh whose cells are the unique edges of this mesh.
1543+
1544+
Each edge (pair of vertices connected in a cell) appears exactly once.
1545+
The resulting Mesh has the same ``points`` array, with ``cells`` of
1546+
shape :math:`(E, 2)` where *E* is the number of unique edges.
1547+
1548+
Cell data from the parent mesh is aggregated onto edges via the
1549+
facet extraction pipeline (mean aggregation by default).
1550+
1551+
Returns
1552+
-------
1553+
Mesh
1554+
A 1D Mesh (``n_manifold_dims == 1``) with edge cells.
1555+
1556+
Examples
1557+
--------
1558+
>>> import torch
1559+
>>> from physicsnemo.mesh import Mesh
1560+
>>> points = torch.tensor([[0., 0.], [1., 0.], [0.5, 1.]])
1561+
>>> cells = torch.tensor([[0, 1, 2]])
1562+
>>> mesh = Mesh(points=points, cells=cells)
1563+
>>> edge_graph = mesh.to_edge_graph()
1564+
>>> assert edge_graph.n_manifold_dims == 1
1565+
>>> assert edge_graph.n_cells == 3 # triangle has 3 edges
1566+
"""
1567+
codim = self.n_manifold_dims - 1
1568+
return self.get_facet_mesh(manifold_codimension=codim, target_counts="all")
1569+
1570+
def to_dual_graph(self) -> "Mesh":
1571+
r"""Return a 1D Mesh representing the cell-adjacency (dual) graph.
1572+
1573+
Points are the cell centroids of this mesh. Cells are
1574+
:math:`(E, 2)` line segments connecting pairs of cells that share a
1575+
codimension-1 facet (e.g., cells sharing an edge in 2D or a face in
1576+
3D). The parent mesh's ``cell_data`` becomes the ``point_data`` of the
1577+
returned Mesh, since each dual-graph node corresponds to a parent cell.
1578+
1579+
Returns
1580+
-------
1581+
Mesh
1582+
A 1D Mesh (``n_manifold_dims == 1``) whose points are cell
1583+
centroids and whose cells encode the cell-neighbor adjacency.
1584+
1585+
Examples
1586+
--------
1587+
>>> import torch
1588+
>>> from physicsnemo.mesh import Mesh
1589+
>>> # Two triangles sharing an edge
1590+
>>> points = torch.tensor([[0., 0.], [1., 0.], [0.5, 1.], [1.5, 1.]])
1591+
>>> cells = torch.tensor([[0, 1, 2], [1, 3, 2]])
1592+
>>> mesh = Mesh(points=points, cells=cells)
1593+
>>> dual = mesh.to_dual_graph()
1594+
>>> assert dual.n_manifold_dims == 1
1595+
>>> assert dual.n_cells == 1 # 1 shared edge -> 1 dual edge
1596+
"""
1597+
adj = self.get_cell_to_cells_adjacency(adjacency_codimension=1)
1598+
sources, targets = adj.expand_to_pairs()
1599+
1600+
# Keep only upper-triangular pairs (source < target) to avoid
1601+
# counting each neighbor relationship twice.
1602+
mask = sources < targets
1603+
edges = torch.stack([sources[mask], targets[mask]], dim=1)
1604+
1605+
return Mesh(
1606+
points=self.cell_centroids,
1607+
cells=edges,
1608+
point_data=self.cell_data,
1609+
global_data=self.global_data,
1610+
)
1611+
1612+
def to_point_cloud(
1613+
self, point_source: "Literal['vertices', 'cell_centroids']" = "vertices"
1614+
) -> "Mesh":
1615+
r"""Return a 0D Mesh (point cloud) with no cell connectivity.
1616+
1617+
Parameters
1618+
----------
1619+
point_source : {"vertices", "cell_centroids"}
1620+
What becomes the points of the returned Mesh:
1621+
1622+
- ``"vertices"`` (default): Uses mesh vertices as points,
1623+
preserving ``point_data``.
1624+
- ``"cell_centroids"``: Uses cell centroids as points,
1625+
mapping ``cell_data`` to ``point_data``.
1626+
1627+
Returns
1628+
-------
1629+
Mesh
1630+
A 0D Mesh (``n_manifold_dims == 0``) with no cells.
1631+
1632+
Examples
1633+
--------
1634+
>>> import torch
1635+
>>> from physicsnemo.mesh import Mesh
1636+
>>> points = torch.tensor([[0., 0.], [1., 0.], [0.5, 1.]])
1637+
>>> cells = torch.tensor([[0, 1, 2]])
1638+
>>> mesh = Mesh(points=points, cells=cells)
1639+
>>> pc = mesh.to_point_cloud()
1640+
>>> assert pc.n_manifold_dims == 0
1641+
>>> assert pc.n_points == 3
1642+
>>> assert pc.n_cells == 0
1643+
"""
1644+
if point_source == "vertices":
1645+
return Mesh(
1646+
points=self.points,
1647+
point_data=self.point_data,
1648+
global_data=self.global_data,
1649+
)
1650+
elif point_source == "cell_centroids":
1651+
return Mesh(
1652+
points=self.cell_centroids,
1653+
point_data=self.cell_data,
1654+
global_data=self.global_data,
1655+
)
1656+
else:
1657+
raise ValueError(
1658+
f"Invalid {point_source=!r}. Must be 'vertices' or 'cell_centroids'."
1659+
)
1660+
15091661
def is_watertight(self) -> bool:
15101662
"""Check if mesh is watertight (has no boundary).
15111663

pyproject.toml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -201,9 +201,10 @@ utils-extras = [
201201
"stl",
202202
]
203203
mesh-extras = [
204-
"matplotlib>=3.10.7",
204+
"matplotlib>=3.10.8",
205205
"pyacvd>=0.3.2",
206-
"pyvista>=0.46.4",
206+
"pyvista>=0.47.0",
207+
"vtk>=9.6.0",
207208
]
208209
nn-extras = [
209210
"scipy",

test/mesh/io/io_pyvista/test_error_handling.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -125,14 +125,14 @@ def test_mixed_geometry_explicit_dim_works(self):
125125
class TestFromPyvista3DErrors:
126126
"""Tests for 3D mesh conversion error handling."""
127127

128-
def test_polydata_3d_no_cells_dict_raises(self):
129-
"""Test that PolyData without cells_dict for 3D raises ValueError."""
128+
def test_polydata_3d_no_celltypes_raises(self):
129+
"""Test that PolyData without celltypes for 3D raises ValueError."""
130130
# Create a simple PolyData (surface mesh)
131131
pv_mesh = pv.Sphere()
132132

133-
# Trying to convert as 3D should fail because PolyData doesn't have cells_dict
134-
# (it's a surface, not a volume)
135-
with pytest.raises(ValueError, match="cells_dict"):
133+
# Trying to convert as 3D should fail because PolyData doesn't have
134+
# celltypes (it's a surface, not a volume)
135+
with pytest.raises(ValueError, match="UnstructuredGrid"):
136136
from_pyvista(pv_mesh, manifold_dim=3)
137137

138138

0 commit comments

Comments
 (0)