mmgpy 0.5.0__cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mmgpy/__init__.py +296 -0
- mmgpy/__main__.py +13 -0
- mmgpy/_io.py +535 -0
- mmgpy/_logging.py +290 -0
- mmgpy/_mesh.py +2286 -0
- mmgpy/_mmgpy.cpython-311-x86_64-linux-gnu.so +0 -0
- mmgpy/_mmgpy.pyi +2140 -0
- mmgpy/_options.py +304 -0
- mmgpy/_progress.py +850 -0
- mmgpy/_pyvista.py +410 -0
- mmgpy/_result.py +143 -0
- mmgpy/_transfer.py +273 -0
- mmgpy/_validation.py +669 -0
- mmgpy/_version.py +3 -0
- mmgpy/_version.py.in +3 -0
- mmgpy/bin/mmg2d_O3 +0 -0
- mmgpy/bin/mmg3d_O3 +0 -0
- mmgpy/bin/mmgs_O3 +0 -0
- mmgpy/interactive/__init__.py +24 -0
- mmgpy/interactive/sizing_editor.py +790 -0
- mmgpy/lagrangian.py +394 -0
- mmgpy/lib/libmmg2d.so +0 -0
- mmgpy/lib/libmmg2d.so.5 +0 -0
- mmgpy/lib/libmmg2d.so.5.8.0 +0 -0
- mmgpy/lib/libmmg3d.so +0 -0
- mmgpy/lib/libmmg3d.so.5 +0 -0
- mmgpy/lib/libmmg3d.so.5.8.0 +0 -0
- mmgpy/lib/libmmgs.so +0 -0
- mmgpy/lib/libmmgs.so.5 +0 -0
- mmgpy/lib/libmmgs.so.5.8.0 +0 -0
- mmgpy/lib/libvtkCommonColor-9.5.so.1 +0 -0
- mmgpy/lib/libvtkCommonComputationalGeometry-9.5.so.1 +0 -0
- mmgpy/lib/libvtkCommonCore-9.5.so.1 +0 -0
- mmgpy/lib/libvtkCommonDataModel-9.5.so.1 +0 -0
- mmgpy/lib/libvtkCommonExecutionModel-9.5.so.1 +0 -0
- mmgpy/lib/libvtkCommonMath-9.5.so.1 +0 -0
- mmgpy/lib/libvtkCommonMisc-9.5.so.1 +0 -0
- mmgpy/lib/libvtkCommonSystem-9.5.so.1 +0 -0
- mmgpy/lib/libvtkCommonTransforms-9.5.so.1 +0 -0
- mmgpy/lib/libvtkDICOMParser-9.5.so.1 +0 -0
- mmgpy/lib/libvtkFiltersCellGrid-9.5.so.1 +0 -0
- mmgpy/lib/libvtkFiltersCore-9.5.so.1 +0 -0
- mmgpy/lib/libvtkFiltersExtraction-9.5.so.1 +0 -0
- mmgpy/lib/libvtkFiltersGeneral-9.5.so.1 +0 -0
- mmgpy/lib/libvtkFiltersGeometry-9.5.so.1 +0 -0
- mmgpy/lib/libvtkFiltersHybrid-9.5.so.1 +0 -0
- mmgpy/lib/libvtkFiltersHyperTree-9.5.so.1 +0 -0
- mmgpy/lib/libvtkFiltersModeling-9.5.so.1 +0 -0
- mmgpy/lib/libvtkFiltersParallel-9.5.so.1 +0 -0
- mmgpy/lib/libvtkFiltersReduction-9.5.so.1 +0 -0
- mmgpy/lib/libvtkFiltersSources-9.5.so.1 +0 -0
- mmgpy/lib/libvtkFiltersStatistics-9.5.so.1 +0 -0
- mmgpy/lib/libvtkFiltersTexture-9.5.so.1 +0 -0
- mmgpy/lib/libvtkFiltersVerdict-9.5.so.1 +0 -0
- mmgpy/lib/libvtkIOCellGrid-9.5.so.1 +0 -0
- mmgpy/lib/libvtkIOCore-9.5.so.1 +0 -0
- mmgpy/lib/libvtkIOGeometry-9.5.so.1 +0 -0
- mmgpy/lib/libvtkIOImage-9.5.so.1 +0 -0
- mmgpy/lib/libvtkIOLegacy-9.5.so.1 +0 -0
- mmgpy/lib/libvtkIOParallel-9.5.so.1 +0 -0
- mmgpy/lib/libvtkIOParallelXML-9.5.so.1 +0 -0
- mmgpy/lib/libvtkIOXML-9.5.so.1 +0 -0
- mmgpy/lib/libvtkIOXMLParser-9.5.so.1 +0 -0
- mmgpy/lib/libvtkImagingCore-9.5.so.1 +0 -0
- mmgpy/lib/libvtkImagingSources-9.5.so.1 +0 -0
- mmgpy/lib/libvtkParallelCore-9.5.so.1 +0 -0
- mmgpy/lib/libvtkParallelDIY-9.5.so.1 +0 -0
- mmgpy/lib/libvtkRenderingCore-9.5.so.1 +0 -0
- mmgpy/lib/libvtkdoubleconversion-9.5.so.1 +0 -0
- mmgpy/lib/libvtkexpat-9.5.so.1 +0 -0
- mmgpy/lib/libvtkfmt-9.5.so.1 +0 -0
- mmgpy/lib/libvtkjpeg-9.5.so.1 +0 -0
- mmgpy/lib/libvtkjsoncpp-9.5.so.1 +0 -0
- mmgpy/lib/libvtkkissfft-9.5.so.1 +0 -0
- mmgpy/lib/libvtkloguru-9.5.so.1 +0 -0
- mmgpy/lib/libvtklz4-9.5.so.1 +0 -0
- mmgpy/lib/libvtklzma-9.5.so.1 +0 -0
- mmgpy/lib/libvtkmetaio-9.5.so.1 +0 -0
- mmgpy/lib/libvtkpng-9.5.so.1 +0 -0
- mmgpy/lib/libvtkpugixml-9.5.so.1 +0 -0
- mmgpy/lib/libvtksys-9.5.so.1 +0 -0
- mmgpy/lib/libvtktiff-9.5.so.1 +0 -0
- mmgpy/lib/libvtktoken-9.5.so.1 +0 -0
- mmgpy/lib/libvtkverdict-9.5.so.1 +0 -0
- mmgpy/lib/libvtkzlib-9.5.so.1 +0 -0
- mmgpy/metrics.py +596 -0
- mmgpy/progress.py +69 -0
- mmgpy/py.typed +0 -0
- mmgpy/repair/__init__.py +37 -0
- mmgpy/repair/_core.py +226 -0
- mmgpy/repair/_elements.py +241 -0
- mmgpy/repair/_vertices.py +219 -0
- mmgpy/sizing.py +370 -0
- mmgpy/ui/__init__.py +97 -0
- mmgpy/ui/__main__.py +87 -0
- mmgpy/ui/app.py +1837 -0
- mmgpy/ui/parsers.py +501 -0
- mmgpy/ui/remeshing.py +448 -0
- mmgpy/ui/samples.py +249 -0
- mmgpy/ui/utils.py +280 -0
- mmgpy/ui/viewer.py +587 -0
- mmgpy-0.5.0.dist-info/METADATA +186 -0
- mmgpy-0.5.0.dist-info/RECORD +109 -0
- mmgpy-0.5.0.dist-info/WHEEL +6 -0
- mmgpy-0.5.0.dist-info/entry_points.txt +13 -0
- mmgpy-0.5.0.dist-info/licenses/LICENSE +38 -0
- share/man/man1/mmg2d.1.gz +0 -0
- share/man/man1/mmg3d.1.gz +0 -0
- share/man/man1/mmgs.1.gz +0 -0
mmgpy/ui/viewer.py
ADDED
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
"""Viewer mixin for mmgpy UI - handles visualization and rendering."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import pyvista as pv
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from mmgpy import Mesh
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ViewerMixin:
|
|
18
|
+
"""Mixin class providing viewer and visualization functionality.
|
|
19
|
+
|
|
20
|
+
This mixin provides methods for:
|
|
21
|
+
- Updating the 3D viewer with mesh data
|
|
22
|
+
- Computing and visualizing scalar fields
|
|
23
|
+
- Camera control and view settings
|
|
24
|
+
- Constraint visualization
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
# These attributes are expected to be defined by the main class
|
|
28
|
+
_mesh: Mesh | None
|
|
29
|
+
_plotter: pv.Plotter | None
|
|
30
|
+
_render_window: object
|
|
31
|
+
_solution_fields: dict[str, dict]
|
|
32
|
+
state: object
|
|
33
|
+
|
|
34
|
+
def _set_view(self, view: str) -> None:
|
|
35
|
+
"""Set the viewer to a predefined view."""
|
|
36
|
+
if self._plotter is None:
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
self.state.current_view = view
|
|
40
|
+
|
|
41
|
+
view_methods = {
|
|
42
|
+
"xy": lambda: self._plotter.view_xy(),
|
|
43
|
+
"-xy": lambda: self._plotter.view_xy(negative=True),
|
|
44
|
+
"xz": lambda: self._plotter.view_xz(),
|
|
45
|
+
"-xz": lambda: self._plotter.view_xz(negative=True),
|
|
46
|
+
"yz": lambda: self._plotter.view_yz(),
|
|
47
|
+
"-yz": lambda: self._plotter.view_yz(negative=True),
|
|
48
|
+
"isometric": lambda: self._plotter.view_isometric(),
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if view in view_methods:
|
|
52
|
+
view_methods[view]()
|
|
53
|
+
|
|
54
|
+
if self._render_window is not None:
|
|
55
|
+
self._render_window.Render()
|
|
56
|
+
if hasattr(self, "_view") and self._view is not None:
|
|
57
|
+
self._view.update()
|
|
58
|
+
self.state.flush()
|
|
59
|
+
|
|
60
|
+
def _toggle_parallel_projection(self) -> None:
|
|
61
|
+
"""Toggle between parallel and perspective projection."""
|
|
62
|
+
if self._plotter is None:
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
self.state.parallel_projection = not self.state.parallel_projection
|
|
66
|
+
|
|
67
|
+
if self.state.parallel_projection:
|
|
68
|
+
self._plotter.enable_parallel_projection()
|
|
69
|
+
else:
|
|
70
|
+
self._plotter.disable_parallel_projection()
|
|
71
|
+
|
|
72
|
+
if self._render_window is not None:
|
|
73
|
+
self._render_window.Render()
|
|
74
|
+
if hasattr(self, "_view") and self._view is not None:
|
|
75
|
+
self._view.update()
|
|
76
|
+
self.state.flush()
|
|
77
|
+
|
|
78
|
+
def _update_viewer(self, *, reset_camera: bool = True) -> None:
|
|
79
|
+
"""Update the 3D viewer with current mesh."""
|
|
80
|
+
if self._mesh is None or self._plotter is None:
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
self._plotter.clear()
|
|
84
|
+
|
|
85
|
+
# Determine which mesh to display (original or current)
|
|
86
|
+
display_mesh = self._mesh
|
|
87
|
+
if (
|
|
88
|
+
getattr(self.state, "show_original_mesh", False)
|
|
89
|
+
and self._original_mesh is not None
|
|
90
|
+
):
|
|
91
|
+
display_mesh = self._original_mesh
|
|
92
|
+
|
|
93
|
+
# Check if we need to show boundary refs for tetrahedral mesh
|
|
94
|
+
show_boundary_refs = (
|
|
95
|
+
self.state.show_scalar == "boundary_refs"
|
|
96
|
+
and self.state.mesh_kind == "tetrahedral"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
if show_boundary_refs:
|
|
100
|
+
# Extract boundary surface and color by boundary refs
|
|
101
|
+
pv_mesh = self._get_boundary_surface_with_refs(mesh=display_mesh)
|
|
102
|
+
if pv_mesh is None:
|
|
103
|
+
# Fallback to regular mesh
|
|
104
|
+
pv_mesh = display_mesh.to_pyvista()
|
|
105
|
+
else:
|
|
106
|
+
pv_mesh = display_mesh.to_pyvista()
|
|
107
|
+
|
|
108
|
+
# Compute normals for smooth shading
|
|
109
|
+
if pv_mesh.n_cells > 0 and hasattr(pv_mesh, "compute_normals"):
|
|
110
|
+
try:
|
|
111
|
+
pv_mesh = pv_mesh.compute_normals(
|
|
112
|
+
cell_normals=True,
|
|
113
|
+
point_normals=True,
|
|
114
|
+
split_vertices=True,
|
|
115
|
+
)
|
|
116
|
+
except Exception:
|
|
117
|
+
logger.debug("Could not compute normals for mesh")
|
|
118
|
+
|
|
119
|
+
scalars = self._compute_scalars(pv_mesh)
|
|
120
|
+
cmap, scalar_bar_title = self._get_colormap_settings(scalars)
|
|
121
|
+
|
|
122
|
+
# Apply slice/threshold for tetrahedral meshes
|
|
123
|
+
pv_mesh = self._apply_slice_if_needed(pv_mesh)
|
|
124
|
+
|
|
125
|
+
# Determine text color based on theme
|
|
126
|
+
is_dark = getattr(self.state, "theme_name", "light") == "dark"
|
|
127
|
+
text_color = "white" if is_dark else "black"
|
|
128
|
+
|
|
129
|
+
# Build scalar bar args with theme-aware colors
|
|
130
|
+
scalar_bar_args = None
|
|
131
|
+
if scalar_bar_title:
|
|
132
|
+
scalar_bar_args = {
|
|
133
|
+
"title": scalar_bar_title,
|
|
134
|
+
"title_font_size": 14,
|
|
135
|
+
"label_font_size": 12,
|
|
136
|
+
"color": text_color,
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
# Check if using face sides visualization (special backface coloring)
|
|
140
|
+
use_face_sides = self.state.show_scalar == "face_sides"
|
|
141
|
+
|
|
142
|
+
# Add mesh with rendering settings
|
|
143
|
+
actor = self._plotter.add_mesh(
|
|
144
|
+
pv_mesh,
|
|
145
|
+
show_edges=self.state.show_edges,
|
|
146
|
+
opacity=self.state.opacity,
|
|
147
|
+
scalars=scalars,
|
|
148
|
+
color="dodgerblue"
|
|
149
|
+
if use_face_sides
|
|
150
|
+
else ("white" if scalars is None else None),
|
|
151
|
+
cmap=cmap,
|
|
152
|
+
show_scalar_bar=scalars is not None and not use_face_sides,
|
|
153
|
+
scalar_bar_args=scalar_bar_args,
|
|
154
|
+
smooth_shading=self.state.smooth_shading,
|
|
155
|
+
pbr=False,
|
|
156
|
+
metallic=0.0,
|
|
157
|
+
roughness=0.5,
|
|
158
|
+
diffuse=0.8,
|
|
159
|
+
ambient=0.2,
|
|
160
|
+
specular=0.3,
|
|
161
|
+
specular_power=30,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
# Apply backface coloring for face sides visualization
|
|
165
|
+
if use_face_sides and actor is not None:
|
|
166
|
+
import vtk
|
|
167
|
+
|
|
168
|
+
back_prop = vtk.vtkProperty()
|
|
169
|
+
back_prop.SetColor(1.0, 0.3, 0.3) # Red for back faces (inward)
|
|
170
|
+
back_prop.SetOpacity(self.state.opacity)
|
|
171
|
+
actor.SetBackfaceProperty(back_prop)
|
|
172
|
+
|
|
173
|
+
# Add legend for face orientation (bottom right, stacked)
|
|
174
|
+
self._plotter.add_text(
|
|
175
|
+
"Outward",
|
|
176
|
+
position=(0.85, 0.08),
|
|
177
|
+
font_size=10,
|
|
178
|
+
color="dodgerblue",
|
|
179
|
+
name="face_legend_out",
|
|
180
|
+
viewport=True,
|
|
181
|
+
)
|
|
182
|
+
self._plotter.add_text(
|
|
183
|
+
"Inward",
|
|
184
|
+
position=(0.85, 0.03),
|
|
185
|
+
font_size=10,
|
|
186
|
+
color=(1.0, 0.3, 0.3),
|
|
187
|
+
name="face_legend_in",
|
|
188
|
+
viewport=True,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
self._visualize_constraints()
|
|
192
|
+
self._plotter.enable_lightkit()
|
|
193
|
+
|
|
194
|
+
# Add axes with theme-aware colors
|
|
195
|
+
self._add_axes_with_theme(is_dark)
|
|
196
|
+
|
|
197
|
+
if reset_camera:
|
|
198
|
+
self._setup_camera_for_mesh(pv_mesh)
|
|
199
|
+
|
|
200
|
+
# Update the view
|
|
201
|
+
if self._render_window is not None:
|
|
202
|
+
self._render_window.Render()
|
|
203
|
+
if hasattr(self, "_view") and self._view is not None:
|
|
204
|
+
self._view.update()
|
|
205
|
+
self.state.flush()
|
|
206
|
+
|
|
207
|
+
def _compute_scalars(self, pv_mesh) -> str | None:
|
|
208
|
+
"""Compute scalar field for visualization."""
|
|
209
|
+
scalars = None
|
|
210
|
+
show_scalar = self.state.show_scalar
|
|
211
|
+
|
|
212
|
+
if show_scalar == "quality":
|
|
213
|
+
try:
|
|
214
|
+
qualities = self._mesh.get_element_qualities()
|
|
215
|
+
if len(qualities) == pv_mesh.n_cells:
|
|
216
|
+
pv_mesh.cell_data["quality"] = qualities
|
|
217
|
+
scalars = "quality"
|
|
218
|
+
except Exception:
|
|
219
|
+
logger.debug("Could not compute quality scalars")
|
|
220
|
+
|
|
221
|
+
elif show_scalar == "pv_quality":
|
|
222
|
+
try:
|
|
223
|
+
pv_mesh_quality = pv_mesh.cell_quality(
|
|
224
|
+
quality_measure="scaled_jacobian",
|
|
225
|
+
)
|
|
226
|
+
pv_mesh.cell_data["scaled_jacobian"] = pv_mesh_quality.cell_data[
|
|
227
|
+
"scaled_jacobian"
|
|
228
|
+
]
|
|
229
|
+
scalars = "scaled_jacobian"
|
|
230
|
+
except Exception:
|
|
231
|
+
logger.debug("Could not compute PyVista quality scalars")
|
|
232
|
+
|
|
233
|
+
elif show_scalar == "area_volume":
|
|
234
|
+
try:
|
|
235
|
+
pv_mesh_sizes = pv_mesh.compute_cell_sizes(
|
|
236
|
+
length=False,
|
|
237
|
+
area=True,
|
|
238
|
+
volume=True,
|
|
239
|
+
)
|
|
240
|
+
if (
|
|
241
|
+
"Volume" in pv_mesh_sizes.cell_data
|
|
242
|
+
and pv_mesh_sizes.cell_data["Volume"].max() > 0
|
|
243
|
+
):
|
|
244
|
+
pv_mesh.cell_data["Volume"] = pv_mesh_sizes.cell_data["Volume"]
|
|
245
|
+
scalars = "Volume"
|
|
246
|
+
elif "Area" in pv_mesh_sizes.cell_data:
|
|
247
|
+
pv_mesh.cell_data["Area"] = pv_mesh_sizes.cell_data["Area"]
|
|
248
|
+
scalars = "Area"
|
|
249
|
+
except Exception:
|
|
250
|
+
logger.debug("Could not compute area/volume scalars")
|
|
251
|
+
|
|
252
|
+
elif show_scalar == "edge_length":
|
|
253
|
+
try:
|
|
254
|
+
edge_lengths = self._compute_edge_lengths_per_cell(pv_mesh)
|
|
255
|
+
if edge_lengths is not None:
|
|
256
|
+
pv_mesh.cell_data["edge_length"] = edge_lengths
|
|
257
|
+
scalars = "edge_length"
|
|
258
|
+
except Exception:
|
|
259
|
+
logger.debug("Could not compute edge length scalars")
|
|
260
|
+
|
|
261
|
+
elif show_scalar == "refs":
|
|
262
|
+
if "refs" in pv_mesh.cell_data:
|
|
263
|
+
scalars = "refs"
|
|
264
|
+
|
|
265
|
+
elif show_scalar == "boundary_refs":
|
|
266
|
+
# For tetrahedral meshes, visualize boundary triangle refs
|
|
267
|
+
# This is handled specially in _update_viewer
|
|
268
|
+
scalars = "boundary_refs"
|
|
269
|
+
|
|
270
|
+
elif show_scalar.startswith("user_"):
|
|
271
|
+
scalars = self._compute_user_scalars(pv_mesh, show_scalar[5:])
|
|
272
|
+
|
|
273
|
+
return scalars
|
|
274
|
+
|
|
275
|
+
def _compute_user_scalars(self, pv_mesh, field_name: str) -> str | None:
|
|
276
|
+
"""Compute user-defined scalar field."""
|
|
277
|
+
if field_name not in self._solution_fields:
|
|
278
|
+
return None
|
|
279
|
+
|
|
280
|
+
try:
|
|
281
|
+
field_info = self._solution_fields[field_name]
|
|
282
|
+
field_data = field_info["data"]
|
|
283
|
+
location = field_info["location"]
|
|
284
|
+
|
|
285
|
+
# Flatten if needed
|
|
286
|
+
if field_data.ndim == 1:
|
|
287
|
+
values = field_data
|
|
288
|
+
elif field_data.shape[1] == 1:
|
|
289
|
+
values = field_data[:, 0]
|
|
290
|
+
else:
|
|
291
|
+
values = np.linalg.norm(field_data, axis=1)
|
|
292
|
+
|
|
293
|
+
# Add to appropriate data array based on location
|
|
294
|
+
if location == "vertices":
|
|
295
|
+
pv_mesh.point_data[field_name] = values
|
|
296
|
+
else:
|
|
297
|
+
pv_mesh.cell_data[field_name] = values
|
|
298
|
+
except Exception:
|
|
299
|
+
logger.debug("Could not compute user scalars for %s", field_name)
|
|
300
|
+
return None
|
|
301
|
+
else:
|
|
302
|
+
return field_name
|
|
303
|
+
|
|
304
|
+
def _get_colormap_settings(
|
|
305
|
+
self,
|
|
306
|
+
scalars: str | None,
|
|
307
|
+
) -> tuple[str | None, str | None]:
|
|
308
|
+
"""Get colormap and scalar bar title based on scalar field."""
|
|
309
|
+
if scalars is None:
|
|
310
|
+
return None, None
|
|
311
|
+
|
|
312
|
+
show_scalar = self.state.show_scalar
|
|
313
|
+
|
|
314
|
+
colormap_map = {
|
|
315
|
+
"quality": ("RdYlGn", "In-Radius Ratio"),
|
|
316
|
+
"pv_quality": ("RdYlGn", "Scaled Jacobian"),
|
|
317
|
+
"edge_length": ("viridis", "Edge Length"),
|
|
318
|
+
"refs": ("tab10", "Reference"),
|
|
319
|
+
"boundary_refs": ("tab10", "Boundary Ref"),
|
|
320
|
+
"area_volume": ("viridis", "Area" if scalars == "Area" else "Volume"),
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if show_scalar in colormap_map:
|
|
324
|
+
return colormap_map[show_scalar]
|
|
325
|
+
|
|
326
|
+
if show_scalar.startswith("user_"):
|
|
327
|
+
return "viridis", show_scalar[5:]
|
|
328
|
+
|
|
329
|
+
return "viridis", None
|
|
330
|
+
|
|
331
|
+
def _apply_slice_if_needed(self, pv_mesh):
|
|
332
|
+
"""Apply slice/threshold for tetrahedral meshes."""
|
|
333
|
+
if not (self.state.slice_enabled and self.state.mesh_kind == "tetrahedral"):
|
|
334
|
+
return pv_mesh
|
|
335
|
+
|
|
336
|
+
try:
|
|
337
|
+
axis = int(self.state.slice_axis)
|
|
338
|
+
threshold = float(self.state.slice_threshold)
|
|
339
|
+
bounds = pv_mesh.bounds
|
|
340
|
+
min_val = bounds[axis * 2]
|
|
341
|
+
max_val = bounds[axis * 2 + 1]
|
|
342
|
+
cut_value = min_val + threshold * (max_val - min_val)
|
|
343
|
+
cell_centers = pv_mesh.cell_centers().points[:, axis]
|
|
344
|
+
mask = cell_centers < cut_value
|
|
345
|
+
if mask.any():
|
|
346
|
+
return pv_mesh.extract_cells(mask)
|
|
347
|
+
except Exception:
|
|
348
|
+
logger.debug("Could not apply slice to mesh")
|
|
349
|
+
|
|
350
|
+
return pv_mesh
|
|
351
|
+
|
|
352
|
+
def _setup_camera_for_mesh(self, pv_mesh) -> None:
|
|
353
|
+
"""Set up camera based on mesh type."""
|
|
354
|
+
self._plotter.reset_camera()
|
|
355
|
+
|
|
356
|
+
is_2d_mesh = False
|
|
357
|
+
if self.state.mesh_kind == "triangular_2d":
|
|
358
|
+
bounds = pv_mesh.bounds
|
|
359
|
+
z_range = bounds[5] - bounds[4]
|
|
360
|
+
xy_range = max(bounds[1] - bounds[0], bounds[3] - bounds[2])
|
|
361
|
+
is_2d_mesh = z_range < xy_range * 0.01
|
|
362
|
+
|
|
363
|
+
if is_2d_mesh:
|
|
364
|
+
self._plotter.view_xy()
|
|
365
|
+
self._plotter.enable_parallel_projection()
|
|
366
|
+
self.state.current_view = "xy"
|
|
367
|
+
self.state.parallel_projection = True
|
|
368
|
+
else:
|
|
369
|
+
self._plotter.view_isometric()
|
|
370
|
+
self._plotter.disable_parallel_projection()
|
|
371
|
+
self.state.current_view = "isometric"
|
|
372
|
+
self.state.parallel_projection = False
|
|
373
|
+
|
|
374
|
+
def _add_axes_with_theme(self, is_dark: bool) -> None:
|
|
375
|
+
"""Add axes widget with theme-aware colors."""
|
|
376
|
+
if self._plotter is None:
|
|
377
|
+
return
|
|
378
|
+
|
|
379
|
+
# Set axes label colors based on theme
|
|
380
|
+
text_color = "white" if is_dark else "black"
|
|
381
|
+
|
|
382
|
+
# Add axes with custom colors
|
|
383
|
+
self._plotter.add_axes(
|
|
384
|
+
xlabel="X",
|
|
385
|
+
ylabel="Y",
|
|
386
|
+
zlabel="Z",
|
|
387
|
+
color=text_color,
|
|
388
|
+
x_color="red",
|
|
389
|
+
y_color="green",
|
|
390
|
+
z_color="blue",
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
def _visualize_constraints(self) -> None:
|
|
394
|
+
"""Visualize sizing constraints on the plotter."""
|
|
395
|
+
if self._plotter is None:
|
|
396
|
+
return
|
|
397
|
+
|
|
398
|
+
constraints = self.state.sizing_constraints or []
|
|
399
|
+
|
|
400
|
+
for constraint in constraints:
|
|
401
|
+
constraint_type = constraint.get("type")
|
|
402
|
+
params = constraint.get("params", {})
|
|
403
|
+
|
|
404
|
+
if constraint_type == "sphere":
|
|
405
|
+
center = params.get("center", [0, 0, 0])
|
|
406
|
+
radius = params.get("radius", 0.5)
|
|
407
|
+
sphere = pv.Sphere(
|
|
408
|
+
center=center,
|
|
409
|
+
radius=radius,
|
|
410
|
+
theta_resolution=16,
|
|
411
|
+
phi_resolution=16,
|
|
412
|
+
)
|
|
413
|
+
self._plotter.add_mesh(
|
|
414
|
+
sphere,
|
|
415
|
+
color="orange",
|
|
416
|
+
opacity=0.3,
|
|
417
|
+
style="wireframe",
|
|
418
|
+
line_width=2,
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
elif constraint_type == "box":
|
|
422
|
+
bounds_data = params.get(
|
|
423
|
+
"bounds",
|
|
424
|
+
[[-0.5, -0.5, -0.5], [0.5, 0.5, 0.5]],
|
|
425
|
+
)
|
|
426
|
+
min_pt = bounds_data[0]
|
|
427
|
+
max_pt = bounds_data[1]
|
|
428
|
+
box = pv.Box(
|
|
429
|
+
bounds=[
|
|
430
|
+
min_pt[0],
|
|
431
|
+
max_pt[0],
|
|
432
|
+
min_pt[1],
|
|
433
|
+
max_pt[1],
|
|
434
|
+
min_pt[2],
|
|
435
|
+
max_pt[2],
|
|
436
|
+
],
|
|
437
|
+
)
|
|
438
|
+
self._plotter.add_mesh(
|
|
439
|
+
box,
|
|
440
|
+
color="cyan",
|
|
441
|
+
opacity=0.3,
|
|
442
|
+
style="wireframe",
|
|
443
|
+
line_width=2,
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
elif constraint_type == "point":
|
|
447
|
+
point = params.get("point", [0, 0, 0])
|
|
448
|
+
influence_radius = params.get("influence_radius", 0.5)
|
|
449
|
+
point_sphere = pv.Sphere(center=point, radius=0.02)
|
|
450
|
+
self._plotter.add_mesh(point_sphere, color="red")
|
|
451
|
+
influence_sphere = pv.Sphere(
|
|
452
|
+
center=point,
|
|
453
|
+
radius=influence_radius,
|
|
454
|
+
theta_resolution=16,
|
|
455
|
+
phi_resolution=16,
|
|
456
|
+
)
|
|
457
|
+
self._plotter.add_mesh(
|
|
458
|
+
influence_sphere,
|
|
459
|
+
color="red",
|
|
460
|
+
opacity=0.2,
|
|
461
|
+
style="wireframe",
|
|
462
|
+
line_width=1,
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
def _get_boundary_surface_with_refs(self, mesh=None):
|
|
466
|
+
"""Extract boundary surface from tetrahedral mesh with boundary refs.
|
|
467
|
+
|
|
468
|
+
Parameters
|
|
469
|
+
----------
|
|
470
|
+
mesh : Mesh, optional
|
|
471
|
+
The mesh to extract from. Defaults to self._mesh.
|
|
472
|
+
|
|
473
|
+
Returns
|
|
474
|
+
-------
|
|
475
|
+
pv.PolyData or None
|
|
476
|
+
PolyData mesh of the boundary triangles colored by their refs.
|
|
477
|
+
|
|
478
|
+
"""
|
|
479
|
+
if mesh is None:
|
|
480
|
+
mesh = self._mesh
|
|
481
|
+
if mesh is None:
|
|
482
|
+
return None
|
|
483
|
+
|
|
484
|
+
try:
|
|
485
|
+
# Get boundary triangles and their refs from the mesh
|
|
486
|
+
triangles, refs = mesh.get_triangles_with_refs()
|
|
487
|
+
vertices = mesh.get_vertices()
|
|
488
|
+
|
|
489
|
+
if len(triangles) == 0:
|
|
490
|
+
return None
|
|
491
|
+
|
|
492
|
+
# Create PolyData from boundary triangles
|
|
493
|
+
faces = np.column_stack(
|
|
494
|
+
[
|
|
495
|
+
np.full(len(triangles), 3, dtype=np.int64),
|
|
496
|
+
triangles,
|
|
497
|
+
],
|
|
498
|
+
).ravel()
|
|
499
|
+
|
|
500
|
+
boundary_mesh = pv.PolyData(vertices, faces)
|
|
501
|
+
|
|
502
|
+
# Add refs as cell data for coloring
|
|
503
|
+
boundary_mesh.cell_data["boundary_refs"] = refs
|
|
504
|
+
|
|
505
|
+
except Exception:
|
|
506
|
+
logger.debug("Could not extract boundary surface with refs")
|
|
507
|
+
return None
|
|
508
|
+
|
|
509
|
+
return boundary_mesh
|
|
510
|
+
|
|
511
|
+
def _compute_edge_lengths_per_cell(self, pv_mesh) -> np.ndarray | None:
|
|
512
|
+
"""Compute average edge length per cell for visualization.
|
|
513
|
+
|
|
514
|
+
Returns an array of average edge lengths, one per cell.
|
|
515
|
+
"""
|
|
516
|
+
points = np.array(pv_mesh.points)
|
|
517
|
+
|
|
518
|
+
if pv_mesh.n_cells == 0:
|
|
519
|
+
return None
|
|
520
|
+
|
|
521
|
+
# Handle UnstructuredGrid (tetrahedra)
|
|
522
|
+
if hasattr(pv_mesh, "cells_dict") and pv.CellType.TETRA in pv_mesh.cells_dict:
|
|
523
|
+
tets = pv_mesh.cells_dict[pv.CellType.TETRA]
|
|
524
|
+
v0, v1, v2, v3 = (points[tets[:, i]] for i in range(4))
|
|
525
|
+
|
|
526
|
+
# 6 edges per tetrahedron
|
|
527
|
+
e01 = np.linalg.norm(v1 - v0, axis=1)
|
|
528
|
+
e02 = np.linalg.norm(v2 - v0, axis=1)
|
|
529
|
+
e03 = np.linalg.norm(v3 - v0, axis=1)
|
|
530
|
+
e12 = np.linalg.norm(v2 - v1, axis=1)
|
|
531
|
+
e13 = np.linalg.norm(v3 - v1, axis=1)
|
|
532
|
+
e23 = np.linalg.norm(v3 - v2, axis=1)
|
|
533
|
+
|
|
534
|
+
return (e01 + e02 + e03 + e12 + e13 + e23) / 6
|
|
535
|
+
|
|
536
|
+
# Handle PolyData (triangles)
|
|
537
|
+
if hasattr(pv_mesh, "is_all_triangles") and pv_mesh.is_all_triangles:
|
|
538
|
+
faces = pv_mesh.faces.reshape(-1, 4)[:, 1:4]
|
|
539
|
+
v0, v1, v2 = points[faces[:, 0]], points[faces[:, 1]], points[faces[:, 2]]
|
|
540
|
+
|
|
541
|
+
# 3 edges per triangle
|
|
542
|
+
e01 = np.linalg.norm(v1 - v0, axis=1)
|
|
543
|
+
e12 = np.linalg.norm(v2 - v1, axis=1)
|
|
544
|
+
e20 = np.linalg.norm(v0 - v2, axis=1)
|
|
545
|
+
|
|
546
|
+
return (e01 + e12 + e20) / 3
|
|
547
|
+
|
|
548
|
+
return None
|
|
549
|
+
|
|
550
|
+
def _compute_all_edge_lengths(self, pv_mesh) -> np.ndarray | None:
|
|
551
|
+
"""Compute all edge lengths in the mesh for statistics.
|
|
552
|
+
|
|
553
|
+
Returns an array of all edge lengths (with duplicates for shared edges).
|
|
554
|
+
"""
|
|
555
|
+
points = np.array(pv_mesh.points)
|
|
556
|
+
|
|
557
|
+
if pv_mesh.n_cells == 0:
|
|
558
|
+
return None
|
|
559
|
+
|
|
560
|
+
# Handle UnstructuredGrid (tetrahedra)
|
|
561
|
+
if hasattr(pv_mesh, "cells_dict") and pv.CellType.TETRA in pv_mesh.cells_dict:
|
|
562
|
+
tets = pv_mesh.cells_dict[pv.CellType.TETRA]
|
|
563
|
+
v0, v1, v2, v3 = (points[tets[:, i]] for i in range(4))
|
|
564
|
+
|
|
565
|
+
edges = [
|
|
566
|
+
np.linalg.norm(v1 - v0, axis=1),
|
|
567
|
+
np.linalg.norm(v2 - v0, axis=1),
|
|
568
|
+
np.linalg.norm(v3 - v0, axis=1),
|
|
569
|
+
np.linalg.norm(v2 - v1, axis=1),
|
|
570
|
+
np.linalg.norm(v3 - v1, axis=1),
|
|
571
|
+
np.linalg.norm(v3 - v2, axis=1),
|
|
572
|
+
]
|
|
573
|
+
return np.concatenate(edges)
|
|
574
|
+
|
|
575
|
+
# Handle PolyData (triangles)
|
|
576
|
+
if hasattr(pv_mesh, "is_all_triangles") and pv_mesh.is_all_triangles:
|
|
577
|
+
faces = pv_mesh.faces.reshape(-1, 4)[:, 1:4]
|
|
578
|
+
v0, v1, v2 = points[faces[:, 0]], points[faces[:, 1]], points[faces[:, 2]]
|
|
579
|
+
|
|
580
|
+
edges = [
|
|
581
|
+
np.linalg.norm(v1 - v0, axis=1),
|
|
582
|
+
np.linalg.norm(v2 - v1, axis=1),
|
|
583
|
+
np.linalg.norm(v0 - v2, axis=1),
|
|
584
|
+
]
|
|
585
|
+
return np.concatenate(edges)
|
|
586
|
+
|
|
587
|
+
return None
|