mmgpy 0.5.0__cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. mmgpy/__init__.py +296 -0
  2. mmgpy/__main__.py +13 -0
  3. mmgpy/_io.py +535 -0
  4. mmgpy/_logging.py +290 -0
  5. mmgpy/_mesh.py +2286 -0
  6. mmgpy/_mmgpy.cpython-311-x86_64-linux-gnu.so +0 -0
  7. mmgpy/_mmgpy.pyi +2140 -0
  8. mmgpy/_options.py +304 -0
  9. mmgpy/_progress.py +850 -0
  10. mmgpy/_pyvista.py +410 -0
  11. mmgpy/_result.py +143 -0
  12. mmgpy/_transfer.py +273 -0
  13. mmgpy/_validation.py +669 -0
  14. mmgpy/_version.py +3 -0
  15. mmgpy/_version.py.in +3 -0
  16. mmgpy/bin/mmg2d_O3 +0 -0
  17. mmgpy/bin/mmg3d_O3 +0 -0
  18. mmgpy/bin/mmgs_O3 +0 -0
  19. mmgpy/interactive/__init__.py +24 -0
  20. mmgpy/interactive/sizing_editor.py +790 -0
  21. mmgpy/lagrangian.py +394 -0
  22. mmgpy/lib/libmmg2d.so +0 -0
  23. mmgpy/lib/libmmg2d.so.5 +0 -0
  24. mmgpy/lib/libmmg2d.so.5.8.0 +0 -0
  25. mmgpy/lib/libmmg3d.so +0 -0
  26. mmgpy/lib/libmmg3d.so.5 +0 -0
  27. mmgpy/lib/libmmg3d.so.5.8.0 +0 -0
  28. mmgpy/lib/libmmgs.so +0 -0
  29. mmgpy/lib/libmmgs.so.5 +0 -0
  30. mmgpy/lib/libmmgs.so.5.8.0 +0 -0
  31. mmgpy/lib/libvtkCommonColor-9.5.so.1 +0 -0
  32. mmgpy/lib/libvtkCommonComputationalGeometry-9.5.so.1 +0 -0
  33. mmgpy/lib/libvtkCommonCore-9.5.so.1 +0 -0
  34. mmgpy/lib/libvtkCommonDataModel-9.5.so.1 +0 -0
  35. mmgpy/lib/libvtkCommonExecutionModel-9.5.so.1 +0 -0
  36. mmgpy/lib/libvtkCommonMath-9.5.so.1 +0 -0
  37. mmgpy/lib/libvtkCommonMisc-9.5.so.1 +0 -0
  38. mmgpy/lib/libvtkCommonSystem-9.5.so.1 +0 -0
  39. mmgpy/lib/libvtkCommonTransforms-9.5.so.1 +0 -0
  40. mmgpy/lib/libvtkDICOMParser-9.5.so.1 +0 -0
  41. mmgpy/lib/libvtkFiltersCellGrid-9.5.so.1 +0 -0
  42. mmgpy/lib/libvtkFiltersCore-9.5.so.1 +0 -0
  43. mmgpy/lib/libvtkFiltersExtraction-9.5.so.1 +0 -0
  44. mmgpy/lib/libvtkFiltersGeneral-9.5.so.1 +0 -0
  45. mmgpy/lib/libvtkFiltersGeometry-9.5.so.1 +0 -0
  46. mmgpy/lib/libvtkFiltersHybrid-9.5.so.1 +0 -0
  47. mmgpy/lib/libvtkFiltersHyperTree-9.5.so.1 +0 -0
  48. mmgpy/lib/libvtkFiltersModeling-9.5.so.1 +0 -0
  49. mmgpy/lib/libvtkFiltersParallel-9.5.so.1 +0 -0
  50. mmgpy/lib/libvtkFiltersReduction-9.5.so.1 +0 -0
  51. mmgpy/lib/libvtkFiltersSources-9.5.so.1 +0 -0
  52. mmgpy/lib/libvtkFiltersStatistics-9.5.so.1 +0 -0
  53. mmgpy/lib/libvtkFiltersTexture-9.5.so.1 +0 -0
  54. mmgpy/lib/libvtkFiltersVerdict-9.5.so.1 +0 -0
  55. mmgpy/lib/libvtkIOCellGrid-9.5.so.1 +0 -0
  56. mmgpy/lib/libvtkIOCore-9.5.so.1 +0 -0
  57. mmgpy/lib/libvtkIOGeometry-9.5.so.1 +0 -0
  58. mmgpy/lib/libvtkIOImage-9.5.so.1 +0 -0
  59. mmgpy/lib/libvtkIOLegacy-9.5.so.1 +0 -0
  60. mmgpy/lib/libvtkIOParallel-9.5.so.1 +0 -0
  61. mmgpy/lib/libvtkIOParallelXML-9.5.so.1 +0 -0
  62. mmgpy/lib/libvtkIOXML-9.5.so.1 +0 -0
  63. mmgpy/lib/libvtkIOXMLParser-9.5.so.1 +0 -0
  64. mmgpy/lib/libvtkImagingCore-9.5.so.1 +0 -0
  65. mmgpy/lib/libvtkImagingSources-9.5.so.1 +0 -0
  66. mmgpy/lib/libvtkParallelCore-9.5.so.1 +0 -0
  67. mmgpy/lib/libvtkParallelDIY-9.5.so.1 +0 -0
  68. mmgpy/lib/libvtkRenderingCore-9.5.so.1 +0 -0
  69. mmgpy/lib/libvtkdoubleconversion-9.5.so.1 +0 -0
  70. mmgpy/lib/libvtkexpat-9.5.so.1 +0 -0
  71. mmgpy/lib/libvtkfmt-9.5.so.1 +0 -0
  72. mmgpy/lib/libvtkjpeg-9.5.so.1 +0 -0
  73. mmgpy/lib/libvtkjsoncpp-9.5.so.1 +0 -0
  74. mmgpy/lib/libvtkkissfft-9.5.so.1 +0 -0
  75. mmgpy/lib/libvtkloguru-9.5.so.1 +0 -0
  76. mmgpy/lib/libvtklz4-9.5.so.1 +0 -0
  77. mmgpy/lib/libvtklzma-9.5.so.1 +0 -0
  78. mmgpy/lib/libvtkmetaio-9.5.so.1 +0 -0
  79. mmgpy/lib/libvtkpng-9.5.so.1 +0 -0
  80. mmgpy/lib/libvtkpugixml-9.5.so.1 +0 -0
  81. mmgpy/lib/libvtksys-9.5.so.1 +0 -0
  82. mmgpy/lib/libvtktiff-9.5.so.1 +0 -0
  83. mmgpy/lib/libvtktoken-9.5.so.1 +0 -0
  84. mmgpy/lib/libvtkverdict-9.5.so.1 +0 -0
  85. mmgpy/lib/libvtkzlib-9.5.so.1 +0 -0
  86. mmgpy/metrics.py +596 -0
  87. mmgpy/progress.py +69 -0
  88. mmgpy/py.typed +0 -0
  89. mmgpy/repair/__init__.py +37 -0
  90. mmgpy/repair/_core.py +226 -0
  91. mmgpy/repair/_elements.py +241 -0
  92. mmgpy/repair/_vertices.py +219 -0
  93. mmgpy/sizing.py +370 -0
  94. mmgpy/ui/__init__.py +97 -0
  95. mmgpy/ui/__main__.py +87 -0
  96. mmgpy/ui/app.py +1837 -0
  97. mmgpy/ui/parsers.py +501 -0
  98. mmgpy/ui/remeshing.py +448 -0
  99. mmgpy/ui/samples.py +249 -0
  100. mmgpy/ui/utils.py +280 -0
  101. mmgpy/ui/viewer.py +587 -0
  102. mmgpy-0.5.0.dist-info/METADATA +186 -0
  103. mmgpy-0.5.0.dist-info/RECORD +109 -0
  104. mmgpy-0.5.0.dist-info/WHEEL +6 -0
  105. mmgpy-0.5.0.dist-info/entry_points.txt +13 -0
  106. mmgpy-0.5.0.dist-info/licenses/LICENSE +38 -0
  107. share/man/man1/mmg2d.1.gz +0 -0
  108. share/man/man1/mmg3d.1.gz +0 -0
  109. share/man/man1/mmgs.1.gz +0 -0
