diff --git a/.github/scripts/basic_smoke_test.py b/.github/scripts/basic_smoke_test.py index 2627e8b..4f838fc 100644 --- a/.github/scripts/basic_smoke_test.py +++ b/.github/scripts/basic_smoke_test.py @@ -1,3 +1,4 @@ +import math import os import sys import tempfile @@ -157,6 +158,7 @@ def main() -> None: import pyvista as pv from svv import Domain, Forest, Simulation, Tree + from svv.domain.routines.tetrahedralize import tetrahedralize from svv.utils.remeshing.mmg import get_mmg_candidates, get_mmg_exe from svv.utils.solvers.solver_0d import get_solver_0d_candidates, get_solver_0d_exe from svv.utils.remeshing.remesh import remesh_surface @@ -185,6 +187,41 @@ def main() -> None: cube.solve() cube.build() + # Real geometry-recovery path using a compact, deterministic folded surface. + # Future TetGen releases may accept the original directly, so assert portable + # mesh invariants rather than a version-specific strategy. + _log("SMOKE: tetrahedralize: folded closed surface") + folded = pv.Sphere(theta_resolution=20, phi_resolution=20) + folded.points[folded.points[:, 2] > 0.2, 2] -= 0.8 + recovery = tetrahedralize( + folded, + order=1, + nobisect=True, + repair_max_distance_ratio=0.4, + return_result=True, + ) + assert recovery.report.selected_strategy in { + "original", + "meshfix", + "pyacvd", + "pyacvd_meshfix", + } + assert recovery.elements.shape[0] > 0 + assert recovery.surface.is_manifold + assert recovery.surface.n_open_edges == 0 + recovery_volumes = recovery.grid.compute_cell_sizes( + length=False, + area=False, + volume=True, + ).cell_data["Volume"] + assert recovery_volumes.size > 0 + assert all( + math.isfinite(float(value)) and float(value) >= 0 + for value in recovery_volumes + ) + assert math.isfinite(float(recovery_volumes.sum())) + assert float(recovery_volumes.sum()) > 0 + # MMG remeshing (validate that packaged/built executables run) _log("SMOKE: mmg: remesh_surface(pv.Cube())") with _temp_cwd(): diff --git a/.github/workflows/basic-smoke-test.yml b/.github/workflows/basic-smoke-test.yml index 152e1f2..9e8f032 100644 --- a/.github/workflows/basic-smoke-test.yml +++ b/.github/workflows/basic-smoke-test.yml @@ -4,6 +4,9 @@ on: push: branches: - main + pull_request: + branches: + - main permissions: contents: read @@ -55,7 +58,7 @@ jobs: run: | python -m pip install --upgrade pip setuptools wheel python -m pip install -r requirements.txt - python -m pip install cmake + python -m pip install cmake pytest - name: Build MMG (v5.8.0, Release, -O3) run: | @@ -132,6 +135,19 @@ jobs: print(f"svzerodsolver -h exit: {proc.returncode}", flush=True) PY + - name: Run domain recovery tests + run: | + python -m pytest -q \ + test/test_mesh_diagnostics.py \ + test/test_tetgen_worker.py \ + test/test_tetrahedralize_recovery.py \ + test/test_domain_tetrahedralization_recovery.py \ + test/test_gui_domain_build_feedback.py + env: + QT_QPA_PLATFORM: "offscreen" + SVV_GUI_DISABLE_VTK: "1" + SVV_TELEMETRY_DISABLED: "1" + - name: Run basic smoke test run: | if [ "${{ runner.os }}" = "Linux" ]; then diff --git a/docs/api/domain.html b/docs/api/domain.html index cc25431..dcc818a 100644 --- a/docs/api/domain.html +++ b/docs/api/domain.html @@ -177,13 +177,23 @@

Attributes

boundary pv.PolyData - Boundary mesh of the domain + The exact boundary surface used to create the current interior mesh + + + original_boundary + pv.PolyData + Unmodified imported surface retained as source provenance mesh pv.UnstructuredGrid Interior tetrahedral/triangular mesh + + mesh_build_report + TetrahedralizationReport or None + Ordered strategies, surface checks, versions, and bounded TetGen diagnostics from the latest 3D mesh build + patches list @@ -282,7 +292,7 @@

Parameters

- build(resolution=25, skip_boundary=False) + build(resolution=25, skip_boundary=False, **interior_kwargs)

Build the implicit function describing the domain and optionally extract boundary/mesh artifacts.

@@ -290,6 +300,7 @@

Parameters

@@ -346,7 +357,7 @@

Mesh Generation

Parameters

Returns

What The GUI Can Do

@@ -187,6 +188,39 @@

Typical Workflow

If point-picking does not work, ensure the domain was created/solved/built before loading.

+

Domain Meshing And Recovery

+

+ Loading a 3D mesh runs the domain create, solve, and build stages. The imported surface is sent to TetGen first. + If TetGen detects a geometry problem, the GUI automatically tries a component-preserving MeshFix repair and then + a validated PyACVD fallback. A recovered surface is accepted only when it is finite, triangular, closed, + manifold, preserves connected components, and stays within the configured geometric-fidelity bound. +

+

+ A surface can be closed, watertight, and manifold while still containing self-intersecting facets. Those + topology checks alone therefore do not guarantee that TetGen can construct a volume mesh. When this occurs, + the expanded diagnostics identify representative intersecting facets or segments when TetGen reports them. +

+

+ A successful recovery is reported in the status bar as a build completed after surface repair; it does not open + a blocking success dialog. The working domain boundary is updated to the exact surface that generated the volume + mesh, while the imported surface remains available as original_boundary. +

+
+ If interior meshing still fails: + +

+ Python callers can disable individual recovery stages or tighten the default 1% displacement limit with + repair_on_failure, remesh_on_failure, and + repair_max_distance_ratio. The GUI retains the structured build report for troubleshooting. +

+
+
NumPy compatibility:

If importing the GUI fails with _ARRAY_API not found or numpy.core.multiarray failed to import, recreate the environment with a NumPy version supported by your Python version. On Python 3.9–3.12, use python -m pip install --force-reinstall "numpy<2" svv. Python 3.13 requires NumPy 2.1 or newer; use python -m pip install --force-reinstall "numpy>=2.1" svv.

