mmgpy 0.5.0__cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. mmgpy/__init__.py +296 -0
  2. mmgpy/__main__.py +13 -0
  3. mmgpy/_io.py +535 -0
  4. mmgpy/_logging.py +290 -0
  5. mmgpy/_mesh.py +2286 -0
  6. mmgpy/_mmgpy.cpython-311-x86_64-linux-gnu.so +0 -0
  7. mmgpy/_mmgpy.pyi +2140 -0
  8. mmgpy/_options.py +304 -0
  9. mmgpy/_progress.py +850 -0
  10. mmgpy/_pyvista.py +410 -0
  11. mmgpy/_result.py +143 -0
  12. mmgpy/_transfer.py +273 -0
  13. mmgpy/_validation.py +669 -0
  14. mmgpy/_version.py +3 -0
  15. mmgpy/_version.py.in +3 -0
  16. mmgpy/bin/mmg2d_O3 +0 -0
  17. mmgpy/bin/mmg3d_O3 +0 -0
  18. mmgpy/bin/mmgs_O3 +0 -0
  19. mmgpy/interactive/__init__.py +24 -0
  20. mmgpy/interactive/sizing_editor.py +790 -0
  21. mmgpy/lagrangian.py +394 -0
  22. mmgpy/lib/libmmg2d.so +0 -0
  23. mmgpy/lib/libmmg2d.so.5 +0 -0
  24. mmgpy/lib/libmmg2d.so.5.8.0 +0 -0
  25. mmgpy/lib/libmmg3d.so +0 -0
  26. mmgpy/lib/libmmg3d.so.5 +0 -0
  27. mmgpy/lib/libmmg3d.so.5.8.0 +0 -0
  28. mmgpy/lib/libmmgs.so +0 -0
  29. mmgpy/lib/libmmgs.so.5 +0 -0
  30. mmgpy/lib/libmmgs.so.5.8.0 +0 -0
  31. mmgpy/lib/libvtkCommonColor-9.5.so.1 +0 -0
  32. mmgpy/lib/libvtkCommonComputationalGeometry-9.5.so.1 +0 -0
  33. mmgpy/lib/libvtkCommonCore-9.5.so.1 +0 -0
  34. mmgpy/lib/libvtkCommonDataModel-9.5.so.1 +0 -0
  35. mmgpy/lib/libvtkCommonExecutionModel-9.5.so.1 +0 -0
  36. mmgpy/lib/libvtkCommonMath-9.5.so.1 +0 -0
  37. mmgpy/lib/libvtkCommonMisc-9.5.so.1 +0 -0
  38. mmgpy/lib/libvtkCommonSystem-9.5.so.1 +0 -0
  39. mmgpy/lib/libvtkCommonTransforms-9.5.so.1 +0 -0
  40. mmgpy/lib/libvtkDICOMParser-9.5.so.1 +0 -0
  41. mmgpy/lib/libvtkFiltersCellGrid-9.5.so.1 +0 -0
  42. mmgpy/lib/libvtkFiltersCore-9.5.so.1 +0 -0
  43. mmgpy/lib/libvtkFiltersExtraction-9.5.so.1 +0 -0
  44. mmgpy/lib/libvtkFiltersGeneral-9.5.so.1 +0 -0
  45. mmgpy/lib/libvtkFiltersGeometry-9.5.so.1 +0 -0
  46. mmgpy/lib/libvtkFiltersHybrid-9.5.so.1 +0 -0
  47. mmgpy/lib/libvtkFiltersHyperTree-9.5.so.1 +0 -0
  48. mmgpy/lib/libvtkFiltersModeling-9.5.so.1 +0 -0
  49. mmgpy/lib/libvtkFiltersParallel-9.5.so.1 +0 -0
  50. mmgpy/lib/libvtkFiltersReduction-9.5.so.1 +0 -0
  51. mmgpy/lib/libvtkFiltersSources-9.5.so.1 +0 -0
  52. mmgpy/lib/libvtkFiltersStatistics-9.5.so.1 +0 -0
  53. mmgpy/lib/libvtkFiltersTexture-9.5.so.1 +0 -0
  54. mmgpy/lib/libvtkFiltersVerdict-9.5.so.1 +0 -0
  55. mmgpy/lib/libvtkIOCellGrid-9.5.so.1 +0 -0
  56. mmgpy/lib/libvtkIOCore-9.5.so.1 +0 -0
  57. mmgpy/lib/libvtkIOGeometry-9.5.so.1 +0 -0
  58. mmgpy/lib/libvtkIOImage-9.5.so.1 +0 -0
  59. mmgpy/lib/libvtkIOLegacy-9.5.so.1 +0 -0
  60. mmgpy/lib/libvtkIOParallel-9.5.so.1 +0 -0
  61. mmgpy/lib/libvtkIOParallelXML-9.5.so.1 +0 -0
  62. mmgpy/lib/libvtkIOXML-9.5.so.1 +0 -0
  63. mmgpy/lib/libvtkIOXMLParser-9.5.so.1 +0 -0
  64. mmgpy/lib/libvtkImagingCore-9.5.so.1 +0 -0
  65. mmgpy/lib/libvtkImagingSources-9.5.so.1 +0 -0
  66. mmgpy/lib/libvtkParallelCore-9.5.so.1 +0 -0
  67. mmgpy/lib/libvtkParallelDIY-9.5.so.1 +0 -0
  68. mmgpy/lib/libvtkRenderingCore-9.5.so.1 +0 -0
  69. mmgpy/lib/libvtkdoubleconversion-9.5.so.1 +0 -0
  70. mmgpy/lib/libvtkexpat-9.5.so.1 +0 -0
  71. mmgpy/lib/libvtkfmt-9.5.so.1 +0 -0
  72. mmgpy/lib/libvtkjpeg-9.5.so.1 +0 -0
  73. mmgpy/lib/libvtkjsoncpp-9.5.so.1 +0 -0
  74. mmgpy/lib/libvtkkissfft-9.5.so.1 +0 -0
  75. mmgpy/lib/libvtkloguru-9.5.so.1 +0 -0
  76. mmgpy/lib/libvtklz4-9.5.so.1 +0 -0
  77. mmgpy/lib/libvtklzma-9.5.so.1 +0 -0
  78. mmgpy/lib/libvtkmetaio-9.5.so.1 +0 -0
  79. mmgpy/lib/libvtkpng-9.5.so.1 +0 -0
  80. mmgpy/lib/libvtkpugixml-9.5.so.1 +0 -0
  81. mmgpy/lib/libvtksys-9.5.so.1 +0 -0
  82. mmgpy/lib/libvtktiff-9.5.so.1 +0 -0
  83. mmgpy/lib/libvtktoken-9.5.so.1 +0 -0
  84. mmgpy/lib/libvtkverdict-9.5.so.1 +0 -0
  85. mmgpy/lib/libvtkzlib-9.5.so.1 +0 -0
  86. mmgpy/metrics.py +596 -0
  87. mmgpy/progress.py +69 -0
  88. mmgpy/py.typed +0 -0
  89. mmgpy/repair/__init__.py +37 -0
  90. mmgpy/repair/_core.py +226 -0
  91. mmgpy/repair/_elements.py +241 -0
  92. mmgpy/repair/_vertices.py +219 -0
  93. mmgpy/sizing.py +370 -0
  94. mmgpy/ui/__init__.py +97 -0
  95. mmgpy/ui/__main__.py +87 -0
  96. mmgpy/ui/app.py +1837 -0
  97. mmgpy/ui/parsers.py +501 -0
  98. mmgpy/ui/remeshing.py +448 -0
  99. mmgpy/ui/samples.py +249 -0
  100. mmgpy/ui/utils.py +280 -0
  101. mmgpy/ui/viewer.py +587 -0
  102. mmgpy-0.5.0.dist-info/METADATA +186 -0
  103. mmgpy-0.5.0.dist-info/RECORD +109 -0
  104. mmgpy-0.5.0.dist-info/WHEEL +6 -0
  105. mmgpy-0.5.0.dist-info/entry_points.txt +13 -0
  106. mmgpy-0.5.0.dist-info/licenses/LICENSE +38 -0
  107. share/man/man1/mmg2d.1.gz +0 -0
  108. share/man/man1/mmg3d.1.gz +0 -0
  109. share/man/man1/mmgs.1.gz +0 -0
mmgpy/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