mmgpy/_mesh.py ADDED
@@ -0,0 +1,2286 @@
1
+ """Unified Mesh class for mmgpy.
2
+
3
+ This module provides a single `Mesh` class that wraps the underlying
4
+ MmgMesh3D, MmgMesh2D, and MmgMeshS implementations with auto-detection
5
+ of mesh type.
6
+
7
+ The Mesh class is the primary public API for mmgpy. The underlying C++
8
+ bindings (MmgMesh3D, MmgMesh2D, MmgMeshS) are implementation details
9
+ and should not be used directly.
10
+
11
+ Example:
12
+ >>> from mmgpy import Mesh, MeshKind
13
+ >>>
14
+ >>> # Auto-detect mesh type from data
15
+ >>> mesh = Mesh(vertices, cells)
16
+ >>> mesh.kind # MeshKind.TETRAHEDRAL
17
+ >>>
18
+ >>> # Remesh and save
19
+ >>> mesh.remesh(hmax=0.1)
20
+ >>> mesh.save("output.vtk")
21
+
22
+ >>> # Context manager usage
23
+ >>> with Mesh(vertices, cells) as mesh:
24
+ ... mesh.remesh(hmax=0.1)
25
+ ... mesh.save("output.vtk")
26
+
27
+ >>> # Transactional modifications with checkpoint
28
+ >>> mesh = Mesh(vertices, cells)
29
+ >>> with mesh.checkpoint() as snapshot:
30
+ ... mesh.remesh(hmax=0.01)
31
+ ... if mesh.validate():
32
+ ... snapshot.commit()
33
+ ... else:
34
+ ... snapshot.rollback()
35
+
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ from contextlib import contextmanager
41
+ from dataclasses import dataclass, field
42
+ from enum import Enum
43
+ from pathlib import Path
44
+ from typing import TYPE_CHECKING, Any, cast
45
+
46
+ import numpy as np
47
+ import pyvista as pv
48
+
49
+ from mmgpy._mmgpy import MmgMesh2D, MmgMesh3D, MmgMeshS
50
+
51
+ if TYPE_CHECKING:
52
+ from collections.abc import Callable, Generator, Sequence
53
+ from types import TracebackType
54
+
55
+ from numpy.typing import NDArray
56
+
57
+ from mmgpy._options import Mmg2DOptions, Mmg3DOptions, MmgSOptions
58
+ from mmgpy._progress import (
59
+ ProgressCallback,
60
+ ProgressEvent,
61
+ RichProgressReporter,
62
+ )
63
+ from mmgpy._result import RemeshResult
64
+ from mmgpy._validation import ValidationReport
65
+ from mmgpy.sizing import SizingConstraint
66
+
67
+ # Progress can be True (default rich), False (disabled), or a callback
68
+ ProgressParam = bool | Callable[[ProgressEvent], bool] | None
69
+
70
+ # Field transfer can be True (all fields), False (no transfer), or list of names
71
+ FieldTransferParam = bool | Sequence[str] | None
72
+
73
+ _DIMS_2D = 2
74
+ _DIMS_3D = 3
75
+ _TETRA_VERTS = 4
76
+ _TRI_VERTS = 3
77
+ _2D_DETECTION_TOLERANCE = 1e-8
78
+
79
+
80
+ def _is_interactive_terminal() -> bool: # pragma: no cover
81
+ """Check if we're running in an interactive terminal.
82
+
83
+ Returns False in CI environments, pytest, or when stdout is not a TTY.
84
+ """
85
+ import os # noqa: PLC0415
86
+ import sys # noqa: PLC0415
87
+
88
+ # Check for common CI environment variables
89
+ ci_vars = ("CI", "GITHUB_ACTIONS", "GITLAB_CI", "TRAVIS", "CIRCLECI", "JENKINS_URL")
90
+ if any(os.environ.get(var) for var in ci_vars):
91
+ return False
92
+
93
+ # Check if running under pytest
94
+ if "pytest" in sys.modules:
95
+ return False
96
+
97
+ # Check if stdout is a TTY
98
+ return hasattr(sys.stdout, "isatty") and sys.stdout.isatty()
99
+
100
+
101
+ def _resolve_progress_callback(
102
+ progress: ProgressParam,
103
+ ) -> tuple[ProgressCallback | None, RichProgressReporter | None]:
104
+ """Resolve progress parameter to callback and optional context manager.
105
+
106
+ Parameters
107
+ ----------
108
+ progress : bool | Callable | None
109
+ The progress parameter from remesh methods.
110
+
111
+ Returns
112
+ -------
113
+ tuple[ProgressCallback | None, RichProgressReporter | None]
114
+ A tuple of (callback, reporter_ctx). If reporter_ctx is not None,
115
+ it must be used as a context manager to manage the Rich display.
116
+
117
+ """
118
+ from mmgpy._progress import RichProgressReporter # noqa: PLC0415
119
+
120
+ if progress is True:
121
+ # Only show progress bar in interactive terminals
122
+ if _is_interactive_terminal(): # pragma: no cover
123
+ reporter = RichProgressReporter(transient=True)
124
+ return reporter, reporter
125
+ return None, None
126
+ if callable(progress):
127
+ return progress, None
128
+ return None, None
129
+
130
+
131
+ def _dict_to_remesh_result(stats: dict[str, Any]) -> RemeshResult:
132
+ """Convert C++ remesh statistics dict to RemeshResult dataclass."""
133
+ from mmgpy._result import RemeshResult as _RemeshResult # noqa: PLC0415
134
+
135
+ return _RemeshResult(
136
+ vertices_before=stats["vertices_before"],
137
+ vertices_after=stats["vertices_after"],
138
+ elements_before=stats["elements_before"],
139
+ elements_after=stats["elements_after"],
140
+ triangles_before=stats["triangles_before"],
141
+ triangles_after=stats["triangles_after"],
142
+ edges_before=stats["edges_before"],
143
+ edges_after=stats["edges_after"],
144
+ quality_min_before=stats["quality_min_before"],
145
+ quality_min_after=stats["quality_min_after"],
146
+ quality_mean_before=stats["quality_mean_before"],
147
+ quality_mean_after=stats["quality_mean_after"],
148
+ duration_seconds=stats["duration_seconds"],
149
+ warnings=tuple(stats["warnings"]),
150
+ return_code=stats["return_code"],
151
+ )
152
+
153
+
154
+ class MeshKind(Enum):
155
+ """Enumeration of mesh types.
156
+
157
+ Attributes
158
+ ----------
159
+ TETRAHEDRAL
160
+ 3D volumetric mesh with tetrahedral elements.
161
+ TRIANGULAR_2D
162
+ 2D planar mesh with triangular elements.
163
+ TRIANGULAR_SURFACE
164
+ 3D surface mesh with triangular elements.
165
+
166
+ """
167
+
168
+ TETRAHEDRAL = "tetrahedral"
169
+ TRIANGULAR_2D = "triangular_2d"
170
+ TRIANGULAR_SURFACE = "triangular_surface"
171
+
172
+
173
+ def _is_2d_points(points: NDArray[np.floating]) -> bool:
174
+ """Check if points are essentially 2D (z coordinates are zero or near-zero)."""
175
+ if points.shape[1] == _DIMS_2D:
176
+ return True
177
+ if points.shape[1] == _DIMS_3D:
178
+ z_coords = points[:, 2]
179
+ return bool(np.allclose(z_coords, 0, atol=_2D_DETECTION_TOLERANCE))
180
+ return False
181
+
182
+
183
+ def _detect_mesh_kind(
184
+ vertices: NDArray[np.floating],
185
+ cells: NDArray[np.integer],
186
+ ) -> MeshKind:
187
+ """Detect mesh kind from vertices and cells arrays.
188
+
189
+ Parameters
190
+ ----------
191
+ vertices : ndarray
192
+ Vertex coordinates (Nx2 or Nx3).
193
+ cells : ndarray
194
+ Cell connectivity (NxM where M is vertices per cell).
195
+
196
+ Returns
197
+ -------
198
+ MeshKind
199
+ Detected mesh kind.
200
+
201
+ Raises
202
+ ------
203
+ ValueError
204
+ If mesh type cannot be determined.
205
+
206
+ """
207
+ n_cell_verts = cells.shape[1]
208
+
209
+ if n_cell_verts == _TETRA_VERTS:
210
+ return MeshKind.TETRAHEDRAL
211
+
212
+ if n_cell_verts == _TRI_VERTS:
213
+ if _is_2d_points(vertices):
214
+ return MeshKind.TRIANGULAR_2D
215
+ return MeshKind.TRIANGULAR_SURFACE
216
+
217
+ msg = f"Cannot determine mesh type from cells with {n_cell_verts} vertices per cell"
218
+ raise ValueError(msg)
219
+
220
+
221
+ def _create_impl(
222
+ vertices: NDArray[np.floating],
223
+ cells: NDArray[np.integer],
224
+ kind: MeshKind,
225
+ ) -> MmgMesh3D | MmgMesh2D | MmgMeshS:
226
+ """Create the appropriate mesh implementation.
227
+
228
+ Parameters
229
+ ----------
230
+ vertices : ndarray
231
+ Vertex coordinates.
232
+ cells : ndarray
233
+ Cell connectivity.
234
+ kind : MeshKind
235
+ Mesh kind to create.
236
+
237
+ Returns
238
+ -------
239
+ MmgMesh3D | MmgMesh2D | MmgMeshS
240
+ The mesh implementation.
241
+
242
+ """
243
+ vertices = np.ascontiguousarray(vertices, dtype=np.float64)
244
+ cells = np.ascontiguousarray(cells, dtype=np.int32)
245
+
246
+ if kind == MeshKind.TETRAHEDRAL:
247
+ return MmgMesh3D(vertices, cells)
248
+
249
+ if kind == MeshKind.TRIANGULAR_2D:
250
+ # Ensure 2D vertices
251
+ if vertices.shape[1] == _DIMS_3D:
252
+ vertices = np.ascontiguousarray(vertices[:, :2])
253
+ return MmgMesh2D(vertices, cells)
254
+
255
+ if kind == MeshKind.TRIANGULAR_SURFACE:
256
+ return MmgMeshS(vertices, cells)
257
+
258
+ msg = f"Unknown mesh kind: {kind}"
259
+ raise ValueError(msg)
260
+
261
+
262
+ @dataclass
263
+ class MeshCheckpoint:
264
+ """Snapshot of mesh state for rollback.
265
+
266
+ This class is returned by `Mesh.checkpoint()` and provides transactional
267
+ semantics for mesh modifications. Changes are automatically rolled back
268
+ on context exit unless `commit()` is called.
269
+
270
+ Parameters
271
+ ----------
272
+ mesh : Mesh
273
+ The mesh to checkpoint.
274
+
275
+ Notes
276
+ -----
277
+ The checkpoint stores a complete copy of the mesh data including vertices,
278
+ elements, reference markers, and solution fields (metric, displacement,
279
+ levelset). For large meshes, this may consume significant memory.
280
+
281
+ Note: The tensor field is not saved because it shares memory with metric
282
+ in MMG's internal representation. Only one of metric or tensor can be
283
+ set at a time.
284
+
285
+ Examples
286
+ --------
287
+ >>> mesh = Mesh(vertices, cells)
288
+ >>> with mesh.checkpoint() as snapshot:
289
+ ... mesh.remesh(hmax=0.01)
290
+ ... if mesh.validate():
291
+ ... snapshot.commit() # Keep changes
292
+ ... # Otherwise, changes are automatically rolled back
293
+
294
+ >>> # Automatic rollback on exception
295
+ >>> with mesh.checkpoint():
296
+ ... mesh.remesh(hmax=0.01)
297
+ ... raise ValueError("Something went wrong")
298
+ >>> # mesh is restored to original state
299
+
300
+ """
301
+
302
+ _mesh: Mesh
303
+ _vertices: NDArray[np.float64] = field(repr=False)
304
+ _vertex_refs: NDArray[np.int64] = field(repr=False)
305
+ _triangles: NDArray[np.int32] = field(repr=False)
306
+ _triangle_refs: NDArray[np.int64] = field(repr=False)
307
+ _edges: NDArray[np.int32] = field(repr=False)
308
+ _edge_refs: NDArray[np.int64] = field(repr=False)
309
+ _tetrahedra: NDArray[np.int32] | None = field(default=None, repr=False)
310
+ _tetrahedra_refs: NDArray[np.int64] | None = field(default=None, repr=False)
311
+ _fields: dict[str, NDArray[np.float64]] = field(default_factory=dict, repr=False)
312
+ _committed: bool = field(default=False, repr=False)
313
+
314
+ def commit(self) -> None:
315
+ """Keep the current mesh state.
316
+
317
+ Call this method to prevent rollback when the context manager exits.
318
+ """
319
+ self._committed = True
320
+
321
+ def rollback(self) -> None:
322
+ """Restore the mesh to its checkpoint state.
323
+
324
+ This is called automatically on context exit if `commit()` was not called,
325
+ or if an exception occurred. Can also be called manually.
326
+ """
327
+ mesh = self._mesh
328
+ kind = mesh._kind # noqa: SLF001
329
+
330
+ if kind == MeshKind.TETRAHEDRAL:
331
+ if self._tetrahedra is None or self._tetrahedra_refs is None:
332
+ msg = "Tetrahedra data missing in checkpoint"
333
+ raise RuntimeError(msg)
334
+ impl = cast("MmgMesh3D", mesh._impl) # noqa: SLF001
335
+ impl.set_mesh_size(
336
+ vertices=len(self._vertices),
337
+ tetrahedra=len(self._tetrahedra),
338
+ triangles=len(self._triangles),
339
+ edges=len(self._edges),
340
+ )
341
+ impl.set_vertices(self._vertices, self._vertex_refs)
342
+ impl.set_tetrahedra(self._tetrahedra, self._tetrahedra_refs)
343
+ if len(self._triangles) > 0:
344
+ impl.set_triangles(self._triangles, self._triangle_refs)
345
+ if len(self._edges) > 0:
346
+ impl.set_edges(self._edges, self._edge_refs)
347
+ elif kind == MeshKind.TRIANGULAR_2D:
348
+ impl_2d = cast("MmgMesh2D", mesh._impl) # noqa: SLF001
349
+ impl_2d.set_mesh_size(
350
+ vertices=len(self._vertices),
351
+ triangles=len(self._triangles),
352
+ edges=len(self._edges),
353
+ )
354
+ impl_2d.set_vertices(self._vertices, self._vertex_refs)
355
+ impl_2d.set_triangles(self._triangles, self._triangle_refs)
356
+ if len(self._edges) > 0:
357
+ impl_2d.set_edges(self._edges, self._edge_refs)
358
+ else: # TRIANGULAR_SURFACE
359
+ impl_s = cast("MmgMeshS", mesh._impl) # noqa: SLF001
360
+ impl_s.set_mesh_size(
361
+ vertices=len(self._vertices),
362
+ triangles=len(self._triangles),
363
+ edges=len(self._edges),
364
+ )
365
+ impl_s.set_vertices(self._vertices, self._vertex_refs)
366
+ impl_s.set_triangles(self._triangles, self._triangle_refs)
367
+ if len(self._edges) > 0:
368
+ impl_s.set_edges(self._edges, self._edge_refs)
369
+
370
+ # Restore solution fields
371
+ for field_name, field_data in self._fields.items():
372
+ mesh._impl.set_field(field_name, field_data) # noqa: SLF001
373
+
374
+ def __enter__(self) -> MeshCheckpoint: # noqa: PYI034
375
+ """Enter the context manager."""
376
+ return self
377
+
378
+ def __exit__(
379
+ self,
380
+ exc_type: type[BaseException] | None,
381
+ exc_val: BaseException | None,
382
+ exc_tb: TracebackType | None,
383
+ ) -> bool:
384
+ """Exit the context manager, rolling back if not committed or on exception."""
385
+ if exc_type is not None or not self._committed:
386
+ self.rollback()
387
+ return False
388
+
389
+
390
+ class Mesh:
391
+ """Unified mesh class with auto-detection of mesh type.
392
+
393
+ This class provides a single interface for working with 2D planar,
394
+ 3D volumetric, and 3D surface meshes. The mesh type is automatically
395
+ detected from the input data.
396
+
397
+ Parameters
398
+ ----------
399
+ source : ndarray | str | Path | pv.UnstructuredGrid | pv.PolyData
400
+ Either:
401
+ - Vertex coordinates array (requires `cells` parameter)
402
+ - File path to load mesh from
403
+ - PyVista mesh object
404
+ cells : ndarray, optional
405
+ Cell connectivity array. Required when `source` is vertices.
406
+
407
+ Attributes
408
+ ----------
409
+ kind : MeshKind
410
+ The type of mesh (TETRAHEDRAL, TRIANGULAR_2D, or TRIANGULAR_SURFACE).
411
+
412
+ Examples
413
+ --------
414
+ Create a mesh from vertices and cells:
415
+
416
+ >>> vertices = np.array([[0, 0, 0], [1, 0, 0], [0, 1, 0], [0, 0, 1]])
417
+ >>> cells = np.array([[0, 1, 2, 3]])
418
+ >>> mesh = Mesh(vertices, cells)
419
+ >>> mesh.kind
420
+ MeshKind.TETRAHEDRAL
421
+
422
+ Load a mesh from file:
423
+
424
+ >>> mesh = Mesh("mesh.vtk")
425
+
426
+ Create from PyVista:
427
+
428
+ >>> pv_mesh = pv.read("mesh.vtk")
429
+ >>> mesh = Mesh(pv_mesh)
430
+
431
+ """
432
+
433
+ __slots__ = ("_impl", "_kind", "_sizing_constraints", "_user_fields")
434
+
435
+ _impl: MmgMesh3D | MmgMesh2D | MmgMeshS
436
+ _kind: MeshKind
437
+ _sizing_constraints: list[SizingConstraint]
438
+ _user_fields: dict[str, NDArray[np.float64]]
439
+
440
+ def __init__(
441
+ self,
442
+ source: NDArray[np.floating] | str | Path | pv.UnstructuredGrid | pv.PolyData,
443
+ cells: NDArray[np.integer] | None = None,
444
+ ) -> None:
445
+ """Initialize a Mesh from various sources."""
446
+ # Import here to avoid circular imports
447
+ from mmgpy._io import read as _read_mesh # noqa: PLC0415
448
+
449
+ self._sizing_constraints = []
450
+ self._user_fields = {}
451
+
452
+ # Handle PyVista objects
453
+ if isinstance(source, pv.UnstructuredGrid | pv.PolyData):
454
+ result = _read_mesh(source)
455
+ self._impl = result._impl # noqa: SLF001
456
+ self._kind = result._kind # noqa: SLF001
457
+ return
458
+
459
+ # Handle file paths
460
+ if isinstance(source, str | Path):
461
+ result = _read_mesh(source)
462
+ self._impl = result._impl # noqa: SLF001
463
+ self._kind = result._kind # noqa: SLF001
464
+ return
465
+
466
+ # Handle vertices + cells
467
+ if cells is None:
468
+ msg = "cells parameter is required when source is a vertices array"
469
+ raise ValueError(msg)
470
+
471
+ vertices = np.asarray(source)
472
+ cells = np.asarray(cells)
473
+
474
+ self._kind = _detect_mesh_kind(vertices, cells)
475
+ self._impl = _create_impl(vertices, cells, self._kind)
476
+
477
+ @classmethod
478
+ def _from_impl(
479
+ cls,
480
+ impl: MmgMesh3D | MmgMesh2D | MmgMeshS,
481
+ kind: MeshKind,
482
+ ) -> Mesh:
483
+ """Create a Mesh from an existing implementation (internal use).
484
+
485
+ Parameters
486
+ ----------
487
+ impl : MmgMesh3D | MmgMesh2D | MmgMeshS
488
+ The underlying mesh implementation.
489
+ kind : MeshKind
490
+ The mesh kind.
491
+
492
+ Returns
493
+ -------
494
+ Mesh
495
+ A new Mesh wrapping the implementation.
496
+
497
+ """
498
+ mesh = object.__new__(cls)
499
+ mesh._impl = impl # noqa: SLF001
500
+ mesh._kind = kind # noqa: SLF001
501
+ mesh._sizing_constraints = [] # noqa: SLF001
502
+ mesh._user_fields = {} # noqa: SLF001
503
+ return mesh
504
+
505
+ @property
506
+ def kind(self) -> MeshKind:
507
+ """Get the mesh kind.
508
+
509
+ Returns
510
+ -------
511
+ MeshKind
512
+ The type of mesh.
513
+
514
+ """
515
+ return self._kind
516
+
517
+ # =========================================================================
518
+ # Vertex operations
519
+ # =========================================================================
520
+
521
+ def get_vertices(self) -> NDArray[np.float64]:
522
+ """Get vertex coordinates.
523
+
524
+ Returns
525
+ -------
526
+ ndarray
527
+ Vertex coordinates (Nx2 for 2D, Nx3 for 3D).
528
+
529
+ """
530
+ return self._impl.get_vertices()
531
+
532
+ def get_vertices_with_refs(self) -> tuple[NDArray[np.float64], NDArray[np.int64]]:
533
+ """Get vertex coordinates and reference markers.
534
+
535
+ Returns
536
+ -------
537
+ vertices : ndarray
538
+ Vertex coordinates.
539
+ refs : ndarray
540
+ Reference markers for each vertex.
541
+
542
+ """
543
+ return self._impl.get_vertices_with_refs()
544
+
545
+ def set_vertices(
546
+ self,
547
+ vertices: NDArray[np.float64],
548
+ refs: NDArray[np.int64] | None = None,
549
+ ) -> None:
550
+ """Set vertex coordinates.
551
+
552
+ Parameters
553
+ ----------
554
+ vertices : ndarray
555
+ Vertex coordinates.
556
+ refs : ndarray, optional
557
+ Reference markers for each vertex.
558
+
559
+ """
560
+ self._impl.set_vertices(vertices, refs)
561
+
562
+ # =========================================================================
563
+ # Triangle operations (shared by all types)
564
+ # =========================================================================
565
+
566
+ def get_triangles(self) -> NDArray[np.int32]:
567
+ """Get triangle connectivity.
568
+
569
+ Returns
570
+ -------
571
+ ndarray
572
+ Triangle connectivity (Nx3).
573
+
574
+ """
575
+ return self._impl.get_triangles()
576
+
577
+ def get_triangles_with_refs(self) -> tuple[NDArray[np.int32], NDArray[np.int64]]:
578
+ """Get triangle connectivity and reference markers.
579
+
580
+ Returns
581
+ -------
582
+ triangles : ndarray
583
+ Triangle connectivity.
584
+ refs : ndarray
585
+ Reference markers for each triangle.
586
+
587
+ """
588
+ return self._impl.get_triangles_with_refs()
589
+
590
+ def set_triangles(
591
+ self,
592
+ triangles: NDArray[np.int32],
593
+ refs: NDArray[np.int64] | None = None,
594
+ ) -> None:
595
+ """Set triangle connectivity.
596
+
597
+ Parameters
598
+ ----------
599
+ triangles : ndarray
600
+ Triangle connectivity (Nx3).
601
+ refs : ndarray, optional
602
+ Reference markers for each triangle.
603
+
604
+ """
605
+ self._impl.set_triangles(triangles, refs)
606
+
607
+ # =========================================================================
608
+ # Edge operations
609
+ # =========================================================================
610
+
611
+ def get_edges(self) -> NDArray[np.int32]:
612
+ """Get edge connectivity.
613
+
614
+ Returns
615
+ -------
616
+ ndarray
617
+ Edge connectivity (Nx2).
618
+
619
+ """
620
+ return self._impl.get_edges()
621
+
622
+ def get_edges_with_refs(self) -> tuple[NDArray[np.int32], NDArray[np.int64]]:
623
+ """Get edge connectivity and reference markers.
624
+
625
+ Returns
626
+ -------
627
+ edges : ndarray
628
+ Edge connectivity.
629
+ refs : ndarray
630
+ Reference markers for each edge.
631
+
632
+ """
633
+ return self._impl.get_edges_with_refs()
634
+
635
+ def set_edges(
636
+ self,
637
+ edges: NDArray[np.int32],
638
+ refs: NDArray[np.int64] | None = None,
639
+ ) -> None:
640
+ """Set edge connectivity.
641
+
642
+ Parameters
643
+ ----------
644
+ edges : ndarray
645
+ Edge connectivity (Nx2).
646
+ refs : ndarray, optional
647
+ Reference markers for each edge.
648
+
649
+ """
650
+ self._impl.set_edges(edges, refs)
651
+
652
+ # =========================================================================
653
+ # Tetrahedra operations (TETRAHEDRAL only)
654
+ # =========================================================================
655
+
656
+ def get_tetrahedra(self) -> NDArray[np.int32]:
657
+ """Get tetrahedra connectivity.
658
+
659
+ Only available for TETRAHEDRAL meshes.
660
+
661
+ Returns
662
+ -------
663
+ ndarray
664
+ Tetrahedra connectivity (Nx4).
665
+
666
+ Raises
667
+ ------
668
+ TypeError
669
+ If mesh is not TETRAHEDRAL.
670
+
671
+ """
672
+ if self._kind != MeshKind.TETRAHEDRAL:
673
+ msg = "get_tetrahedra() is only available for TETRAHEDRAL meshes"
674
+ raise TypeError(msg)
675
+ return self._impl.get_tetrahedra() # type: ignore[union-attr]
676
+
677
+ def get_tetrahedra_with_refs(
678
+ self,
679
+ ) -> tuple[NDArray[np.int32], NDArray[np.int64]]:
680
+ """Get tetrahedra connectivity and reference markers.
681
+
682
+ Only available for TETRAHEDRAL meshes.
683
+
684
+ Returns
685
+ -------
686
+ tetrahedra : ndarray
687
+ Tetrahedra connectivity.
688
+ refs : ndarray
689
+ Reference markers for each tetrahedron.
690
+
691
+ Raises
692
+ ------
693
+ TypeError
694
+ If mesh is not TETRAHEDRAL.
695
+
696
+ """
697
+ if self._kind != MeshKind.TETRAHEDRAL:
698
+ msg = "get_tetrahedra_with_refs() is only available for TETRAHEDRAL meshes"
699
+ raise TypeError(msg)
700
+ return self._impl.get_tetrahedra_with_refs() # type: ignore[union-attr]
701
+
702
+ def get_elements(self) -> NDArray[np.int32]:
703
+ """Get primary element connectivity (alias for get_tetrahedra).
704
+
705
+ Only available for TETRAHEDRAL meshes.
706
+
707
+ Returns
708
+ -------
709
+ ndarray
710
+ Element connectivity (Nx4 tetrahedra).
711
+
712
+ Raises
713
+ ------
714
+ TypeError
715
+ If mesh is not TETRAHEDRAL.
716
+
717
+ """
718
+ if self._kind != MeshKind.TETRAHEDRAL:
719
+ msg = "get_elements() is only available for TETRAHEDRAL meshes"
720
+ raise TypeError(msg)
721
+ return self._impl.get_elements() # type: ignore[union-attr]
722
+
723
+ def get_elements_with_refs(self) -> tuple[NDArray[np.int32], NDArray[np.int64]]:
724
+ """Get primary element connectivity and reference markers.
725
+
726
+ Only available for TETRAHEDRAL meshes.
727
+
728
+ Returns
729
+ -------
730
+ elements : ndarray
731
+ Element connectivity.
732
+ refs : ndarray
733
+ Reference markers for each element.
734
+
735
+ Raises
736
+ ------
737
+ TypeError
738
+ If mesh is not TETRAHEDRAL.
739
+
740
+ """
741
+ if self._kind != MeshKind.TETRAHEDRAL:
742
+ msg = "get_elements_with_refs() is only available for TETRAHEDRAL meshes"
743
+ raise TypeError(msg)
744
+ return self._impl.get_elements_with_refs() # type: ignore[union-attr]
745
+
746
+ # =========================================================================
747
+ # Field operations (solution data)
748
+ # =========================================================================
749
+
750
+ def set_field(self, key: str, value: NDArray[np.float64]) -> None:
751
+ """Set a solution field.
752
+
753
+ Parameters
754
+ ----------
755
+ key : str
756
+ Field name.
757
+ value : ndarray
758
+ Field values (one per vertex).
759
+
760
+ """
761
+ self._impl.set_field(key, value)
762
+
763
+ def get_field(self, key: str) -> NDArray[np.float64]:
764
+ """Get a solution field.
765
+
766
+ Parameters
767
+ ----------
768
+ key : str
769
+ Field name.
770
+
771
+ Returns
772
+ -------
773
+ ndarray
774
+ Field values.
775
+
776
+ """
777
+ return self._impl.get_field(key)
778
+
779
+ def _try_get_field(self, key: str) -> NDArray[np.float64] | None:
780
+ """Try to get a field, returning None if not set or contains garbage.
781
+
782
+ The underlying C++ bindings may return uninitialized memory for fields
783
+ that haven't been explicitly set. This method filters out such garbage
784
+ by checking for subnormal (denormalized) floating point values.
785
+ """
786
+ try:
787
+ data = self._impl.get_field(key)
788
+ except RuntimeError:
789
+ return None
790
+ # Check for uninitialized memory: subnormal values indicate garbage
791
+ if np.any(~np.isfinite(data)) or np.any(
792
+ (data != 0) & (np.abs(data) < np.finfo(np.float64).tiny),
793
+ ):
794
+ return None
795
+ return data
796
+
797
+ def __setitem__(self, key: str, value: NDArray[np.float64]) -> None:
798
+ """Set a solution field using dictionary syntax."""
799
+ self._impl[key] = value
800
+
801
+ def __getitem__(self, key: str) -> NDArray[np.float64]:
802
+ """Get a solution field using dictionary syntax."""
803
+ return self._impl[key]
804
+
805
+ # =========================================================================
806
+ # User field operations (arbitrary fields for transfer)
807
+ # =========================================================================
808
+
809
+ def set_user_field(self, name: str, values: NDArray[np.float64]) -> None:
810
+ """Set a user-defined field for transfer during remeshing.
811
+
812
+ Unlike MMG's built-in fields (metric, displacement, levelset), user fields
813
+ are arbitrary data arrays that can be transferred to the new mesh after
814
+ remeshing via interpolation.
815
+
816
+ Parameters
817
+ ----------
818
+ name : str
819
+ Field name (any string except reserved names like "metric").
820
+ values : ndarray
821
+ Field values, shape (n_vertices,) for scalars or
822
+ (n_vertices, n_components) for vectors/tensors.
823
+
824
+ Examples
825
+ --------
826
+ >>> mesh.set_user_field("temperature", temperature_array)
827
+ >>> mesh.set_user_field("velocity", velocity_array) # (N, 3) vector
828
+ >>> mesh.remesh(hmax=0.1, transfer_fields=True)
829
+ >>> new_temp = mesh.get_user_field("temperature")
830
+
831
+ """
832
+ n_vertices = len(self.get_vertices())
833
+ values = np.asarray(values, dtype=np.float64)
834
+ if values.shape[0] != n_vertices:
835
+ msg = (
836
+ f"Field '{name}' has {values.shape[0]} values but mesh "
837
+ f"has {n_vertices} vertices"
838
+ )
839
+ raise ValueError(msg)
840
+ self._user_fields[name] = values
841
+
842
+ def get_user_field(self, name: str) -> NDArray[np.float64]:
843
+ """Get a user-defined field.
844
+
845
+ Parameters
846
+ ----------
847
+ name : str
848
+ Field name.
849
+
850
+ Returns
851
+ -------
852
+ ndarray
853
+ Field values at vertices.
854
+
855
+ Raises
856
+ ------
857
+ KeyError
858
+ If the field does not exist.
859
+
860
+ """
861
+ if name not in self._user_fields:
862
+ msg = f"User field '{name}' not found. Available: {list(self._user_fields)}"
863
+ raise KeyError(msg)
864
+ return self._user_fields[name]
865
+
866
+ def get_user_fields(self) -> dict[str, NDArray[np.float64]]:
867
+ """Get all user-defined fields.
868
+
869
+ Returns
870
+ -------
871
+ dict[str, ndarray]
872
+ Dictionary mapping field names to values.
873
+
874
+ """
875
+ return dict(self._user_fields)
876
+
877
+ def clear_user_fields(self) -> None:
878
+ """Remove all user-defined fields."""
879
+ self._user_fields.clear()
880
+
881
+ def has_user_field(self, name: str) -> bool:
882
+ """Check if a user field exists.
883
+
884
+ Parameters
885
+ ----------
886
+ name : str
887
+ Field name.
888
+
889
+ Returns
890
+ -------
891
+ bool
892
+ True if the field exists.
893
+
894
+ """
895
+ return name in self._user_fields
896
+
897
+ # =========================================================================
898
+ # Geometry operations
899
+ # =========================================================================
900
+
901
+ def _compute_tetrahedra_volumes(
902
+ self,
903
+ vertices: NDArray[np.float64],
904
+ tetrahedra: NDArray[np.int32],
905
+ ) -> NDArray[np.float64]:
906
+ """Compute volumes for an array of tetrahedra.
907
+
908
+ Parameters
909
+ ----------
910
+ vertices : ndarray
911
+ Vertex coordinates, shape (N, 3).
912
+ tetrahedra : ndarray
913
+ Tetrahedra connectivity, shape (M, 4).
914
+
915
+ Returns
916
+ -------
917
+ ndarray
918
+ Volume of each tetrahedron, shape (M,).
919
+
920
+ """
921
+ v0 = vertices[tetrahedra[:, 0]]
922
+ v1 = vertices[tetrahedra[:, 1]]
923
+ v2 = vertices[tetrahedra[:, 2]]
924
+ v3 = vertices[tetrahedra[:, 3]]
925
+ return np.abs(np.einsum("ij,ij->i", v1 - v0, np.cross(v2 - v0, v3 - v0))) / 6
926
+
927
+ def _compute_triangle_areas(
928
+ self,
929
+ vertices: NDArray[np.float64],
930
+ triangles: NDArray[np.int32],
931
+ ) -> NDArray[np.float64]:
932
+ """Compute areas for an array of triangles.
933
+
934
+ Automatically handles 2D (shoelace formula) and 3D (cross product) cases.
935
+
936
+ Parameters
937
+ ----------
938
+ vertices : ndarray
939
+ Vertex coordinates, shape (N, 2) or (N, 3).
940
+ triangles : ndarray
941
+ Triangle connectivity, shape (M, 3).
942
+
943
+ Returns
944
+ -------
945
+ ndarray
946
+ Area of each triangle, shape (M,).
947
+
948
+ """
949
+ v0 = vertices[triangles[:, 0]]
950
+ v1 = vertices[triangles[:, 1]]
951
+ v2 = vertices[triangles[:, 2]]
952
+
953
+ if self._kind == MeshKind.TRIANGULAR_2D:
954
+ # 2D: use shoelace formula
955
+ return 0.5 * np.abs(
956
+ (v1[:, 0] - v0[:, 0]) * (v2[:, 1] - v0[:, 1])
957
+ - (v2[:, 0] - v0[:, 0]) * (v1[:, 1] - v0[:, 1]),
958
+ )
959
+ # 3D: use cross product magnitude
960
+ return 0.5 * np.linalg.norm(np.cross(v1 - v0, v2 - v0), axis=1)
961
+
962
+ def get_bounds(self) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
963
+ """Get the bounding box of the mesh.
964
+
965
+ Returns
966
+ -------
967
+ tuple[ndarray, ndarray]
968
+ Tuple of (min_coords, max_coords), each shape (3,) for 3D
969
+ or (2,) for 2D meshes.
970
+
971
+ Raises
972
+ ------
973
+ ValueError
974
+ If the mesh has no vertices.
975
+
976
+ Examples
977
+ --------
978
+ >>> mesh = Mesh(vertices, cells)
979
+ >>> min_pt, max_pt = mesh.get_bounds()
980
+ >>> print(f"Size: {max_pt - min_pt}")
981
+
982
+ """
983
+ vertices = self.get_vertices()
984
+ if len(vertices) == 0:
985
+ msg = "Cannot compute bounds for mesh with no vertices"
986
+ raise ValueError(msg)
987
+ return vertices.min(axis=0), vertices.max(axis=0)
988
+
989
+ def get_center_of_mass(self) -> NDArray[np.float64]:
990
+ """Get the centroid (center of mass) of the mesh.
991
+
992
+ For volume meshes, computes volume-weighted centroid.
993
+ For surface meshes, computes area-weighted centroid.
994
+ For 2D meshes, computes area-weighted centroid.
995
+
996
+ Returns
997
+ -------
998
+ ndarray
999
+ Centroid coordinates, shape (3,) for 3D or (2,) for 2D.
1000
+
1001
+ Examples
1002
+ --------
1003
+ >>> mesh = Mesh(vertices, cells)
1004
+ >>> center = mesh.get_center_of_mass()
1005
+ >>> print(f"Center: {center}")
1006
+
1007
+ """
1008
+ vertices = self.get_vertices()
1009
+
1010
+ if self._kind == MeshKind.TETRAHEDRAL:
1011
+ tetrahedra = self.get_tetrahedra()
1012
+ if len(tetrahedra) == 0:
1013
+ return vertices.mean(axis=0)
1014
+
1015
+ volumes = self._compute_tetrahedra_volumes(vertices, tetrahedra)
1016
+ v0 = vertices[tetrahedra[:, 0]]
1017
+ v1 = vertices[tetrahedra[:, 1]]
1018
+ v2 = vertices[tetrahedra[:, 2]]
1019
+ v3 = vertices[tetrahedra[:, 3]]
1020
+ centroids = (v0 + v1 + v2 + v3) / 4
1021
+ total_volume = volumes.sum()
1022
+
1023
+ if total_volume < np.finfo(np.float64).tiny:
1024
+ return vertices.mean(axis=0)
1025
+
1026
+ return (centroids * volumes[:, np.newaxis]).sum(axis=0) / total_volume
1027
+
1028
+ triangles = self.get_triangles()
1029
+ if len(triangles) == 0:
1030
+ return vertices.mean(axis=0)
1031
+
1032
+ areas = self._compute_triangle_areas(vertices, triangles)
1033
+ v0 = vertices[triangles[:, 0]]
1034
+ v1 = vertices[triangles[:, 1]]
1035
+ v2 = vertices[triangles[:, 2]]
1036
+ centroids = (v0 + v1 + v2) / 3
1037
+ total_area = areas.sum()
1038
+
1039
+ if total_area < np.finfo(np.float64).tiny:
1040
+ return vertices.mean(axis=0)
1041
+
1042
+ return (centroids * areas[:, np.newaxis]).sum(axis=0) / total_area
1043
+
1044
+ def compute_volume(self) -> float:
1045
+ """Compute the total volume of the mesh.
1046
+
1047
+ Only available for 3D volume meshes (TETRAHEDRAL).
1048
+
1049
+ Returns
1050
+ -------
1051
+ float
1052
+ Total volume in mesh units cubed.
1053
+
1054
+ Raises
1055
+ ------
1056
+ TypeError
1057
+ If mesh is not a 3D volume mesh.
1058
+
1059
+ Examples
1060
+ --------
1061
+ >>> mesh = Mesh(vertices, cells)
1062
+ >>> volume = mesh.compute_volume()
1063
+ >>> print(f"Volume: {volume:.2f} mm^3")
1064
+
1065
+ """
1066
+ if self._kind != MeshKind.TETRAHEDRAL:
1067
+ msg = "compute_volume() is only available for TETRAHEDRAL meshes"
1068
+ raise TypeError(msg)
1069
+
1070
+ vertices = self.get_vertices()
1071
+ tetrahedra = self.get_tetrahedra()
1072
+
1073
+ if len(tetrahedra) == 0:
1074
+ return 0.0
1075
+
1076
+ volumes = self._compute_tetrahedra_volumes(vertices, tetrahedra)
1077
+ return float(volumes.sum())
1078
+
1079
+ def compute_surface_area(self) -> float:
1080
+ """Compute the total surface area of the mesh.
1081
+
1082
+ For volume meshes (TETRAHEDRAL), computes boundary surface area
1083
+ (triangles stored in the mesh represent boundary faces).
1084
+ For surface meshes (TRIANGULAR_SURFACE), computes total area.
1085
+ For 2D meshes (TRIANGULAR_2D), computes total area.
1086
+
1087
+ Returns
1088
+ -------
1089
+ float
1090
+ Total surface area in mesh units squared.
1091
+
1092
+ Examples
1093
+ --------
1094
+ >>> mesh = Mesh(vertices, cells)
1095
+ >>> area = mesh.compute_surface_area()
1096
+ >>> print(f"Surface area: {area:.2f} mm^2")
1097
+
1098
+ """
1099
+ vertices = self.get_vertices()
1100
+ triangles = self.get_triangles()
1101
+
1102
+ if len(triangles) == 0:
1103
+ return 0.0
1104
+
1105
+ areas = self._compute_triangle_areas(vertices, triangles)
1106
+ return float(areas.sum())
1107
+
1108
+ def get_diagonal(self) -> float:
1109
+ """Get the diagonal length of the bounding box.
1110
+
1111
+ Returns
1112
+ -------
1113
+ float
1114
+ The diagonal length of the bounding box.
1115
+
1116
+ Raises
1117
+ ------
1118
+ ValueError
1119
+ If the mesh has no vertices.
1120
+
1121
+ Examples
1122
+ --------
1123
+ >>> mesh = Mesh(vertices, cells)
1124
+ >>> diagonal = mesh.get_diagonal()
1125
+ >>> print(f"Bounding box diagonal: {diagonal:.2f}")
1126
+
1127
+ """
1128
+ min_pt, max_pt = self.get_bounds()
1129
+ return float(np.linalg.norm(max_pt - min_pt))
1130
+
1131
+ # =========================================================================
1132
+ # Topology queries
1133
+ # =========================================================================
1134
+
1135
+ def get_adjacent_elements(self, idx: int) -> NDArray[np.int32]:
1136
+ """Get indices of elements adjacent to a given element.
1137
+
1138
+ Parameters
1139
+ ----------
1140
+ idx : int
1141
+ Element index (1-based for MMG).
1142
+
1143
+ Returns
1144
+ -------
1145
+ ndarray
1146
+ Indices of adjacent elements.
1147
+
1148
+ """
1149
+ return self._impl.get_adjacent_elements(idx)
1150
+
1151
+ def get_vertex_neighbors(self, idx: int) -> NDArray[np.int32]:
1152
+ """Get indices of vertices connected to a given vertex.
1153
+
1154
+ Parameters
1155
+ ----------
1156
+ idx : int
1157
+ Vertex index (1-based for MMG).
1158
+
1159
+ Returns
1160
+ -------
1161
+ ndarray
1162
+ Indices of neighboring vertices.
1163
+
1164
+ """
1165
+ return self._impl.get_vertex_neighbors(idx)
1166
+
1167
+ def get_element_quality(self, idx: int) -> float:
1168
+ """Get quality metric for a single element.
1169
+
1170
+ Parameters
1171
+ ----------
1172
+ idx : int
1173
+ Element index (1-based for MMG).
1174
+
1175
+ Returns
1176
+ -------
1177
+ float
1178
+ Quality metric (0-1, higher is better).
1179
+
1180
+ """
1181
+ return self._impl.get_element_quality(idx)
1182
+
1183
+ def get_element_qualities(self) -> NDArray[np.float64]:
1184
+ """Get quality metrics for all elements.
1185
+
1186
+ Returns
1187
+ -------
1188
+ ndarray
1189
+ Quality metrics for all elements.
1190
+
1191
+ """
1192
+ return self._impl.get_element_qualities()
1193
+
1194
+ # =========================================================================
1195
+ # File I/O
1196
+ # =========================================================================
1197
+
1198
+ def save(self, filename: str | Path) -> None:
1199
+ """Save mesh to file.
1200
+
1201
+ Parameters
1202
+ ----------
1203
+ filename : str or Path
1204
+ Output file path. Format determined by extension.
1205
+
1206
+ """
1207
+ self._impl.save(filename)
1208
+
1209
+ # =========================================================================
1210
+ # Remeshing operations
1211
+ # =========================================================================
1212
+
1213
+ def _prepare_field_transfer(
1214
+ self,
1215
+ transfer_fields: FieldTransferParam,
1216
+ ) -> tuple[
1217
+ dict[str, NDArray[np.float64]],
1218
+ NDArray[np.float64] | None,
1219
+ NDArray[np.int32] | None,
1220
+ ]:
1221
+ """Prepare for field transfer before remeshing.
1222
+
1223
+ Returns fields to transfer, old vertices, and old elements.
1224
+ """
1225
+ fields_to_transfer: dict[str, NDArray[np.float64]] = {}
1226
+ if not transfer_fields:
1227
+ return fields_to_transfer, None, None
1228
+
1229
+ if transfer_fields is True:
1230
+ fields_to_transfer = dict(self._user_fields)
1231
+ else:
1232
+ for name in transfer_fields:
1233
+ if name in self._user_fields:
1234
+ fields_to_transfer[name] = self._user_fields[name]
1235
+ else:
1236
+ msg = f"User field '{name}' not found for transfer"
1237
+ raise KeyError(msg)
1238
+
1239
+ if not fields_to_transfer:
1240
+ return fields_to_transfer, None, None
1241
+
1242
+ old_vertices = self._impl.get_vertices().copy()
1243
+ if self._kind == MeshKind.TETRAHEDRAL:
1244
+ impl_3d = cast("MmgMesh3D", self._impl)
1245
+ old_elements = impl_3d.get_tetrahedra().copy()
1246
+ else:
1247
+ old_elements = self._impl.get_triangles().copy()
1248
+
1249
+ return fields_to_transfer, old_vertices, old_elements
1250
+
1251
+ def _execute_field_transfer(
1252
+ self,
1253
+ fields_to_transfer: dict[str, NDArray[np.float64]],
1254
+ old_vertices: NDArray[np.float64],
1255
+ old_elements: NDArray[np.int32],
1256
+ interpolation: str,
1257
+ ) -> None:
1258
+ """Execute field transfer after remeshing."""
1259
+ from mmgpy._transfer import transfer_fields as _transfer # noqa: PLC0415
1260
+
1261
+ new_vertices = self._impl.get_vertices()
1262
+ self._user_fields = _transfer(
1263
+ source_vertices=old_vertices,
1264
+ source_elements=old_elements,
1265
+ target_points=new_vertices,
1266
+ fields=fields_to_transfer,
1267
+ method=interpolation,
1268
+ )
1269
+
1270
+ def remesh( # noqa: C901, PLR0912
1271
+ self,
1272
+ options: Mmg3DOptions | Mmg2DOptions | MmgSOptions | None = None,
1273
+ *,
1274
+ progress: ProgressParam = True,
1275
+ transfer_fields: FieldTransferParam = False,
1276
+ interpolation: str = "linear",
1277
+ **kwargs: Any, # noqa: ANN401
1278
+ ) -> RemeshResult:
1279
+ """Remesh the mesh in-place.
1280
+
1281
+ Parameters
1282
+ ----------
1283
+ options : Mmg3DOptions | Mmg2DOptions | MmgSOptions, optional
1284
+ Options object for remeshing parameters.
1285
+ progress : bool | Callable[[ProgressEvent], bool] | None, default=True
1286
+ Progress reporting option:
1287
+ - True: Show Rich progress bar (default)
1288
+ - False or None: No progress reporting
1289
+ - Callable: Custom callback that receives ProgressEvent and returns
1290
+ True to continue or False to cancel
1291
+ transfer_fields : bool | Sequence[str] | None, default=False
1292
+ Transfer user-defined fields to the new mesh via interpolation:
1293
+ - False or None: No field transfer (default), clears existing user fields
1294
+ - True: Transfer all user fields
1295
+ - List of field names: Transfer only specified fields
1296
+ interpolation : str, default="linear"
1297
+ Interpolation method for field transfer:
1298
+ - "linear": Barycentric interpolation (recommended)
1299
+ - "nearest": Nearest vertex value
1300
+ **kwargs : float
1301
+ Individual remeshing parameters (hmin, hmax, hsiz, hausd, etc.).
1302
+
1303
+ Notes
1304
+ -----
1305
+ Memory: When ``transfer_fields`` is enabled, the original mesh vertices
1306
+ and elements are copied before remeshing, temporarily doubling memory
1307
+ usage for large meshes.
1308
+
1309
+ Surface meshes (TRIANGULAR_SURFACE): Field transfer uses 3D Delaunay
1310
+ triangulation for point location, which may not work well for nearly
1311
+ planar surface meshes. Consider using volumetric meshes for field transfer.
1312
+
1313
+ Returns
1314
+ -------
1315
+ RemeshResult
1316
+ Statistics from the remeshing operation.
1317
+
1318
+ Raises
1319
+ ------
1320
+ CancellationError
1321
+ If the progress callback returns False to cancel the operation.
1322
+
1323
+ Examples
1324
+ --------
1325
+ >>> mesh.remesh(hmax=0.1) # Shows progress bar by default
1326
+
1327
+ >>> mesh.remesh(hmax=0.1, progress=False) # No progress bar
1328
+
1329
+ >>> # Transfer fields during remeshing
1330
+ >>> mesh.set_user_field("temperature", temperature_array)
1331
+ >>> mesh.remesh(hmax=0.1, transfer_fields=True)
1332
+ >>> new_temp = mesh.get_user_field("temperature")
1333
+
1334
+ >>> # Transfer specific fields
1335
+ >>> mesh.remesh(hmax=0.1, transfer_fields=["temperature", "velocity"])
1336
+
1337
+ >>> def my_callback(event):
1338
+ ... print(f"{event.phase}: {event.message}")
1339
+ ... return True # Continue
1340
+ >>> mesh.remesh(hmax=0.1, progress=my_callback)
1341
+
1342
+ """
1343
+ from mmgpy._options import ( # noqa: PLC0415
1344
+ Mmg2DOptions,
1345
+ Mmg3DOptions,
1346
+ MmgSOptions,
1347
+ )
1348
+ from mmgpy._progress import CancellationError, _emit_event # noqa: PLC0415
1349
+
1350
+ # Validate interpolation method
1351
+ valid_methods = ("linear", "nearest")
1352
+ if interpolation not in valid_methods:
1353
+ msg = (
1354
+ f"Invalid interpolation method: {interpolation!r}. "
1355
+ f"Must be one of {valid_methods}"
1356
+ )
1357
+ raise ValueError(msg)
1358
+
1359
+ # Validate and convert options object
1360
+ if options is not None:
1361
+ if kwargs:
1362
+ msg = (
1363
+ "Cannot pass both options object and keyword arguments. "
1364
+ "Use one or the other."
1365
+ )
1366
+ raise TypeError(msg)
1367
+
1368
+ # Validate options type matches mesh type
1369
+ options_type_map = {
1370
+ MeshKind.TETRAHEDRAL: Mmg3DOptions,
1371
+ MeshKind.TRIANGULAR_2D: Mmg2DOptions,
1372
+ MeshKind.TRIANGULAR_SURFACE: MmgSOptions,
1373
+ }
1374
+ expected_type = options_type_map[self._kind]
1375
+ if not isinstance(options, expected_type):
1376
+ msg = (
1377
+ f"Expected {expected_type.__name__} for {self._kind.value} mesh, "
1378
+ f"got {type(options).__name__}"
1379
+ )
1380
+ raise TypeError(msg)
1381
+ kwargs = options.to_dict()
1382
+
1383
+ # Apply sizing constraints before remeshing
1384
+ if self._sizing_constraints:
1385
+ self._apply_sizing_to_metric()
1386
+
1387
+ # Prepare field transfer
1388
+ fields_to_transfer, old_vertices, old_elements = self._prepare_field_transfer(
1389
+ transfer_fields,
1390
+ )
1391
+
1392
+ # Resolve progress callback
1393
+ callback, reporter_ctx = _resolve_progress_callback(progress)
1394
+ if reporter_ctx is not None: # pragma: no cover
1395
+ reporter_ctx.__enter__()
1396
+
1397
+ try:
1398
+ # Emit progress events
1399
+ if not _emit_event(callback, "init", "start", "Initializing", progress=0.0):
1400
+ raise CancellationError.for_phase("init") # noqa: EM101
1401
+
1402
+ initial_vertices = len(self._impl.get_vertices())
1403
+
1404
+ if not _emit_event(callback, "options", "start", "Options", progress=0.0):
1405
+ raise CancellationError.for_phase("options") # noqa: EM101
1406
+
1407
+ _emit_event(callback, "options", "complete", "Options set", progress=1.0)
1408
+
1409
+ if not _emit_event(callback, "remesh", "start", "Remeshing", progress=0.0):
1410
+ raise CancellationError.for_phase("remesh") # noqa: EM101
1411
+
1412
+ # Call raw C++ method and convert result
1413
+ stats = self._impl.remesh(**kwargs) # type: ignore[arg-type]
1414
+ final_vertices = len(self._impl.get_vertices())
1415
+
1416
+ # Transfer fields to new mesh if captured, otherwise clear stale fields
1417
+ if (
1418
+ fields_to_transfer
1419
+ and old_vertices is not None
1420
+ and old_elements is not None
1421
+ ):
1422
+ self._execute_field_transfer(
1423
+ fields_to_transfer,
1424
+ old_vertices,
1425
+ old_elements,
1426
+ interpolation,
1427
+ )
1428
+ else:
1429
+ # Clear user fields as they have incorrect vertex count after remeshing
1430
+ self._user_fields.clear()
1431
+
1432
+ _emit_event(
1433
+ callback,
1434
+ "remesh",
1435
+ "complete",
1436
+ "Remeshing complete",
1437
+ progress=1.0,
1438
+ details={
1439
+ "initial_vertices": initial_vertices,
1440
+ "final_vertices": final_vertices,
1441
+ "vertex_change": final_vertices - initial_vertices,
1442
+ },
1443
+ )
1444
+ return _dict_to_remesh_result(stats)
1445
+ finally:
1446
+ if reporter_ctx is not None: # pragma: no cover
1447
+ reporter_ctx.__exit__(None, None, None)
1448
+
1449
+ def remesh_lagrangian(
1450
+ self,
1451
+ displacement: NDArray[np.float64],
1452
+ *,
1453
+ progress: ProgressParam = True,
1454
+ **kwargs: Any, # noqa: ANN401
1455
+ ) -> RemeshResult:
1456
+ """Remesh with Lagrangian motion.
1457
+
1458
+ Only available for TETRAHEDRAL and TRIANGULAR_2D meshes.
1459
+
1460
+ Parameters
1461
+ ----------
1462
+ displacement : ndarray
1463
+ Displacement field for each vertex.
1464
+ progress : bool | Callable[[ProgressEvent], bool] | None, default=True
1465
+ Progress reporting option:
1466
+ - True: Show Rich progress bar (default)
1467
+ - False or None: No progress reporting
1468
+ - Callable: Custom callback that receives ProgressEvent and returns
1469
+ True to continue or False to cancel
1470
+ **kwargs : float
1471
+ Additional remeshing parameters.
1472
+
1473
+ Returns
1474
+ -------
1475
+ RemeshResult
1476
+ Statistics from the remeshing operation.
1477
+
1478
+ Raises
1479
+ ------
1480
+ TypeError
1481
+ If mesh is TRIANGULAR_SURFACE.
1482
+ CancellationError
1483
+ If the progress callback returns False to cancel the operation.
1484
+
1485
+ """
1486
+ from mmgpy._progress import CancellationError, _emit_event # noqa: PLC0415
1487
+
1488
+ if self._kind == MeshKind.TRIANGULAR_SURFACE:
1489
+ msg = "remesh_lagrangian() is not available for TRIANGULAR_SURFACE meshes"
1490
+ raise TypeError(msg)
1491
+
1492
+ callback, reporter_ctx = _resolve_progress_callback(progress)
1493
+ if reporter_ctx is not None: # pragma: no cover
1494
+ reporter_ctx.__enter__()
1495
+
1496
+ try:
1497
+ if not _emit_event(callback, "init", "start", "Initializing", progress=0.0):
1498
+ raise CancellationError.for_phase("init") # noqa: EM101
1499
+
1500
+ initial_vertices = len(self._impl.get_vertices())
1501
+
1502
+ if not _emit_event(
1503
+ callback,
1504
+ "options",
1505
+ "start",
1506
+ "Displacement",
1507
+ progress=0.0,
1508
+ ):
1509
+ raise CancellationError.for_phase("options") # noqa: EM101
1510
+
1511
+ _emit_event(
1512
+ callback,
1513
+ "options",
1514
+ "complete",
1515
+ "Displacement set",
1516
+ progress=1.0,
1517
+ )
1518
+
1519
+ if not _emit_event(
1520
+ callback,
1521
+ "remesh",
1522
+ "start",
1523
+ "Lagrangian remeshing",
1524
+ progress=0.0,
1525
+ ):
1526
+ raise CancellationError.for_phase("remesh") # noqa: EM101
1527
+
1528
+ impl = cast("MmgMesh3D | MmgMesh2D", self._impl)
1529
+ stats = impl.remesh_lagrangian(displacement, **kwargs) # type: ignore[arg-type]
1530
+ final_vertices = len(self._impl.get_vertices())
1531
+
1532
+ _emit_event(
1533
+ callback,
1534
+ "remesh",
1535
+ "complete",
1536
+ "Lagrangian complete",
1537
+ progress=1.0,
1538
+ details={
1539
+ "initial_vertices": initial_vertices,
1540
+ "final_vertices": final_vertices,
1541
+ "vertex_change": final_vertices - initial_vertices,
1542
+ },
1543
+ )
1544
+ return _dict_to_remesh_result(stats)
1545
+ finally:
1546
+ if reporter_ctx is not None: # pragma: no cover
1547
+ reporter_ctx.__exit__(None, None, None)
1548
+
1549
+ def remesh_levelset(
1550
+ self,
1551
+ levelset: NDArray[np.float64],
1552
+ *,
1553
+ progress: ProgressParam = True,
1554
+ **kwargs: Any, # noqa: ANN401
1555
+ ) -> RemeshResult:
1556
+ """Remesh with level-set discretization.
1557
+
1558
+ Parameters
1559
+ ----------
1560
+ levelset : ndarray
1561
+ Level-set field for each vertex.
1562
+ progress : bool | Callable[[ProgressEvent], bool] | None, default=True
1563
+ Progress reporting option:
1564
+ - True: Show Rich progress bar (default)
1565
+ - False or None: No progress reporting
1566
+ - Callable: Custom callback that receives ProgressEvent and returns
1567
+ True to continue or False to cancel
1568
+ **kwargs : float
1569
+ Additional remeshing parameters.
1570
+
1571
+ Returns
1572
+ -------
1573
+ RemeshResult
1574
+ Statistics from the remeshing operation.
1575
+
1576
+ Raises
1577
+ ------
1578
+ CancellationError
1579
+ If the progress callback returns False to cancel the operation.
1580
+
1581
+ """
1582
+ from mmgpy._progress import CancellationError, _emit_event # noqa: PLC0415
1583
+
1584
+ callback, reporter_ctx = _resolve_progress_callback(progress)
1585
+ if reporter_ctx is not None: # pragma: no cover
1586
+ reporter_ctx.__enter__()
1587
+
1588
+ try:
1589
+ if not _emit_event(callback, "init", "start", "Initializing", progress=0.0):
1590
+ raise CancellationError.for_phase("init") # noqa: EM101
1591
+
1592
+ initial_vertices = len(self._impl.get_vertices())
1593
+
1594
+ if not _emit_event(callback, "options", "start", "Level-set", progress=0.0):
1595
+ raise CancellationError.for_phase("options") # noqa: EM101
1596
+
1597
+ _emit_event(callback, "options", "complete", "Level-set set", progress=1.0)
1598
+
1599
+ if not _emit_event(
1600
+ callback,
1601
+ "remesh",
1602
+ "start",
1603
+ "Level-set remeshing",
1604
+ progress=0.0,
1605
+ ):
1606
+ raise CancellationError.for_phase("remesh") # noqa: EM101
1607
+
1608
+ stats = self._impl.remesh_levelset(levelset, **kwargs) # type: ignore[arg-type]
1609
+ final_vertices = len(self._impl.get_vertices())
1610
+
1611
+ _emit_event(
1612
+ callback,
1613
+ "remesh",
1614
+ "complete",
1615
+ "Level-set complete",
1616
+ progress=1.0,
1617
+ details={
1618
+ "initial_vertices": initial_vertices,
1619
+ "final_vertices": final_vertices,
1620
+ "vertex_change": final_vertices - initial_vertices,
1621
+ },
1622
+ )
1623
+ return _dict_to_remesh_result(stats)
1624
+ finally:
1625
+ if reporter_ctx is not None: # pragma: no cover
1626
+ reporter_ctx.__exit__(None, None, None)
1627
+
1628
+ def remesh_optimize(
1629
+ self,
1630
+ *,
1631
+ progress: ProgressParam = True,
1632
+ verbose: int | None = None,
1633
+ ) -> RemeshResult:
1634
+ """Optimize mesh quality without changing topology.
1635
+
1636
+ Only moves vertices to improve element quality.
1637
+ No points are inserted or removed.
1638
+
1639
+ Parameters
1640
+ ----------
1641
+ progress : bool | Callable[[ProgressEvent], bool] | None, default=True
1642
+ Progress reporting option:
1643
+ - True: Show Rich progress bar (default)
1644
+ - False or None: No progress reporting
1645
+ - Callable: Custom callback
1646
+ verbose : int | None
1647
+ Verbosity level (-1=silent, 0=errors, 1=info).
1648
+
1649
+ Returns
1650
+ -------
1651
+ RemeshResult
1652
+ Statistics from the remeshing operation.
1653
+
1654
+ """
1655
+ opts: dict[str, int | float] = {"optim": 1, "noinsert": 1}
1656
+ if verbose is not None:
1657
+ opts["verbose"] = verbose
1658
+ return self.remesh(progress=progress, **opts) # type: ignore[arg-type]
1659
+
1660
+ def remesh_uniform(
1661
+ self,
1662
+ size: float,
1663
+ *,
1664
+ progress: ProgressParam = True,
1665
+ verbose: int | None = None,
1666
+ ) -> RemeshResult:
1667
+ """Remesh with uniform element size.
1668
+
1669
+ Parameters
1670
+ ----------
1671
+ size : float
1672
+ Target edge size for all elements.
1673
+ progress : bool | Callable[[ProgressEvent], bool] | None, default=True
1674
+ Progress reporting option:
1675
+ - True: Show Rich progress bar (default)
1676
+ - False or None: No progress reporting
1677
+ - Callable: Custom callback
1678
+ verbose : int | None
1679
+ Verbosity level (-1=silent, 0=errors, 1=info).
1680
+
1681
+ Returns
1682
+ -------
1683
+ RemeshResult
1684
+ Statistics from the remeshing operation.
1685
+
1686
+ """
1687
+ opts: dict[str, int | float] = {"hsiz": size}
1688
+ if verbose is not None:
1689
+ opts["verbose"] = verbose
1690
+ return self.remesh(progress=progress, **opts) # type: ignore[arg-type]
1691
+
1692
+ # =========================================================================
1693
+ # Local sizing constraints
1694
+ # =========================================================================
1695
+
1696
+ def set_size_sphere(
1697
+ self,
1698
+ center: Sequence[float] | NDArray[np.float64],
1699
+ radius: float,
1700
+ size: float,
1701
+ ) -> None:
1702
+ """Set target edge size within a spherical region.
1703
+
1704
+ Parameters
1705
+ ----------
1706
+ center : array-like
1707
+ Center point of the sphere.
1708
+ radius : float
1709
+ Radius of the sphere.
1710
+ size : float
1711
+ Target edge size within the sphere.
1712
+
1713
+ """
1714
+ from mmgpy.sizing import SphereSize # noqa: PLC0415
1715
+
1716
+ center_arr = np.asarray(center, dtype=np.float64)
1717
+ self._sizing_constraints.append(SphereSize(center_arr, radius, size))
1718
+
1719
+ def set_size_box(
1720
+ self,
1721
+ bounds: Sequence[Sequence[float]] | NDArray[np.float64],
1722
+ size: float,
1723
+ ) -> None:
1724
+ """Set target edge size within a box region.
1725
+
1726
+ Parameters
1727
+ ----------
1728
+ bounds : array-like
1729
+ Bounding box as [[xmin, ymin, zmin], [xmax, ymax, zmax]].
1730
+ size : float
1731
+ Target edge size within the box.
1732
+
1733
+ """
1734
+ from mmgpy.sizing import BoxSize # noqa: PLC0415
1735
+
1736
+ bounds_arr = np.asarray(bounds, dtype=np.float64)
1737
+ self._sizing_constraints.append(BoxSize(bounds_arr, size))
1738
+
1739
+ def set_size_cylinder(
1740
+ self,
1741
+ point1: Sequence[float] | NDArray[np.float64],
1742
+ point2: Sequence[float] | NDArray[np.float64],
1743
+ radius: float,
1744
+ size: float,
1745
+ ) -> None:
1746
+ """Set target edge size within a cylindrical region.
1747
+
1748
+ Only available for TETRAHEDRAL and TRIANGULAR_SURFACE meshes.
1749
+
1750
+ Parameters
1751
+ ----------
1752
+ point1 : array-like
1753
+ First endpoint of the cylinder axis.
1754
+ point2 : array-like
1755
+ Second endpoint of the cylinder axis.
1756
+ radius : float
1757
+ Radius of the cylinder.
1758
+ size : float
1759
+ Target edge size within the cylinder.
1760
+
1761
+ Raises
1762
+ ------
1763
+ TypeError
1764
+ If mesh is TRIANGULAR_2D.
1765
+
1766
+ """
1767
+ if self._kind == MeshKind.TRIANGULAR_2D:
1768
+ msg = "set_size_cylinder() is not available for TRIANGULAR_2D meshes"
1769
+ raise TypeError(msg)
1770
+
1771
+ from mmgpy.sizing import CylinderSize # noqa: PLC0415
1772
+
1773
+ point1_arr = np.asarray(point1, dtype=np.float64)
1774
+ point2_arr = np.asarray(point2, dtype=np.float64)
1775
+ self._sizing_constraints.append(
1776
+ CylinderSize(point1_arr, point2_arr, radius, size),
1777
+ )
1778
+
1779
+ def set_size_from_point(
1780
+ self,
1781
+ point: Sequence[float] | NDArray[np.float64],
1782
+ near_size: float,
1783
+ far_size: float,
1784
+ influence_radius: float,
1785
+ ) -> None:
1786
+ """Set target edge size based on distance from a point.
1787
+
1788
+ Parameters
1789
+ ----------
1790
+ point : array-like
1791
+ Reference point.
1792
+ near_size : float
1793
+ Target edge size at the reference point.
1794
+ far_size : float
1795
+ Target edge size at the influence radius.
1796
+ influence_radius : float
1797
+ Radius of influence.
1798
+
1799
+ """
1800
+ from mmgpy.sizing import PointSize # noqa: PLC0415
1801
+
1802
+ point_arr = np.asarray(point, dtype=np.float64)
1803
+ self._sizing_constraints.append(
1804
+ PointSize(point_arr, near_size, far_size, influence_radius),
1805
+ )
1806
+
1807
+ def clear_local_sizing(self) -> None:
1808
+ """Clear all local sizing constraints."""
1809
+ self._sizing_constraints.clear()
1810
+
1811
+ def get_local_sizing_count(self) -> int:
1812
+ """Get the number of local sizing constraints.
1813
+
1814
+ Returns
1815
+ -------
1816
+ int
1817
+ Number of sizing constraints.
1818
+
1819
+ """
1820
+ return len(self._sizing_constraints)
1821
+
1822
+ def apply_local_sizing(self) -> None:
1823
+ """Apply local sizing constraints to the metric field.
1824
+
1825
+ This is called automatically before remeshing if sizing
1826
+ constraints have been added.
1827
+ """
1828
+ self._apply_sizing_to_metric()
1829
+
1830
+ def _apply_sizing_to_metric(self) -> None:
1831
+ """Apply sizing constraints to the metric field."""
1832
+ if not self._sizing_constraints:
1833
+ return
1834
+
1835
+ from mmgpy.sizing import apply_sizing_constraints # noqa: PLC0415
1836
+
1837
+ # apply_sizing_constraints expects a mesh object and sets the field directly
1838
+ apply_sizing_constraints(self._impl, self._sizing_constraints)
1839
+
1840
+ # =========================================================================
1841
+ # PyVista conversion
1842
+ # =========================================================================
1843
+
1844
+ def to_pyvista(
1845
+ self,
1846
+ *,
1847
+ include_refs: bool = True,
1848
+ ) -> pv.UnstructuredGrid | pv.PolyData:
1849
+ """Convert to PyVista mesh.
1850
+
1851
+ Parameters
1852
+ ----------
1853
+ include_refs : bool
1854
+ Include reference markers as cell data.
1855
+
1856
+ Returns
1857
+ -------
1858
+ pv.UnstructuredGrid | pv.PolyData
1859
+ PyVista mesh object.
1860
+
1861
+ """
1862
+ from mmgpy._pyvista import to_pyvista as _to_pyvista # noqa: PLC0415
1863
+
1864
+ return _to_pyvista(self._impl, include_refs=include_refs)
1865
+
1866
+ @property
1867
+ def vtk(self) -> pv.UnstructuredGrid | pv.PolyData:
1868
+ """Get the PyVista mesh representation.
1869
+
1870
+ This property provides direct access to the PyVista mesh for use with
1871
+ custom plotters or other PyVista operations.
1872
+
1873
+ Returns
1874
+ -------
1875
+ pv.UnstructuredGrid | pv.PolyData
1876
+ PyVista mesh object.
1877
+
1878
+ Examples
1879
+ --------
1880
+ >>> plotter = pv.Plotter()
1881
+ >>> plotter.add_mesh(mesh.vtk, show_edges=True)
1882
+ >>> plotter.show()
1883
+
1884
+ """
1885
+ return self.to_pyvista()
1886
+
1887
+ def plot(
1888
+ self,
1889
+ *,
1890
+ show_edges: bool = True,
1891
+ **kwargs: Any, # noqa: ANN401
1892
+ ) -> None:
1893
+ """Plot the mesh using PyVista.
1894
+
1895
+ Parameters
1896
+ ----------
1897
+ show_edges : bool
1898
+ Show mesh edges (default: True).
1899
+ **kwargs : Any
1900
+ Additional arguments passed to PyVista's plot() method.
1901
+
1902
+ Examples
1903
+ --------
1904
+ >>> mesh = Mesh(vertices, cells)
1905
+ >>> mesh.plot() # Simple plot with edges
1906
+
1907
+ >>> mesh.plot(color="blue", opacity=0.8) # Custom styling
1908
+
1909
+ """
1910
+ self.to_pyvista().plot(show_edges=show_edges, **kwargs)
1911
+
1912
+ # =========================================================================
1913
+ # Validation
1914
+ # =========================================================================
1915
+
1916
+ def validate( # noqa: PLR0913
1917
+ self,
1918
+ *,
1919
+ detailed: bool = False,
1920
+ strict: bool = False,
1921
+ check_geometry: bool = True,
1922
+ check_topology: bool = True,
1923
+ check_quality: bool = True,
1924
+ min_quality: float = 0.1,
1925
+ ) -> bool | ValidationReport:
1926
+ """Validate the mesh and check for issues.
1927
+
1928
+ Parameters
1929
+ ----------
1930
+ detailed : bool
1931
+ If True, return a ValidationReport with detailed information.
1932
+ If False, return a simple boolean.
1933
+ strict : bool
1934
+ If True, raise ValidationError on any issue (including warnings).
1935
+ check_geometry : bool
1936
+ Check for geometric issues (inverted/degenerate elements).
1937
+ check_topology : bool
1938
+ Check for topological issues (orphan vertices, non-manifold edges).
1939
+ check_quality : bool
1940
+ Check element quality against threshold.
1941
+ min_quality : float
1942
+ Minimum acceptable element quality (0-1).
1943
+
1944
+ Returns
1945
+ -------
1946
+ bool | ValidationReport
1947
+ If detailed=False, returns True if valid, False otherwise.
1948
+ If detailed=True, returns full ValidationReport.
1949
+
1950
+ Raises
1951
+ ------
1952
+ ValidationError
1953
+ If strict=True and any issues are found.
1954
+
1955
+ Examples
1956
+ --------
1957
+ >>> mesh = Mesh(vertices, cells)
1958
+ >>> if mesh.validate():
1959
+ ... print("Mesh is valid")
1960
+
1961
+ >>> report = mesh.validate(detailed=True)
1962
+ >>> print(f"Quality: {report.quality.mean:.3f}")
1963
+
1964
+ """
1965
+ from mmgpy._validation import ( # noqa: PLC0415
1966
+ ValidationError,
1967
+ validate_mesh_2d,
1968
+ validate_mesh_3d,
1969
+ validate_mesh_surface,
1970
+ )
1971
+
1972
+ # Dispatch to the correct validation function based on mesh kind
1973
+ if self._kind == MeshKind.TETRAHEDRAL:
1974
+ report = validate_mesh_3d(
1975
+ cast("MmgMesh3D", self._impl),
1976
+ check_geometry=check_geometry,
1977
+ check_topology=check_topology,
1978
+ check_quality=check_quality,
1979
+ min_quality=min_quality,
1980
+ )
1981
+ elif self._kind == MeshKind.TRIANGULAR_2D:
1982
+ report = validate_mesh_2d(
1983
+ cast("MmgMesh2D", self._impl),
1984
+ check_geometry=check_geometry,
1985
+ check_topology=check_topology,
1986
+ check_quality=check_quality,
1987
+ min_quality=min_quality,
1988
+ )
1989
+ else: # TRIANGULAR_SURFACE
1990
+ report = validate_mesh_surface(
1991
+ cast("MmgMeshS", self._impl),
1992
+ check_geometry=check_geometry,
1993
+ check_topology=check_topology,
1994
+ check_quality=check_quality,
1995
+ min_quality=min_quality,
1996
+ )
1997
+
1998
+ # Handle strict mode - raise on any issues
1999
+ if strict and (report.errors or report.warnings):
2000
+ raise ValidationError(report)
2001
+
2002
+ # Return report or boolean based on detailed flag
2003
+ if detailed:
2004
+ return report
2005
+ return report.is_valid
2006
+
2007
+ # =========================================================================
2008
+ # Interactive editing
2009
+ # =========================================================================
2010
+
2011
+ def edit_sizing(
2012
+ self,
2013
+ *,
2014
+ mode: str = "sphere",
2015
+ default_size: float = 0.01,
2016
+ default_radius: float = 0.1,
2017
+ ) -> None:
2018
+ """Launch interactive sizing editor.
2019
+
2020
+ Opens a PyVista window for visually defining local sizing
2021
+ constraints by clicking on the mesh.
2022
+
2023
+ Parameters
2024
+ ----------
2025
+ mode : str
2026
+ Initial interaction mode: "sphere", "box", "cylinder", or "point".
2027
+ default_size : float
2028
+ Default target edge size for constraints.
2029
+ default_radius : float
2030
+ Default radius for sphere and cylinder constraints.
2031
+
2032
+ Examples
2033
+ --------
2034
+ >>> mesh = Mesh(vertices, cells)
2035
+ >>> mesh.edit_sizing() # Opens interactive editor
2036
+ >>> mesh.remesh() # Uses interactively defined sizing
2037
+
2038
+ """
2039
+ from mmgpy.interactive import SizingEditor # noqa: PLC0415
2040
+
2041
+ editor = SizingEditor(self._impl)
2042
+ editor._current_size = default_size # noqa: SLF001
2043
+ editor._current_radius = default_radius # noqa: SLF001
2044
+
2045
+ mode_map = {
2046
+ "sphere": editor.add_sphere_tool,
2047
+ "box": editor.add_box_tool,
2048
+ "cylinder": editor.add_cylinder_tool,
2049
+ "point": editor.add_point_tool,
2050
+ }
2051
+
2052
+ if mode in mode_map:
2053
+ mode_map[mode]()
2054
+
2055
+ editor.run()
2056
+ editor.apply_to_mesh()
2057
+
2058
+ # =========================================================================
2059
+ # Context manager support
2060
+ # =========================================================================
2061
+
2062
+ def __enter__(self) -> Mesh: # noqa: PYI034
2063
+ """Enter the context manager.
2064
+
2065
+ Returns
2066
+ -------
2067
+ Mesh
2068
+ The mesh instance.
2069
+
2070
+ Examples
2071
+ --------
2072
+ >>> with Mesh(vertices, cells) as mesh:
2073
+ ... mesh.remesh(hmax=0.1)
2074
+ ... mesh.save("output.vtk")
2075
+
2076
+ """
2077
+ return self
2078
+
2079
+ def __exit__(
2080
+ self,
2081
+ exc_type: type[BaseException] | None,
2082
+ exc_val: BaseException | None,
2083
+ exc_tb: TracebackType | None,
2084
+ ) -> bool:
2085
+ """Exit the context manager.
2086
+
2087
+ Currently performs no cleanup, but provides a consistent API
2088
+ for resource management patterns.
2089
+
2090
+ Returns
2091
+ -------
2092
+ bool
2093
+ False, to not suppress any exceptions.
2094
+
2095
+ """
2096
+ return False
2097
+
2098
+ def checkpoint(self) -> MeshCheckpoint:
2099
+ """Create a checkpoint for transactional modifications.
2100
+
2101
+ Returns a context manager that captures the current mesh state.
2102
+ On exit, if `commit()` was not called or an exception occurred,
2103
+ the mesh is automatically rolled back to its checkpoint state.
2104
+
2105
+ Returns
2106
+ -------
2107
+ MeshCheckpoint
2108
+ A context manager for transactional mesh modifications.
2109
+
2110
+ Notes
2111
+ -----
2112
+ The checkpoint stores a complete copy of the mesh data including
2113
+ vertices, elements, reference markers, and solution fields
2114
+ (metric, displacement, levelset). For large meshes, this may
2115
+ consume significant memory.
2116
+
2117
+ Note: The tensor field is not saved because it shares memory with
2118
+ metric in MMG's internal representation.
2119
+
2120
+ Examples
2121
+ --------
2122
+ >>> mesh = Mesh(vertices, cells)
2123
+ >>> with mesh.checkpoint() as snapshot:
2124
+ ... mesh.remesh(hmax=0.01)
2125
+ ... if mesh.validate():
2126
+ ... snapshot.commit() # Keep changes
2127
+ ... # If not committed, changes are rolled back
2128
+
2129
+ >>> # Automatic rollback on exception
2130
+ >>> with mesh.checkpoint():
2131
+ ... mesh.remesh(hmax=0.01)
2132
+ ... raise ValueError("Simulated failure")
2133
+ >>> # mesh is restored to original state
2134
+
2135
+ """
2136
+ vertices, vertex_refs = self._impl.get_vertices_with_refs()
2137
+ triangles, triangle_refs = self._impl.get_triangles_with_refs()
2138
+ edges, edge_refs = self._impl.get_edges_with_refs()
2139
+
2140
+ tetrahedra = None
2141
+ tetrahedra_refs = None
2142
+ if self._kind == MeshKind.TETRAHEDRAL:
2143
+ impl_3d = cast("MmgMesh3D", self._impl)
2144
+ tetrahedra, tetrahedra_refs = impl_3d.get_tetrahedra_with_refs()
2145
+
2146
+ # Save solution fields (metric, displacement, levelset)
2147
+ # Note: tensor is not saved because it shares memory with metric in MMG
2148
+ fields: dict[str, NDArray[np.float64]] = {}
2149
+ for field_name in ("metric", "displacement", "levelset"):
2150
+ field_data = self._try_get_field(field_name)
2151
+ if field_data is not None:
2152
+ fields[field_name] = field_data.copy()
2153
+
2154
+ return MeshCheckpoint(
2155
+ _mesh=self,
2156
+ _vertices=vertices.copy(),
2157
+ _vertex_refs=vertex_refs.copy(),
2158
+ _triangles=triangles.copy(),
2159
+ _triangle_refs=triangle_refs.copy(),
2160
+ _edges=edges.copy(),
2161
+ _edge_refs=edge_refs.copy(),
2162
+ _tetrahedra=tetrahedra.copy() if tetrahedra is not None else None,
2163
+ _tetrahedra_refs=(
2164
+ tetrahedra_refs.copy() if tetrahedra_refs is not None else None
2165
+ ),
2166
+ _fields=fields,
2167
+ )
2168
+
2169
+ @contextmanager
2170
+ def copy(self) -> Generator[Mesh, None, None]:
2171
+ """Create a working copy that is discarded on exit.
2172
+
2173
+ Returns a context manager that yields a copy of the mesh.
2174
+ The copy can be freely modified without affecting the original.
2175
+ Use `update_from()` to apply changes from the copy to the original.
2176
+
2177
+ Yields
2178
+ ------
2179
+ Mesh
2180
+ A copy of this mesh.
2181
+
2182
+ Examples
2183
+ --------
2184
+ >>> original = Mesh(vertices, cells)
2185
+ >>> with original.copy() as working:
2186
+ ... working.remesh(hmax=0.1)
2187
+ ... if len(working.get_vertices()) < len(original.get_vertices()) * 2:
2188
+ ... original.update_from(working)
2189
+ >>> # working is discarded on exit
2190
+
2191
+ """
2192
+ vertices = self._impl.get_vertices().copy()
2193
+
2194
+ if self._kind == MeshKind.TETRAHEDRAL:
2195
+ impl_3d = cast("MmgMesh3D", self._impl)
2196
+ cells = impl_3d.get_tetrahedra().copy()
2197
+ else:
2198
+ cells = self._impl.get_triangles().copy()
2199
+
2200
+ working = Mesh(vertices, cells)
2201
+
2202
+ try:
2203
+ yield working
2204
+ finally:
2205
+ # No cleanup needed - working mesh is garbage collected automatically
2206
+ # when it goes out of scope. The finally block is kept for future
2207
+ # extensibility (e.g., releasing resources or logging).
2208
+ pass
2209
+
2210
+ def update_from(self, other: Mesh) -> None:
2211
+ """Update this mesh from another mesh's state.
2212
+
2213
+ Replaces the vertices and elements of this mesh with those from
2214
+ the other mesh. Both meshes must be of the same kind.
2215
+
2216
+ Parameters
2217
+ ----------
2218
+ other : Mesh
2219
+ The mesh to copy state from.
2220
+
2221
+ Raises
2222
+ ------
2223
+ TypeError
2224
+ If the meshes are of different kinds.
2225
+
2226
+ Examples
2227
+ --------
2228
+ >>> original = Mesh(vertices, cells)
2229
+ >>> with original.copy() as working:
2230
+ ... working.remesh(hmax=0.1)
2231
+ ... original.update_from(working)
2232
+
2233
+ """
2234
+ if self._kind != other._kind:
2235
+ msg = f"Cannot update {self._kind.value} mesh from {other._kind.value} mesh"
2236
+ raise TypeError(msg)
2237
+
2238
+ vertices, vertex_refs = other._impl.get_vertices_with_refs()
2239
+ triangles, triangle_refs = other._impl.get_triangles_with_refs()
2240
+ edges, edge_refs = other._impl.get_edges_with_refs()
2241
+
2242
+ if self._kind == MeshKind.TETRAHEDRAL:
2243
+ other_impl = cast("MmgMesh3D", other._impl)
2244
+ self_impl = cast("MmgMesh3D", self._impl)
2245
+ tetrahedra, tetrahedra_refs = other_impl.get_tetrahedra_with_refs()
2246
+ self_impl.set_mesh_size(
2247
+ vertices=len(vertices),
2248
+ tetrahedra=len(tetrahedra),
2249
+ triangles=len(triangles),
2250
+ edges=len(edges),
2251
+ )
2252
+ self_impl.set_vertices(vertices, vertex_refs)
2253
+ self_impl.set_tetrahedra(tetrahedra, tetrahedra_refs)
2254
+ if len(triangles) > 0:
2255
+ self_impl.set_triangles(triangles, triangle_refs)
2256
+ if len(edges) > 0:
2257
+ self_impl.set_edges(edges, edge_refs)
2258
+ elif self._kind == MeshKind.TRIANGULAR_2D:
2259
+ impl_2d = cast("MmgMesh2D", self._impl)
2260
+ impl_2d.set_mesh_size(
2261
+ vertices=len(vertices),
2262
+ triangles=len(triangles),
2263
+ edges=len(edges),
2264
+ )
2265
+ impl_2d.set_vertices(vertices, vertex_refs)
2266
+ impl_2d.set_triangles(triangles, triangle_refs)
2267
+ if len(edges) > 0:
2268
+ impl_2d.set_edges(edges, edge_refs)
2269
+ else: # TRIANGULAR_SURFACE
2270
+ impl_s = cast("MmgMeshS", self._impl)
2271
+ impl_s.set_mesh_size(
2272
+ vertices=len(vertices),
2273
+ triangles=len(triangles),
2274
+ edges=len(edges),
2275
+ )
2276
+ impl_s.set_vertices(vertices, vertex_refs)
2277
+ impl_s.set_triangles(triangles, triangle_refs)
2278
+ if len(edges) > 0:
2279
+ impl_s.set_edges(edges, edge_refs)
2280
+
2281
+
2282
+ __all__ = [
2283
+ "Mesh",
2284
+ "MeshCheckpoint",
2285
+ "MeshKind",
2286
+ ]