diff --git a/pyproject.toml b/pyproject.toml index 4847ffc..2ae2000 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,3 +10,6 @@ requires = [ ] build-backend = "setuptools.build_meta" + +[tool.pytest.ini_options] +testpaths = ["test"] diff --git a/svv/domain/domain.py b/svv/domain/domain.py index 5db8628..acb07b4 100644 --- a/svv/domain/domain.py +++ b/svv/domain/domain.py @@ -5,7 +5,11 @@ from svv.domain.routines.allocate import allocate from svv.domain.routines.discretize import contour from svv.domain.io.read import read -from svv.domain.routines.tetrahedralize import tetrahedralize, triangulate +from svv.domain.routines.tetrahedralize import ( + tetrahedralize, + triangulate, + validate_recovery_surface, +) from svv.domain.routines.c_sample import pick_from_tetrahedron, pick_from_triangle, pick_from_line from concurrent.futures import ProcessPoolExecutor, as_completed from svv.domain.routines.boolean import boolean @@ -60,10 +64,14 @@ def __init__(self, *args, **kwargs): self.random_generator = None self.characteristic_length = None self.mesh_tree = None + self.mesh_tree_2 = None + self.mesh_build_report = None self.boundary_nodes = None self.boundary_vertices = None self.mesh_nodes = None self.mesh_vertices = None + self.all_mesh_cells = None + self.cumulative_probability = None self.convexity = None self.random_points = None if len(args) > 0: @@ -162,7 +170,7 @@ def set_data(self, *args, **kwargs): if isinstance(boundary, pv.UnstructuredGrid): boundary = boundary.extract_surface() points, normals, n, d = read(boundary, **kwargs) - self.original_boundary = boundary + self.original_boundary = boundary.copy(deep=True) self.points = points self.normals = normals self.n = n @@ -349,7 +357,10 @@ def build(self, resolution: int = 25, skip_boundary: bool = False, only assemble fast‑evaluation structures. Default False. interior_kwargs : dict, optional Additional keyword arguments forwarded to get_interior(), including - TetGen options and remeshing fallback controls. + TetGen options and the bounded ``repair_on_failure``, + ``repair_max_distance_ratio``, and remeshing controls. In 3D, + ``original_boundary`` remains the imported source while ``boundary`` + is aligned with the surface that produced the volume mesh. """ # If this Domain was loaded from a .dmn file, it already has # A/B/C/D/PTS and possibly a function_tree. In that case, skip @@ -407,14 +418,14 @@ def report(progress=None, label=None, indeterminate=None, force=False): # Check if boundary/mesh were already loaded from .dmn file # If so, skip expensive regeneration has_boundary = getattr(self, 'boundary', None) is not None - has_mesh = ( - getattr(self, 'mesh', None) is not None and - getattr(self, 'mesh_tree', None) is not None - ) if not has_boundary: report(0.5, "Extracting domain boundary...") self.get_boundary(resolution) report(0.7, "Domain boundary extracted") + has_mesh = ( + getattr(self, 'mesh', None) is not None and + getattr(self, 'mesh_tree', None) is not None + ) if not has_mesh: if self.points.shape[1] == 3: report(None, "Tetrahedralizing domain interior...", True) @@ -750,38 +761,122 @@ def within(self, points, level=0, **kwargs): values = self.__call__(points, **kwargs) return values <= level + @staticmethod + def _normalize_cell_measure(mesh, measure_name, normalized_name): + values = np.asarray(mesh.cell_data[measure_name], dtype=np.float64) + total = float(np.sum(values)) + if ( + values.size == 0 + or not np.isfinite(values).all() + or np.any(values < 0) + or not np.isfinite(total) + or total <= 0 + ): + raise ValueError( + "{} requires a positive finite total with finite nonnegative " + "cell values".format(normalized_name) + ) + normalized = values / total + mesh.cell_data[normalized_name] = normalized + return normalized + + def _prepare_boundary_mesh(self, boundary): + if not isinstance(boundary, pv.PolyData): + boundary = boundary.extract_surface() + prepared = boundary.copy(deep=True) + dimension = self.points.shape[1] + if dimension == 3 and not prepared.is_all_triangles: + prepared = prepared.triangulate() + prepared = prepared.compute_cell_sizes() + if prepared.n_points == 0 or prepared.n_cells == 0: + raise ValueError("Boundary mesh must be non-empty") + if not np.isfinite(np.asarray(prepared.points)).all(): + raise ValueError("Boundary mesh points must be finite") + + if dimension == 2: + self._normalize_cell_measure( + prepared, + "Length", + "Normalized_Length", + ) + nodes = np.asarray(prepared.points, dtype=np.float64) + try: + vertices = np.asarray(prepared.lines).reshape(-1, 3)[:, 1:] + except ValueError as exc: + raise ValueError("2D boundary must contain two-node line cells") from exc + elif dimension == 3: + if not prepared.is_all_triangles: + raise ValueError("3D boundary must contain only triangles") + self._normalize_cell_measure( + prepared, + "Area", + "Normalized_Area", + ) + nodes = np.asarray(prepared.points, dtype=np.float64) + try: + vertices = np.asarray(prepared.faces).reshape(-1, 4)[:, 1:] + except ValueError as exc: + raise ValueError("3D boundary must contain three-node triangles") from exc + else: + raise ValueError("Only 2D and 3D domains are supported.") + return prepared, nodes.copy(), vertices.astype(np.int64, copy=True) + + def _invalidate_interior_mesh_state(self): + """Clear volume-mesh state that depends on the current boundary.""" + + self.mesh = None + self.mesh_nodes = None + self.mesh_vertices = None + self.mesh_tree = None + self.mesh_tree_2 = None + self.all_mesh_cells = None + self.cumulative_probability = None + self.characteristic_length = None + self.area = None + self.volume = None + self.convexity = None + self.mesh_build_report = None + self.random_points = None + + def _set_boundary_mesh(self, boundary): + """Install a validated boundary and its sampling arrays atomically.""" + + prepared, nodes, vertices = self._prepare_boundary_mesh(boundary) + self._invalidate_interior_mesh_state() + self.boundary = prepared + self.boundary_nodes = nodes + self.boundary_vertices = vertices + return prepared + def get_boundary(self, resolution, **kwargs): """ Descretize the domain into a set of points. """ get_largest = kwargs.get('get_largest', True) if isinstance(self.original_boundary, type(None)): - self.boundary, self.grid = contour(self.__call__, self.points, resolution) + boundary, grid = contour(self.__call__, self.points, resolution) else: if not self.original_boundary.is_all_triangles: - self.boundary = self.original_boundary.triangulate() + boundary = self.original_boundary.triangulate() else: - self.boundary = self.original_boundary - _, self.grid = contour(self.__call__, self.points, resolution) - self.boundary = self.boundary.connectivity(extraction_mode='largest') - self.boundary = self.boundary.compute_cell_sizes() - if self.points.shape[1] == 2: - self.boundary.cell_data['Normalized_Length'] = (self.boundary.cell_data['Length'] / - sum(self.boundary.cell_data['Length'])) - self.boundary_nodes = self.boundary.points.astype(np.float64) - self.boundary_vertices = self.boundary.lines.reshape(-1, 3)[:, 1:].astype(np.int64) - elif self.points.shape[1] == 3: - self.boundary.cell_data['Normalized_Area'] = (self.boundary.cell_data['Area'] / - sum(self.boundary.cell_data['Area'])) - self.boundary_nodes = self.boundary.points.astype(np.float64) - self.boundary_vertices = self.boundary.faces.reshape(-1, 4)[:, 1:].astype(np.int64) - else: - raise ValueError("Only 2D and 3D domains are supported.") + boundary = self.original_boundary.copy(deep=True) + _, grid = contour(self.__call__, self.points, resolution) + if get_largest: + boundary = boundary.connectivity(extraction_mode='largest') + self._set_boundary_mesh(boundary) + self.grid = grid return self.boundary, self.grid def get_interior(self, verbose=False, **kwargs): """ - Tetrahedralize the implicit function describing the domain + Tetrahedralize the implicit function describing the domain. + + In 3D, the original boundary is tried first with ``order=1`` and + ``nobisect=True``. Geometry-related failures use bounded, + component-preserving MeshFix recovery and an optional validated PyACVD + fallback. Successful state is installed atomically from the exact + selected surface; ``original_boundary`` is retained unchanged and the + ordered attempt record is stored in ``mesh_build_report``. Parameters ---------- @@ -789,42 +884,152 @@ def get_interior(self, verbose=False, **kwargs): A flag to indicate if mesh fixing should be verbose. kwargs : dict A dictionary of keyword arguments to be passed to TetGen. In 3D, - this also accepts tetrahedralize() retry controls such as - remesh_on_failure, remesh_subdivisions, remesh_clusters, and - remesh_clean_tolerance. + this also accepts tetrahedralize() recovery controls such as + repair_on_failure, repair_max_distance_ratio, remesh_on_failure, + remesh_subdivisions, remesh_clusters, and remesh_clean_tolerance. + Raw TetGen ``switches`` strings are rejected because Domain enforces + ``order=1`` and ``nobisect=True``. Returns ------- mesh : PyMesh mesh object The tetrahedralized mesh. + + Raises + ------ + TetGenWorkerError + If the isolated worker or its dependencies fail. + TetrahedralizationError + If all enabled safe surface strategies fail. + ValueError + If the returned volume mesh or selected surface fails validation. """ if self.boundary is None: raise ValueError("Boundary not defined. Call get_boundary() method first.") if self.points.shape[1] == 2: _mesh, nodes, vertices = triangulate(self.boundary, verbose=verbose, **kwargs) _mesh = _mesh.compute_cell_sizes() - _mesh.cell_data['Normalized_Area'] = (_mesh.cell_data['Area'] / sum(_mesh.cell_data['Area'])) - self.all_mesh_cells = np.arange(_mesh.n_cells, dtype=np.int64) - self.cumulative_probability = np.cumsum(_mesh.cell_data['Normalized_Area']) - self.characteristic_length = _mesh.area**(1/self.points.shape[1]) - self.area = _mesh.area - self.volume = 0.0 + normalized_measure = self._normalize_cell_measure( + _mesh, + "Area", + "Normalized_Area", + ) + characteristic_length = _mesh.area**(1/self.points.shape[1]) + area = _mesh.area + volume = 0.0 + mesh_build_report = None elif self.points.shape[1] == 3: - _mesh, nodes, vertices = tetrahedralize(self.boundary, order=1, nobisect=True, verbose=verbose, **kwargs) - _mesh = _mesh.compute_cell_sizes() - _mesh.cell_data['Normalized_Volume'] = (_mesh.cell_data['Volume'] / sum(_mesh.cell_data['Volume'])) - self.all_mesh_cells = np.arange(_mesh.n_cells, dtype=np.int64) - self.cumulative_probability = np.cumsum(_mesh.cell_data['Normalized_Volume']) - self.characteristic_length = _mesh.volume**(1/self.points.shape[1]) - self.area = _mesh.area - self.volume = _mesh.volume + if kwargs.get("switches") is not None: + raise ValueError( + "Raw TetGen switches cannot be used by Domain because " + "order=1 and nobisect=True are enforced" + ) + tetgen_options = dict(kwargs) + tetgen_options.update( + order=1, + nobisect=True, + verbose=verbose, + return_result=True, + ) + result = tetrahedralize(self.boundary, **tetgen_options) + _mesh = result.grid.copy(deep=True) + nodes = np.asarray(result.nodes) + vertices = np.asarray(result.elements) + if _mesh.n_points == 0 or _mesh.n_cells == 0: + raise ValueError("Tetrahedral mesh must be non-empty") + if nodes.ndim != 2 or nodes.shape[1] != 3 or nodes.shape[0] == 0: + raise ValueError("Tetrahedral mesh nodes must have non-empty shape (N, 3)") + if not np.isfinite(nodes).all(): + raise ValueError("Tetrahedral mesh nodes must be finite") + if ( + vertices.ndim != 2 + or vertices.shape[0] == 0 + or vertices.shape[1] != 4 + or not np.issubdtype(vertices.dtype, np.integer) + ): + raise ValueError( + "Domain requires first-order tetrahedral connectivity with " + "integer shape (M, 4)" + ) + if vertices.min() < 0 or vertices.max() >= nodes.shape[0]: + raise ValueError("Tetrahedral connectivity contains out-of-range indices") + if _mesh.n_points != nodes.shape[0]: + raise ValueError( + "Volume-grid node count does not match TetGen worker nodes" + ) + if _mesh.n_cells != vertices.shape[0]: + raise ValueError( + "Volume-grid cell count does not match TetGen worker elements" + ) + if not np.allclose( + np.asarray(_mesh.points), + nodes, + rtol=0.0, + atol=0.0, + ): + raise ValueError("Volume-grid points do not match TetGen worker nodes") + if not np.all(np.asarray(_mesh.celltypes) == int(pv.CellType.TETRA)): + raise ValueError( + "Domain volume-grid cells must all be first-order tetrahedra" + ) + grid_connectivity = np.asarray(_mesh.cell_connectivity).reshape( + vertices.shape + ) + if not np.array_equal(grid_connectivity, vertices): + raise ValueError( + "Volume-grid connectivity does not match TetGen worker elements" + ) + + _mesh = _mesh.compute_cell_sizes( + length=False, + area=False, + volume=True, + ) + normalized_measure = self._normalize_cell_measure( + _mesh, + "Volume", + "Normalized_Volume", + ) + volumes = np.asarray(_mesh.cell_data["Volume"], dtype=np.float64) + volume = float(np.sum(volumes)) + characteristic_length = volume**(1/self.points.shape[1]) + area = float(_mesh.extract_surface().area) + + selected_surface = result.surface.copy(deep=True) + extracted_surface = _mesh.extract_surface().triangulate() + validate_recovery_surface( + selected_surface, + extracted_surface, + max_distance_ratio=0.01, + ) + selected_volume = abs(float(selected_surface.volume)) + if not np.isfinite(selected_volume) or selected_volume <= 0: + raise ValueError( + "The selected surface volume must be positive and finite" + ) + volume_delta = abs(volume - selected_volume) / selected_volume + if not np.isfinite(volume_delta) or volume_delta > 0.005: + raise ValueError( + "Tetrahedral mesh volume differs from its selected surface " + "by {:.3%}".format(volume_delta) + ) + mesh_build_report = result.report else: raise ValueError("Only 2D and 3D domains are supported.") - self.mesh_tree = cKDTree(_mesh.cell_centers().points[:, :self.points.shape[1]], leafsize=4) - self.mesh_tree_2 = BallTree(_mesh.cell_centers().points[:, :self.points.shape[1]]) - self.mesh = _mesh - self.mesh_nodes = nodes.astype(np.float64) - self.mesh_vertices = vertices.astype(np.int64) + + centers = np.asarray( + _mesh.cell_centers().points[:, :self.points.shape[1]], + dtype=np.float64, + ) + if centers.shape[0] != _mesh.n_cells or not np.isfinite(centers).all(): + raise ValueError("Volume-mesh cell centers must be finite") + mesh_tree = cKDTree(centers, leafsize=4) + mesh_tree_2 = BallTree(centers) + all_mesh_cells = np.arange(_mesh.n_cells, dtype=np.int64) + cumulative_probability = np.cumsum(normalized_measure) + mesh_nodes = nodes.astype(np.float64) + mesh_vertices = vertices.astype(np.int64) + if self.points.shape[1] == 2: delaunay = pv.PolyData() tmp_points = np.zeros((self.points.shape[0], 3)) @@ -832,15 +1037,32 @@ def get_interior(self, verbose=False, **kwargs): delaunay.points = tmp_points delaunay = delaunay.delaunay_2d(offset=2*np.linalg.norm(np.max(self.points, axis=0) - np.min(self.points, axis=0))) - self.convexity = self.mesh.area / delaunay.area + convexity = area / delaunay.area elif self.points.shape[1] == 3: delaunay = pv.PolyData() delaunay.points = np.unique(self.points, axis=0) delaunay = delaunay.delaunay_3d(offset=2*np.linalg.norm(np.max(self.points, axis=0) - np.min(self.points, axis=0))) - self.convexity = self.mesh.volume / delaunay.volume + convexity = volume / delaunay.volume else: raise ValueError("Only 2D and 3D domains are supported.") + + if not np.isfinite(convexity) or convexity <= 0: + raise ValueError("Domain convexity must be positive and finite") + if self.points.shape[1] == 3: + self._set_boundary_mesh(selected_surface) + self.mesh = _mesh + self.mesh_nodes = mesh_nodes + self.mesh_vertices = mesh_vertices + self.mesh_tree = mesh_tree + self.mesh_tree_2 = mesh_tree_2 + self.all_mesh_cells = all_mesh_cells + self.cumulative_probability = cumulative_probability + self.characteristic_length = characteristic_length + self.area = area + self.volume = volume + self.convexity = convexity + self.mesh_build_report = mesh_build_report return _mesh def get_interior_points(self, n, tree=None, volume_threshold=None, @@ -855,6 +1077,14 @@ def get_interior_points(self, n, tree=None, volume_threshold=None, # print("method not specified") #if self.mesh is None: # print("mesh not defined") + if self.mesh is not None and method == 'voronoi': + return self._get_voronoi_cell_centers( + n, + tree=tree, + threshold=threshold, + volume_threshold=volume_threshold, + implicit_range=implicit_range, + ) if self.mesh is None or method == 'implicit_only': min_dims = np.min(self.points, axis=0) max_dims = np.max(self.points, axis=0) @@ -1031,6 +1261,85 @@ def get_interior_points(self, n, tree=None, volume_threshold=None, # print(f'Domain Calculation took {domain_calc} seconds') return points, cells + def _get_voronoi_cell_centers( + self, + n, + *, + tree=None, + threshold=None, + volume_threshold=None, + implicit_range=(-1.0, 0.0), + ): + """Select deterministic farthest cell centers from existing seed points.""" + + if not isinstance(n, (int, np.integer)) or n < 0: + raise ValueError("n must be a non-negative integer") + centers = np.asarray( + self.mesh.cell_centers().points[:, :self.points.shape[1]], + dtype=np.float64, + ) + if centers.shape[0] == 0 or not np.isfinite(centers).all(): + raise ValueError("Voronoi sampling requires finite mesh cell centers") + + seed_source = tree + active_tree = getattr(tree, "active_tree", None) + if active_tree is not None: + seed_source = getattr(active_tree, "data", active_tree) + elif hasattr(tree, "data"): + seed_source = tree.data + if seed_source is None: + seeds = np.empty((0, centers.shape[1]), dtype=np.float64) + else: + seeds = np.asarray(seed_source, dtype=np.float64) + if seeds.ndim == 1: + seeds = seeds.reshape(1, -1) + if seeds.ndim != 2 or seeds.shape[1] < centers.shape[1]: + raise ValueError("Voronoi seed points have an invalid shape") + seeds = seeds[:, :centers.shape[1]] + if not np.isfinite(seeds).all(): + raise ValueError("Voronoi seed points must be finite") + + if seeds.shape[0] == 0: + nearest_distance = np.full(centers.shape[0], np.inf) + else: + seed_tree = BallTree(seeds) + nearest_distance = seed_tree.query(centers, k=1)[0].reshape(-1) + + values = np.asarray(self.__call__(centers)).reshape(-1) + eligible = np.isfinite(values) + eligible &= values >= float(implicit_range[0]) + eligible &= values <= float(implicit_range[1]) + if volume_threshold is not None: + if not np.isfinite(volume_threshold) or volume_threshold < 0: + raise ValueError("volume_threshold must be finite and non-negative") + eligible &= nearest_distance <= float(volume_threshold) + if threshold is not None and ( + not np.isfinite(threshold) or threshold < 0 + ): + raise ValueError("threshold must be finite and non-negative") + + selected = [] + available = eligible.copy() + for _ in range(min(int(n), int(np.count_nonzero(eligible)))): + candidates = np.flatnonzero(available) + if threshold is not None: + candidates = candidates[ + nearest_distance[candidates] > float(threshold) + ] + if candidates.size == 0: + break + selected_id = int(candidates[np.argmax(nearest_distance[candidates])]) + selected.append(selected_id) + available[selected_id] = False + distance_to_selected = np.linalg.norm( + centers - centers[selected_id], + axis=1, + ) + nearest_distance = np.minimum(nearest_distance, distance_to_selected) + + cell_ids = np.asarray(selected, dtype=np.int64) + return centers[cell_ids].copy(), cell_ids + def get_boundary_points(self, n, method=None, **kwargs): """ Pick n points randomly from the boundary of the domain. diff --git a/svv/domain/routines/mesh_diagnostics.py b/svv/domain/routines/mesh_diagnostics.py new file mode 100644 index 0000000..97b1e1a --- /dev/null +++ b/svv/domain/routines/mesh_diagnostics.py @@ -0,0 +1,351 @@ +"""Structured diagnostics for surface preparation and TetGen attempts.""" + +from dataclasses import asdict, dataclass +import re +import signal +from typing import Any, Dict, List, Optional, Tuple + +import numpy as np +import pyvista as pv + + +MAX_CAPTURE_CHARS = 32 * 1024 +MAX_DIAGNOSTIC_EXAMPLES = 12 + + +@dataclass(frozen=True) +class SurfaceMeshSummary: + """Small, serializable description of a candidate surface mesh.""" + + n_points: int + n_cells: int + n_triangles: int + n_components: int + is_all_triangles: bool + points_finite: bool + is_manifold: bool + n_open_edges: int + bounds: Tuple[float, float, float, float, float, float] + diagonal: float + area: float + volume: Optional[float] + + +@dataclass(frozen=True) +class TetGenDiagnosticSummary: + """Parsed, bounded output from one TetGen subprocess.""" + + segment_facet_intersections: int + facet_facet_intersections: int + missing_segments: int + missing_subfaces: int + python_exception: bool + native_abort: bool + return_code: int + signal_name: Optional[str] + examples: Tuple[str, ...] + stdout: str + stderr: str + + +@dataclass(frozen=True) +class TetGenAttemptReport: + """Outcome and bounded diagnostics for one surface strategy.""" + + strategy: str + status: str + surface: SurfaceMeshSummary + duration_seconds: float + recoverable: bool + tetgen_args: Tuple[Any, ...] + tetgen_kwargs: Dict[str, Any] + diagnostics: Optional[TetGenDiagnosticSummary] + message: str + + +@dataclass +class TetrahedralizationReport: + """Ordered record of candidate preparation and TetGen attempts.""" + + source: SurfaceMeshSummary + attempts: List[TetGenAttemptReport] + selected_strategy: Optional[str] + selected_surface: Optional[SurfaceMeshSummary] + versions: Dict[str, str] + + def user_summary(self) -> str: + """Return a concise cause and next action suitable for a dialog.""" + + if self.selected_strategy == "original": + return "TetGen built the interior mesh from the original surface." + if self.selected_strategy: + return ( + "TetGen built the interior mesh after automatic surface recovery " + "using {}.".format(self.selected_strategy) + ) + + diagnostics = [ + attempt.diagnostics + for attempt in self.attempts + if attempt.diagnostics is not None + ] + if any( + item.segment_facet_intersections or item.facet_facet_intersections + for item in diagnostics + ): + return ( + "Volume meshing failed because TetGen detected intersecting surface " + "facets. Inspect the facet and segment identifiers in the technical " + "details, repair the source surface, and retry." + ) + + messages = " ".join(attempt.message.lower() for attempt in self.attempts) + if "open edge" in messages or "non-manifold" in messages: + return ( + "Volume meshing failed because a recovery surface was open or " + "non-manifold. Repair the source surface and retry." + ) + if any(not attempt.recoverable for attempt in self.attempts): + return ( + "Volume meshing failed because the TetGen worker or its environment " + "failed. Review the technical details and verify the installation." + ) + return ( + "Volume meshing failed after all safe surface recovery attempts. " + "Review the technical details, repair the source surface, and retry." + ) + + def detailed_text(self) -> str: + """Render a bounded attempt report for troubleshooting.""" + + lines = ["Tetrahedralization report"] + if self.versions: + versions = ", ".join( + "{}={}".format(key, value) for key, value in sorted(self.versions.items()) + ) + lines.append("Versions: {}".format(versions)) + lines.append("Source: {}".format(_format_surface(self.source))) + lines.append("Selected strategy: {}".format(self.selected_strategy or "none")) + + for index, attempt in enumerate(self.attempts, start=1): + lines.append("") + lines.append( + "Attempt {}: {} [{}] ({:.3f}s)".format( + index, + attempt.strategy, + attempt.status, + attempt.duration_seconds, + ) + ) + lines.append("Surface: {}".format(_format_surface(attempt.surface))) + if attempt.tetgen_args: + lines.append("TetGen args: {}".format(repr(attempt.tetgen_args))) + if attempt.tetgen_kwargs: + kwargs = ", ".join( + "{}={}".format(key, repr(value)) + for key, value in sorted(attempt.tetgen_kwargs.items()) + ) + lines.append("TetGen options: {}".format(kwargs)) + lines.append("Message: {}".format(attempt.message)) + diagnostic = attempt.diagnostics + if diagnostic is None: + continue + return_label = str(diagnostic.return_code) + if diagnostic.signal_name: + return_label += " ({})".format(diagnostic.signal_name) + lines.append("Worker return: {}".format(return_label)) + lines.append( + "Intersections: segment-facet={}, facet-facet={}".format( + diagnostic.segment_facet_intersections, + diagnostic.facet_facet_intersections, + ) + ) + if diagnostic.examples: + lines.append("Representative TetGen diagnostics:") + lines.extend(" {}".format(line) for line in diagnostic.examples) + if diagnostic.stdout.strip(): + lines.append("STDOUT:\n{}".format(diagnostic.stdout.rstrip())) + if diagnostic.stderr.strip(): + lines.append("STDERR:\n{}".format(diagnostic.stderr.rstrip())) + + return "\n".join(lines) + + def to_dict(self) -> Dict[str, Any]: + """Return a JSON-safe report containing no mesh coordinate arrays.""" + + return _json_safe(asdict(self)) + + +class TetGenWorkerError(RuntimeError): + """A typed worker failure carrying a single attempt report.""" + + def __init__(self, attempt: TetGenAttemptReport): + self.attempt = attempt + super().__init__(attempt.message) + + @property + def recoverable(self) -> bool: + return self.attempt.recoverable + + +class TetrahedralizationError(RuntimeError): + """Failure of all enabled, safe tetrahedralization attempts.""" + + def __init__(self, report: TetrahedralizationReport): + self.report = report + super().__init__(report.user_summary()) + + +def summarize_surface(surface: pv.DataSet) -> SurfaceMeshSummary: + """Return topology and scale information without mutating ``surface``.""" + + poly = surface if isinstance(surface, pv.PolyData) else surface.extract_surface() + bounds = tuple(float(value) for value in poly.bounds) + lengths = np.asarray(bounds[1::2]) - np.asarray(bounds[::2]) + connected = poly.connectivity() + region_ids = connected.cell_data.get("RegionId") + n_components = int(np.unique(region_ids).size) if region_ids is not None else 0 + is_all_triangles = bool(poly.is_all_triangles) + + if is_all_triangles: + n_triangles = int(poly.n_cells) + else: + faces = np.asarray(poly.faces) + n_triangles = 0 + offset = 0 + while offset < faces.size: + cell_size = int(faces[offset]) + n_triangles += int(cell_size == 3) + offset += cell_size + 1 + + try: + volume = float(poly.volume) + except Exception: + volume = None + + return SurfaceMeshSummary( + n_points=int(poly.n_points), + n_cells=int(poly.n_cells), + n_triangles=n_triangles, + n_components=n_components, + is_all_triangles=is_all_triangles, + points_finite=bool(np.isfinite(np.asarray(poly.points)).all()), + is_manifold=bool(poly.is_manifold), + n_open_edges=int(poly.n_open_edges), + bounds=bounds, + diagonal=float(np.linalg.norm(lengths)), + area=float(poly.area), + volume=volume, + ) + + +def _bounded_text(value: str, limit: int = MAX_CAPTURE_CHARS) -> str: + if len(value) <= limit: + return value + omitted = len(value) - limit + return value[:limit] + "\n...[truncated {} characters]".format(omitted) + + +def _format_surface(summary: SurfaceMeshSummary) -> str: + return ( + "{} points, {} cells, {} triangles, {} components, manifold={}, " + "open_edges={}, diagonal={:.6g}".format( + summary.n_points, + summary.n_cells, + summary.n_triangles, + summary.n_components, + summary.is_manifold, + summary.n_open_edges, + summary.diagonal, + ) + ) + + +def _json_safe(value: Any) -> Any: + if isinstance(value, dict): + return {str(key): _json_safe(item) for key, item in value.items()} + if isinstance(value, (list, tuple)): + return [_json_safe(item) for item in value] + if isinstance(value, np.generic): + return value.item() + if isinstance(value, (str, int, float, bool)) or value is None: + return value + return repr(value) + + +def _missing_count(text: str, noun: str) -> int: + pattern = r"\((\d+)\)\s+{}\b[^\n]*\(missing\)".format(re.escape(noun)) + return sum(int(match) for match in re.findall(pattern, text, flags=re.IGNORECASE)) + + +def summarize_tetgen_output( + stdout: str, + stderr: str, + return_code: int, +) -> TetGenDiagnosticSummary: + """Classify TetGen output while retaining only bounded diagnostic text.""" + + stdout = stdout or "" + stderr = stderr or "" + combined = stdout + "\n" + stderr + lower = combined.lower() + segment_facet = lower.count("a segment and a facet intersect") + facet_facet = lower.count("two facets exactly intersect") + + signal_name = None + windows_native_status = 0x80000000 <= return_code <= 0xFFFFFFFF + if windows_native_status: + signal_name = "NTSTATUS_0x{:08X}".format(return_code) + elif return_code < 0: + signal_number = -return_code + # A negative subprocess code uses POSIX numbering even when a saved + # diagnostic is later rendered on a host with a different signal enum. + if signal_number == 6: + signal_name = "SIGABRT" + else: + try: + signal_name = signal.Signals(signal_number).name + except (ValueError, OSError): + signal_name = "SIGNAL_{}".format(signal_number) + + native_markers = ( + "free():", + "segmentation fault", + "core dumped", + "access violation", + "malloc():", + ) + native_abort = ( + return_code < 0 + or windows_native_status + or any(marker in lower for marker in native_markers) + ) + python_exception = "traceback (most recent call last)" in lower or "runtimeerror:" in lower + + examples = [] + for line in combined.splitlines(): + normalized = line.strip() + lowered = normalized.lower() + if ( + "intersect" in lowered + or lowered.startswith("segment:") + or "facet triangle:" in lowered + or "recovered (missing)" in lowered + ): + examples.append(normalized) + if len(examples) >= MAX_DIAGNOSTIC_EXAMPLES: + break + + return TetGenDiagnosticSummary( + segment_facet_intersections=segment_facet, + facet_facet_intersections=facet_facet, + missing_segments=_missing_count(combined, "segments"), + missing_subfaces=_missing_count(combined, "subfaces"), + python_exception=python_exception, + native_abort=native_abort, + return_code=int(return_code), + signal_name=signal_name, + examples=tuple(examples), + stdout=_bounded_text(stdout), + stderr=_bounded_text(stderr), + ) diff --git a/svv/domain/routines/tetrahedralize.py b/svv/domain/routines/tetrahedralize.py index 75b8510..60f5907 100644 --- a/svv/domain/routines/tetrahedralize.py +++ b/svv/domain/routines/tetrahedralize.py @@ -1,5 +1,7 @@ import tetgen import pymeshfix +from dataclasses import dataclass, replace +from numbers import Integral, Real import subprocess import tempfile import os @@ -11,8 +13,17 @@ import numpy as np import pyvista as pv from svv.utils.remeshing import remesh +from svv.domain.routines.mesh_diagnostics import ( + TetGenAttemptReport, + TetGenWorkerError, + TetrahedralizationError, + TetrahedralizationReport, + summarize_surface, + summarize_tetgen_output, +) import shutil import json +from concurrent.futures import ThreadPoolExecutor filepath = os.path.abspath(__file__) dirpath = os.path.dirname(filepath) @@ -87,6 +98,173 @@ def _run_tetgen(surface_mesh): nodes, elems = tgen.tetrahedralize(verbose=0) return nodes, elems + +def prepare_surface(surface: pv.DataSet) -> pv.PolyData: + """Return a finite, non-empty triangular deep copy of ``surface``.""" + + if isinstance(surface, pv.PolyData): + prepared = surface.copy(deep=True) + else: + prepared = surface.extract_surface().copy(deep=True) + if not prepared.is_all_triangles: + prepared = prepared.triangulate() + prepared = prepared.clean(tolerance=0.0, absolute=True) + if prepared.n_points == 0 or prepared.n_cells == 0: + raise ValueError("Cannot tetrahedralize an empty surface") + if not np.isfinite(np.asarray(prepared.points)).all(): + raise ValueError("Surface points must contain only finite coordinates") + if not prepared.is_all_triangles: + raise ValueError("Surface preparation did not produce only triangles") + return prepared + + +def _validated_real_scalar(value, name, *, allow_zero): + requirement = ( + "finite and non-negative scalar" if allow_zero else "finite and positive scalar" + ) + if isinstance(value, (bool, np.bool_)) or not isinstance(value, Real): + raise ValueError("{} must be a {}".format(name, requirement)) + numeric = float(value) + if not np.isfinite(numeric) or (numeric < 0 if allow_zero else numeric <= 0): + raise ValueError("{} must be a {}".format(name, requirement)) + return numeric + + +def _symmetric_surface_distance(first: pv.PolyData, second: pv.PolyData) -> float: + first_distances = np.abs( + np.asarray(first.compute_implicit_distance(second)["implicit_distance"]) + ) + second_distances = np.abs( + np.asarray(second.compute_implicit_distance(first)["implicit_distance"]) + ) + if ( + first_distances.size == 0 + or second_distances.size == 0 + or not np.isfinite(first_distances).all() + or not np.isfinite(second_distances).all() + ): + raise ValueError("Surface distance arrays must be finite and non-empty") + return float(max(first_distances.max(), second_distances.max())) + + +def validate_recovery_surface( + source: pv.PolyData, + candidate: pv.PolyData, + *, + max_distance_ratio: float, +): + """Validate topology and geometric displacement of a recovery candidate.""" + + max_distance_ratio = _validated_real_scalar( + max_distance_ratio, + "max_distance_ratio", + allow_zero=False, + ) + source_summary = summarize_surface(source) + candidate_summary = summarize_surface(candidate) + if not candidate_summary.points_finite: + raise ValueError("Recovery surface points must be finite") + if not candidate_summary.is_all_triangles: + raise ValueError("Recovery surface must contain only triangles") + if not candidate_summary.is_manifold: + raise ValueError("Recovery surface is non-manifold") + if candidate_summary.n_open_edges != 0: + raise ValueError( + "Recovery surface has {} open edges".format(candidate_summary.n_open_edges) + ) + if candidate_summary.n_components != source_summary.n_components: + raise ValueError( + "Recovery changed connected-component count from {} to {}".format( + source_summary.n_components, + candidate_summary.n_components, + ) + ) + + source_bounds = np.asarray(source_summary.bounds, dtype=float) + candidate_bounds = np.asarray(candidate_summary.bounds, dtype=float) + if not np.isfinite(source_bounds).all() or not np.isfinite(candidate_bounds).all(): + raise ValueError("Source and recovery surface bounds must be finite") + if ( + not np.isfinite(source_summary.diagonal) + or source_summary.diagonal <= 0 + or not np.isfinite(candidate_summary.diagonal) + or candidate_summary.diagonal <= 0 + ): + raise ValueError("Source and recovery surface diagonals must be finite and positive") + + allowed_distance = source_summary.diagonal * max_distance_ratio + if not np.isfinite(allowed_distance): + raise ValueError("The allowed recovery distance must be finite") + bounds_delta = float( + np.max( + np.abs(candidate_bounds - source_bounds) + ) + ) + if not np.isfinite(bounds_delta): + raise ValueError("Recovery surface bounds delta must be finite") + if bounds_delta > allowed_distance: + raise ValueError( + "Recovery surface bounds changed by {:.6g}, exceeding {:.6g}".format( + bounds_delta, + allowed_distance, + ) + ) + distance = _symmetric_surface_distance(source, candidate) + if not np.isfinite(distance): + raise ValueError("Recovery surface distance must be finite") + if distance > allowed_distance: + raise ValueError( + "Recovery surface displacement {:.6g} exceeds {:.6g} " + "({:.3%} of the source diagonal)".format( + distance, + allowed_distance, + max_distance_ratio, + ) + ) + return candidate_summary + + +class RecoverySurfaceRejected(ValueError): + """A fidelity or topology rejection that retains the candidate surface.""" + + def __init__(self, message, surface): + self.surface = surface.copy(deep=True) + super().__init__(message) + + +def repair_surface_with_meshfix( + surface: pv.PolyData, + *, + max_distance_ratio: float = 0.01, +) -> pv.PolyData: + """Repair a copy of ``surface`` without joining or dropping components.""" + + prepared = prepare_surface(surface) + faces = np.asarray(prepared.faces).reshape(-1, 4)[:, 1:] + meshfix = pymeshfix.MeshFix(np.asarray(prepared.points), faces) + meshfix.repair( + verbose=False, + joincomp=False, + remove_smallest_components=False, + ) + repaired_faces = np.column_stack( + ( + np.full(len(meshfix.f), 3, dtype=np.int64), + np.asarray(meshfix.f, dtype=np.int64), + ) + ) + repaired = pv.PolyData(np.asarray(meshfix.v).copy(), repaired_faces) + try: + repaired = prepare_surface(repaired) + validate_recovery_surface( + prepared, + repaired, + max_distance_ratio=max_distance_ratio, + ) + except ValueError as exc: + raise RecoverySurfaceRejected(str(exc), repaired) from exc + return repaired + def uniform_remesh_surface(surface: pv.PolyData, *, subdivisions: int = 3, @@ -138,7 +316,32 @@ def _tetgen_worker_tetrahedralize(surface: pv.PolyData, tet_args, tet_kwargs, worker_script: str, - python_exe: str): + python_exe: str, + *, + strategy: str = "original"): + attempt_start = time.perf_counter() + surface_summary = summarize_surface(surface) + worker_script, python_exe = _resolve_worker_launch_paths( + worker_script, + python_exe, + ) + + def infrastructure_error(message, exc): + details = "{}: {}".format(type(exc).__name__, exc) + return TetGenWorkerError( + TetGenAttemptReport( + strategy=strategy, + status="infrastructure-error", + surface=surface_summary, + duration_seconds=time.perf_counter() - attempt_start, + recoverable=False, + tetgen_args=tuple(tet_args), + tetgen_kwargs=dict(tet_kwargs), + diagnostics=summarize_tetgen_output("", details, 1), + message="{}: {}".format(message, details), + ) + ) + # On Windows, `tempfile` honors TMPDIR, which may be set to a POSIX-style # path such as '/tmp' and is not a valid directory there. Prefer the # standard TEMP/TMP locations when available to avoid spurious @@ -160,22 +363,35 @@ def _tetgen_worker_tetrahedralize(surface: pv.PolyData, "args": list(tet_args), "kwargs": tet_kwargs, } - with open(config_path, "w") as f: - json.dump(cfg, f) + try: + with open(config_path, "w") as f: + json.dump(cfg, f) - # Save the surface mesh so the worker can read it - surface.save(surface_path) + # Save the surface mesh so the worker can read it. + surface.save(surface_path) + except Exception as exc: + raise infrastructure_error( + "TetGen worker input preparation failed", + exc, + ) from exc # Command: call the worker script as a separate Python process cmd = [python_exe, worker_script, surface_path, out_path, config_path] # Start the worker process - proc = subprocess.Popen( - cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, # decode to strings - ) + try: + proc = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, # decode to strings + cwd=tmpdir, + ) + except Exception as exc: + raise infrastructure_error( + "TetGen worker process could not be launched", + exc, + ) from exc show_spinner = sys.stdout.isatty() if show_spinner: @@ -186,76 +402,148 @@ def _tetgen_worker_tetrahedralize(surface: pv.PolyData, sys.stdout.write("TetGen meshing| ") sys.stdout.flush() - # Live spinner loop - while proc.poll() is None: - # Compute elapsed time - elapsed = time.time() - start_time - elapsed_str = format_elapsed(elapsed) - - # Build left side message - spin_char = next(spinner) - left = f"TetGen meshing| {spin_char}" - - # Get terminal width (fallback if IDE doesn't report it) - try: - width = shutil.get_terminal_size(fallback=(80, 20)).columns - except Exception: - width = 80 - - # Compute spacing so elapsed time is right-aligned - # We'll always keep at least one space between left and right - min_gap = 1 - total_len = len(left) + min_gap + len(elapsed_str) - if total_len <= width: - spaces = width - len(left) - len(elapsed_str) - else: - # If line is longer than terminal, don't try to be clever; just put a single space - spaces = min_gap - - line = f"{left}{' ' * spaces}{elapsed_str}" - - # '\r' to return to the start of the same line and overwrite - sys.stdout.write("\r" + line) - sys.stdout.flush() - - time.sleep(0.1) + # Drain both pipes in a background thread while updating the spinner. + # Waiting for process exit before reading can deadlock when TetGen emits + # more output than an OS pipe can buffer. + with ThreadPoolExecutor(max_workers=1) as executor: + communication = executor.submit(proc.communicate) + while not communication.done(): + elapsed = time.time() - start_time + elapsed_str = format_elapsed(elapsed) + spin_char = next(spinner) + left = f"TetGen meshing| {spin_char}" + + try: + width = shutil.get_terminal_size(fallback=(80, 20)).columns + except Exception: + width = 80 + + min_gap = 1 + total_len = len(left) + min_gap + len(elapsed_str) + if total_len <= width: + spaces = width - len(left) - len(elapsed_str) + else: + spaces = min_gap + + line = f"{left}{' ' * spaces}{elapsed_str}" + sys.stdout.write("\r" + line) + sys.stdout.flush() + time.sleep(0.1) + stdout, stderr = communication.result() # Finish line sys.stdout.write("\n") sys.stdout.flush() else: - # Non-interactive environment (e.g., CI): just wait for the - # worker process to finish without a live spinner to avoid - # any potential overhead from frequent stdout updates. - proc.wait() - - # Collect output (so the pipes don't hang) - stdout, stderr = proc.communicate() + # communicate() drains both pipes while waiting for completion. + stdout, stderr = proc.communicate() if proc.returncode != 0: - raise RuntimeError( - f"TetGen worker failed with code {proc.returncode}\n" - f"STDOUT:\n{stdout}\n\nSTDERR:\n{stderr}" + diagnostics = summarize_tetgen_output(stdout, stderr, proc.returncode) + lower_output = (stdout + "\n" + stderr).lower() + recoverable = bool( + diagnostics.segment_facet_intersections + or diagnostics.facet_facet_intersections + or diagnostics.missing_segments + or diagnostics.missing_subfaces + or diagnostics.native_abort + or "failed to tetrahedralize" in lower_output + or "internal tetgen error" in lower_output + or "input surface mesh contain self-intersection" in lower_output + or "input surface mesh contains self-intersection" in lower_output + or "unknown exception" in lower_output + ) + if recoverable: + message = "TetGen rejected the {} surface.".format(strategy) + else: + message = "TetGen worker infrastructure failed for the {} surface.".format( + strategy + ) + raise TetGenWorkerError( + TetGenAttemptReport( + strategy=strategy, + status="failed", + surface=surface_summary, + duration_seconds=time.perf_counter() - attempt_start, + recoverable=recoverable, + tetgen_args=tuple(tet_args), + tetgen_kwargs=dict(tet_kwargs), + diagnostics=diagnostics, + message=message, + ) ) - # Load results and ensure the file handle is closed before the - # temporary directory is cleaned up (important on Windows). - with np.load(out_path) as data: - nodes = data["nodes"] - elems = data["elems"] + # Load and validate results before cleaning the temporary directory. + try: + with np.load(out_path) as data: + nodes = np.asarray(data["nodes"]) + elems = np.asarray(data["elems"]) + elems = _validate_tetgen_arrays(nodes, elems) + except Exception as exc: + diagnostics = summarize_tetgen_output(stdout, stderr, proc.returncode) + raise TetGenWorkerError( + TetGenAttemptReport( + strategy=strategy, + status="invalid-output", + surface=surface_summary, + duration_seconds=time.perf_counter() - attempt_start, + recoverable=False, + tetgen_args=tuple(tet_args), + tetgen_kwargs=dict(tet_kwargs), + diagnostics=diagnostics, + message="TetGen worker returned invalid output: {}".format(exc), + ) + ) from exc return nodes, elems +def _resolve_worker_launch_paths(worker_script, python_exe): + """Resolve paths that would otherwise be interpreted from the worker cwd.""" + + worker_script = os.fspath(worker_script) + python_exe = os.fspath(python_exe) + if not os.path.isabs(worker_script): + worker_script = os.path.abspath(worker_script) + if not os.path.isabs(python_exe) and os.path.dirname(python_exe): + python_exe = os.path.abspath(python_exe) + return worker_script, python_exe + + +def _validate_tetgen_arrays(nodes, elems): + """Validate node coordinates and tetrahedral connectivity from a worker.""" + + if nodes.ndim != 2 or nodes.shape[1] != 3 or nodes.shape[0] == 0: + raise ValueError("TetGen nodes must have non-empty shape (N, 3)") + if not np.isfinite(nodes).all(): + raise ValueError("TetGen nodes must contain only finite coordinates") + if elems.ndim != 2 or elems.shape[0] == 0 or elems.shape[1] not in (4, 10): + raise ValueError("TetGen elements must have non-empty shape (M, 4) or (M, 10)") + if not np.issubdtype(elems.dtype, np.integer): + raise ValueError("TetGen element connectivity must use an integer dtype") + + minimum = int(elems.min()) + maximum = int(elems.max()) + if minimum < 0 or maximum > nodes.shape[0]: + raise ValueError("TetGen element connectivity contains out-of-range node indices") + if maximum == nodes.shape[0]: + if minimum < 1: + raise ValueError( + "TetGen element connectivity contains an out-of-range or mixed-base " + "node index" + ) + elems = elems - 1 + elif maximum >= nodes.shape[0]: + raise ValueError("TetGen element connectivity contains out-of-range node indices") + return elems + + def _tetgen_grid_from_arrays(nodes, elems): """ Convert TetGen node/connectivity arrays into a PyVista unstructured grid. """ nodes = np.asarray(nodes) elems = np.asarray(elems) - if elems.min() == 1: - elems = elems - 1 - n_cells, n_vertices_per_cell = elems.shape cells = np.hstack( [ @@ -275,74 +563,389 @@ def _tetgen_grid_from_arrays(nodes, elems): return grid, nodes, elems +@dataclass +class TetrahedralizationResult: + """Rich tetrahedralization result for callers that need provenance.""" + + grid: pv.UnstructuredGrid + nodes: np.ndarray + elements: np.ndarray + surface: pv.PolyData + report: TetrahedralizationReport + + +def _dependency_versions(): + return { + "tetgen": str(getattr(tetgen, "__version__", "unknown")), + "pyvista": str(getattr(pv, "__version__", "unknown")), + "pymeshfix": str(getattr(pymeshfix, "__version__", "unknown")), + } + + +def _success_result( + candidate, + strategy, + nodes, + elems, + report, + duration, + tet_args, + tet_kwargs, +): + grid, nodes, elems = _tetgen_grid_from_arrays(nodes, elems) + candidate_summary = summarize_surface(candidate) + report.attempts.append( + TetGenAttemptReport( + strategy=strategy, + status="succeeded", + surface=candidate_summary, + duration_seconds=duration, + recoverable=True, + tetgen_args=tuple(tet_args), + tetgen_kwargs=dict(tet_kwargs), + diagnostics=summarize_tetgen_output("", "", 0), + message="TetGen accepted the {} surface.".format(strategy), + ) + ) + report.selected_strategy = strategy + report.selected_surface = candidate_summary + return TetrahedralizationResult( + grid=grid, + nodes=nodes, + elements=elems, + surface=candidate.copy(deep=True), + report=report, + ) + + +def _rejected_attempt(strategy, surface, report, tet_args, tet_kwargs, message, duration): + report.attempts.append( + TetGenAttemptReport( + strategy=strategy, + status="rejected", + surface=summarize_surface(surface), + duration_seconds=duration, + recoverable=True, + tetgen_args=tuple(tet_args), + tetgen_kwargs=dict(tet_kwargs), + diagnostics=None, + message=message, + ) + ) + + def tetrahedralize(surface: pv.PolyData, *tet_args, worker_script: str = dirpath+os.sep+"tetgen_worker.py", python_exe: str = sys.executable, + repair_on_failure: bool = True, + repair_max_distance_ratio: float = 0.01, remesh_on_failure: bool = True, remesh_subdivisions: int = 3, remesh_clusters: int = 20000, remesh_clean_tolerance: float = 1e-5, + return_result: bool = False, **tet_kwargs): """ - Tetrahedralize a surface mesh using TetGen. + Tetrahedralize a surface mesh using isolated TetGen worker processes. + + The unchanged, prepared surface is tried first. Geometry rejections then + use a component-preserving MeshFix repair, followed by a validated PyACVD + candidate as the final optional fallback. Recovery candidates must be + closed, manifold, triangular, component-preserving, and within the + configured displacement and bounds envelope. The caller's TetGen options + are unchanged across attempts, and the input surface is never mutated. Parameters ---------- - surface_mesh : PyMesh mesh object - The surface mesh to tetrahedralize. - verbose : bool - A flag to indicate if mesh fixing should be verbose. - kwargs : dict - A dictionary of keyword arguments to be passed to TetGen. + surface : pyvista.DataSet + Surface mesh to tetrahedralize. A deep triangular copy is prepared. + *tet_args + Positional arguments forwarded unchanged to TetGen. + worker_script : str + Worker entry point used to isolate native TetGen calls. Relative paths + are resolved before entering the worker's temporary directory. + python_exe : str + Python interpreter used for the worker process. Relative paths with a + directory component are resolved before entering the temporary directory; + bare command names continue to use the process ``PATH``. + repair_on_failure : bool + If True, retry a geometry-related TetGen failure after a + component-preserving PyMeshFix repair. + repair_max_distance_ratio : float + Maximum symmetric repair displacement as a fraction of the source + bounding-box diagonal. remesh_on_failure : bool - If True, retry TetGen once using a PyACVD uniform isotropic remesh - when the original surface fails to tetrahedralize. + If True, retain a validated PyACVD remesh as the final recovery path. remesh_subdivisions : int Number of PyACVD subdivision passes used by the retry path. remesh_clusters : int Number of PyACVD clusters used by the retry path. remesh_clean_tolerance : float PyVista clean tolerance applied before and after PyACVD remeshing. + return_result : bool + Return a ``TetrahedralizationResult`` containing the selected surface + and structured report instead of the historical three-value tuple. + **tet_kwargs + Keyword arguments forwarded unchanged to every TetGen meshing attempt. Returns ------- - mesh : PyMesh mesh object - An unstructured grid mesh representing the tetrahedralized - volume enclosed by the surface mesh manifold. + tuple or TetrahedralizationResult + The historical ``(grid, nodes, elements)`` tuple, or a rich result + when ``return_result=True``. + + Raises + ------ + TetGenWorkerError + If worker launch, dependencies, serialization, or result validation + fails. Infrastructure failures do not start geometry recovery. + TetrahedralizationError + If every enabled safe geometry strategy fails. The exception carries + the ordered ``report`` used by the GUI and troubleshooting tools. """ - tet_kwargs.setdefault("verbose", 0) + if not isinstance(repair_on_failure, bool): + raise ValueError("repair_on_failure must be a boolean") + if not isinstance(remesh_on_failure, bool): + raise ValueError("remesh_on_failure must be a boolean") + if not isinstance(return_result, bool): + raise ValueError("return_result must be a boolean") + repair_max_distance_ratio = _validated_real_scalar( + repair_max_distance_ratio, + "repair_max_distance_ratio", + allow_zero=False, + ) + if ( + isinstance(remesh_subdivisions, (bool, np.bool_)) + or not isinstance(remesh_subdivisions, Integral) + or remesh_subdivisions < 0 + ): + raise ValueError("remesh_subdivisions must be a non-negative integer") + if ( + isinstance(remesh_clusters, (bool, np.bool_)) + or not isinstance(remesh_clusters, Integral) + or remesh_clusters <= 0 + ): + raise ValueError("remesh_clusters must be a positive integer") + remesh_subdivisions = int(remesh_subdivisions) + remesh_clusters = int(remesh_clusters) + if remesh_clean_tolerance is not None: + remesh_clean_tolerance = _validated_real_scalar( + remesh_clean_tolerance, + "remesh_clean_tolerance", + allow_zero=True, + ) - try: - nodes, elems = _tetgen_worker_tetrahedralize( - surface, tet_args, tet_kwargs, worker_script, python_exe + tet_kwargs.setdefault("verbose", 0) + source = prepare_surface(surface) + report = TetrahedralizationReport( + source=summarize_surface(source), + attempts=[], + selected_strategy=None, + selected_surface=None, + versions=_dependency_versions(), + ) + + def diagnose_opaque_failure(candidate, error): + diagnostic = error.attempt.diagnostics + if diagnostic is None: + return error.attempt + has_geometry_details = bool( + diagnostic.segment_facet_intersections + or diagnostic.facet_facet_intersections + or diagnostic.missing_segments + or diagnostic.missing_subfaces ) - except RuntimeError as original_error: - if not remesh_on_failure: - raise + if has_geometry_details: + return error.attempt + + diagnostic_kwargs = dict(tet_kwargs) + diagnostic_kwargs["diagnose"] = 1 + diagnostic_kwargs["quiet"] = False + diagnostic_kwargs["verbose"] = 1 + diagnostic_switches = diagnostic_kwargs.get("switches") + if diagnostic_switches: + diagnostic_switches = diagnostic_switches.replace("Q", "") + if "d" not in diagnostic_switches: + diagnostic_switches += "d" + if "V" not in diagnostic_switches: + diagnostic_switches += "V" + diagnostic_kwargs["switches"] = diagnostic_switches try: - remeshed_surface = uniform_remesh_surface( - surface, - subdivisions=remesh_subdivisions, - clusters=remesh_clusters, - clean_tolerance=remesh_clean_tolerance, + _tetgen_worker_tetrahedralize( + candidate, + tet_args, + diagnostic_kwargs, + worker_script, + python_exe, + strategy="{}-diagnostic".format(error.attempt.strategy), + ) + except TetGenWorkerError as diagnostic_error: + extra = diagnostic_error.attempt.diagnostics + if extra is None: + return error.attempt + merged = summarize_tetgen_output( + diagnostic.stdout + "\n" + extra.stdout, + diagnostic.stderr + "\n" + extra.stderr, + extra.return_code, ) - except Exception as remesh_error: - raise RuntimeError( - "TetGen failed and uniform surface remeshing fallback failed.\n\n" - f"Original TetGen error:\n{original_error}\n\n" - f"Remeshing error:\n{remesh_error}" - ) from remesh_error + return replace( + error.attempt, + duration_seconds=( + error.attempt.duration_seconds + + diagnostic_error.attempt.duration_seconds + ), + diagnostics=merged, + message=( + error.attempt.message + + " A diagnostic TetGen pass captured additional geometry details." + ), + ) + return error.attempt + def attempt(candidate, strategy): + started = time.perf_counter() try: nodes, elems = _tetgen_worker_tetrahedralize( - remeshed_surface, tet_args, tet_kwargs, worker_script, python_exe + candidate, + tet_args, + tet_kwargs, + worker_script, + python_exe, + strategy=strategy, + ) + except TetGenWorkerError as exc: + attempt_report = ( + diagnose_opaque_failure(candidate, exc) + if exc.recoverable + else exc.attempt ) - except RuntimeError as retry_error: - raise RuntimeError( - "TetGen failed after PyACVD uniform surface remeshing fallback.\n\n" - f"Original TetGen error:\n{original_error}\n\n" - f"Retry TetGen error:\n{retry_error}" - ) from retry_error - - return _tetgen_grid_from_arrays(nodes, elems) + report.attempts.append(attempt_report) + if not exc.recoverable: + raise + return None + return _success_result( + candidate, + strategy, + nodes, + elems, + report, + time.perf_counter() - started, + tet_args, + tet_kwargs, + ) + + result = attempt(source, "original") + if result is not None: + return result if return_result else (result.grid, result.nodes, result.elements) + + if repair_on_failure: + started = time.perf_counter() + repaired = None + try: + repaired = repair_surface_with_meshfix( + source, + max_distance_ratio=repair_max_distance_ratio, + ) + try: + repaired = prepare_surface(repaired) + validate_recovery_surface( + source, + repaired, + max_distance_ratio=repair_max_distance_ratio, + ) + except ValueError as exc: + raise RecoverySurfaceRejected(str(exc), repaired) from exc + except RecoverySurfaceRejected as exc: + _rejected_attempt( + "meshfix", + exc.surface, + report, + tet_args, + tet_kwargs, + "PyMeshFix recovery was rejected: {}".format(exc), + time.perf_counter() - started, + ) + else: + result = attempt(repaired, "meshfix") + if result is not None: + return result if return_result else (result.grid, result.nodes, result.elements) + + if remesh_on_failure: + started = time.perf_counter() + remeshed = None + raw_remeshed = uniform_remesh_surface( + source, + subdivisions=remesh_subdivisions, + clusters=remesh_clusters, + clean_tolerance=remesh_clean_tolerance, + ) + try: + remeshed = prepare_surface(raw_remeshed) + validate_recovery_surface( + source, + remeshed, + max_distance_ratio=repair_max_distance_ratio, + ) + except ValueError as remesh_error: + pyacvd_candidate = remeshed if remeshed is not None else raw_remeshed + remesh_rejection = RecoverySurfaceRejected( + str(remesh_error), + pyacvd_candidate, + ) + if repair_on_failure: + repaired_remesh = None + try: + repaired_remesh = repair_surface_with_meshfix( + pyacvd_candidate, + max_distance_ratio=repair_max_distance_ratio, + ) + try: + remeshed = prepare_surface(repaired_remesh) + validate_recovery_surface( + source, + remeshed, + max_distance_ratio=repair_max_distance_ratio, + ) + except ValueError as exc: + raise RecoverySurfaceRejected( + str(exc), + remeshed + if remeshed is not None + else repaired_remesh, + ) from exc + remesh_strategy = "pyacvd_meshfix" + except RecoverySurfaceRejected as repair_error: + _rejected_attempt( + "pyacvd_meshfix", + repair_error.surface, + report, + tet_args, + tet_kwargs, + "PyACVD recovery was rejected: {}; repair failed: {}".format( + remesh_rejection, + repair_error, + ), + time.perf_counter() - started, + ) + remeshed = None + else: + _rejected_attempt( + "pyacvd", + remesh_rejection.surface, + report, + tet_args, + tet_kwargs, + "PyACVD recovery was rejected: {}".format(remesh_rejection), + time.perf_counter() - started, + ) + remeshed = None + else: + remesh_strategy = "pyacvd" + + if remeshed is not None: + result = attempt(remeshed, remesh_strategy) + if result is not None: + return result if return_result else (result.grid, result.nodes, result.elements) + + raise TetrahedralizationError(report) diff --git a/svv/visualize/gui/domain_build_feedback.py b/svv/visualize/gui/domain_build_feedback.py new file mode 100644 index 0000000..f042cc2 --- /dev/null +++ b/svv/visualize/gui/domain_build_feedback.py @@ -0,0 +1,197 @@ +"""User-facing feedback for Domain tetrahedralization outcomes.""" + +from dataclasses import dataclass, replace +import re +from typing import Any, Dict, Optional + +from svv.domain.routines.mesh_diagnostics import TetrahedralizationReport + + +_QUOTED_WINDOWS_PATH = re.compile( + r'''(?i)(?P["'])(?:[a-z]:[\\/]|\\\\)[^"']+(?P=quote)''' +) +_QUOTED_UNIX_PATH = re.compile( + r'''(?P["'])/(?!/)[^"']+(?P=quote)''' +) +_WINDOWS_EXTENDED_PATH = re.compile( + r'''(?i)(? str: + """Replace absolute local filesystem paths in diagnostic text.""" + + def replace_quoted(match): + quote = match.group("quote") + return "{}{}".format(quote, quote) + + sanitized = _QUOTED_WINDOWS_PATH.sub(replace_quoted, str(value)) + sanitized = _QUOTED_UNIX_PATH.sub(replace_quoted, sanitized) + sanitized = _WINDOWS_EXTENDED_PATH.sub("", sanitized) + sanitized = _WINDOWS_UNC_PATH.sub("", sanitized) + sanitized = _WINDOWS_DRIVE_PATH.sub("", sanitized) + return _UNIX_PATH.sub("", sanitized) + + +def extract_build_report(exception=None, domain=None) -> Optional[TetrahedralizationReport]: + """Find a structured report on an exception or completed Domain.""" + + report = getattr(exception, "report", None) + if report is not None: + return report + attempt = getattr(exception, "attempt", None) + if attempt is not None: + return TetrahedralizationReport( + source=attempt.surface, + attempts=[attempt], + selected_strategy=None, + selected_surface=None, + versions={}, + ) + return getattr(domain, "mesh_build_report", None) + + +def _report_without_duplicate_tracebacks(report): + seen_tracebacks = set() + attempts = [] + for attempt in report.attempts: + diagnostic = attempt.diagnostics + if diagnostic is None: + attempts.append(attempt) + continue + replacements = {} + for stream_name in ("stdout", "stderr"): + stream = getattr(diagnostic, stream_name) + if "traceback (most recent call last)" not in stream.lower(): + continue + signature = stream.strip() + if signature in seen_tracebacks: + replacements[stream_name] = "[duplicate Python traceback omitted]" + else: + seen_tracebacks.add(signature) + if replacements: + diagnostic = replace(diagnostic, **replacements) + attempt = replace(attempt, diagnostics=diagnostic) + attempts.append(attempt) + return TetrahedralizationReport( + source=report.source, + attempts=attempts, + selected_strategy=report.selected_strategy, + selected_surface=report.selected_surface, + versions=dict(report.versions), + ) + + +def build_domain_feedback( + *, + report: Optional[TetrahedralizationReport] = None, + exception=None, + success: bool, +) -> DomainBuildFeedback: + """Format a Domain build result for status and optional warning display.""" + + if report is None: + report = extract_build_report(exception=exception) + + if success: + recovered = bool( + report is not None + and report.selected_strategy + and report.selected_strategy != "original" + ) + if recovered: + strategy = report.selected_strategy + status = "Domain built successfully after surface repair ({})".format( + strategy + ) + informative = report.user_summary() + else: + status = "Domain built successfully" + informative = ( + report.user_summary() + if report is not None + else "The interior mesh was built successfully." + ) + details = report.detailed_text() if report is not None else "" + return DomainBuildFeedback( + text="The domain and its interior mesh were built successfully.", + informative_text=sanitize_local_paths(informative), + detailed_text=sanitize_local_paths(details), + status=status, + recovered=recovered, + ) + + if report is not None: + display_report = _report_without_duplicate_tracebacks(report) + informative = display_report.user_summary() + details = display_report.detailed_text() + else: + informative = ( + "Volume meshing failed. Review the technical details, verify the " + "meshing installation, and inspect the source surface." + ) + details = "{}: {}".format( + type(exception).__name__ if exception is not None else "Error", + exception if exception is not None else "No additional details were recorded.", + ) + return DomainBuildFeedback( + text="The domain loaded, but its interior mesh could not be built.", + informative_text=sanitize_local_paths(informative), + detailed_text=sanitize_local_paths(details), + status="Domain loaded without an interior mesh", + recovered=False, + ) + + +def apply_feedback_to_message_box(message_box, feedback: DomainBuildFeedback) -> None: + """Populate a QMessageBox-compatible object with structured feedback.""" + + box_type = type(message_box) + message_box.setIcon(box_type.Warning) + message_box.setText(feedback.text) + message_box.setInformativeText(feedback.informative_text) + message_box.setDetailedText(feedback.detailed_text) + message_box.setStandardButtons(box_type.Ok) + # Some native dialog backends initialize the platform window while setting + # the icon and clear an earlier title in the process. + message_box.setWindowTitle("Domain Build Warning") + + +def _sanitize_payload(value: Any) -> Any: + if isinstance(value, dict): + return {str(key): _sanitize_payload(item) for key, item in value.items()} + if isinstance(value, (list, tuple)): + return [_sanitize_payload(item) for item in value] + if isinstance(value, str): + return sanitize_local_paths(value) + if isinstance(value, (str, int, float, bool)) or value is None: + return value + return repr(value) + + +def report_for_telemetry(report: Optional[TetrahedralizationReport]) -> Optional[Dict[str, Any]]: + """Return a path-sanitized, JSON-safe report without mesh arrays.""" + + if report is None: + return None + return _sanitize_payload(report.to_dict()) diff --git a/svv/visualize/gui/main_window.py b/svv/visualize/gui/main_window.py index d9990f5..e411389 100644 --- a/svv/visualize/gui/main_window.py +++ b/svv/visualize/gui/main_window.py @@ -30,9 +30,15 @@ from svv.visualize.gui.point_selector import PointSelectorWidget from svv.visualize.gui.parameter_panel import ParameterPanel from svv.visualize.gui.theme import CADTheme, CADIcons +from svv.visualize.gui.domain_build_feedback import ( + apply_feedback_to_message_box, + build_domain_feedback, + extract_build_report, + report_for_telemetry, +) import svv.tree.tree as _svv_tree_mod import svv.forest.forest as _svv_forest_mod -from svv.telemetry import capture_exception, capture_message +from svv.telemetry import capture_exception, capture_message, telemetry_enabled from svv.visualize.spline_export import export_spline_files @@ -2673,7 +2679,8 @@ def show_about(self): ) def _record_telemetry(self, exc=None, message: Optional[str] = None, level: str = "error", - traceback_str: Optional[str] = None, **tags): + traceback_str: Optional[str] = None, + telemetry_context: Optional[dict] = None, **tags): """ Send errors or warnings to telemetry without interrupting the GUI. @@ -2687,10 +2694,14 @@ def _record_telemetry(self, exc=None, message: Optional[str] = None, level: str Sentry level ("error", "warning", "info"). traceback_str : str, optional Full traceback string to include as extra context. + telemetry_context : dict, optional + Sanitized structured context to attach to the event. **tags Additional tags to attach to the event. """ try: + if not telemetry_enabled(): + return if exc is not None: try: import sentry_sdk # type: ignore[import] @@ -2700,6 +2711,8 @@ def _record_telemetry(self, exc=None, message: Optional[str] = None, level: str scope.set_tag(key, value) if traceback_str: scope.set_extra("full_traceback", traceback_str) + if telemetry_context: + scope.set_context("domain_build", telemetry_context) sentry_sdk.capture_exception(exc) # Flush to ensure the event is sent before the popup blocks sentry_sdk.flush(timeout=2.0) @@ -2707,6 +2720,19 @@ def _record_telemetry(self, exc=None, message: Optional[str] = None, level: str capture_exception(exc) return if message: + if telemetry_context: + try: + import sentry_sdk # type: ignore[import] + + with sentry_sdk.push_scope() as scope: + for key, value in tags.items(): + scope.set_tag(key, value) + scope.set_context("domain_build", telemetry_context) + sentry_sdk.capture_message(message, level=level) + sentry_sdk.flush(timeout=2.0) + return + except Exception: + pass capture_message(message, level=level, **tags) except Exception: # Telemetry should never break the UI @@ -3059,6 +3085,8 @@ def _stage_report(progress=None, label=None, indeterminate=None): build_failed = False build_error_msg = None + build_exception = None + build_report = extract_build_report(domain=domain) if mesh_already_loaded: # Mesh was included in .dmn file - skip rebuild @@ -3072,24 +3100,49 @@ def _stage_report(progress=None, label=None, indeterminate=None): domain.build(resolution=build_resolution, progress_callback=build_progress) else: domain.build(progress_callback=build_progress) - report_progress(90, "Domain built successfully") + build_report = extract_build_report(domain=domain) + feedback = build_domain_feedback( + report=build_report, + success=True, + ) + report_progress(90, feedback.status) + if feedback.recovered: + self._record_telemetry( + message=feedback.status, + level="info", + action="load_domain_build_recovered", + telemetry_context={ + "tetrahedralization": report_for_telemetry(build_report) + }, + ) except Exception as exc: # If build fails (e.g., TetGen errors) continue with loaded # fast-eval structures only so tree/forest generation still # works, but record the failure in telemetry for diagnosis. build_failed = True - build_error_msg = str(exc) - try: - import traceback - tb = traceback.format_exc() - self._record_telemetry(exc, action="load_domain_build", traceback_str=tb) - except Exception: - pass + build_exception = exc + build_report = extract_build_report(exception=exc, domain=domain) + feedback = build_domain_feedback( + exception=exc, + report=build_report, + success=False, + ) + build_error_msg = feedback.informative_text + self._record_telemetry( + message=feedback.informative_text, + level="warning", + action="load_domain_build", + telemetry_context={ + "tetrahedralization": report_for_telemetry(build_report) + }, + ) report_progress(90, "Domain build failed (continuing without mesh)") # Store build status on domain for later reference domain._build_failed = build_failed domain._build_error = build_error_msg + domain._build_exception = build_exception + domain._build_report = build_report if cancel_event.is_set(): return None elif suffix in {".vtp", ".vtu", ".stl"}: @@ -3120,27 +3173,54 @@ def _stage_report(progress=None, label=None, indeterminate=None): report_progress(65, "Building domain (tetrahedralization + boundary extraction)...") build_failed = False build_error_msg = None + build_exception = None + build_report = None try: build_progress = make_stage_reporter(65, 90) if build_resolution is not None: domain.build(resolution=build_resolution, progress_callback=build_progress) else: domain.build(progress_callback=build_progress) - report_progress(90, "Domain built successfully") + build_report = extract_build_report(domain=domain) + feedback = build_domain_feedback( + report=build_report, + success=True, + ) + report_progress(90, feedback.status) + if feedback.recovered: + self._record_telemetry( + message=feedback.status, + level="info", + action="load_mesh_build_recovered", + telemetry_context={ + "tetrahedralization": report_for_telemetry(build_report) + }, + ) except Exception as exc: build_failed = True - build_error_msg = str(exc) - try: - import traceback - tb = traceback.format_exc() - self._record_telemetry(exc, action="load_mesh_build", traceback_str=tb) - except Exception: - pass + build_exception = exc + build_report = extract_build_report(exception=exc, domain=domain) + feedback = build_domain_feedback( + exception=exc, + report=build_report, + success=False, + ) + build_error_msg = feedback.informative_text + self._record_telemetry( + message=feedback.informative_text, + level="warning", + action="load_mesh_build", + telemetry_context={ + "tetrahedralization": report_for_telemetry(build_report) + }, + ) report_progress(90, "Domain build failed (continuing without mesh)") # Store build status on domain for later reference domain._build_failed = build_failed domain._build_error = build_error_msg + domain._build_exception = build_exception + domain._build_report = build_report else: raise ValueError(f"Unsupported file type: {suffix}") @@ -3322,19 +3402,24 @@ def _finish_load_future(self): # Warn user if domain build failed (mesh not available) if getattr(result, '_build_failed', False): - error_msg = getattr(result, '_build_error', 'Unknown error') - self._record_telemetry( - message=f"Domain build failed: {error_msg}", - level="warning", - action="domain_build_failed_warning", + build_exception = getattr(result, '_build_exception', None) + build_report = getattr(result, '_build_report', None) + feedback = build_domain_feedback( + exception=build_exception, + report=build_report, + success=False, ) - QMessageBox.warning( - self, - "Domain Build Warning", - f"The domain was loaded but mesh building failed:\n\n{error_msg}\n\n" - f"Some features (like auto-selecting boundary start points) may not work.\n" - f"You can still use the domain by manually selecting start points." + self.update_status(feedback.status) + message_box = QMessageBox(self) + apply_feedback_to_message_box(message_box, feedback) + message_box.exec() + else: + build_report = getattr(result, '_build_report', None) + feedback = build_domain_feedback( + report=build_report, + success=True, ) + self.update_status(feedback.status) # Backwards compatibility alias diff --git a/test/conftest.py b/test/conftest.py index 8ca9cac..6dc484b 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,5 +1,45 @@ +import hashlib +import os import sys from pathlib import Path +import pyvista as pv +import pytest + # Add the parent directory to PYTHONPATH sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + + +@pytest.fixture +def closed_self_intersecting_surface(): + """Small closed manifold whose folded cap intersects the lower surface.""" + + surface = pv.Sphere(theta_resolution=20, phi_resolution=20) + surface.points[surface.points[:, 2] > 0.2, 2] -= 0.8 + regions = surface.connectivity().cell_data["RegionId"] + + assert surface.is_all_triangles + assert int(regions.max()) + 1 == 1 + assert surface.is_manifold + assert surface.n_open_edges == 0 + return surface.copy(deep=True) + + +@pytest.fixture +def issue_102_stl_path(): + """Return the verified issue attachment path when explicitly provided.""" + + configured = os.environ.get("SVV_ISSUE_102_STL") + if not configured: + pytest.skip("Set SVV_ISSUE_102_STL to run the issue #102 attachment regression") + + path = Path(configured).expanduser().resolve(strict=True) + digest = hashlib.sha256() + with path.open("rb") as stream: + for block in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(block) + + assert digest.hexdigest() == ( + "ec3d4e23757659604c939e7d2f418587bfedc2a067479e4964f0ab40ee637275" + ) + return path diff --git a/test/test_domain_tetrahedralization_recovery.py b/test/test_domain_tetrahedralization_recovery.py new file mode 100644 index 0000000..1c7a309 --- /dev/null +++ b/test/test_domain_tetrahedralization_recovery.py @@ -0,0 +1,364 @@ +import importlib +from types import SimpleNamespace + +import numpy as np +import pyvista as pv +import pytest + +from svv.domain.domain import Domain +from svv.domain.routines.tetrahedralize import _symmetric_surface_distance +from svv.tree.tree import Tree + + +domain_mod = importlib.import_module("svv.domain.domain") + + +def _domain_with_boundary(surface): + domain = Domain(surface) + domain.boundary = surface.copy(deep=True) + domain.set_random_seed(102) + domain.set_random_generator() + return domain + + +def test_domain_installs_recovered_surface_and_sampling_state( + closed_self_intersecting_surface, +): + source = closed_self_intersecting_surface + source_points = np.asarray(source.points).copy() + source_faces = np.asarray(source.faces).copy() + domain = _domain_with_boundary(source) + + mesh = domain.get_interior(repair_max_distance_ratio=0.4) + + assert domain.mesh_build_report.selected_strategy in {"original", "meshfix"} + if domain.mesh_build_report.attempts[0].status == "failed": + assert domain.mesh_build_report.selected_strategy == "meshfix" + assert [ + attempt.strategy for attempt in domain.mesh_build_report.attempts[:2] + ] == ["original", "meshfix"] + else: + assert domain.mesh_build_report.attempts[0].strategy == "original" + assert domain.mesh_build_report.attempts[0].status == "succeeded" + assert np.array_equal(domain.original_boundary.points, source_points) + assert np.array_equal(domain.original_boundary.faces, source_faces) + assert np.array_equal(source.points, source_points) + assert np.array_equal(source.faces, source_faces) + + assert domain.boundary.is_manifold + assert domain.boundary.n_open_edges == 0 + assert domain.boundary.n_points == domain.mesh_build_report.selected_surface.n_points + assert domain.boundary.n_cells == domain.mesh_build_report.selected_surface.n_cells + assert np.array_equal(domain.boundary_nodes, domain.boundary.points) + assert np.array_equal( + domain.boundary_vertices, + domain.boundary.faces.reshape(-1, 4)[:, 1:], + ) + boundary_weights = np.asarray(domain.boundary.cell_data["Normalized_Area"]) + assert np.isfinite(boundary_weights).all() + assert (boundary_weights >= 0).all() + assert boundary_weights.sum() == pytest.approx(1.0) + + assert mesh is domain.mesh + assert mesh.n_cells > 0 + assert set(np.unique(mesh.celltypes)).issubset( + {int(pv.CellType.TETRA), int(pv.CellType.QUADRATIC_TETRA)} + ) + assert np.isfinite(domain.mesh_nodes).all() + assert domain.mesh_vertices.min() >= 0 + assert domain.mesh_vertices.max() < domain.mesh_nodes.shape[0] + volume_weights = np.asarray(mesh.cell_data["Normalized_Volume"]) + assert np.isfinite(volume_weights).all() + assert (volume_weights >= 0).all() + assert volume_weights.sum() == pytest.approx(1.0) + assert domain.cumulative_probability[-1] == pytest.approx(1.0) + assert domain.mesh_tree is not None + assert domain.mesh_tree_2 is not None + + extracted = mesh.extract_surface().triangulate() + diagonal = np.linalg.norm( + np.asarray(domain.boundary.bounds)[1::2] + - np.asarray(domain.boundary.bounds)[::2] + ) + assert _symmetric_surface_distance(domain.boundary, extracted) <= 0.01 * diagonal + + boundary_point = domain.get_boundary_points(1) + assert boundary_point.shape == (1, 3) + assert np.isfinite(boundary_point).all() + + domain.evaluate_fast = lambda points, **kwargs: -0.5 * np.ones( + (np.asarray(points).shape[0], 1), + dtype=float, + ) + tree = Tree() + tree.set_domain(domain) + tree.set_root(max_attempts=5, attempts=20) + assert tree.data.shape[0] == 1 + assert np.isfinite(np.asarray(tree.data)[0, :6]).all() + + +def test_domain_clean_surface_keeps_original_strategy_and_geometry(): + source = pv.Cube().triangulate() + domain = _domain_with_boundary(source) + + domain.get_interior() + + assert domain.mesh_build_report.selected_strategy == "original" + assert domain.boundary.n_points == source.n_points + assert domain.boundary.n_cells == source.n_cells + assert _symmetric_surface_distance(domain.boundary, source) == pytest.approx(0.0) + + +def test_domain_failure_does_not_install_partial_volume_state(monkeypatch): + source = pv.Cube().triangulate() + domain = _domain_with_boundary(source) + previous_boundary = domain.boundary.copy(deep=True) + previous_mesh = object() + previous_tree = object() + previous_tree_2 = object() + previous_report = object() + previous_nodes = np.array([[9.0, 8.0, 7.0]]) + previous_vertices = np.array([[0, 0, 0, 0]]) + domain.mesh = previous_mesh + domain.mesh_tree = previous_tree + domain.mesh_tree_2 = previous_tree_2 + domain.mesh_build_report = previous_report + domain.mesh_nodes = previous_nodes + domain.mesh_vertices = previous_vertices + + invalid_result = SimpleNamespace( + grid=pv.UnstructuredGrid(), + nodes=np.empty((0, 3)), + elements=np.empty((0, 4), dtype=np.int64), + surface=pv.Sphere(), + report=object(), + ) + monkeypatch.setattr(domain_mod, "tetrahedralize", lambda *args, **kwargs: invalid_result) + + with pytest.raises(ValueError, match="empty|non-empty"): + domain.get_interior() + + assert domain.mesh is previous_mesh + assert domain.mesh_tree is previous_tree + assert domain.mesh_tree_2 is previous_tree_2 + assert domain.mesh_build_report is previous_report + assert domain.mesh_nodes is previous_nodes + assert domain.mesh_vertices is previous_vertices + assert np.array_equal(domain.boundary.points, previous_boundary.points) + assert np.array_equal(domain.boundary.faces, previous_boundary.faces) + + +def test_domain_rejects_worker_arrays_that_do_not_match_grid(monkeypatch): + points = np.array( + [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]] + ) + cells = np.array([4, 0, 1, 2, 3], dtype=np.int64) + grid = pv.UnstructuredGrid( + cells, + np.array([pv.CellType.TETRA], dtype=np.uint8), + points, + ) + surface = grid.extract_surface().triangulate() + domain = _domain_with_boundary(surface) + invalid_result = SimpleNamespace( + grid=grid, + nodes=np.vstack((points, [[2.0, 2.0, 2.0]])), + elements=np.array([[0, 1, 2, 3]], dtype=np.int64), + surface=surface, + report=object(), + ) + monkeypatch.setattr(domain_mod, "tetrahedralize", lambda *args, **kwargs: invalid_result) + + with pytest.raises(ValueError, match="node count"): + domain.get_interior() + + assert domain.mesh is None + assert domain.mesh_tree is None + assert domain.mesh_tree_2 is None + assert domain.mesh_build_report is None + + +def test_domain_rejects_quadratic_tetrahedra_from_first_order_worker(monkeypatch): + corner_points = np.array( + [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]] + ) + edge_points = np.array( + [ + [0.5, 0.0, 0.0], + [0.5, 0.5, 0.0], + [0.0, 0.5, 0.0], + [0.0, 0.0, 0.5], + [0.5, 0.0, 0.5], + [0.0, 0.5, 0.5], + ] + ) + points = np.vstack((corner_points, edge_points)) + elements = np.arange(10, dtype=np.int64).reshape(1, 10) + grid = pv.UnstructuredGrid( + np.concatenate(([10], elements[0])), + np.array([pv.CellType.QUADRATIC_TETRA], dtype=np.uint8), + points, + ) + surface = grid.extract_surface().triangulate() + domain = _domain_with_boundary(surface) + result = SimpleNamespace( + grid=grid, + nodes=points, + elements=elements, + surface=surface, + report=object(), + ) + monkeypatch.setattr(domain_mod, "tetrahedralize", lambda *args, **kwargs: result) + + with pytest.raises(ValueError, match="first-order.*M, 4"): + domain.get_interior() + + assert domain.mesh is None + assert domain.mesh_build_report is None + + +def test_domain_rejects_raw_tetgen_switches_before_worker(monkeypatch): + domain = _domain_with_boundary(pv.Cube().triangulate()) + + def unexpected_worker(*args, **kwargs): + raise AssertionError("Raw switches must not reach the Domain TetGen worker") + + monkeypatch.setattr(domain_mod, "tetrahedralize", unexpected_worker) + + with pytest.raises(ValueError, match="switches.*order=1.*nobisect"): + domain.get_interior(switches="pq1.2") + + +def test_boundary_rebuild_failure_cannot_leave_stale_volume_state(monkeypatch): + domain = _domain_with_boundary(pv.Cube().triangulate()) + stale = object() + domain.mesh = stale + domain.mesh_nodes = stale + domain.mesh_vertices = stale + domain.mesh_tree = stale + domain.mesh_tree_2 = stale + domain.all_mesh_cells = stale + domain.cumulative_probability = stale + domain.characteristic_length = 2.0 + domain.area = 3.0 + domain.volume = 4.0 + domain.convexity = 0.5 + domain.mesh_build_report = stale + domain.random_points = stale + + replacement = pv.Sphere(theta_resolution=8, phi_resolution=8).triangulate() + domain.original_boundary = replacement + replacement_grid = object() + monkeypatch.setattr( + domain_mod, + "contour", + lambda *args, **kwargs: (replacement.copy(deep=True), replacement_grid), + ) + + boundary, grid = domain.get_boundary(25) + + assert grid is replacement_grid + assert boundary.n_points == replacement.n_points + assert domain.mesh is None + assert domain.mesh_nodes is None + assert domain.mesh_vertices is None + assert domain.mesh_tree is None + assert domain.mesh_tree_2 is None + assert domain.all_mesh_cells is None + assert domain.cumulative_probability is None + assert domain.characteristic_length is None + assert domain.area is None + assert domain.volume is None + assert domain.convexity is None + assert domain.mesh_build_report is None + assert domain.random_points is None + + monkeypatch.setattr( + domain_mod, + "tetrahedralize", + lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("worker failed")), + ) + with pytest.raises(RuntimeError, match="worker failed"): + domain.get_interior() + + assert domain.mesh is None + assert domain.mesh_build_report is None + assert domain.boundary.n_points == replacement.n_points + + +def test_cached_build_rechecks_mesh_after_boundary_replacement(monkeypatch): + domain = _domain_with_boundary(pv.Cube().triangulate()) + domain.patches = [] + domain.PTS = np.zeros((1, 1, 1, 1, 1, 3)) + domain.function_tree = object() + domain.random_generator = object() + domain.boundary = None + domain.mesh = object() + domain.mesh_tree = object() + domain.mesh_tree_2 = object() + calls = [] + replacement = pv.Sphere(theta_resolution=8, phi_resolution=8).triangulate() + + def rebuild_boundary(resolution): + domain._set_boundary_mesh(replacement) + domain.grid = object() + return domain.boundary, domain.grid + + def rebuild_interior(**kwargs): + calls.append("interior") + domain.mesh = object() + domain.mesh_tree = object() + domain.mesh_tree_2 = object() + return domain.mesh + + monkeypatch.setattr(domain, "get_boundary", rebuild_boundary) + monkeypatch.setattr(domain, "get_interior", rebuild_interior) + + domain.build(resolution=25) + + assert calls == ["interior"] + assert domain.mesh is not None + assert domain.mesh_tree is not None + + +def test_domain_rejects_zero_volume_selected_surface(monkeypatch): + points = np.array( + [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]] + ) + elements = np.array([[0, 1, 2, 3]], dtype=np.int64) + grid = pv.UnstructuredGrid( + np.array([4, 0, 1, 2, 3], dtype=np.int64), + np.array([pv.CellType.TETRA], dtype=np.uint8), + points, + ) + domain = _domain_with_boundary(pv.Cube().triangulate()) + result = SimpleNamespace( + grid=grid, + nodes=points, + elements=elements, + surface=pv.Plane().triangulate(), + report=object(), + ) + monkeypatch.setattr(domain_mod, "tetrahedralize", lambda *args, **kwargs: result) + monkeypatch.setattr(domain_mod, "validate_recovery_surface", lambda *args, **kwargs: None) + + with pytest.raises(ValueError, match="selected surface volume.*positive.*finite"): + domain.get_interior() + + assert domain.mesh is None + assert domain.mesh_build_report is None + + +def test_boundary_install_rejects_zero_measure_without_partial_state(): + domain = Domain(np.zeros((1, 3))) + previous = pv.Cube().triangulate() + domain.boundary = previous + degenerate = pv.PolyData( + np.array([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0]]), + np.array([3, 0, 1, 2]), + ) + + with pytest.raises(ValueError, match="positive finite"): + domain._set_boundary_mesh(degenerate) + + assert domain.boundary is previous diff --git a/test/test_gui_domain_build_feedback.py b/test/test_gui_domain_build_feedback.py new file mode 100644 index 0000000..678c398 --- /dev/null +++ b/test/test_gui_domain_build_feedback.py @@ -0,0 +1,434 @@ +import importlib +import json +from queue import Queue +import sys +import threading +from types import SimpleNamespace + +import pyvista as pv +import pytest +from PySide6.QtWidgets import QApplication, QMessageBox + +from svv.domain.routines.mesh_diagnostics import ( + TetGenAttemptReport, + TetrahedralizationError, + TetrahedralizationReport, + summarize_surface, + summarize_tetgen_output, +) +from svv.visualize.gui.domain_build_feedback import ( + apply_feedback_to_message_box, + build_domain_feedback, + report_for_telemetry, + sanitize_local_paths, +) + + +@pytest.fixture(scope="module") +def qt_app(): + return QApplication.instance() or QApplication([]) + + +def _attempt( + *, + strategy="original", + status="failed", + recoverable=True, + message="TetGen rejected the surface.", + diagnostics=None, +): + return TetGenAttemptReport( + strategy=strategy, + status=status, + surface=summarize_surface(pv.Cube().triangulate()), + duration_seconds=1.25, + recoverable=recoverable, + tetgen_args=(), + tetgen_kwargs={"order": 1, "nobisect": True}, + diagnostics=diagnostics, + message=message, + ) + + +def _report(attempts, selected_strategy=None): + surface = summarize_surface(pv.Cube().triangulate()) + return TetrahedralizationReport( + source=surface, + attempts=list(attempts), + selected_strategy=selected_strategy, + selected_surface=surface if selected_strategy else None, + versions={"tetgen": "0.6.4", "pyvista": "0.46.5"}, + ) + + +def test_recovered_success_feedback_names_automatic_strategy(): + report = _report( + [ + _attempt(), + _attempt(strategy="meshfix", status="succeeded", recoverable=False), + ], + selected_strategy="meshfix", + ) + + feedback = build_domain_feedback(report=report, success=True) + + assert feedback.recovered is True + assert "successfully after surface repair" in feedback.status.lower() + assert "meshfix" in feedback.status.lower() + assert "automatic surface recovery" in feedback.informative_text.lower() + + +def test_intersection_failure_names_cause_and_action(): + diagnostics = summarize_tetgen_output( + "Warning: Two facets exactly intersect.\n" + " 1st facet triangle: [1,2,3] tag(-1).\n" + " 2nd facet triangle: [4,5,6] tag(-1).\n", + "free(): invalid next size (normal)\n", + -6, + ) + report = _report([_attempt(diagnostics=diagnostics)]) + + feedback = build_domain_feedback( + exception=TetrahedralizationError(report), + report=report, + success=False, + ) + + assert "intersecting surface facets" in feedback.informative_text.lower() + assert "repair the source surface" in feedback.informative_text.lower() + assert "facet triangle: [1,2,3]" in feedback.detailed_text + + +def test_open_nonmanifold_fallback_names_rejected_geometry(): + report = _report( + [ + _attempt(), + _attempt( + strategy="pyacvd", + status="rejected", + message="Recovery surface is non-manifold and has 16 open edges.", + diagnostics=None, + ), + ] + ) + + feedback = build_domain_feedback(report=report, success=False) + + assert "open or non-manifold" in feedback.informative_text.lower() + assert "repair the source surface" in feedback.informative_text.lower() + + +def test_infrastructure_failure_does_not_blame_source_geometry(): + diagnostics = summarize_tetgen_output( + "", + "ModuleNotFoundError: No module named 'tetgen'\n", + 1, + ) + report = _report( + [ + _attempt( + status="infrastructure-error", + recoverable=False, + message="TetGen worker dependency failed.", + diagnostics=diagnostics, + ) + ] + ) + + feedback = build_domain_feedback(report=report, success=False) + + assert "worker or its environment failed" in feedback.informative_text.lower() + assert "verify the installation" in feedback.informative_text.lower() + assert "intersecting" not in feedback.informative_text.lower() + + +def test_details_are_bounded_path_sanitized_and_deduplicate_tracebacks(): + traceback_text = ( + "Traceback (most recent call last):\n" + " File \"/home/person/private/project/worker.py\", line 9, in \n" + "RuntimeError: Failed to tetrahedralize\n" + ) + diagnostics = summarize_tetgen_output("x" * 40000, traceback_text, 1) + report = _report( + [ + _attempt(strategy="original", diagnostics=diagnostics), + _attempt(strategy="meshfix", diagnostics=diagnostics), + ] + ) + + feedback = build_domain_feedback(report=report, success=False) + + assert "Attempt 1: original" in feedback.detailed_text + assert "Attempt 2: meshfix" in feedback.detailed_text + assert len(feedback.detailed_text) < 70000 + assert feedback.detailed_text.count("Traceback (most recent call last)") == 1 + assert "/home/person/private" not in feedback.detailed_text + assert "" in feedback.detailed_text + assert "array([" not in feedback.detailed_text + + +def test_message_box_receives_short_informative_and_detailed_fields(qt_app): + report = _report([_attempt()]) + feedback = build_domain_feedback(report=report, success=False) + box = QMessageBox() + + apply_feedback_to_message_box(box, feedback) + + # Headless macOS Qt backends may not retain an unshown native window title. + # The portable message-box test below verifies the title setter and ordering. + assert box.text() == "The domain loaded, but its interior mesh could not be built." + assert box.informativeText() == feedback.informative_text + assert box.detailedText() == feedback.detailed_text + assert box.standardButtons() == QMessageBox.Ok + + +def test_message_box_title_survives_native_icon_initialization(): + class NativeResettingMessageBox: + Warning = "warning" + Ok = "ok" + + def __init__(self): + self.title = "" + + def setWindowTitle(self, title): + self.title = title + + def setIcon(self, icon): + assert icon == self.Warning + self.title = "" + + def setText(self, text): + self.text = text + + def setInformativeText(self, text): + self.informative_text = text + + def setDetailedText(self, text): + self.detailed_text = text + + def setStandardButtons(self, buttons): + self.standard_buttons = buttons + + report = _report([_attempt()]) + feedback = build_domain_feedback(report=report, success=False) + box = NativeResettingMessageBox() + + apply_feedback_to_message_box(box, feedback) + + assert box.title == "Domain Build Warning" + + +def test_telemetry_report_is_json_safe_and_removes_local_paths(): + diagnostics = summarize_tetgen_output( + "", + "Failure while reading /home/person/private/surface.stl\n", + 1, + ) + report = _report([_attempt(diagnostics=diagnostics)]) + + payload = report_for_telemetry(report) + + json.dumps(payload) + serialized = json.dumps(payload) + assert "/home/person/private" not in serialized + assert "" in serialized + assert "points" not in payload + assert "faces" not in payload + + +@pytest.mark.parametrize( + "local_path", + [ + "/private.stl", + r"C:\private.stl", + "C:/private.stl", + r"\\server\share\private.stl", + r"\\?\C:\private.stl", + r"\\?\UNC\server\share\private.stl", + ], +) +def test_path_sanitizer_removes_root_drive_and_unc_paths(local_path): + sanitized = sanitize_local_paths("Failure while reading {}".format(local_path)) + + assert sanitized == "Failure while reading " + + +@pytest.mark.parametrize( + "local_path", + [ + "/home/person/My Files/private.stl", + r"C:\Users\Person Name\private.stl", + r"\\server\share\Private Files\private.stl", + ], +) +def test_path_sanitizer_removes_quoted_paths_containing_spaces(local_path): + sanitized = sanitize_local_paths('Failure while reading "{}"'.format(local_path)) + + assert sanitized == 'Failure while reading ""' + + +def test_structured_telemetry_honors_disabled_gate(monkeypatch): + main_window_mod = importlib.import_module("svv.visualize.gui.main_window") + sentry_calls = [] + + class FakeScope: + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def set_tag(self, *args): + sentry_calls.append(("tag", args)) + + def set_extra(self, *args): + sentry_calls.append(("extra", args)) + + def set_context(self, *args): + sentry_calls.append(("context", args)) + + fake_sentry = SimpleNamespace( + push_scope=lambda: FakeScope(), + capture_exception=lambda *args, **kwargs: sentry_calls.append( + ("exception", args, kwargs) + ), + capture_message=lambda *args, **kwargs: sentry_calls.append( + ("message", args, kwargs) + ), + flush=lambda *args, **kwargs: sentry_calls.append(("flush", args, kwargs)), + ) + monkeypatch.setitem(sys.modules, "sentry_sdk", fake_sentry) + monkeypatch.setattr(main_window_mod, "telemetry_enabled", lambda: False, raising=False) + monkeypatch.setattr( + main_window_mod, + "capture_exception", + lambda *args, **kwargs: sentry_calls.append(("wrapped-exception", args, kwargs)), + ) + monkeypatch.setattr( + main_window_mod, + "capture_message", + lambda *args, **kwargs: sentry_calls.append(("wrapped-message", args, kwargs)), + ) + + main_window_mod.VascularizeGUI._record_telemetry( + object(), + RuntimeError("disabled"), + telemetry_context={"tetrahedralization": {"selected_strategy": "meshfix"}}, + ) + main_window_mod.VascularizeGUI._record_telemetry( + object(), + message="disabled", + telemetry_context={"tetrahedralization": {"selected_strategy": "meshfix"}}, + ) + + assert sentry_calls == [] + + +class _LoaderHarness: + def __init__(self): + self.telemetry = [] + + def _record_telemetry(self, *args, **kwargs): + self.telemetry.append((args, kwargs)) + + +def _progress_labels(progress_queue): + labels = [] + while not progress_queue.empty(): + item = progress_queue.get_nowait() + if isinstance(item, dict) and item.get("label"): + labels.append(item["label"]) + return labels + + +def test_mesh_background_load_preserves_success_report(monkeypatch): + main_window_mod = importlib.import_module("svv.visualize.gui.main_window") + domain_module = importlib.import_module("svv.domain.domain") + report = _report( + [_attempt(strategy="meshfix", status="succeeded", recoverable=False)], + selected_strategy="meshfix", + ) + + class FakeDomain: + def __init__(self, mesh): + self.mesh = None + self.mesh_tree = None + self.boundary = None + self.patches = [] + self.mesh_build_report = None + + def create(self, progress_callback=None): + return None + + def solve(self, progress_callback=None): + return None + + def build(self, resolution=25, progress_callback=None): + self.mesh_build_report = report + self.boundary = object() + + monkeypatch.setattr(domain_module, "Domain", FakeDomain) + monkeypatch.setattr(pv, "read", lambda path: object()) + harness = _LoaderHarness() + progress = Queue() + + result = main_window_mod.VascularizeGUI._load_domain_file( + harness, + "/tmp/surface.stl", + threading.Event(), + progress, + 25, + ) + + assert result._build_failed is False + assert result._build_error is None + assert result._build_exception is None + assert result._build_report is report + assert any("successfully after surface repair" in label.lower() for label in _progress_labels(progress)) + assert harness.telemetry[0][1]["telemetry_context"]["tetrahedralization"]["selected_strategy"] == "meshfix" + + +def test_dmn_background_load_preserves_structured_failure(monkeypatch): + main_window_mod = importlib.import_module("svv.visualize.gui.main_window") + domain_module = importlib.import_module("svv.domain.domain") + diagnostics = summarize_tetgen_output( + "Warning: A segment and a facet intersect.\n", + "free(): invalid next size (normal)\n", + -6, + ) + report = _report([_attempt(diagnostics=diagnostics)]) + build_exception = TetrahedralizationError(report) + + class FakeDomain: + def __init__(self): + self.mesh = None + self.mesh_tree = None + self.boundary = None + self.patches = [] + self.mesh_build_report = None + + @classmethod + def load(cls, path): + return cls() + + def build(self, resolution=25, progress_callback=None): + raise build_exception + + monkeypatch.setattr(domain_module, "Domain", FakeDomain) + harness = _LoaderHarness() + + result = main_window_mod.VascularizeGUI._load_domain_file( + harness, + "/tmp/domain.dmn", + threading.Event(), + Queue(), + 25, + ) + + assert result._build_failed is True + assert "intersecting surface facets" in result._build_error.lower() + assert result._build_exception is build_exception + assert result._build_report is report + telemetry = harness.telemetry[0][1] + assert telemetry["action"] == "load_domain_build" + assert telemetry["telemetry_context"]["tetrahedralization"]["attempts"][0]["strategy"] == "original" diff --git a/test/test_issue_102_attachment.py b/test/test_issue_102_attachment.py new file mode 100644 index 0000000..e4761fa --- /dev/null +++ b/test/test_issue_102_attachment.py @@ -0,0 +1,56 @@ +import numpy as np +import pyvista as pv +import pytest + +from svv.domain.domain import Domain +from svv.domain.routines.tetrahedralize import _symmetric_surface_distance + + +def test_issue_102_attachment_completes_domain_build(issue_102_stl_path): + surface = pv.read(issue_102_stl_path) + source_points = np.asarray(surface.points).copy() + source_faces = np.asarray(surface.faces).copy() + source_diagonal = np.linalg.norm( + np.asarray(surface.bounds)[1::2] - np.asarray(surface.bounds)[::2] + ) + + domain = Domain(surface) + domain.create() + domain.solve() + domain.build(resolution=25) + + assert domain.mesh is not None + assert domain.mesh.n_cells > 0 + assert domain.mesh_tree is not None + assert domain.mesh_tree_2 is not None + assert domain.mesh_build_report.selected_strategy == "meshfix" + assert np.isfinite(domain.mesh_nodes).all() + assert domain.mesh_vertices.min() >= 0 + assert domain.mesh_vertices.max() < domain.mesh_nodes.shape[0] + + probabilities = np.asarray(domain.mesh.cell_data["Normalized_Volume"]) + assert np.isfinite(probabilities).all() + assert (probabilities >= 0).all() + assert probabilities.sum() == pytest.approx(1.0) + assert domain.cumulative_probability[-1] == pytest.approx(1.0) + + cell_volumes = np.asarray(domain.mesh.cell_data["Volume"]) + total_volume = float(cell_volumes.sum()) + assert np.isfinite(total_volume) + assert total_volume > 0 + selected_volume = abs(float(domain.boundary.volume)) + assert abs(total_volume - selected_volume) / selected_volume <= 0.005 + + assert domain.boundary.is_manifold + assert domain.boundary.n_open_edges == 0 + extracted = domain.mesh.extract_surface().triangulate() + assert _symmetric_surface_distance(domain.boundary, extracted) <= 0.01 * source_diagonal + + assert np.array_equal(domain.original_boundary.points, source_points) + assert np.array_equal(domain.original_boundary.faces, source_faces) + assert np.array_equal(surface.points, source_points) + assert np.array_equal(surface.faces, source_faces) + + boundary_point = domain.get_boundary_points(1) + assert boundary_point.shape == (1, 3) + assert np.isfinite(boundary_point).all() diff --git a/test/test_mesh_diagnostics.py b/test/test_mesh_diagnostics.py new file mode 100644 index 0000000..197f617 --- /dev/null +++ b/test/test_mesh_diagnostics.py @@ -0,0 +1,139 @@ +import math +import json + +import pyvista as pv +import pytest + +import svv.domain.routines.mesh_diagnostics as mesh_diagnostics +from svv.domain.routines.mesh_diagnostics import ( + TetGenAttemptReport, + TetrahedralizationError, + TetrahedralizationReport, + summarize_surface, + summarize_tetgen_output, +) + + +def test_summarize_surface_reports_closed_cube_geometry(): + summary = summarize_surface(pv.Cube().triangulate()) + + assert summary.n_points == 8 + assert summary.n_triangles == 12 + assert summary.n_components == 1 + assert summary.is_all_triangles is True + assert summary.points_finite is True + assert summary.is_manifold is True + assert summary.n_open_edges == 0 + assert summary.bounds == (-0.5, 0.5, -0.5, 0.5, -0.5, 0.5) + assert summary.diagonal == math.sqrt(3.0) + assert summary.area == 6.0 + assert summary.volume == pytest.approx(1.0) + + +def test_summarize_tetgen_output_identifies_intersections_and_native_abort(): + stdout = """\ +Warning: A segment and a facet intersect. + segment: [38341,38340] tag(-1). + facet triangle: [1033,1035,1238] tag(-1) +Warning: A segment and a facet intersect. + segment: [38311,38310] tag(-1). + facet triangle: [37032,74417,74416] tag(-1) +Warning: Two facets exactly intersect. + 1st facet triangle: [1608,1810,75715] tag(-1). + 2nd facet triangle: [1605,1608,75721] tag(-1). + 151404 (12) subfaces are recovered (missing). +""" + + summary = summarize_tetgen_output( + stdout, + "free(): invalid next size (normal)\n", + -6, + ) + + assert summary.segment_facet_intersections == 2 + assert summary.facet_facet_intersections == 1 + assert summary.missing_subfaces == 12 + assert summary.native_abort is True + assert summary.return_code == -6 + assert summary.signal_name == "SIGABRT" + assert any("segment: [38341,38340]" in line for line in summary.examples) + + +def test_sigabrt_name_is_stable_when_host_signal_enum_uses_other_numbers( + monkeypatch, +): + def unsupported_signal_number(number): + raise ValueError(number) + + monkeypatch.setattr(mesh_diagnostics.signal, "Signals", unsupported_signal_number) + + summary = summarize_tetgen_output("", "", -6) + + assert summary.signal_name == "SIGABRT" + + +def test_failure_report_provides_actionable_summary_details_and_json(): + surface = summarize_surface(pv.Cube().triangulate()) + diagnostic = summarize_tetgen_output( + "Warning: A segment and a facet intersect.\n" + " segment: [4,5] tag(-1).\n" + " facet triangle: [1,2,3] tag(-1).\n", + "free(): invalid next size (normal)\n", + -6, + ) + attempt = TetGenAttemptReport( + strategy="original", + status="failed", + surface=surface, + duration_seconds=1.25, + recoverable=True, + tetgen_args=(), + tetgen_kwargs={"order": 1, "nobisect": True}, + diagnostics=diagnostic, + message="TetGen rejected the input surface.", + ) + report = TetrahedralizationReport( + source=surface, + attempts=[attempt], + selected_strategy=None, + selected_surface=None, + versions={"tetgen": "0.6.4"}, + ) + + summary = report.user_summary() + details = report.detailed_text() + payload = report.to_dict() + + assert "intersecting surface facets" in summary + assert "repair the source surface" in summary.lower() + assert "original" in details + assert "SIGABRT" in details + assert "nobisect=True" in details + assert "8 points" in details + assert "segment: [4,5]" in details + assert payload["source"]["n_points"] == 8 + assert payload["attempts"][0]["diagnostics"]["return_code"] == -6 + json.dumps(payload) + assert str(TetrahedralizationError(report)) == summary + + +def test_tetgen_output_is_bounded_without_losing_python_failure_classification(): + summary = summarize_tetgen_output( + "x" * 40000, + "Traceback (most recent call last):\nRuntimeError: Unknown exception\n", + 1, + ) + + assert summary.python_exception is True + assert summary.native_abort is False + assert summary.signal_name is None + assert len(summary.stdout) < 33000 + assert "[truncated" in summary.stdout + + +def test_tetgen_output_identifies_windows_native_crash_status(): + summary = summarize_tetgen_output("", "", 0xC0000005) + + assert summary.native_abort is True + assert summary.return_code == 0xC0000005 + assert summary.signal_name == "NTSTATUS_0xC0000005" diff --git a/test/test_tetgen_worker.py b/test/test_tetgen_worker.py new file mode 100644 index 0000000..8cbe0af --- /dev/null +++ b/test/test_tetgen_worker.py @@ -0,0 +1,464 @@ +import importlib +import json +import os +from pathlib import Path +import signal +import subprocess +import sys + +import numpy as np +import pyvista as pv +import pytest + +from svv.domain.routines.mesh_diagnostics import TetGenWorkerError + + +tetrahedralize_mod = importlib.import_module("svv.domain.routines.tetrahedralize") +tetgen_worker_mod = importlib.import_module("svv.domain.routines.tetgen_worker") + + +def _write_array_worker(tmp_path, nodes_expression, elements_expression): + worker = tmp_path / "array_worker.py" + worker.write_text( + "import sys\n" + "import numpy as np\n" + "nodes = {}\n".format(nodes_expression) + + "elements = {}\n".format(elements_expression) + + "np.savez(sys.argv[2], nodes=nodes, elems=elements)\n" + ) + return worker + + +def test_worker_confines_tetgen_artifacts_to_its_temporary_directory(tmp_path, monkeypatch): + caller_dir = tmp_path / "caller" + caller_dir.mkdir() + monkeypatch.chdir(caller_dir) + worker = tmp_path / "artifact_worker.py" + worker.write_text( + """\ +import pathlib +import sys +import numpy as np + +pathlib.Path('_skipped.node').write_text('diagnostic') +pathlib.Path('_skipped.face').write_text('diagnostic') +nodes = np.array([[0., 0., 0.], [1., 0., 0.], [0., 1., 0.], [0., 0., 1.]]) +elements = np.array([[0, 1, 2, 3]], dtype=np.int64) +np.savez(sys.argv[2], nodes=nodes, elems=elements) +""" + ) + + nodes, elements = tetrahedralize_mod._tetgen_worker_tetrahedralize( + pv.Tetrahedron().extract_surface(), + (), + {"verbose": 0}, + str(worker), + sys.executable, + ) + leaked = list(caller_dir.glob("_skipped.*")) + for path in leaked: + path.unlink() + + assert nodes.shape == (4, 3) + assert elements.shape == (1, 4) + assert leaked == [] + + +def test_worker_drains_large_output_without_deadlocking(tmp_path): + worker = tmp_path / "verbose_worker.py" + worker.write_text( + """\ +import sys +import numpy as np + +print('x' * 200000, flush=True) +nodes = np.array([[0., 0., 0.], [1., 0., 0.], [0., 1., 0.], [0., 0., 1.]]) +elements = np.array([[0, 1, 2, 3]], dtype=np.int64) +np.savez(sys.argv[2], nodes=nodes, elems=elements) +""" + ) + driver = tmp_path / "driver.py" + driver.write_text( + """\ +import importlib +import sys +import pyvista as pv + +module = importlib.import_module('svv.domain.routines.tetrahedralize') +module._tetgen_worker_tetrahedralize( + pv.Tetrahedron().extract_surface(), (), {'verbose': 0}, sys.argv[1], sys.executable +) +""" + ) + env = os.environ.copy() + env["PYTHONPATH"] = str(Path(tetrahedralize_mod.__file__).resolve().parents[3]) + + completed = subprocess.run( + [sys.executable, str(driver), str(worker)], + cwd=tmp_path, + env=env, + capture_output=True, + text=True, + timeout=20, + check=False, + ) + + assert completed.returncode == 0, completed.stderr + + +def test_worker_raises_typed_recoverable_error_for_geometry_rejection(tmp_path): + worker = tmp_path / "geometry_failure_worker.py" + worker.write_text( + """\ +import sys + +print('Warning: A segment and a facet intersect.') +print(' segment: [4,5] tag(-1).') +print(' facet triangle: [1,2,3] tag(-1).') +raise SystemExit(3) +""" + ) + + with pytest.raises(TetGenWorkerError) as error_info: + tetrahedralize_mod._tetgen_worker_tetrahedralize( + pv.Tetrahedron().extract_surface(), + (), + {"order": 1, "nobisect": True, "verbose": 0}, + str(worker), + sys.executable, + strategy="original", + ) + + error = error_info.value + assert error.recoverable is True + assert error.attempt.strategy == "original" + assert error.attempt.status == "failed" + assert error.attempt.diagnostics.return_code == 3 + assert error.attempt.diagnostics.segment_facet_intersections == 1 + + +def test_worker_treats_internal_tetgen_error_as_geometry_rejection(tmp_path): + worker = tmp_path / "internal_tetgen_failure_worker.py" + worker.write_text( + "raise RuntimeError('Internal TetGen error within `recoversubfaces`.')\n" + ) + + with pytest.raises(TetGenWorkerError) as error_info: + tetrahedralize_mod._tetgen_worker_tetrahedralize( + pv.Tetrahedron().extract_surface(), + (), + {"order": 1, "nobisect": True, "verbose": 0}, + str(worker), + sys.executable, + strategy="original", + ) + + error = error_info.value + assert error.recoverable is True + assert error.attempt.status == "failed" + assert error.attempt.diagnostics.python_exception is True + assert "Internal TetGen error" in error.attempt.diagnostics.stderr + + +def test_worker_treats_tetgen_self_intersection_error_as_geometry_rejection( + tmp_path, +): + worker = tmp_path / "self_intersection_failure_worker.py" + worker.write_text( + "raise RuntimeError('The input surface mesh contain self-intersections.')\n" + ) + + with pytest.raises(TetGenWorkerError) as error_info: + tetrahedralize_mod._tetgen_worker_tetrahedralize( + pv.Tetrahedron().extract_surface(), + (), + {"order": 1, "nobisect": True, "verbose": 0}, + str(worker), + sys.executable, + strategy="original", + ) + + error = error_info.value + assert error.recoverable is True + assert error.attempt.status == "failed" + assert "self-intersections" in error.attempt.diagnostics.stderr + + +def test_worker_classifies_missing_dependency_as_nonrecoverable(tmp_path): + worker = tmp_path / "dependency_failure_worker.py" + worker.write_text("import missing_issue_102_dependency\n") + + with pytest.raises(TetGenWorkerError) as error_info: + tetrahedralize_mod._tetgen_worker_tetrahedralize( + pv.Tetrahedron().extract_surface(), + (), + {"verbose": 0}, + str(worker), + sys.executable, + strategy="original", + ) + + error = error_info.value + assert error.recoverable is False + assert "infrastructure" in str(error).lower() + assert error.attempt.diagnostics.python_exception is True + assert "ModuleNotFoundError" in error.attempt.diagnostics.stderr + + +def test_self_intersection_in_worker_path_does_not_hide_dependency_failure(tmp_path): + worker = tmp_path / "self-intersection-dependency-worker.py" + worker.write_text("import missing_issue_102_dependency\n") + + with pytest.raises(TetGenWorkerError) as error_info: + tetrahedralize_mod._tetgen_worker_tetrahedralize( + pv.Tetrahedron().extract_surface(), + (), + {"verbose": 0}, + str(worker), + sys.executable, + strategy="original", + ) + + error = error_info.value + assert error.recoverable is False + assert "infrastructure" in str(error).lower() + assert error.attempt.diagnostics.python_exception is True + assert "ModuleNotFoundError" in error.attempt.diagnostics.stderr + + +def test_worker_rejects_nonfinite_result_arrays_as_infrastructure_failure(tmp_path): + worker = tmp_path / "invalid_result_worker.py" + worker.write_text( + """\ +import sys +import numpy as np + +nodes = np.array([[np.nan, 0., 0.], [1., 0., 0.], [0., 1., 0.], [0., 0., 1.]]) +elements = np.array([[0, 1, 2, 3]], dtype=np.int64) +np.savez(sys.argv[2], nodes=nodes, elems=elements) +""" + ) + + with pytest.raises(TetGenWorkerError) as error_info: + tetrahedralize_mod._tetgen_worker_tetrahedralize( + pv.Tetrahedron().extract_surface(), + (), + {"verbose": 0}, + str(worker), + sys.executable, + ) + + error = error_info.value + assert error.recoverable is False + assert error.attempt.status == "invalid-output" + assert "finite" in str(error).lower() + + +def test_worker_normalizes_one_based_connectivity_once(tmp_path): + worker = _write_array_worker( + tmp_path, + "np.array([[0., 0., 0.], [1., 0., 0.], [0., 1., 0.], [0., 0., 1.]])", + "np.array([[1, 2, 3, 4]], dtype=np.int64)", + ) + + _, elements = tetrahedralize_mod._tetgen_worker_tetrahedralize( + pv.Tetrahedron().extract_surface(), + (), + {"verbose": 0}, + str(worker), + sys.executable, + ) + + assert np.array_equal(elements, [[0, 1, 2, 3]]) + + +@pytest.mark.parametrize( + ("nodes_expression", "elements_expression", "message"), + [ + ( + "np.empty((0, 3))", + "np.array([[0, 1, 2, 3]], dtype=np.int64)", + "nodes", + ), + ( + "np.zeros((4, 2))", + "np.array([[0, 1, 2, 3]], dtype=np.int64)", + "nodes", + ), + ( + "np.zeros((4, 3))", + "np.empty((0, 4), dtype=np.int64)", + "elements", + ), + ( + "np.zeros((4, 3))", + "np.array([[0, 1, 2]], dtype=np.int64)", + "elements", + ), + ( + "np.zeros((4, 3))", + "np.array([[0., 1., 2., 3.]])", + "integer", + ), + ( + "np.zeros((4, 3))", + "np.array([[0, 1, 2, 4]], dtype=np.int64)", + "range", + ), + ( + "np.zeros((4, 3))", + "np.array([[-1, 1, 2, 3]], dtype=np.int64)", + "range", + ), + ], +) +def test_worker_rejects_malformed_result_arrays( + tmp_path, + nodes_expression, + elements_expression, + message, +): + worker = _write_array_worker(tmp_path, nodes_expression, elements_expression) + + with pytest.raises(TetGenWorkerError) as error_info: + tetrahedralize_mod._tetgen_worker_tetrahedralize( + pv.Tetrahedron().extract_surface(), + (), + {"verbose": 0}, + str(worker), + sys.executable, + ) + + assert error_info.value.recoverable is False + assert error_info.value.attempt.status == "invalid-output" + assert message in str(error_info.value).lower() + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX signal return codes are unavailable") +def test_worker_reports_native_signal_as_recoverable(tmp_path): + worker = tmp_path / "signal_worker.py" + worker.write_text( + "import os\n" + "import signal\n" + "os.kill(os.getpid(), signal.SIGABRT)\n" + ) + + with pytest.raises(TetGenWorkerError) as error_info: + tetrahedralize_mod._tetgen_worker_tetrahedralize( + pv.Tetrahedron().extract_surface(), + (), + {"verbose": 0}, + str(worker), + sys.executable, + ) + + diagnostics = error_info.value.attempt.diagnostics + assert error_info.value.recoverable is True + assert diagnostics.return_code == -signal.SIGABRT + assert diagnostics.signal_name == "SIGABRT" + + +def test_worker_wraps_launch_failure_as_infrastructure_error(tmp_path): + missing_worker = tmp_path / "missing_worker.py" + + with pytest.raises(TetGenWorkerError) as error_info: + tetrahedralize_mod._tetgen_worker_tetrahedralize( + pv.Tetrahedron().extract_surface(), + (), + {"verbose": 0}, + str(missing_worker), + str(tmp_path / "missing-python"), + ) + + assert error_info.value.recoverable is False + assert error_info.value.attempt.status == "infrastructure-error" + + +def test_worker_wraps_unserializable_configuration_as_infrastructure_error(tmp_path): + worker = tmp_path / "unused_worker.py" + worker.write_text("pass\n") + + with pytest.raises(TetGenWorkerError) as error_info: + tetrahedralize_mod._tetgen_worker_tetrahedralize( + pv.Tetrahedron().extract_surface(), + (), + {"unsupported": object()}, + str(worker), + sys.executable, + ) + + assert error_info.value.recoverable is False + assert error_info.value.attempt.status == "infrastructure-error" + + +def test_worker_removes_temporary_directory_after_failure(tmp_path, monkeypatch): + temp_root = tmp_path / "worker-temp" + temp_root.mkdir() + monkeypatch.setattr(tetrahedralize_mod.tempfile, "tempdir", str(temp_root)) + worker = tmp_path / "failing_worker.py" + worker.write_text("raise SystemExit(7)\n") + + with pytest.raises(TetGenWorkerError): + tetrahedralize_mod._tetgen_worker_tetrahedralize( + pv.Tetrahedron().extract_surface(), + (), + {"verbose": 0}, + str(worker), + sys.executable, + ) + + assert list(temp_root.iterdir()) == [] + + +def test_worker_wraps_missing_result_archive_as_infrastructure_failure(tmp_path): + worker = tmp_path / "missing_result_worker.py" + worker.write_text("pass\n") + + with pytest.raises(TetGenWorkerError) as error_info: + tetrahedralize_mod._tetgen_worker_tetrahedralize( + pv.Tetrahedron().extract_surface(), + (), + {"verbose": 0}, + str(worker), + sys.executable, + ) + + error = error_info.value + assert error.recoverable is False + assert error.attempt.status == "invalid-output" + assert "invalid output" in str(error).lower() + + +@pytest.mark.parametrize("return_tuple", [True, False]) +def test_tetgen_worker_main_saves_supported_result_formats( + tmp_path, + monkeypatch, + return_tuple, +): + surface_path = tmp_path / "surface.vtp" + output_path = tmp_path / "tet.npz" + config_path = tmp_path / "config.json" + pv.Tetrahedron().extract_surface().save(surface_path) + config_path.write_text(json.dumps({"args": [], "kwargs": {"verbose": 0}})) + expected_nodes = np.array( + [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]] + ) + expected_elements = np.array([[0, 1, 2, 3]], dtype=np.int64) + + class TetGenResult: + def __init__(self, surface): + self.node = expected_nodes + self.elem = expected_elements + + def tetrahedralize(self, *args, **kwargs): + if return_tuple: + return self.node, self.elem + return pv.UnstructuredGrid() + + monkeypatch.setattr(tetgen_worker_mod.tetgen, "TetGen", TetGenResult) + + tetgen_worker_mod.main(str(surface_path), str(output_path), str(config_path)) + + with np.load(output_path) as result: + assert np.array_equal(result["nodes"], expected_nodes) + assert np.array_equal(result["elems"], expected_elements) diff --git a/test/test_tetrahedralize_recovery.py b/test/test_tetrahedralize_recovery.py new file mode 100644 index 0000000..c9fc99e --- /dev/null +++ b/test/test_tetrahedralize_recovery.py @@ -0,0 +1,675 @@ +import importlib +import os +import sys +from dataclasses import replace + +import numpy as np +import pyvista as pv +import pytest + +from svv.domain.routines.mesh_diagnostics import ( + TetGenAttemptReport, + TetGenWorkerError, + TetrahedralizationError, + summarize_surface, + summarize_tetgen_output, +) + + +tetrahedralize_mod = importlib.import_module("svv.domain.routines.tetrahedralize") + + +def _geometry_worker_error(surface, strategy, tet_kwargs): + return TetGenWorkerError( + TetGenAttemptReport( + strategy=strategy, + status="failed", + surface=summarize_surface(surface), + duration_seconds=0.01, + recoverable=True, + tetgen_args=(), + tetgen_kwargs=dict(tet_kwargs), + diagnostics=summarize_tetgen_output( + "Warning: A segment and a facet intersect.\n", "", 1 + ), + message="TetGen rejected the {} surface.".format(strategy), + ) + ) + + +def test_closed_manifold_self_intersection_recovers_with_meshfix( + closed_self_intersecting_surface, +): + result = tetrahedralize_mod.tetrahedralize( + closed_self_intersecting_surface, + order=1, + nobisect=True, + repair_max_distance_ratio=0.4, + remesh_on_failure=False, + return_result=True, + ) + + assert result.report.selected_strategy in {"original", "meshfix"} + if result.report.attempts[0].status == "failed": + assert result.report.selected_strategy == "meshfix" + else: + assert result.report.attempts[0].strategy == "original" + assert result.report.attempts[0].status == "succeeded" + assert result.grid.n_cells > 0 + assert np.isfinite(result.nodes).all() + assert result.elements.shape[1] == 4 + assert result.surface.is_manifold + assert result.surface.n_open_edges == 0 + assert set(np.unique(result.grid.celltypes)) == {pv.CellType.TETRA} + + +def test_clean_surface_keeps_original_fast_path_and_legacy_tuple(monkeypatch): + def unexpected_recovery(*args, **kwargs): + raise AssertionError("Recovery must not run after the original surface succeeds") + + monkeypatch.setattr( + tetrahedralize_mod, + "repair_surface_with_meshfix", + unexpected_recovery, + ) + monkeypatch.setattr( + tetrahedralize_mod, + "uniform_remesh_surface", + unexpected_recovery, + ) + surface = pv.Cube().triangulate() + + result = tetrahedralize_mod.tetrahedralize(surface, return_result=True) + legacy = tetrahedralize_mod.tetrahedralize(surface) + + assert result.report.selected_strategy == "original" + assert len(result.report.attempts) == 1 + assert result.report.attempts[0].status == "succeeded" + assert isinstance(legacy, tuple) + assert len(legacy) == 3 + assert legacy[0].n_cells > 0 + + +def test_recovery_does_not_mutate_or_share_storage_with_input( + closed_self_intersecting_surface, +): + original_points = closed_self_intersecting_surface.points.copy() + original_faces = closed_self_intersecting_surface.faces.copy() + + result = tetrahedralize_mod.tetrahedralize( + closed_self_intersecting_surface, + repair_max_distance_ratio=0.4, + remesh_on_failure=False, + return_result=True, + ) + result.surface.points[0] += 1.0 + + assert np.array_equal(closed_self_intersecting_surface.points, original_points) + assert np.array_equal(closed_self_intersecting_surface.faces, original_faces) + assert not np.shares_memory(result.surface.points, closed_self_intersecting_surface.points) + + +def test_default_repair_bound_rejects_large_surface_change( + closed_self_intersecting_surface, +): + with pytest.raises(ValueError, match="bounds|displacement"): + tetrahedralize_mod.repair_surface_with_meshfix( + closed_self_intersecting_surface, + max_distance_ratio=0.01, + ) + + +def test_recovery_validation_rejects_internal_displacement_with_unchanged_bounds(): + source = pv.Sphere(theta_resolution=20, phi_resolution=20) + candidate = source.copy(deep=True) + interior_index = int(np.argmin(np.abs(candidate.points[:, 2]))) + candidate.points[interior_index] *= 0.5 + + with pytest.raises(ValueError, match="displacement"): + tetrahedralize_mod.validate_recovery_surface( + source, + candidate, + max_distance_ratio=0.01, + ) + + +def test_recovery_validation_rejects_nonfinite_surface_distance(monkeypatch): + source = pv.Cube().triangulate() + candidate = source.copy(deep=True) + monkeypatch.setattr( + tetrahedralize_mod, + "_symmetric_surface_distance", + lambda *args: np.nan, + ) + + with pytest.raises(ValueError, match="distance.*finite"): + tetrahedralize_mod.validate_recovery_surface( + source, + candidate, + max_distance_ratio=0.01, + ) + + +@pytest.mark.parametrize("distances", [np.array([]), np.array([np.nan]), np.array([np.inf])]) +def test_symmetric_surface_distance_rejects_invalid_arrays(distances): + class FakeSurface: + def compute_implicit_distance(self, other): + return {"implicit_distance": distances} + + with pytest.raises(ValueError, match="distance arrays.*finite.*non-empty"): + tetrahedralize_mod._symmetric_surface_distance( + FakeSurface(), + FakeSurface(), + ) + + +def test_recovery_validation_rejects_nonfinite_bounds(monkeypatch): + source = pv.Cube().triangulate() + candidate = source.copy(deep=True) + real_summary = tetrahedralize_mod.summarize_surface + + def summary(surface): + result = real_summary(surface) + if surface is candidate: + return replace(result, bounds=(np.nan,) + result.bounds[1:]) + return result + + monkeypatch.setattr(tetrahedralize_mod, "summarize_surface", summary) + + with pytest.raises(ValueError, match="bounds.*finite"): + tetrahedralize_mod.validate_recovery_surface( + source, + candidate, + max_distance_ratio=0.01, + ) + + +def test_recovery_validation_rejects_nonfinite_allowed_distance(): + source = pv.Cube().triangulate() + + with pytest.raises(ValueError, match="allowed recovery distance.*finite"): + tetrahedralize_mod.validate_recovery_surface( + source, + source.copy(deep=True), + max_distance_ratio=np.finfo(float).max, + ) + + +def test_invalid_repair_candidate_is_rejected_before_tetgen(monkeypatch): + source = pv.Cube().triangulate() + invalid_candidate = pv.Plane().triangulate() + calls = [] + + def worker(surface, tet_args, tet_kwargs, worker_script, python_exe, *, strategy): + calls.append(strategy) + if strategy != "original": + raise AssertionError("Invalid recovery candidate reached TetGen") + raise TetGenWorkerError( + TetGenAttemptReport( + strategy="original", + status="failed", + surface=summarize_surface(surface), + duration_seconds=0.01, + recoverable=True, + tetgen_args=(), + tetgen_kwargs=dict(tet_kwargs), + diagnostics=summarize_tetgen_output( + "Warning: A segment and a facet intersect.\n", "", 1 + ), + message="TetGen rejected the original surface.", + ) + ) + + monkeypatch.setattr(tetrahedralize_mod, "_tetgen_worker_tetrahedralize", worker) + monkeypatch.setattr( + tetrahedralize_mod, + "repair_surface_with_meshfix", + lambda *args, **kwargs: invalid_candidate.copy(deep=True), + ) + + with pytest.raises(TetrahedralizationError) as error_info: + tetrahedralize_mod.tetrahedralize( + source, + remesh_on_failure=False, + return_result=True, + ) + + assert calls == ["original"] + assert [attempt.status for attempt in error_info.value.report.attempts] == [ + "failed", + "rejected", + ] + rejected = error_info.value.report.attempts[1] + assert rejected.surface.n_points == invalid_candidate.n_points + assert rejected.surface.n_cells == invalid_candidate.n_cells + + +def test_meshfix_rejection_report_describes_the_repaired_candidate(monkeypatch): + source = pv.Cube().triangulate() + + class DistortingMeshFix: + def __init__(self, points, faces): + self.v = np.asarray(points).copy() + self.f = np.asarray(faces).copy() + + def repair(self, **kwargs): + self.v[:, 0] += 10.0 + + def worker(surface, tet_args, tet_kwargs, worker_script, python_exe, *, strategy): + raise _geometry_worker_error(surface, strategy, tet_kwargs) + + monkeypatch.setattr(tetrahedralize_mod.pymeshfix, "MeshFix", DistortingMeshFix) + monkeypatch.setattr(tetrahedralize_mod, "_tetgen_worker_tetrahedralize", worker) + + with pytest.raises(TetrahedralizationError) as error_info: + tetrahedralize_mod.tetrahedralize(source, remesh_on_failure=False) + + rejected = error_info.value.report.attempts[1] + assert rejected.status == "rejected" + assert rejected.surface.bounds[0] > 9.0 + + +def test_unexpected_meshfix_error_is_not_relabelled_as_geometry_rejection(monkeypatch): + source = pv.Cube().triangulate() + + def worker(surface, tet_args, tet_kwargs, worker_script, python_exe, *, strategy): + raise _geometry_worker_error(surface, strategy, tet_kwargs) + + def broken_repair(*args, **kwargs): + raise ValueError("meshfix implementation failed") + + monkeypatch.setattr(tetrahedralize_mod, "_tetgen_worker_tetrahedralize", worker) + monkeypatch.setattr(tetrahedralize_mod, "repair_surface_with_meshfix", broken_repair) + + with pytest.raises(ValueError, match="meshfix implementation failed"): + tetrahedralize_mod.tetrahedralize(source, remesh_on_failure=False) + + +def test_unexpected_pyacvd_error_is_not_relabelled_as_geometry_rejection(monkeypatch): + source = pv.Cube().triangulate() + + def worker(surface, tet_args, tet_kwargs, worker_script, python_exe, *, strategy): + raise _geometry_worker_error(surface, strategy, tet_kwargs) + + def broken_remesh(*args, **kwargs): + raise RuntimeError("remesher implementation failed") + + monkeypatch.setattr(tetrahedralize_mod, "_tetgen_worker_tetrahedralize", worker) + monkeypatch.setattr(tetrahedralize_mod, "uniform_remesh_surface", broken_remesh) + + with pytest.raises(RuntimeError, match="remesher implementation failed"): + tetrahedralize_mod.tetrahedralize(source, repair_on_failure=False) + + +def test_open_pyacvd_candidate_is_repaired_before_tetgen(monkeypatch): + source = pv.Cube().triangulate() + face_rows = source.faces.reshape(-1, 4) + open_surface = pv.PolyData(source.points.copy(), face_rows[:-1].copy()) + worker_calls = [] + repair_calls = [] + + def worker(surface, tet_args, tet_kwargs, worker_script, python_exe, *, strategy): + worker_calls.append(strategy) + if strategy == "original": + raise TetGenWorkerError( + TetGenAttemptReport( + strategy=strategy, + status="failed", + surface=summarize_surface(surface), + duration_seconds=0.01, + recoverable=True, + tetgen_args=(), + tetgen_kwargs=dict(tet_kwargs), + diagnostics=summarize_tetgen_output( + "Warning: A segment and a facet intersect.\n", "", 1 + ), + message="TetGen rejected the original surface.", + ) + ) + if strategy != "pyacvd_meshfix": + raise AssertionError("Open PyACVD surface reached TetGen") + nodes = np.array( + [[0.0, 0.0, 0.0], [0.25, 0.0, 0.0], [0.0, 0.25, 0.0], [0.0, 0.0, 0.25]] + ) + return nodes, np.array([[0, 1, 2, 3]], dtype=np.int64) + + def repair(surface, **kwargs): + repair_calls.append(surface.n_open_edges) + if len(repair_calls) == 1: + return open_surface.copy(deep=True) + return source.copy(deep=True) + + monkeypatch.setattr(tetrahedralize_mod, "_tetgen_worker_tetrahedralize", worker) + monkeypatch.setattr(tetrahedralize_mod, "repair_surface_with_meshfix", repair) + monkeypatch.setattr( + tetrahedralize_mod, + "uniform_remesh_surface", + lambda *args, **kwargs: open_surface.copy(deep=True), + ) + + result = tetrahedralize_mod.tetrahedralize(source, return_result=True) + + assert result.report.selected_strategy == "pyacvd_meshfix" + assert worker_calls == ["original", "pyacvd_meshfix"] + assert repair_calls == [0, open_surface.n_open_edges] + + +@pytest.mark.parametrize("switches", [None, "pqQ"]) +def test_quiet_unknown_failure_runs_isolated_geometry_diagnostic( + tmp_path, + monkeypatch, + switches, +): + worker = tmp_path / "diagnostic_worker.py" + worker.write_text( + """\ +import json +import pathlib +import sys + +with open(sys.argv[3], 'r') as stream: + options = json.load(stream)['kwargs'] +switches = options.get('switches') +if switches: + diagnostic_enabled = 'd' in switches and 'V' in switches and 'Q' not in switches +else: + diagnostic_enabled = options.get('diagnose') and options.get('quiet') is False +if diagnostic_enabled: + pathlib.Path('_skipped.node').write_text('diagnostic') + print('Warning: A segment and a facet intersect.') + print(' segment: [4,5] tag(-1).') + print(' facet triangle: [1,2,3] tag(-1).') + raise SystemExit(3) +print('RuntimeError: Failed to tetrahedralize: Unknown exception', file=sys.stderr) +raise SystemExit(1) +""" + ) + monkeypatch.chdir(tmp_path) + + tetgen_options = {"switches": switches} if switches is not None else {} + with pytest.raises(TetrahedralizationError) as error_info: + tetrahedralize_mod.tetrahedralize( + pv.Cube().triangulate(), + worker_script=str(worker), + python_exe=sys.executable, + repair_on_failure=False, + remesh_on_failure=False, + return_result=True, + **tetgen_options, + ) + + report = error_info.value.report + assert len(report.attempts) == 1 + assert report.attempts[0].diagnostics.segment_facet_intersections == 1 + assert "intersecting surface facets" in str(error_info.value) + assert not list(tmp_path.glob("_skipped.*")) + + +def test_relative_worker_path_is_resolved_before_temporary_chdir(tmp_path, monkeypatch): + worker = tmp_path / "relative_worker.py" + worker.write_text( + """\ +import numpy as np +import sys + +nodes = np.array( + [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]] +) +elems = np.array([[0, 1, 2, 3]], dtype=np.int64) +np.savez(sys.argv[2], nodes=nodes, elems=elems) +""" + ) + monkeypatch.chdir(tmp_path) + + result = tetrahedralize_mod.tetrahedralize( + pv.Cube().triangulate(), + worker_script="relative_worker.py", + python_exe=sys.executable, + repair_on_failure=False, + remesh_on_failure=False, + return_result=True, + ) + + assert result.grid.n_cells == 1 + + +def test_worker_launch_path_resolution_preserves_path_lookup(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + + worker, interpreter = tetrahedralize_mod._resolve_worker_launch_paths( + "worker.py", + os.path.join("env", "python"), + ) + _, path_interpreter = tetrahedralize_mod._resolve_worker_launch_paths( + "worker.py", + "python", + ) + + assert worker == str(tmp_path / "worker.py") + assert interpreter == str(tmp_path / "env" / "python") + assert path_interpreter == "python" + + +def test_opaque_native_abort_runs_isolated_geometry_diagnostic(tmp_path): + worker = tmp_path / "native_diagnostic_worker.py" + worker.write_text( + """\ +import json +import sys + +with open(sys.argv[3], 'r') as stream: + options = json.load(stream)['kwargs'] +if options.get('diagnose'): + print('Warning: Two facets exactly intersect.') + print(' 1st facet triangle: [1,2,3] tag(-1).') + print(' 2nd facet triangle: [4,5,6] tag(-1).') + raise SystemExit(3) +print('free(): invalid next size (normal)', file=sys.stderr) +raise SystemExit(1) +""" + ) + + with pytest.raises(TetrahedralizationError) as error_info: + tetrahedralize_mod.tetrahedralize( + pv.Cube().triangulate(), + worker_script=str(worker), + python_exe=sys.executable, + repair_on_failure=False, + remesh_on_failure=False, + ) + + diagnostic = error_info.value.report.attempts[0].diagnostics + assert diagnostic.facet_facet_intersections == 1 + assert "intersecting surface facets" in str(error_info.value) + + +def test_disabling_repair_and_remesh_returns_structured_direct_failure(monkeypatch): + def worker(surface, tet_args, tet_kwargs, worker_script, python_exe, *, strategy): + raise _geometry_worker_error(surface, strategy, tet_kwargs) + + monkeypatch.setattr(tetrahedralize_mod, "_tetgen_worker_tetrahedralize", worker) + + with pytest.raises(TetrahedralizationError) as error_info: + tetrahedralize_mod.tetrahedralize( + pv.Cube().triangulate(), + repair_on_failure=False, + remesh_on_failure=False, + ) + + assert [attempt.strategy for attempt in error_info.value.report.attempts] == [ + "original" + ] + + +def test_nongeometry_worker_failure_does_not_start_recovery(monkeypatch): + source = pv.Cube().triangulate() + infrastructure_attempt = TetGenAttemptReport( + strategy="original", + status="failed", + surface=summarize_surface(source), + duration_seconds=0.01, + recoverable=False, + tetgen_args=(), + tetgen_kwargs={"verbose": 0}, + diagnostics=summarize_tetgen_output( + "", "ModuleNotFoundError: missing dependency\n", 1 + ), + message="TetGen worker infrastructure failed for the original surface.", + ) + + def worker(*args, **kwargs): + raise TetGenWorkerError(infrastructure_attempt) + + def unexpected_recovery(*args, **kwargs): + raise AssertionError("Infrastructure failures must not start geometry recovery") + + monkeypatch.setattr(tetrahedralize_mod, "_tetgen_worker_tetrahedralize", worker) + monkeypatch.setattr( + tetrahedralize_mod, "repair_surface_with_meshfix", unexpected_recovery + ) + monkeypatch.setattr(tetrahedralize_mod, "uniform_remesh_surface", unexpected_recovery) + + with pytest.raises(TetGenWorkerError) as error_info: + tetrahedralize_mod.tetrahedralize(source) + + assert error_info.value.recoverable is False + + +def test_all_geometry_attempts_fail_with_ordered_aggregate_report(monkeypatch): + source = pv.Cube().triangulate() + + def worker(surface, tet_args, tet_kwargs, worker_script, python_exe, *, strategy): + raise _geometry_worker_error(surface, strategy, tet_kwargs) + + monkeypatch.setattr(tetrahedralize_mod, "_tetgen_worker_tetrahedralize", worker) + monkeypatch.setattr( + tetrahedralize_mod, + "repair_surface_with_meshfix", + lambda *args, **kwargs: source.copy(deep=True), + ) + monkeypatch.setattr( + tetrahedralize_mod, + "uniform_remesh_surface", + lambda *args, **kwargs: source.copy(deep=True), + ) + + with pytest.raises(TetrahedralizationError) as error_info: + tetrahedralize_mod.tetrahedralize(source) + + report = error_info.value.report + assert [attempt.strategy for attempt in report.attempts] == [ + "original", + "meshfix", + "pyacvd", + ] + assert report.selected_strategy is None + assert "intersecting surface facets" in report.user_summary() + + +def test_meshfix_repair_preserves_components_by_policy(monkeypatch): + source = pv.Cube().triangulate() + captured = {} + + class FakeMeshFix: + def __init__(self, points, faces): + self.v = np.asarray(points).copy() + self.f = np.asarray(faces).copy() + + def repair(self, **kwargs): + captured.update(kwargs) + + monkeypatch.setattr(tetrahedralize_mod.pymeshfix, "MeshFix", FakeMeshFix) + + repaired = tetrahedralize_mod.repair_surface_with_meshfix(source) + + assert repaired.n_points == source.n_points + assert captured["joincomp"] is False + assert captured["remove_smallest_components"] is False + + +def test_recovery_validation_rejects_component_loss(): + first = pv.Cube(center=(0.0, 0.0, 0.0)).triangulate() + second = pv.Cube(center=(3.0, 0.0, 0.0)).triangulate() + source = first.merge(second, merge_points=False) + + with pytest.raises(ValueError, match="connected-component count from 2 to 1"): + tetrahedralize_mod.validate_recovery_surface( + source, + first, + max_distance_ratio=1.0, + ) + + +@pytest.mark.parametrize("value", [0.0, -0.1, np.nan, np.inf]) +def test_repair_distance_ratio_must_be_finite_and_positive(value): + with pytest.raises(ValueError, match="finite and positive"): + tetrahedralize_mod.tetrahedralize( + pv.Cube().triangulate(), + repair_max_distance_ratio=value, + ) + + +@pytest.mark.parametrize( + "value", + [True, np.bool_(True), np.array(0.1), np.array([0.1]), "0.1", None], +) +def test_repair_distance_ratio_must_be_a_real_scalar(value): + with pytest.raises(ValueError, match="finite and positive scalar"): + tetrahedralize_mod.tetrahedralize( + pv.Cube().triangulate(), + repair_max_distance_ratio=value, + ) + + +@pytest.mark.parametrize("value", [-1.0, np.nan, np.inf]) +def test_remesh_clean_tolerance_must_be_finite_and_nonnegative(value): + with pytest.raises(ValueError, match="finite and non-negative"): + tetrahedralize_mod.tetrahedralize( + pv.Cube().triangulate(), + remesh_clean_tolerance=value, + ) + + +@pytest.mark.parametrize( + "value", + [True, np.bool_(True), np.array(0.1), np.array([0.1]), "0.1"], +) +def test_remesh_clean_tolerance_must_be_a_real_scalar(value): + with pytest.raises(ValueError, match="finite and non-negative scalar"): + tetrahedralize_mod.tetrahedralize( + pv.Cube().triangulate(), + remesh_clean_tolerance=value, + ) + + +@pytest.mark.parametrize( + ("option", "value", "message"), + [ + ("remesh_subdivisions", np.nan, "non-negative integer"), + ("remesh_subdivisions", 1.5, "non-negative integer"), + ("remesh_subdivisions", True, "non-negative integer"), + ("remesh_clusters", np.nan, "positive integer"), + ("remesh_clusters", 2.5, "positive integer"), + ("remesh_clusters", True, "positive integer"), + ], +) +def test_remesh_integer_controls_are_validated_before_tetgen( + monkeypatch, + option, + value, + message, +): + def unexpected_worker(*args, **kwargs): + raise AssertionError("Invalid recovery controls must fail before TetGen") + + monkeypatch.setattr( + tetrahedralize_mod, + "_tetgen_worker_tetrahedralize", + unexpected_worker, + ) + + with pytest.raises(ValueError, match=message): + tetrahedralize_mod.tetrahedralize( + pv.Cube().triangulate(), + **{option: value}, + )