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.
- mmgpy/__init__.py +296 -0
- mmgpy/__main__.py +13 -0
- mmgpy/_io.py +535 -0
- mmgpy/_logging.py +290 -0
- mmgpy/_mesh.py +2286 -0
- mmgpy/_mmgpy.cpython-311-x86_64-linux-gnu.so +0 -0
- mmgpy/_mmgpy.pyi +2140 -0
- mmgpy/_options.py +304 -0
- mmgpy/_progress.py +850 -0
- mmgpy/_pyvista.py +410 -0
- mmgpy/_result.py +143 -0
- mmgpy/_transfer.py +273 -0
- mmgpy/_validation.py +669 -0
- mmgpy/_version.py +3 -0
- mmgpy/_version.py.in +3 -0
- mmgpy/bin/mmg2d_O3 +0 -0
- mmgpy/bin/mmg3d_O3 +0 -0
- mmgpy/bin/mmgs_O3 +0 -0
- mmgpy/interactive/__init__.py +24 -0
- mmgpy/interactive/sizing_editor.py +790 -0
- mmgpy/lagrangian.py +394 -0
- mmgpy/lib/libmmg2d.so +0 -0
- mmgpy/lib/libmmg2d.so.5 +0 -0
- mmgpy/lib/libmmg2d.so.5.8.0 +0 -0
- mmgpy/lib/libmmg3d.so +0 -0
- mmgpy/lib/libmmg3d.so.5 +0 -0
- mmgpy/lib/libmmg3d.so.5.8.0 +0 -0
- mmgpy/lib/libmmgs.so +0 -0
- mmgpy/lib/libmmgs.so.5 +0 -0
- mmgpy/lib/libmmgs.so.5.8.0 +0 -0
- mmgpy/lib/libvtkCommonColor-9.5.so.1 +0 -0
- mmgpy/lib/libvtkCommonComputationalGeometry-9.5.so.1 +0 -0
- mmgpy/lib/libvtkCommonCore-9.5.so.1 +0 -0
- mmgpy/lib/libvtkCommonDataModel-9.5.so.1 +0 -0
- mmgpy/lib/libvtkCommonExecutionModel-9.5.so.1 +0 -0
- mmgpy/lib/libvtkCommonMath-9.5.so.1 +0 -0
- mmgpy/lib/libvtkCommonMisc-9.5.so.1 +0 -0
- mmgpy/lib/libvtkCommonSystem-9.5.so.1 +0 -0
- mmgpy/lib/libvtkCommonTransforms-9.5.so.1 +0 -0
- mmgpy/lib/libvtkDICOMParser-9.5.so.1 +0 -0
- mmgpy/lib/libvtkFiltersCellGrid-9.5.so.1 +0 -0
- mmgpy/lib/libvtkFiltersCore-9.5.so.1 +0 -0
- mmgpy/lib/libvtkFiltersExtraction-9.5.so.1 +0 -0
- mmgpy/lib/libvtkFiltersGeneral-9.5.so.1 +0 -0
- mmgpy/lib/libvtkFiltersGeometry-9.5.so.1 +0 -0
- mmgpy/lib/libvtkFiltersHybrid-9.5.so.1 +0 -0
- mmgpy/lib/libvtkFiltersHyperTree-9.5.so.1 +0 -0
- mmgpy/lib/libvtkFiltersModeling-9.5.so.1 +0 -0
- mmgpy/lib/libvtkFiltersParallel-9.5.so.1 +0 -0
- mmgpy/lib/libvtkFiltersReduction-9.5.so.1 +0 -0
- mmgpy/lib/libvtkFiltersSources-9.5.so.1 +0 -0
- mmgpy/lib/libvtkFiltersStatistics-9.5.so.1 +0 -0
- mmgpy/lib/libvtkFiltersTexture-9.5.so.1 +0 -0
- mmgpy/lib/libvtkFiltersVerdict-9.5.so.1 +0 -0
- mmgpy/lib/libvtkIOCellGrid-9.5.so.1 +0 -0
- mmgpy/lib/libvtkIOCore-9.5.so.1 +0 -0
- mmgpy/lib/libvtkIOGeometry-9.5.so.1 +0 -0
- mmgpy/lib/libvtkIOImage-9.5.so.1 +0 -0
- mmgpy/lib/libvtkIOLegacy-9.5.so.1 +0 -0
- mmgpy/lib/libvtkIOParallel-9.5.so.1 +0 -0
- mmgpy/lib/libvtkIOParallelXML-9.5.so.1 +0 -0
- mmgpy/lib/libvtkIOXML-9.5.so.1 +0 -0
- mmgpy/lib/libvtkIOXMLParser-9.5.so.1 +0 -0
- mmgpy/lib/libvtkImagingCore-9.5.so.1 +0 -0
- mmgpy/lib/libvtkImagingSources-9.5.so.1 +0 -0
- mmgpy/lib/libvtkParallelCore-9.5.so.1 +0 -0
- mmgpy/lib/libvtkParallelDIY-9.5.so.1 +0 -0
- mmgpy/lib/libvtkRenderingCore-9.5.so.1 +0 -0
- mmgpy/lib/libvtkdoubleconversion-9.5.so.1 +0 -0
- mmgpy/lib/libvtkexpat-9.5.so.1 +0 -0
- mmgpy/lib/libvtkfmt-9.5.so.1 +0 -0
- mmgpy/lib/libvtkjpeg-9.5.so.1 +0 -0
- mmgpy/lib/libvtkjsoncpp-9.5.so.1 +0 -0
- mmgpy/lib/libvtkkissfft-9.5.so.1 +0 -0
- mmgpy/lib/libvtkloguru-9.5.so.1 +0 -0
- mmgpy/lib/libvtklz4-9.5.so.1 +0 -0
- mmgpy/lib/libvtklzma-9.5.so.1 +0 -0
- mmgpy/lib/libvtkmetaio-9.5.so.1 +0 -0
- mmgpy/lib/libvtkpng-9.5.so.1 +0 -0
- mmgpy/lib/libvtkpugixml-9.5.so.1 +0 -0
- mmgpy/lib/libvtksys-9.5.so.1 +0 -0
- mmgpy/lib/libvtktiff-9.5.so.1 +0 -0
- mmgpy/lib/libvtktoken-9.5.so.1 +0 -0
- mmgpy/lib/libvtkverdict-9.5.so.1 +0 -0
- mmgpy/lib/libvtkzlib-9.5.so.1 +0 -0
- mmgpy/metrics.py +596 -0
- mmgpy/progress.py +69 -0
- mmgpy/py.typed +0 -0
- mmgpy/repair/__init__.py +37 -0
- mmgpy/repair/_core.py +226 -0
- mmgpy/repair/_elements.py +241 -0
- mmgpy/repair/_vertices.py +219 -0
- mmgpy/sizing.py +370 -0
- mmgpy/ui/__init__.py +97 -0
- mmgpy/ui/__main__.py +87 -0
- mmgpy/ui/app.py +1837 -0
- mmgpy/ui/parsers.py +501 -0
- mmgpy/ui/remeshing.py +448 -0
- mmgpy/ui/samples.py +249 -0
- mmgpy/ui/utils.py +280 -0
- mmgpy/ui/viewer.py +587 -0
- mmgpy-0.5.0.dist-info/METADATA +186 -0
- mmgpy-0.5.0.dist-info/RECORD +109 -0
- mmgpy-0.5.0.dist-info/WHEEL +6 -0
- mmgpy-0.5.0.dist-info/entry_points.txt +13 -0
- mmgpy-0.5.0.dist-info/licenses/LICENSE +38 -0
- share/man/man1/mmg2d.1.gz +0 -0
- share/man/man1/mmg3d.1.gz +0 -0
- share/man/man1/mmgs.1.gz +0 -0
mmgpy/_validation.py
ADDED
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
"""Mesh validation module for mmgpy.
|
|
2
|
+
|
|
3
|
+
This module provides mesh validation capabilities including geometry checks,
|
|
4
|
+
topology checks, and quality assessment.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
from scipy.spatial import cKDTree
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from numpy.typing import NDArray
|
|
18
|
+
|
|
19
|
+
from ._mmgpy import MmgMesh2D, MmgMesh3D, MmgMeshS
|
|
20
|
+
|
|
21
|
+
# Tolerance for geometry checks (volume/area near zero)
|
|
22
|
+
_GEOMETRY_TOLERANCE = 1e-15
|
|
23
|
+
|
|
24
|
+
# Maximum number of issues to display in summary
|
|
25
|
+
_MAX_DISPLAYED_ISSUES = 10
|
|
26
|
+
|
|
27
|
+
# Maximum faces per edge for manifold mesh
|
|
28
|
+
_MAX_FACES_PER_MANIFOLD_EDGE = 2
|
|
29
|
+
|
|
30
|
+
# Minimum vertices for duplicate check
|
|
31
|
+
_MIN_VERTICES_FOR_DUPLICATE_CHECK = 2
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class IssueSeverity(Enum):
|
|
35
|
+
"""Severity level for validation issues."""
|
|
36
|
+
|
|
37
|
+
ERROR = "error"
|
|
38
|
+
WARNING = "warning"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, slots=True)
|
|
42
|
+
class ValidationIssue:
|
|
43
|
+
"""A single validation issue found in the mesh.
|
|
44
|
+
|
|
45
|
+
Attributes
|
|
46
|
+
----------
|
|
47
|
+
severity : IssueSeverity
|
|
48
|
+
Whether this is an error (mesh unusable) or warning (may cause issues).
|
|
49
|
+
check_name : str
|
|
50
|
+
Name of the validation check that found this issue.
|
|
51
|
+
message : str
|
|
52
|
+
Human-readable description of the issue.
|
|
53
|
+
element_ids : tuple[int, ...]
|
|
54
|
+
Indices of affected elements (empty for global issues).
|
|
55
|
+
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
severity: IssueSeverity
|
|
59
|
+
check_name: str
|
|
60
|
+
message: str
|
|
61
|
+
element_ids: tuple[int, ...] = field(default_factory=tuple)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True, slots=True)
|
|
65
|
+
class QualityStats:
|
|
66
|
+
"""Statistics about mesh element quality.
|
|
67
|
+
|
|
68
|
+
Quality values are normalized to [0, 1] where 1 is a perfect element.
|
|
69
|
+
|
|
70
|
+
Attributes
|
|
71
|
+
----------
|
|
72
|
+
min : float
|
|
73
|
+
Minimum element quality.
|
|
74
|
+
max : float
|
|
75
|
+
Maximum element quality.
|
|
76
|
+
mean : float
|
|
77
|
+
Mean element quality.
|
|
78
|
+
std : float
|
|
79
|
+
Standard deviation of element quality.
|
|
80
|
+
histogram : tuple[tuple[str, int], ...]
|
|
81
|
+
Quality distribution as (bin_label, count) pairs.
|
|
82
|
+
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
min: float
|
|
86
|
+
max: float
|
|
87
|
+
mean: float
|
|
88
|
+
std: float
|
|
89
|
+
histogram: tuple[tuple[str, int], ...]
|
|
90
|
+
|
|
91
|
+
def below_threshold(self, threshold: float) -> int:
|
|
92
|
+
"""Count elements below a quality threshold.
|
|
93
|
+
|
|
94
|
+
Parameters
|
|
95
|
+
----------
|
|
96
|
+
threshold : float
|
|
97
|
+
Quality threshold (0-1).
|
|
98
|
+
|
|
99
|
+
Returns
|
|
100
|
+
-------
|
|
101
|
+
int
|
|
102
|
+
Number of elements with quality below threshold.
|
|
103
|
+
|
|
104
|
+
"""
|
|
105
|
+
total = 0
|
|
106
|
+
for bin_label, count in self.histogram:
|
|
107
|
+
bin_upper = float(bin_label.split("-")[1])
|
|
108
|
+
if bin_upper <= threshold:
|
|
109
|
+
total += count
|
|
110
|
+
return total
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(frozen=True, slots=True)
|
|
114
|
+
class ValidationReport:
|
|
115
|
+
"""Complete validation report for a mesh.
|
|
116
|
+
|
|
117
|
+
Attributes
|
|
118
|
+
----------
|
|
119
|
+
is_valid : bool
|
|
120
|
+
True if no errors were found (warnings are OK).
|
|
121
|
+
issues : tuple[ValidationIssue, ...]
|
|
122
|
+
All validation issues found.
|
|
123
|
+
quality : QualityStats | None
|
|
124
|
+
Quality statistics (None if quality check was skipped).
|
|
125
|
+
mesh_type : str
|
|
126
|
+
Type of mesh that was validated.
|
|
127
|
+
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
is_valid: bool
|
|
131
|
+
issues: tuple[ValidationIssue, ...]
|
|
132
|
+
quality: QualityStats | None
|
|
133
|
+
mesh_type: str
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def errors(self) -> list[ValidationIssue]:
|
|
137
|
+
"""Get all error-level issues."""
|
|
138
|
+
return [i for i in self.issues if i.severity == IssueSeverity.ERROR]
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def warnings(self) -> list[ValidationIssue]:
|
|
142
|
+
"""Get all warning-level issues."""
|
|
143
|
+
return [i for i in self.issues if i.severity == IssueSeverity.WARNING]
|
|
144
|
+
|
|
145
|
+
def __str__(self) -> str:
|
|
146
|
+
"""Return a human-readable summary."""
|
|
147
|
+
lines = [
|
|
148
|
+
f"ValidationReport({self.mesh_type}):",
|
|
149
|
+
f" Valid: {self.is_valid}",
|
|
150
|
+
f" Errors: {len(self.errors)}",
|
|
151
|
+
f" Warnings: {len(self.warnings)}",
|
|
152
|
+
]
|
|
153
|
+
if self.quality:
|
|
154
|
+
lines.extend(
|
|
155
|
+
[
|
|
156
|
+
" Quality:",
|
|
157
|
+
f" Min: {self.quality.min:.3f}",
|
|
158
|
+
f" Max: {self.quality.max:.3f}",
|
|
159
|
+
f" Mean: {self.quality.mean:.3f}",
|
|
160
|
+
],
|
|
161
|
+
)
|
|
162
|
+
if self.issues:
|
|
163
|
+
lines.append(" Issues:")
|
|
164
|
+
for issue in self.issues[:_MAX_DISPLAYED_ISSUES]:
|
|
165
|
+
severity = "ERROR" if issue.severity == IssueSeverity.ERROR else "WARN"
|
|
166
|
+
lines.append(f" [{severity}] {issue.message}")
|
|
167
|
+
if len(self.issues) > _MAX_DISPLAYED_ISSUES:
|
|
168
|
+
remaining = len(self.issues) - _MAX_DISPLAYED_ISSUES
|
|
169
|
+
lines.append(f" ... and {remaining} more")
|
|
170
|
+
return "\n".join(lines)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class ValidationError(Exception):
|
|
174
|
+
"""Exception raised when strict validation fails."""
|
|
175
|
+
|
|
176
|
+
def __init__(self, report: ValidationReport) -> None:
|
|
177
|
+
"""Initialize with a validation report."""
|
|
178
|
+
self.report = report
|
|
179
|
+
issues = report.errors if report.errors else report.warnings
|
|
180
|
+
messages = [i.message for i in issues[:3]]
|
|
181
|
+
super().__init__(f"Mesh validation failed: {'; '.join(messages)}")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _compute_quality_stats(qualities: NDArray[np.float64]) -> QualityStats:
|
|
185
|
+
"""Compute quality statistics from an array of quality values."""
|
|
186
|
+
if len(qualities) == 0:
|
|
187
|
+
return QualityStats(
|
|
188
|
+
min=0.0,
|
|
189
|
+
max=0.0,
|
|
190
|
+
mean=0.0,
|
|
191
|
+
std=0.0,
|
|
192
|
+
histogram=(),
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
# Compute histogram with fixed bins
|
|
196
|
+
bins = [0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]
|
|
197
|
+
hist_counts, _ = np.histogram(qualities, bins=bins)
|
|
198
|
+
|
|
199
|
+
histogram = []
|
|
200
|
+
for i, count in enumerate(hist_counts):
|
|
201
|
+
bin_label = f"{bins[i]:.1f}-{bins[i + 1]:.1f}"
|
|
202
|
+
histogram.append((bin_label, int(count)))
|
|
203
|
+
|
|
204
|
+
return QualityStats(
|
|
205
|
+
min=float(np.min(qualities)),
|
|
206
|
+
max=float(np.max(qualities)),
|
|
207
|
+
mean=float(np.mean(qualities)),
|
|
208
|
+
std=float(np.std(qualities)),
|
|
209
|
+
histogram=tuple(histogram),
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _check_geometry_3d(
|
|
214
|
+
mesh: MmgMesh3D,
|
|
215
|
+
issues: list[ValidationIssue],
|
|
216
|
+
) -> None:
|
|
217
|
+
"""Check 3D mesh geometry (positive volumes, valid areas)."""
|
|
218
|
+
vertices = mesh.get_vertices()
|
|
219
|
+
elements = mesh.get_tetrahedra()
|
|
220
|
+
|
|
221
|
+
if len(elements) == 0:
|
|
222
|
+
return
|
|
223
|
+
|
|
224
|
+
# Vectorized volume computation for all tetrahedra
|
|
225
|
+
# Get vertices for each tetrahedron: shape (n_elements, 4, 3)
|
|
226
|
+
tet_verts = vertices[elements]
|
|
227
|
+
v0, v1, v2, v3 = tet_verts[:, 0], tet_verts[:, 1], tet_verts[:, 2], tet_verts[:, 3]
|
|
228
|
+
|
|
229
|
+
# Build edge vectors: shape (n_elements, 3, 3)
|
|
230
|
+
edge_matrices = np.stack([v1 - v0, v2 - v0, v3 - v0], axis=-1)
|
|
231
|
+
|
|
232
|
+
# Compute signed volumes using determinant
|
|
233
|
+
volumes = np.linalg.det(edge_matrices) / 6.0
|
|
234
|
+
|
|
235
|
+
# Find inverted and degenerate elements
|
|
236
|
+
inverted_mask = volumes < -_GEOMETRY_TOLERANCE
|
|
237
|
+
degenerate_mask = np.abs(volumes) < _GEOMETRY_TOLERANCE
|
|
238
|
+
|
|
239
|
+
inverted_indices = np.where(inverted_mask)[0]
|
|
240
|
+
degenerate_indices = np.where(degenerate_mask)[0]
|
|
241
|
+
|
|
242
|
+
if len(inverted_indices) > 0:
|
|
243
|
+
issues.append(
|
|
244
|
+
ValidationIssue(
|
|
245
|
+
severity=IssueSeverity.ERROR,
|
|
246
|
+
check_name="inverted_elements",
|
|
247
|
+
message=f"Found {len(inverted_indices)} inverted tetrahedra "
|
|
248
|
+
f"with negative volume",
|
|
249
|
+
element_ids=tuple(int(i) for i in inverted_indices[:100]),
|
|
250
|
+
),
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
if len(degenerate_indices) > 0:
|
|
254
|
+
issues.append(
|
|
255
|
+
ValidationIssue(
|
|
256
|
+
severity=IssueSeverity.WARNING,
|
|
257
|
+
check_name="degenerate_elements",
|
|
258
|
+
message=f"Found {len(degenerate_indices)} degenerate tetrahedra "
|
|
259
|
+
f"with near-zero volume",
|
|
260
|
+
element_ids=tuple(int(i) for i in degenerate_indices[:100]),
|
|
261
|
+
),
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _check_geometry_2d(
|
|
266
|
+
mesh: MmgMesh2D,
|
|
267
|
+
issues: list[ValidationIssue],
|
|
268
|
+
) -> None:
|
|
269
|
+
"""Check 2D mesh geometry (positive areas)."""
|
|
270
|
+
vertices = mesh.get_vertices()
|
|
271
|
+
triangles = mesh.get_triangles()
|
|
272
|
+
|
|
273
|
+
if len(triangles) == 0:
|
|
274
|
+
return
|
|
275
|
+
|
|
276
|
+
# Vectorized area computation for all triangles
|
|
277
|
+
# Get vertices for each triangle: shape (n_elements, 3, 2)
|
|
278
|
+
tri_verts = vertices[triangles]
|
|
279
|
+
v0, v1, v2 = tri_verts[:, 0], tri_verts[:, 1], tri_verts[:, 2]
|
|
280
|
+
|
|
281
|
+
# Compute signed area using cross product (2D)
|
|
282
|
+
cross_z = (v1[:, 0] - v0[:, 0]) * (v2[:, 1] - v0[:, 1]) - (v2[:, 0] - v0[:, 0]) * (
|
|
283
|
+
v1[:, 1] - v0[:, 1]
|
|
284
|
+
)
|
|
285
|
+
areas = 0.5 * cross_z
|
|
286
|
+
|
|
287
|
+
# Find inverted and degenerate elements
|
|
288
|
+
inverted_mask = areas < -_GEOMETRY_TOLERANCE
|
|
289
|
+
degenerate_mask = np.abs(areas) < _GEOMETRY_TOLERANCE
|
|
290
|
+
|
|
291
|
+
inverted_indices = np.where(inverted_mask)[0]
|
|
292
|
+
degenerate_indices = np.where(degenerate_mask)[0]
|
|
293
|
+
|
|
294
|
+
if len(inverted_indices) > 0:
|
|
295
|
+
issues.append(
|
|
296
|
+
ValidationIssue(
|
|
297
|
+
severity=IssueSeverity.ERROR,
|
|
298
|
+
check_name="inverted_elements",
|
|
299
|
+
message=f"Found {len(inverted_indices)} inverted triangles "
|
|
300
|
+
f"with negative area",
|
|
301
|
+
element_ids=tuple(int(i) for i in inverted_indices[:100]),
|
|
302
|
+
),
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
if len(degenerate_indices) > 0:
|
|
306
|
+
issues.append(
|
|
307
|
+
ValidationIssue(
|
|
308
|
+
severity=IssueSeverity.WARNING,
|
|
309
|
+
check_name="degenerate_elements",
|
|
310
|
+
message=f"Found {len(degenerate_indices)} degenerate triangles "
|
|
311
|
+
f"with near-zero area",
|
|
312
|
+
element_ids=tuple(int(i) for i in degenerate_indices[:100]),
|
|
313
|
+
),
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _check_geometry_surface(
|
|
318
|
+
mesh: MmgMeshS,
|
|
319
|
+
issues: list[ValidationIssue],
|
|
320
|
+
) -> None:
|
|
321
|
+
"""Check surface mesh geometry (positive areas)."""
|
|
322
|
+
vertices = mesh.get_vertices()
|
|
323
|
+
triangles = mesh.get_triangles()
|
|
324
|
+
|
|
325
|
+
if len(triangles) == 0:
|
|
326
|
+
return
|
|
327
|
+
|
|
328
|
+
# Vectorized area computation for all triangles
|
|
329
|
+
# Get vertices for each triangle: shape (n_elements, 3, 3)
|
|
330
|
+
tri_verts = vertices[triangles]
|
|
331
|
+
v0, v1, v2 = tri_verts[:, 0], tri_verts[:, 1], tri_verts[:, 2]
|
|
332
|
+
|
|
333
|
+
# Compute area using cross product (3D)
|
|
334
|
+
edge1 = v1 - v0
|
|
335
|
+
edge2 = v2 - v0
|
|
336
|
+
cross = np.cross(edge1, edge2)
|
|
337
|
+
areas = 0.5 * np.linalg.norm(cross, axis=1)
|
|
338
|
+
|
|
339
|
+
# Find degenerate elements
|
|
340
|
+
degenerate_mask = areas < _GEOMETRY_TOLERANCE
|
|
341
|
+
degenerate_indices = np.where(degenerate_mask)[0]
|
|
342
|
+
|
|
343
|
+
if len(degenerate_indices) > 0:
|
|
344
|
+
issues.append(
|
|
345
|
+
ValidationIssue(
|
|
346
|
+
severity=IssueSeverity.WARNING,
|
|
347
|
+
check_name="degenerate_elements",
|
|
348
|
+
message=f"Found {len(degenerate_indices)} degenerate triangles "
|
|
349
|
+
f"with near-zero area",
|
|
350
|
+
element_ids=tuple(int(i) for i in degenerate_indices[:100]),
|
|
351
|
+
),
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _check_topology_surface(
|
|
356
|
+
mesh: MmgMeshS | MmgMesh2D,
|
|
357
|
+
issues: list[ValidationIssue],
|
|
358
|
+
mesh_type: str,
|
|
359
|
+
) -> None:
|
|
360
|
+
"""Check surface/2D mesh topology (manifold edges, watertight)."""
|
|
361
|
+
triangles = mesh.get_triangles()
|
|
362
|
+
|
|
363
|
+
if len(triangles) == 0:
|
|
364
|
+
return
|
|
365
|
+
|
|
366
|
+
# Build edge-to-face mapping
|
|
367
|
+
edge_faces: dict[tuple[int, int], list[int]] = {}
|
|
368
|
+
for face_idx, tri in enumerate(triangles):
|
|
369
|
+
for j in range(3):
|
|
370
|
+
v1, v2 = int(tri[j]), int(tri[(j + 1) % 3])
|
|
371
|
+
edge = (min(v1, v2), max(v1, v2))
|
|
372
|
+
if edge not in edge_faces:
|
|
373
|
+
edge_faces[edge] = []
|
|
374
|
+
edge_faces[edge].append(face_idx)
|
|
375
|
+
|
|
376
|
+
# Check for non-manifold edges (shared by >2 faces)
|
|
377
|
+
non_manifold_edges = []
|
|
378
|
+
boundary_edges = []
|
|
379
|
+
|
|
380
|
+
for edge, faces in edge_faces.items():
|
|
381
|
+
if len(faces) > _MAX_FACES_PER_MANIFOLD_EDGE:
|
|
382
|
+
non_manifold_edges.append(edge)
|
|
383
|
+
elif len(faces) == 1:
|
|
384
|
+
boundary_edges.append(edge)
|
|
385
|
+
|
|
386
|
+
if non_manifold_edges:
|
|
387
|
+
issues.append(
|
|
388
|
+
ValidationIssue(
|
|
389
|
+
severity=IssueSeverity.ERROR,
|
|
390
|
+
check_name="non_manifold_edges",
|
|
391
|
+
message=f"Found {len(non_manifold_edges)} non-manifold edges "
|
|
392
|
+
f"(shared by more than 2 faces)",
|
|
393
|
+
element_ids=(),
|
|
394
|
+
),
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
if boundary_edges and mesh_type == "surface":
|
|
398
|
+
issues.append(
|
|
399
|
+
ValidationIssue(
|
|
400
|
+
severity=IssueSeverity.WARNING,
|
|
401
|
+
check_name="open_boundary",
|
|
402
|
+
message=f"Found {len(boundary_edges)} boundary edges "
|
|
403
|
+
f"(mesh is not watertight)",
|
|
404
|
+
element_ids=(),
|
|
405
|
+
),
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _check_orphan_vertices(
|
|
410
|
+
vertices: NDArray[np.float64],
|
|
411
|
+
elements: NDArray[np.int32],
|
|
412
|
+
issues: list[ValidationIssue],
|
|
413
|
+
) -> None:
|
|
414
|
+
"""Check for vertices not referenced by any element."""
|
|
415
|
+
if len(vertices) == 0 or len(elements) == 0:
|
|
416
|
+
return
|
|
417
|
+
|
|
418
|
+
used_vertices = set(elements.flatten())
|
|
419
|
+
all_vertices = set(range(len(vertices)))
|
|
420
|
+
orphan_vertices = all_vertices - used_vertices
|
|
421
|
+
|
|
422
|
+
if orphan_vertices:
|
|
423
|
+
issues.append(
|
|
424
|
+
ValidationIssue(
|
|
425
|
+
severity=IssueSeverity.WARNING,
|
|
426
|
+
check_name="orphan_vertices",
|
|
427
|
+
message=f"Found {len(orphan_vertices)} orphan vertices "
|
|
428
|
+
f"not referenced by any element",
|
|
429
|
+
element_ids=tuple(sorted(orphan_vertices)[:100]),
|
|
430
|
+
),
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _check_quality(
|
|
435
|
+
qualities: NDArray[np.float64],
|
|
436
|
+
min_quality: float,
|
|
437
|
+
issues: list[ValidationIssue],
|
|
438
|
+
) -> None:
|
|
439
|
+
"""Check element quality against threshold."""
|
|
440
|
+
if len(qualities) == 0:
|
|
441
|
+
return
|
|
442
|
+
|
|
443
|
+
low_quality_mask = qualities < min_quality
|
|
444
|
+
low_quality_count = int(np.sum(low_quality_mask))
|
|
445
|
+
|
|
446
|
+
if low_quality_count > 0:
|
|
447
|
+
low_quality_indices = np.where(low_quality_mask)[0]
|
|
448
|
+
issues.append(
|
|
449
|
+
ValidationIssue(
|
|
450
|
+
severity=IssueSeverity.WARNING,
|
|
451
|
+
check_name="low_quality",
|
|
452
|
+
message=f"Found {low_quality_count} elements with quality "
|
|
453
|
+
f"below {min_quality:.2f}",
|
|
454
|
+
element_ids=tuple(int(i) for i in low_quality_indices[:100]),
|
|
455
|
+
),
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _check_duplicate_vertices(
|
|
460
|
+
vertices: NDArray[np.float64],
|
|
461
|
+
issues: list[ValidationIssue],
|
|
462
|
+
tolerance: float = 1e-10,
|
|
463
|
+
) -> None:
|
|
464
|
+
"""Check for duplicate (coincident) vertices using KD-tree.
|
|
465
|
+
|
|
466
|
+
Time complexity: O(n log n) for tree construction, O(n) expected for queries.
|
|
467
|
+
Space complexity: O(n)
|
|
468
|
+
"""
|
|
469
|
+
if len(vertices) < _MIN_VERTICES_FOR_DUPLICATE_CHECK:
|
|
470
|
+
return
|
|
471
|
+
|
|
472
|
+
tree = cKDTree(vertices)
|
|
473
|
+
pairs = tree.query_pairs(r=tolerance, output_type="ndarray")
|
|
474
|
+
|
|
475
|
+
if len(pairs) > 0:
|
|
476
|
+
issues.append(
|
|
477
|
+
ValidationIssue(
|
|
478
|
+
severity=IssueSeverity.WARNING,
|
|
479
|
+
check_name="duplicate_vertices",
|
|
480
|
+
message=f"Found {len(pairs)} duplicate vertex pairs "
|
|
481
|
+
f"within tolerance {tolerance}",
|
|
482
|
+
element_ids=(),
|
|
483
|
+
),
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def validate_mesh_3d(
|
|
488
|
+
mesh: MmgMesh3D,
|
|
489
|
+
*,
|
|
490
|
+
check_geometry: bool = True,
|
|
491
|
+
check_topology: bool = True,
|
|
492
|
+
check_quality: bool = True,
|
|
493
|
+
min_quality: float = 0.1,
|
|
494
|
+
) -> ValidationReport:
|
|
495
|
+
"""Validate a 3D tetrahedral mesh.
|
|
496
|
+
|
|
497
|
+
Parameters
|
|
498
|
+
----------
|
|
499
|
+
mesh : MmgMesh3D
|
|
500
|
+
The mesh to validate.
|
|
501
|
+
check_geometry : bool
|
|
502
|
+
Check for geometric issues (inverted/degenerate elements).
|
|
503
|
+
check_topology : bool
|
|
504
|
+
Check for topological issues (orphan vertices).
|
|
505
|
+
check_quality : bool
|
|
506
|
+
Check element quality against threshold.
|
|
507
|
+
min_quality : float
|
|
508
|
+
Minimum acceptable element quality (0-1).
|
|
509
|
+
|
|
510
|
+
Returns
|
|
511
|
+
-------
|
|
512
|
+
ValidationReport
|
|
513
|
+
Complete validation report.
|
|
514
|
+
|
|
515
|
+
"""
|
|
516
|
+
issues: list[ValidationIssue] = []
|
|
517
|
+
quality_stats: QualityStats | None = None
|
|
518
|
+
|
|
519
|
+
vertices = mesh.get_vertices()
|
|
520
|
+
elements = mesh.get_tetrahedra()
|
|
521
|
+
|
|
522
|
+
if check_geometry:
|
|
523
|
+
_check_geometry_3d(mesh, issues)
|
|
524
|
+
_check_duplicate_vertices(vertices, issues)
|
|
525
|
+
|
|
526
|
+
if check_topology:
|
|
527
|
+
_check_orphan_vertices(vertices, elements, issues)
|
|
528
|
+
|
|
529
|
+
if check_quality and len(elements) > 0:
|
|
530
|
+
qualities = mesh.get_element_qualities()
|
|
531
|
+
quality_stats = _compute_quality_stats(qualities)
|
|
532
|
+
_check_quality(qualities, min_quality, issues)
|
|
533
|
+
|
|
534
|
+
has_errors = any(i.severity == IssueSeverity.ERROR for i in issues)
|
|
535
|
+
|
|
536
|
+
return ValidationReport(
|
|
537
|
+
is_valid=not has_errors,
|
|
538
|
+
issues=tuple(issues),
|
|
539
|
+
quality=quality_stats,
|
|
540
|
+
mesh_type="MmgMesh3D",
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def validate_mesh_2d(
|
|
545
|
+
mesh: MmgMesh2D,
|
|
546
|
+
*,
|
|
547
|
+
check_geometry: bool = True,
|
|
548
|
+
check_topology: bool = True,
|
|
549
|
+
check_quality: bool = True,
|
|
550
|
+
min_quality: float = 0.1,
|
|
551
|
+
) -> ValidationReport:
|
|
552
|
+
"""Validate a 2D planar mesh.
|
|
553
|
+
|
|
554
|
+
Parameters
|
|
555
|
+
----------
|
|
556
|
+
mesh : MmgMesh2D
|
|
557
|
+
The mesh to validate.
|
|
558
|
+
check_geometry : bool
|
|
559
|
+
Check for geometric issues (inverted/degenerate elements).
|
|
560
|
+
check_topology : bool
|
|
561
|
+
Check for topological issues (orphan vertices, non-manifold edges).
|
|
562
|
+
check_quality : bool
|
|
563
|
+
Check element quality against threshold.
|
|
564
|
+
min_quality : float
|
|
565
|
+
Minimum acceptable element quality (0-1).
|
|
566
|
+
|
|
567
|
+
Returns
|
|
568
|
+
-------
|
|
569
|
+
ValidationReport
|
|
570
|
+
Complete validation report.
|
|
571
|
+
|
|
572
|
+
"""
|
|
573
|
+
issues: list[ValidationIssue] = []
|
|
574
|
+
quality_stats: QualityStats | None = None
|
|
575
|
+
|
|
576
|
+
vertices = mesh.get_vertices()
|
|
577
|
+
triangles = mesh.get_triangles()
|
|
578
|
+
|
|
579
|
+
if check_geometry:
|
|
580
|
+
_check_geometry_2d(mesh, issues)
|
|
581
|
+
_check_duplicate_vertices(vertices, issues)
|
|
582
|
+
|
|
583
|
+
if check_topology:
|
|
584
|
+
_check_orphan_vertices(vertices, triangles, issues)
|
|
585
|
+
_check_topology_surface(mesh, issues, "2d")
|
|
586
|
+
|
|
587
|
+
if check_quality and len(triangles) > 0:
|
|
588
|
+
qualities = mesh.get_element_qualities()
|
|
589
|
+
quality_stats = _compute_quality_stats(qualities)
|
|
590
|
+
_check_quality(qualities, min_quality, issues)
|
|
591
|
+
|
|
592
|
+
has_errors = any(i.severity == IssueSeverity.ERROR for i in issues)
|
|
593
|
+
|
|
594
|
+
return ValidationReport(
|
|
595
|
+
is_valid=not has_errors,
|
|
596
|
+
issues=tuple(issues),
|
|
597
|
+
quality=quality_stats,
|
|
598
|
+
mesh_type="MmgMesh2D",
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def validate_mesh_surface(
|
|
603
|
+
mesh: MmgMeshS,
|
|
604
|
+
*,
|
|
605
|
+
check_geometry: bool = True,
|
|
606
|
+
check_topology: bool = True,
|
|
607
|
+
check_quality: bool = True,
|
|
608
|
+
min_quality: float = 0.1,
|
|
609
|
+
) -> ValidationReport:
|
|
610
|
+
"""Validate a surface mesh.
|
|
611
|
+
|
|
612
|
+
Parameters
|
|
613
|
+
----------
|
|
614
|
+
mesh : MmgMeshS
|
|
615
|
+
The mesh to validate.
|
|
616
|
+
check_geometry : bool
|
|
617
|
+
Check for geometric issues (degenerate elements).
|
|
618
|
+
check_topology : bool
|
|
619
|
+
Check for topological issues (orphan vertices, non-manifold edges).
|
|
620
|
+
check_quality : bool
|
|
621
|
+
Check element quality against threshold.
|
|
622
|
+
min_quality : float
|
|
623
|
+
Minimum acceptable element quality (0-1).
|
|
624
|
+
|
|
625
|
+
Returns
|
|
626
|
+
-------
|
|
627
|
+
ValidationReport
|
|
628
|
+
Complete validation report.
|
|
629
|
+
|
|
630
|
+
"""
|
|
631
|
+
issues: list[ValidationIssue] = []
|
|
632
|
+
quality_stats: QualityStats | None = None
|
|
633
|
+
|
|
634
|
+
vertices = mesh.get_vertices()
|
|
635
|
+
triangles = mesh.get_triangles()
|
|
636
|
+
|
|
637
|
+
if check_geometry:
|
|
638
|
+
_check_geometry_surface(mesh, issues)
|
|
639
|
+
_check_duplicate_vertices(vertices, issues)
|
|
640
|
+
|
|
641
|
+
if check_topology:
|
|
642
|
+
_check_orphan_vertices(vertices, triangles, issues)
|
|
643
|
+
_check_topology_surface(mesh, issues, "surface")
|
|
644
|
+
|
|
645
|
+
if check_quality and len(triangles) > 0:
|
|
646
|
+
qualities = mesh.get_element_qualities()
|
|
647
|
+
quality_stats = _compute_quality_stats(qualities)
|
|
648
|
+
_check_quality(qualities, min_quality, issues)
|
|
649
|
+
|
|
650
|
+
has_errors = any(i.severity == IssueSeverity.ERROR for i in issues)
|
|
651
|
+
|
|
652
|
+
return ValidationReport(
|
|
653
|
+
is_valid=not has_errors,
|
|
654
|
+
issues=tuple(issues),
|
|
655
|
+
quality=quality_stats,
|
|
656
|
+
mesh_type="MmgMeshS",
|
|
657
|
+
)
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
__all__ = [
|
|
661
|
+
"IssueSeverity",
|
|
662
|
+
"QualityStats",
|
|
663
|
+
"ValidationError",
|
|
664
|
+
"ValidationIssue",
|
|
665
|
+
"ValidationReport",
|
|
666
|
+
"validate_mesh_2d",
|
|
667
|
+
"validate_mesh_3d",
|
|
668
|
+
"validate_mesh_surface",
|
|
669
|
+
]
|
mmgpy/_version.py
ADDED
mmgpy/_version.py.in
ADDED
mmgpy/bin/mmg2d_O3
ADDED
|
Binary file
|
mmgpy/bin/mmg3d_O3
ADDED
|
Binary file
|
mmgpy/bin/mmgs_O3
ADDED
|
Binary file
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Interactive sizing tools for mmgpy.
|
|
2
|
+
|
|
3
|
+
This module provides PyVista-based interactive tools for defining local
|
|
4
|
+
mesh sizing constraints through visual interaction.
|
|
5
|
+
|
|
6
|
+
Example:
|
|
7
|
+
-------
|
|
8
|
+
>>> from mmgpy import Mesh
|
|
9
|
+
>>> from mmgpy.interactive import SizingEditor
|
|
10
|
+
>>>
|
|
11
|
+
>>> mesh = Mesh("model.mesh")
|
|
12
|
+
>>> editor = SizingEditor(mesh)
|
|
13
|
+
>>> editor.add_sphere_tool()
|
|
14
|
+
>>> editor.run() # Opens PyVista window
|
|
15
|
+
>>>
|
|
16
|
+
>>> # After editing, apply constraints
|
|
17
|
+
>>> editor.apply_to_mesh()
|
|
18
|
+
>>> mesh.remesh()
|
|
19
|
+
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from mmgpy.interactive.sizing_editor import SizingEditor
|
|
23
|
+
|
|
24
|
+
__all__ = ["SizingEditor"]
|