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/app.py ADDED
@@ -0,0 +1,1837 @@
1
+ """Main trame application for mmgpy."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import logging
7
+ import tempfile
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING
10
+
11
+ import numpy as np
12
+ import pyvista as pv
13
+ from trame.app import get_server
14
+ from trame.app.file_upload import ClientFile
15
+ from trame.ui.vuetify3 import SinglePageWithDrawerLayout
16
+ from trame.widgets import html
17
+ from trame.widgets import vtk as vtk_widgets
18
+ from trame.widgets import vuetify3 as v3
19
+
20
+ from mmgpy.ui.parsers import parse_sol_file
21
+ from mmgpy.ui.remeshing import RemeshingMixin
22
+ from mmgpy.ui.samples import get_sample_mesh
23
+ from mmgpy.ui.utils import (
24
+ DEFAULT_REMESH_MODE_ITEMS,
25
+ DEFAULT_SCALAR_FIELD_OPTIONS,
26
+ DEFAULT_STATE,
27
+ reset_solution_state,
28
+ )
29
+ from mmgpy.ui.viewer import ViewerMixin
30
+
31
+ if TYPE_CHECKING:
32
+ from mmgpy import Mesh
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+ pv.OFF_SCREEN = True
37
+
38
+
39
+ class MmgpyApp(ViewerMixin, RemeshingMixin):
40
+ """Main mmgpy web application.
41
+
42
+ Inherits visualization functionality from ViewerMixin and
43
+ remeshing functionality from RemeshingMixin.
44
+ """
45
+
46
+ def __init__(
47
+ self,
48
+ server: str | None = None,
49
+ mesh: Mesh | None = None,
50
+ debug: bool = False,
51
+ ) -> None:
52
+ """Initialize the application.
53
+
54
+ Parameters
55
+ ----------
56
+ server : str | None
57
+ Server name for trame. If None, creates a new server.
58
+ mesh : Mesh | None
59
+ Pre-loaded mesh to display.
60
+ debug : bool
61
+ Enable debug mode with HTML structure printing.
62
+
63
+ """
64
+ self.server = get_server(server, client_type="vue3")
65
+ self.state = self.server.state
66
+ self.ctrl = self.server.controller
67
+ self._debug = debug
68
+
69
+ self._mesh: Mesh | None = mesh
70
+ self._original_mesh: Mesh | None = mesh # Store original for re-remeshing
71
+ self._solution_metric: np.ndarray | None = None # Current solution for metric
72
+ self._solution_fields: dict[str, dict] = {} # name -> {data, location}
73
+ # Store original solution for remeshing from original mesh
74
+ self._original_solution_metric: np.ndarray | None = None
75
+ self._original_solution_fields: dict[str, dict] = {}
76
+ self._plotter: pv.Plotter | None = None
77
+ self._render_window = None
78
+
79
+ self._init_state()
80
+ self._setup_callbacks()
81
+ self.ui = self._build_ui()
82
+
83
+ if mesh is not None:
84
+ self._update_viewer()
85
+
86
+ def _init_state(self) -> None:
87
+ """Initialize application state."""
88
+ # Apply default state values
89
+ for key, value in DEFAULT_STATE.items():
90
+ self.state.setdefault(key, value)
91
+
92
+ # Set complex defaults that need special handling
93
+ self.state.setdefault("scalar_field_options", DEFAULT_SCALAR_FIELD_OPTIONS)
94
+ self.state.setdefault("remesh_mode_items", DEFAULT_REMESH_MODE_ITEMS)
95
+
96
+ def _setup_callbacks(self) -> None:
97
+ """Set up state change callbacks."""
98
+ self._applying_preset = False # Flag to prevent feedback loop
99
+
100
+ @self.state.change("file_upload")
101
+ def on_file_upload(file_upload, **_):
102
+ if file_upload is None:
103
+ return
104
+ self._handle_file_upload(file_upload)
105
+
106
+ @self.state.change("sol_file_upload")
107
+ def on_sol_file_upload(sol_file_upload, **_):
108
+ if sol_file_upload is None:
109
+ return
110
+ self._handle_sol_file_upload(sol_file_upload)
111
+
112
+ @self.state.change(
113
+ "show_edges",
114
+ "opacity",
115
+ "show_scalar",
116
+ "smooth_shading",
117
+ "slice_enabled",
118
+ "slice_axis",
119
+ "slice_threshold",
120
+ "show_original_mesh",
121
+ )
122
+ def on_view_settings_change(**_):
123
+ if self._mesh is not None:
124
+ self._update_viewer(reset_camera=False)
125
+
126
+ @self.state.change("mesh_kind")
127
+ def on_mesh_kind_change(mesh_kind, **_):
128
+ base_modes = [
129
+ {"title": "Standard Remesh", "value": "standard"},
130
+ {"title": "Levelset Discretization", "value": "levelset"},
131
+ ]
132
+ if mesh_kind != "triangular_surface":
133
+ base_modes.append(
134
+ {"title": "Lagrangian Motion", "value": "lagrangian"},
135
+ )
136
+ self.state.remesh_mode_items = base_modes
137
+ if (
138
+ self.state.remesh_mode == "lagrangian"
139
+ and mesh_kind == "triangular_surface"
140
+ ):
141
+ self.state.remesh_mode = "standard"
142
+
143
+ @self.state.change("theme_name")
144
+ def on_theme_change(theme_name, **_):
145
+ self._update_viewer_background(theme_name == "dark")
146
+
147
+ self.ctrl.load_sample_mesh = self._load_sample_mesh
148
+ self.ctrl.run_remesh = self._run_remesh
149
+ self.ctrl.run_validation = self._run_validation
150
+ self.ctrl.export_mesh = self._export_mesh
151
+ self.ctrl.reset_mesh = self._reset_mesh
152
+ self.ctrl.add_sizing_constraint = self._add_sizing_constraint
153
+ self.ctrl.clear_sizing_constraints = self._clear_sizing_constraints
154
+
155
+ self.server.trigger("run_remesh")(self._run_remesh)
156
+ self.server.trigger("run_validation")(self._run_validation)
157
+ self.server.trigger("reset_mesh")(self._reset_mesh)
158
+ self.server.trigger("export_mesh")(self._export_mesh)
159
+ self.server.trigger("load_sample_mesh")(self._load_sample_mesh)
160
+ self.server.trigger("clear_sizing_constraints")(self._clear_sizing_constraints)
161
+ self.server.trigger("add_sizing_constraint")(self._add_sizing_constraint)
162
+ self.server.trigger("apply_preset")(self._apply_preset_trigger)
163
+ self.server.trigger("set_custom_preset")(self._set_custom_preset)
164
+ self.server.trigger("set_view")(self._set_view)
165
+ self.server.trigger("toggle_parallel_projection")(
166
+ self._toggle_parallel_projection,
167
+ )
168
+ self.server.trigger("toggle_theme")(self._toggle_theme)
169
+
170
+ def _toggle_theme(self) -> None:
171
+ """Toggle between light and dark theme."""
172
+ current = self.state.theme_name
173
+ self.state.theme_name = "dark" if current == "light" else "light"
174
+
175
+ def _update_viewer_background(self, dark_theme: bool) -> None:
176
+ """Update viewer background and colors based on theme."""
177
+ if self._plotter is None:
178
+ return
179
+ # Use dark gray for dark theme, white for light
180
+ if dark_theme:
181
+ self._plotter.set_background("#1e1e1e")
182
+ else:
183
+ self._plotter.set_background("white")
184
+ # Re-render the mesh to update axes and scalar bar colors
185
+ if self._mesh is not None:
186
+ self._update_viewer(reset_camera=False)
187
+
188
+ def _set_custom_preset(self) -> None:
189
+ """Set preset to custom when user manually changes values."""
190
+ if self.state.use_preset != "custom":
191
+ self.state.use_preset = "custom"
192
+
193
+ def _handle_file_upload(self, file_upload) -> None:
194
+ """Handle uploaded mesh file."""
195
+ from mmgpy import Mesh
196
+
197
+ client_file = ClientFile(file_upload)
198
+
199
+ # Check file size limit (50 MB)
200
+ max_size_mb = 50
201
+ if len(client_file.content) > max_size_mb * 1024 * 1024:
202
+ self.state.remesh_result = {
203
+ "error": f"File too large. Maximum size is {max_size_mb} MB.",
204
+ }
205
+ self.state.file_upload = None
206
+ return
207
+
208
+ suffix = Path(client_file.name).suffix
209
+ tmp_path = None
210
+
211
+ try:
212
+ with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
213
+ tmp.write(client_file.content)
214
+ tmp_path = tmp.name
215
+
216
+ self._mesh = Mesh(tmp_path)
217
+ self._original_mesh = Mesh(tmp_path)
218
+ self.state.has_original_mesh = True
219
+ self.state.show_original_mesh = False
220
+ self._update_mesh_state_after_load(client_file.name)
221
+ except Exception:
222
+ logger.exception("Error loading mesh file: %s", client_file.name)
223
+ self.state.mesh_info = "Error loading mesh. Check file format."
224
+ finally:
225
+ self.state.file_upload = None
226
+ self.state.flush()
227
+ if tmp_path is not None:
228
+ Path(tmp_path).unlink(missing_ok=True)
229
+
230
+ def _update_mesh_state_after_load(self, filename: str) -> None:
231
+ """Update state after loading a mesh."""
232
+ self._update_mesh_info()
233
+ self._apply_adaptive_defaults()
234
+ self._check_multi_material()
235
+ self._update_viewer()
236
+ self.state.mesh_loaded = True
237
+ self.state.mesh_filename = filename
238
+ self.state.remesh_result = None
239
+
240
+ # Reset solution state
241
+ for key, value in reset_solution_state().items():
242
+ setattr(self.state, key, value)
243
+ self._solution_metric = None
244
+ self._solution_fields = {}
245
+ self._original_solution_metric = None
246
+ self._original_solution_fields = {}
247
+
248
+ def _check_multi_material(self) -> None:
249
+ """Check if mesh has multiple materials/regions and switch to refs visualization."""
250
+ if self._mesh is None:
251
+ return
252
+
253
+ try:
254
+ pv_mesh = self._mesh.to_pyvista()
255
+ element_refs_uniform = True
256
+
257
+ if "refs" in pv_mesh.cell_data:
258
+ refs = pv_mesh.cell_data["refs"]
259
+ unique_refs = np.unique(refs)
260
+ if len(unique_refs) > 1:
261
+ # Multi-material mesh detected - switch to refs visualization
262
+ self.state.show_scalar = "refs"
263
+ logger.info(
264
+ "Multi-material mesh detected with %d regions, "
265
+ "switching to Refs visualization",
266
+ len(unique_refs),
267
+ )
268
+ return
269
+ element_refs_uniform = len(unique_refs) <= 1
270
+
271
+ # For tetrahedral meshes, also check boundary triangle refs
272
+ if self._mesh.kind.value == "tetrahedral":
273
+ try:
274
+ _, tri_refs = self._mesh.get_triangles_with_refs()
275
+ unique_tri_refs = np.unique(tri_refs)
276
+ if len(unique_tri_refs) > 1:
277
+ # Multi-boundary mesh - auto-switch to boundary refs
278
+ # if element refs are uniform
279
+ if element_refs_uniform:
280
+ self.state.show_scalar = "boundary_refs"
281
+ logger.info(
282
+ "Tetrahedral mesh with %d boundary regions (refs: %s), "
283
+ "switching to Boundary Refs visualization",
284
+ len(unique_tri_refs),
285
+ unique_tri_refs.tolist(),
286
+ )
287
+ else:
288
+ logger.info(
289
+ "Tetrahedral mesh with %d boundary regions (refs: %s). "
290
+ "Volume elements have multiple refs.",
291
+ len(unique_tri_refs),
292
+ unique_tri_refs.tolist(),
293
+ )
294
+ except Exception:
295
+ pass
296
+ except Exception:
297
+ pass # Silently ignore errors
298
+
299
+ def _handle_sol_file_upload(self, sol_file_upload) -> None:
300
+ """Handle uploaded solution file."""
301
+ if self._mesh is None:
302
+ return
303
+
304
+ client_file = ClientFile(sol_file_upload)
305
+
306
+ # Check file size limit (10 MB for solution files)
307
+ max_size_mb = 10
308
+ if len(client_file.content) > max_size_mb * 1024 * 1024:
309
+ self.state.remesh_result = {
310
+ "error": f"Solution file too large. Maximum size is {max_size_mb} MB.",
311
+ }
312
+ self.state.sol_file_upload = None
313
+ return
314
+
315
+ content = client_file.content.decode("utf-8")
316
+
317
+ try:
318
+ fields = parse_sol_file(content)
319
+
320
+ # Get mesh entity counts for validation
321
+ n_vertices = len(self._mesh.get_vertices())
322
+ kind = self._mesh.kind.value
323
+ if kind == "tetrahedral":
324
+ n_elements = len(self._mesh.get_tetrahedra())
325
+ element_type = "tetrahedra"
326
+ else:
327
+ n_elements = len(self._mesh.get_triangles())
328
+ element_type = "triangles"
329
+
330
+ # Map location to expected count
331
+ expected_counts = {
332
+ "vertices": n_vertices,
333
+ "triangles": n_elements if element_type == "triangles" else 0,
334
+ "tetrahedra": n_elements if element_type == "tetrahedra" else 0,
335
+ }
336
+
337
+ # Check which fields match the mesh
338
+ valid_fields = {}
339
+ mismatched_fields = {}
340
+ for name, field_info in fields.items():
341
+ data = field_info["data"]
342
+ location = field_info["location"]
343
+ expected = expected_counts.get(location, 0)
344
+
345
+ if len(data) == expected and expected > 0:
346
+ valid_fields[name] = {"data": data, "location": location}
347
+ else:
348
+ mismatched_fields[name] = {
349
+ "count": len(data),
350
+ "location": location,
351
+ "expected": expected,
352
+ }
353
+
354
+ # Warn user if fields were skipped due to count mismatch
355
+ if mismatched_fields and not valid_fields:
356
+ field_parts = []
357
+ for name, info in mismatched_fields.items():
358
+ field_parts.append(
359
+ f"{name}: {info['count']} values "
360
+ f"(expected {info['expected']} {info['location']})",
361
+ )
362
+ field_info_str = "; ".join(field_parts)
363
+ mesh_info = f"{n_vertices} vertices, {n_elements} {element_type}"
364
+ self.state.remesh_result = {
365
+ "error": (
366
+ f"Solution file mismatch: {field_info_str}. "
367
+ f"Mesh has {mesh_info}."
368
+ ),
369
+ }
370
+ self.state.sol_filename = ""
371
+ return
372
+
373
+ self._process_valid_solution_fields(valid_fields, client_file.name)
374
+
375
+ except Exception:
376
+ logger.exception("Error loading solution file: %s", client_file.name)
377
+ self.state.remesh_result = {"error": "Error loading solution file"}
378
+ finally:
379
+ self.state.sol_file_upload = None
380
+
381
+ def _process_valid_solution_fields(
382
+ self,
383
+ valid_fields: dict,
384
+ filename: str,
385
+ ) -> None:
386
+ """Process and store valid solution fields."""
387
+ # Store solution fields in app instance for visualization
388
+ self._solution_fields = valid_fields
389
+ # Store deep copy as original (for remeshing from original mesh)
390
+ self._original_solution_fields = {
391
+ name: {"data": info["data"].copy(), "location": info["location"]}
392
+ for name, info in valid_fields.items()
393
+ }
394
+ self.state.solution_fields = {
395
+ name: {
396
+ "shape": info["data"].shape,
397
+ "location": info["location"],
398
+ }
399
+ for name, info in valid_fields.items()
400
+ }
401
+ self._update_scalar_field_options()
402
+
403
+ if valid_fields:
404
+ first_field = next(iter(valid_fields.keys()))
405
+ self.state.show_scalar = f"user_{first_field}"
406
+ first_info = valid_fields[first_field]
407
+
408
+ # Only use vertex-based solutions for metric/levelset
409
+ if first_info["location"] == "vertices":
410
+ data = first_info["data"]
411
+ self._solution_metric = data.copy()
412
+ self._original_solution_metric = data.copy()
413
+
414
+ # Auto-detect: levelset (has negatives) vs metric (all positive)
415
+ has_negative = np.any(data < 0)
416
+ has_zero_or_negative = np.any(data <= 0)
417
+
418
+ if has_negative or has_zero_or_negative:
419
+ # Signed distance field or ambiguous -> use as levelset
420
+ self.state.solution_type = "levelset"
421
+ self.state.use_solution_as_levelset = True
422
+ self.state.use_solution_as_metric = False
423
+ self.state.remesh_mode = "levelset"
424
+ else:
425
+ # All positive -> use as metric (sizing field)
426
+ self.state.solution_type = "metric"
427
+ self.state.use_solution_as_metric = True
428
+ self.state.use_solution_as_levelset = False
429
+
430
+ self._update_viewer(reset_camera=False)
431
+ self.state.sol_filename = filename
432
+
433
+ def _update_scalar_field_options(self) -> None:
434
+ """Update scalar field dropdown options based on available fields and mesh type."""
435
+ base_options = list(DEFAULT_SCALAR_FIELD_OPTIONS)
436
+
437
+ # Remove Face Orientation for tetrahedral meshes (volumetric, no front/back)
438
+ if self.state.mesh_kind == "tetrahedral":
439
+ base_options = [
440
+ opt
441
+ for opt in base_options
442
+ if opt.get("value") != "face_sides"
443
+ and opt.get("title") != "-- Orientation --"
444
+ ]
445
+ # Reset to "none" if face_sides was selected
446
+ if self.state.show_scalar == "face_sides":
447
+ self.state.show_scalar = "none"
448
+
449
+ # Add Boundary Refs option for tetrahedral meshes with boundary triangles
450
+ if self._mesh is not None:
451
+ try:
452
+ _, tri_refs = self._mesh.get_triangles_with_refs()
453
+ unique_tri_refs = np.unique(tri_refs)
454
+ if len(unique_tri_refs) > 0:
455
+ # Find the "-- Other --" section and add Boundary Refs after Refs
456
+ for i, opt in enumerate(base_options):
457
+ if opt.get("value") == "refs":
458
+ base_options.insert(
459
+ i + 1,
460
+ {
461
+ "title": "Boundary Refs",
462
+ "value": "boundary_refs",
463
+ },
464
+ )
465
+ break
466
+ except Exception:
467
+ pass
468
+
469
+ if self._solution_fields:
470
+ base_options.append({"type": "subheader", "title": "-- Solution --"})
471
+ for name, info in self._solution_fields.items():
472
+ # Create display name: "solution (vertices)" or "solution (triangles)"
473
+ base_name = name.split("@")[0] if "@" in name else name
474
+ location = info["location"]
475
+ display_name = f"{base_name} ({location})"
476
+ base_options.append({"title": display_name, "value": f"user_{name}"})
477
+
478
+ self.state.scalar_field_options = base_options
479
+
480
+ def _load_sample_mesh(self, sample_name: str) -> None:
481
+ """Load a sample mesh."""
482
+ from mmgpy import Mesh
483
+
484
+ pv_mesh = get_sample_mesh(sample_name)
485
+ if pv_mesh is None:
486
+ logger.warning("Unknown sample mesh: %s", sample_name)
487
+ return
488
+
489
+ self._mesh = Mesh(pv_mesh)
490
+ self._original_mesh = Mesh(pv_mesh)
491
+ self.state.has_original_mesh = True
492
+ self.state.show_original_mesh = False
493
+ self._update_mesh_state_after_load(f"sample:{sample_name}")
494
+
495
+ def _update_mesh_info(self) -> None:
496
+ """Update mesh information display."""
497
+ if self._mesh is None:
498
+ self.state.mesh_info = ""
499
+ self.state.mesh_kind = ""
500
+ self.state.mesh_stats = None
501
+ return
502
+
503
+ vertices = self._mesh.get_vertices()
504
+ n_verts = len(vertices)
505
+ kind = self._mesh.kind.value
506
+
507
+ mmg_module_map = {
508
+ "tetrahedral": "mmg3d",
509
+ "triangular_2d": "mmg2d",
510
+ "triangular_surface": "mmgs",
511
+ }
512
+ mmg_module = mmg_module_map.get(kind, "unknown")
513
+
514
+ if kind == "tetrahedral":
515
+ n_elements = len(self._mesh.get_tetrahedra())
516
+ elem_type = "tetrahedra"
517
+ else:
518
+ n_elements = len(self._mesh.get_triangles())
519
+ elem_type = "triangles"
520
+
521
+ bounds = self._mesh.get_bounds()
522
+ size = bounds[1] - bounds[0]
523
+
524
+ # Compute quality statistics
525
+ quality_stats = None
526
+ try:
527
+ qualities = self._mesh.get_element_qualities()
528
+ quality_stats = {
529
+ "min": float(np.min(qualities)),
530
+ "max": float(np.max(qualities)),
531
+ "mean": float(np.mean(qualities)),
532
+ "std": float(np.std(qualities)),
533
+ }
534
+ except Exception:
535
+ logger.debug("Could not compute quality statistics")
536
+
537
+ # Compute edge length statistics
538
+ edge_stats = None
539
+ try:
540
+ pv_mesh = self._mesh.to_pyvista()
541
+ edge_lengths = self._compute_all_edge_lengths(pv_mesh)
542
+ if edge_lengths is not None and len(edge_lengths) > 0:
543
+ edge_stats = {
544
+ "min": float(np.min(edge_lengths)),
545
+ "max": float(np.max(edge_lengths)),
546
+ "mean": float(np.mean(edge_lengths)),
547
+ "median": float(np.median(edge_lengths)),
548
+ }
549
+ except Exception:
550
+ logger.debug("Could not compute edge length statistics")
551
+
552
+ # Compute refs statistics
553
+ refs_stats = None
554
+ try:
555
+ pv_mesh = self._mesh.to_pyvista()
556
+ if "refs" in pv_mesh.cell_data:
557
+ refs = pv_mesh.cell_data["refs"]
558
+ unique_refs = np.unique(refs)
559
+ refs_stats = {
560
+ "element_refs": unique_refs.tolist(),
561
+ "element_count": len(unique_refs),
562
+ }
563
+
564
+ # For tetrahedral meshes, also get boundary triangle refs
565
+ if kind == "tetrahedral":
566
+ try:
567
+ _, tri_refs = self._mesh.get_triangles_with_refs()
568
+ unique_tri_refs = np.unique(tri_refs)
569
+ if refs_stats is None:
570
+ refs_stats = {}
571
+ refs_stats["boundary_refs"] = unique_tri_refs.tolist()
572
+ refs_stats["boundary_count"] = len(unique_tri_refs)
573
+ except Exception:
574
+ pass
575
+ except Exception:
576
+ logger.debug("Could not compute refs statistics")
577
+
578
+ # Build detailed mesh stats
579
+ self.state.mesh_stats = {
580
+ "vertices": n_verts,
581
+ "elements": n_elements,
582
+ "element_type": elem_type,
583
+ "kind": kind,
584
+ "mmg_module": mmg_module,
585
+ "bounds": {
586
+ "min": bounds[0].tolist()
587
+ if hasattr(bounds[0], "tolist")
588
+ else list(bounds[0]),
589
+ "max": bounds[1].tolist()
590
+ if hasattr(bounds[1], "tolist")
591
+ else list(bounds[1]),
592
+ },
593
+ "size": size.tolist() if hasattr(size, "tolist") else list(size),
594
+ "quality": quality_stats,
595
+ "edge_length": edge_stats,
596
+ "refs": refs_stats,
597
+ }
598
+
599
+ # Build size string based on dimensionality
600
+ is_3d = len(size) >= 3 and size[2] != 0
601
+ if is_3d:
602
+ size_str = f"{size[0]:.3f} × {size[1]:.3f} × {size[2]:.3f}"
603
+ else:
604
+ size_str = f"{size[0]:.3f} × {size[1]:.3f}"
605
+ self.state.mesh_info = (
606
+ f"Vertices: {n_verts:,} | {elem_type.title()}: {n_elements:,}\n"
607
+ f"Size: {size_str}"
608
+ )
609
+ self.state.mesh_kind = kind
610
+ self._update_scalar_field_options()
611
+
612
+ def _export_mesh(self) -> None:
613
+ """Export mesh to file and trigger download."""
614
+ if self._mesh is None:
615
+ return
616
+
617
+ export_format = self.state.export_format
618
+ filename = self.state.mesh_filename.split(":")[
619
+ -1
620
+ ] # Remove "sample:" prefix if present
621
+ if not filename:
622
+ filename = "mesh"
623
+
624
+ # Remove existing extension and add new one
625
+ base_name = Path(filename).stem
626
+ new_filename = f"{base_name}.{export_format}"
627
+
628
+ try:
629
+ # Export to temporary file
630
+ with tempfile.NamedTemporaryFile(
631
+ suffix=f".{export_format}",
632
+ delete=False,
633
+ ) as tmp:
634
+ tmp_path = tmp.name
635
+
636
+ pv_mesh = self._mesh.to_pyvista()
637
+ pv_mesh.save(tmp_path)
638
+
639
+ # Read file and encode as base64 for download
640
+ with Path(tmp_path).open("rb") as f:
641
+ content = f.read()
642
+
643
+ # Trigger download via JavaScript
644
+ b64_content = base64.b64encode(content).decode("utf-8")
645
+
646
+ # Determine MIME type
647
+ mime_types = {
648
+ "vtk": "application/octet-stream",
649
+ "vtu": "application/octet-stream",
650
+ "stl": "model/stl",
651
+ "obj": "model/obj",
652
+ "ply": "application/x-ply",
653
+ "mesh": "application/octet-stream",
654
+ }
655
+ mime_type = mime_types.get(export_format, "application/octet-stream")
656
+
657
+ # Execute JavaScript to trigger download
658
+ self.server.js_call(
659
+ "utils",
660
+ "download",
661
+ new_filename,
662
+ f"data:{mime_type};base64,{b64_content}",
663
+ )
664
+
665
+ logger.info("Exported mesh to %s", new_filename)
666
+
667
+ except Exception:
668
+ logger.exception("Failed to export mesh")
669
+ self.state.remesh_result = {"error": "Failed to export mesh"}
670
+ finally:
671
+ if "tmp_path" in locals():
672
+ Path(tmp_path).unlink(missing_ok=True)
673
+
674
+ def _reset_mesh(self) -> None:
675
+ """Reset to original mesh state."""
676
+ self._mesh = None
677
+ self._original_mesh = None
678
+ self._solution_metric = None
679
+ self._solution_fields = {}
680
+ self._original_solution_metric = None
681
+ self._original_solution_fields = {}
682
+
683
+ self.state.mesh_loaded = False
684
+ self.state.has_original_mesh = False
685
+ self.state.show_original_mesh = False
686
+ self.state.mesh_info = ""
687
+ self.state.mesh_kind = ""
688
+ self.state.mesh_filename = ""
689
+ self.state.mesh_stats = None
690
+ self.state.validation_report = None
691
+ self.state.remesh_result = None
692
+ self.state.scalar_field_options = list(DEFAULT_SCALAR_FIELD_OPTIONS)
693
+
694
+ for key, value in reset_solution_state().items():
695
+ setattr(self.state, key, value)
696
+
697
+ # Clear plotter contents but keep it alive to avoid invalidating _view widget
698
+ if self._plotter is not None:
699
+ self._plotter.clear()
700
+ # Render the cleared state
701
+ if self._render_window is not None:
702
+ self._render_window.Render()
703
+ if hasattr(self, "_view") and self._view is not None:
704
+ self._view.update()
705
+ self.state.flush()
706
+
707
+ def _build_ui(self):
708
+ """Build the trame UI."""
709
+ with SinglePageWithDrawerLayout(
710
+ self.server,
711
+ full_height=True,
712
+ theme=("theme_name", "light"),
713
+ ) as layout:
714
+ layout.title.set_text("mmgpy")
715
+ layout.icon.click = "drawer_open = !drawer_open"
716
+
717
+ # Add JavaScript utilities
718
+ html.Script(
719
+ """
720
+ window.utils = {
721
+ download: function(filename, dataUrl) {
722
+ const link = document.createElement('a');
723
+ link.href = dataUrl;
724
+ link.download = filename;
725
+ document.body.appendChild(link);
726
+ link.click();
727
+ document.body.removeChild(link);
728
+ }
729
+ };
730
+
731
+ // Detect system theme preference and set initial theme
732
+ (function() {
733
+ const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
734
+ const checkAndSetInitial = () => {
735
+ if (window.trame?.state) {
736
+ // Only set if not already set by user
737
+ const current = window.trame.state.get('theme_name');
738
+ if (!current || current === 'light') {
739
+ // Follow system preference on first load
740
+ if (prefersDark) {
741
+ window.trame.state.set('theme_name', 'dark');
742
+ }
743
+ }
744
+ } else {
745
+ setTimeout(checkAndSetInitial, 100);
746
+ }
747
+ };
748
+ checkAndSetInitial();
749
+ })();
750
+ """,
751
+ )
752
+
753
+ with layout.toolbar:
754
+ v3.VSpacer()
755
+ self._build_toolbar()
756
+
757
+ with layout.drawer as drawer:
758
+ drawer.width = 320
759
+ self._build_drawer()
760
+
761
+ with layout.content:
762
+ self._build_content()
763
+
764
+ # Right info drawer (separate from main content to isolate scroll)
765
+ with v3.VNavigationDrawer(
766
+ v_model=("info_panel_open",),
767
+ location="right",
768
+ width=300,
769
+ temporary=False,
770
+ permanent=False,
771
+ ):
772
+ self._build_info_panel()
773
+
774
+ # Clear default trame footer content and add our own
775
+ layout.footer.clear()
776
+ with layout.footer:
777
+ self._build_footer()
778
+ if self._debug:
779
+ v3.VBtn(
780
+ "Print HTML",
781
+ click=lambda: print(layout.html),
782
+ variant="text",
783
+ size="small",
784
+ )
785
+
786
+ return layout
787
+
788
+ def _build_footer(self) -> None:
789
+ """Build footer with version and GitHub link."""
790
+ try:
791
+ from importlib.metadata import version
792
+
793
+ ver = version("mmgpy")
794
+ except Exception:
795
+ ver = "dev"
796
+
797
+ with html.Div(
798
+ classes="d-flex justify-center align-center",
799
+ style="width: 100%; gap: 8px;",
800
+ ):
801
+ html.Span(f"mmgpy v{ver}", classes="text-caption")
802
+ html.Span("•", classes="text-caption")
803
+ html.A(
804
+ "GitHub",
805
+ href="https://github.com/kmarchais/mmgpy",
806
+ target="_blank",
807
+ classes="text-caption",
808
+ )
809
+
810
+ def _build_toolbar(self) -> None:
811
+ """Build toolbar content."""
812
+ v3.VBtn(
813
+ icon="mdi-refresh",
814
+ click="trigger('reset_mesh')",
815
+ title="Reset mesh",
816
+ variant="text",
817
+ disabled=("!mesh_loaded",),
818
+ )
819
+
820
+ v3.VDivider(vertical=True, classes="mx-2")
821
+
822
+ # Export menu
823
+ with v3.VMenu():
824
+ with v3.Template(v_slot_activator="{ props }"):
825
+ v3.VBtn(
826
+ icon="mdi-download",
827
+ v_bind="props",
828
+ title="Export mesh",
829
+ variant="text",
830
+ disabled=("!mesh_loaded",),
831
+ )
832
+ with v3.VList(density="compact"):
833
+ v3.VListItem(
834
+ title="VTK (.vtk)",
835
+ click="export_format = 'vtk'; trigger('export_mesh')",
836
+ )
837
+ v3.VListItem(
838
+ title="VTU (.vtu)",
839
+ click="export_format = 'vtu'; trigger('export_mesh')",
840
+ )
841
+ v3.VListItem(
842
+ title="STL (.stl)",
843
+ click="export_format = 'stl'; trigger('export_mesh')",
844
+ )
845
+ v3.VListItem(
846
+ title="OBJ (.obj)",
847
+ click="export_format = 'obj'; trigger('export_mesh')",
848
+ )
849
+ v3.VListItem(
850
+ title="PLY (.ply)",
851
+ click="export_format = 'ply'; trigger('export_mesh')",
852
+ )
853
+ v3.VListItem(
854
+ title="Medit (.mesh)",
855
+ click="export_format = 'mesh'; trigger('export_mesh')",
856
+ )
857
+
858
+ v3.VDivider(vertical=True, classes="mx-2")
859
+
860
+ # Theme toggle
861
+ v3.VBtn(
862
+ icon=("theme_name === 'dark' ? 'mdi-weather-sunny' : 'mdi-weather-night'",),
863
+ click="trigger('toggle_theme')",
864
+ title=(
865
+ "theme_name === 'dark' ? 'Switch to light theme' : "
866
+ "'Switch to dark theme'",
867
+ ),
868
+ variant="text",
869
+ )
870
+
871
+ def _build_drawer(self) -> None:
872
+ """Build drawer content - single panel layout."""
873
+ with v3.VContainer(classes="pa-2"):
874
+ self._build_remesh_panel()
875
+
876
+ def _build_remesh_panel(self) -> None:
877
+ """Build remeshing options panel."""
878
+ self._build_file_upload_section()
879
+ self._build_solution_options_section()
880
+ # Show original mesh toggle (only visible after remeshing)
881
+ v3.VSwitch(
882
+ v_model=("show_original_mesh",),
883
+ label="Show original mesh",
884
+ density="compact",
885
+ hide_details=True,
886
+ color="warning",
887
+ v_show="has_original_mesh && remesh_result",
888
+ classes="mb-2",
889
+ )
890
+ v3.VDivider(classes="mb-3")
891
+ self._build_mode_and_preset_section()
892
+ v3.VDivider(classes="mb-3")
893
+ self._build_size_parameters_section()
894
+ v3.VDivider(classes="mb-3")
895
+ self._build_advanced_options_section()
896
+ self._build_mode_specific_options()
897
+ v3.VDivider(classes="mb-3")
898
+ self._build_run_section()
899
+
900
+ def _build_file_upload_section(self) -> None:
901
+ """Build file upload inputs for mesh and solution files."""
902
+ # Import mesh row with file input and sample menu
903
+ with v3.VRow(dense=True, classes="mb-2", no_gutters=True):
904
+ with v3.VCol(classes="flex-grow-1"):
905
+ v3.VFileInput(
906
+ v_model=("file_upload",),
907
+ label=("mesh_filename ? `Mesh: ${mesh_filename}` : 'Import Mesh'",),
908
+ accept=".vtk,.vtu,.vtp,.stl,.obj,.ply,.mesh,.msh",
909
+ prepend_icon="mdi-import",
910
+ density="compact",
911
+ variant="outlined",
912
+ hide_details=True,
913
+ clearable=True,
914
+ title="Supported formats: VTK, VTU, VTP, STL, OBJ, PLY, Medit (.mesh), Gmsh (.msh). Max 50 MB.",
915
+ click="file_upload = null",
916
+ )
917
+ with v3.VCol(cols="auto", classes="pl-1 d-flex align-center"):
918
+ # Sample meshes menu
919
+ with v3.VMenu():
920
+ with v3.Template(v_slot_activator="{ props }"):
921
+ v3.VBtn(
922
+ icon="mdi-shape",
923
+ v_bind="props",
924
+ title="Load sample mesh",
925
+ variant="outlined",
926
+ size="small",
927
+ )
928
+ with v3.VList(density="compact"):
929
+ v3.VListSubheader("Surface Meshes (mmgs)")
930
+ v3.VListItem(
931
+ title="Sphere",
932
+ click="trigger('load_sample_mesh', ['sphere'])",
933
+ prepend_icon="mdi-sphere",
934
+ )
935
+ v3.VListItem(
936
+ title="Cube",
937
+ click="trigger('load_sample_mesh', ['cube'])",
938
+ prepend_icon="mdi-cube-outline",
939
+ )
940
+ v3.VListItem(
941
+ title="Torus",
942
+ click="trigger('load_sample_mesh', ['torus'])",
943
+ prepend_icon="mdi-circle-double",
944
+ )
945
+ v3.VListItem(
946
+ title="Bunny",
947
+ click="trigger('load_sample_mesh', ['bunny'])",
948
+ prepend_icon="mdi-rabbit",
949
+ )
950
+ v3.VDivider()
951
+ v3.VListSubheader("Tetrahedral Meshes (mmg3d)")
952
+ v3.VListItem(
953
+ title="Tetra Cube",
954
+ click="trigger('load_sample_mesh', ['tetra_cube'])",
955
+ prepend_icon="mdi-cube",
956
+ )
957
+ v3.VListItem(
958
+ title="Tetra Sphere",
959
+ click="trigger('load_sample_mesh', ['tetra_sphere'])",
960
+ prepend_icon="mdi-sphere",
961
+ )
962
+ v3.VDivider()
963
+ v3.VListSubheader("2D Meshes (mmg2d)")
964
+ v3.VListItem(
965
+ title="Disc",
966
+ click="trigger('load_sample_mesh', ['disc_2d'])",
967
+ prepend_icon="mdi-circle",
968
+ )
969
+ v3.VListItem(
970
+ title="Rectangle",
971
+ click="trigger('load_sample_mesh', ['rect_2d'])",
972
+ prepend_icon="mdi-rectangle",
973
+ )
974
+ v3.VFileInput(
975
+ v_model=("sol_file_upload",),
976
+ label=(
977
+ "sol_filename ? `Solution: ${sol_filename}` : 'Import Solution (.sol)'",
978
+ ),
979
+ accept=".sol",
980
+ prepend_icon="mdi-chart-line",
981
+ density="compact",
982
+ variant="outlined",
983
+ hide_details=True,
984
+ clearable=True,
985
+ classes="mb-2",
986
+ disabled=("!mesh_loaded",),
987
+ title="Load solution file to visualize scalar/vector fields. Max 10 MB.",
988
+ click="sol_file_upload = null",
989
+ )
990
+
991
+ def _build_solution_options_section(self) -> None:
992
+ """Build solution type alerts and usage options."""
993
+ v3.VAlert(
994
+ text="Solution detected as levelset (signed distance)",
995
+ type="info",
996
+ density="compact",
997
+ variant="tonal",
998
+ v_show="sol_filename && solution_type === 'levelset'",
999
+ classes="mb-2",
1000
+ )
1001
+ v3.VAlert(
1002
+ text="Solution detected as metric (sizing field)",
1003
+ type="success",
1004
+ density="compact",
1005
+ variant="tonal",
1006
+ v_show="sol_filename && solution_type === 'metric'",
1007
+ classes="mb-2",
1008
+ )
1009
+ v3.VCheckbox(
1010
+ v_model=("use_solution_as_metric",),
1011
+ label="As metric (sizing)",
1012
+ density="compact",
1013
+ hide_details=True,
1014
+ classes="mb-1",
1015
+ disabled=("!sol_filename || remesh_mode !== 'standard'",),
1016
+ title="Use solution values to control local mesh size",
1017
+ )
1018
+ v3.VCheckbox(
1019
+ v_model=("use_solution_as_levelset",),
1020
+ label="As levelset (iso-surface)",
1021
+ density="compact",
1022
+ hide_details=True,
1023
+ classes="mb-3",
1024
+ disabled=("!sol_filename || remesh_mode !== 'levelset'",),
1025
+ title="Use solution as levelset field for iso-surface extraction",
1026
+ )
1027
+
1028
+ def _build_mode_and_preset_section(self) -> None:
1029
+ """Build mode selection and preset buttons."""
1030
+ with html.Div(classes="d-flex align-center mb-2"):
1031
+ v3.VIcon("mdi-tune", size="small", color="primary", classes="mr-2")
1032
+ html.Span("Mode & Presets", classes="text-subtitle-2 font-weight-medium")
1033
+ v3.VSelect(
1034
+ v_model=("remesh_mode",),
1035
+ label="Mode",
1036
+ items=("remesh_mode_items",),
1037
+ density="compact",
1038
+ variant="outlined",
1039
+ hide_details=True,
1040
+ classes="mb-3",
1041
+ title="Standard: global remesh | Levelset: iso-surface extraction | Lagrangian: move vertices",
1042
+ )
1043
+ # Default/Custom row
1044
+ with v3.VBtnToggle(
1045
+ v_model=("use_preset",),
1046
+ density="compact",
1047
+ mandatory=True,
1048
+ divided=True,
1049
+ classes="mb-1",
1050
+ style="width: 100%;",
1051
+ disabled=("selected_options.includes('optim')",),
1052
+ ):
1053
+ v3.VBtn(
1054
+ value="default",
1055
+ text="Default",
1056
+ size="small",
1057
+ style="flex: 1;",
1058
+ title="Use MMG's internal defaults",
1059
+ click="trigger('apply_preset', ['default'])",
1060
+ )
1061
+ v3.VBtn(
1062
+ value="custom",
1063
+ text="Custom",
1064
+ size="small",
1065
+ style="flex: 1;",
1066
+ title="Custom parameters",
1067
+ click="trigger('apply_preset', ['custom'])",
1068
+ )
1069
+ # Sizing presets row
1070
+ with v3.VBtnToggle(
1071
+ v_model=("use_preset",),
1072
+ density="compact",
1073
+ mandatory=True,
1074
+ divided=True,
1075
+ classes="mb-3",
1076
+ style="width: 100%;",
1077
+ disabled=("selected_options.includes('optim')",),
1078
+ ):
1079
+ v3.VBtn(
1080
+ value="fine",
1081
+ text="Fine",
1082
+ size="small",
1083
+ style="flex: 1;",
1084
+ title="2% of diagonal, high accuracy",
1085
+ click="trigger('apply_preset', ['fine'])",
1086
+ )
1087
+ v3.VBtn(
1088
+ value="medium",
1089
+ text="Medium",
1090
+ size="small",
1091
+ style="flex: 1;",
1092
+ title="4% of diagonal, balanced",
1093
+ click="trigger('apply_preset', ['medium'])",
1094
+ )
1095
+ v3.VBtn(
1096
+ value="coarse",
1097
+ text="Coarse",
1098
+ size="small",
1099
+ style="flex: 1;",
1100
+ title="10% of diagonal, fast",
1101
+ click="trigger('apply_preset', ['coarse'])",
1102
+ )
1103
+
1104
+ def _build_size_parameters_section(self) -> None:
1105
+ """Build size control parameters (hsiz, hmax, hmin, hausd, hgrad, ar)."""
1106
+ with html.Div(classes="d-flex align-center mb-2"):
1107
+ v3.VIcon("mdi-resize", size="small", color="success", classes="mr-2")
1108
+ html.Span("Size Parameters", classes="text-subtitle-2 font-weight-medium")
1109
+ # Uniform size (hsiz) - overrides hmin/hmax
1110
+ v3.VTextField(
1111
+ v_model=("hsiz",),
1112
+ label="hsiz (uniform size)",
1113
+ type="number",
1114
+ min=0.001,
1115
+ step=0.01,
1116
+ density="compact",
1117
+ variant="outlined",
1118
+ hide_details=True,
1119
+ clearable=True,
1120
+ classes="mb-2",
1121
+ title="Uniform edge size. When set, overrides hmin/hmax.",
1122
+ change="trigger('set_custom_preset')",
1123
+ disabled=("selected_options.includes('optim')",),
1124
+ )
1125
+ # Range-based sizing (hmin/hmax)
1126
+ with v3.VRow(dense=True):
1127
+ with v3.VCol(cols=6):
1128
+ v3.VTextField(
1129
+ v_model=("hmax",),
1130
+ label="hmax",
1131
+ type="number",
1132
+ min=0.001,
1133
+ step=0.01,
1134
+ density="compact",
1135
+ variant="outlined",
1136
+ hide_details=True,
1137
+ clearable=True,
1138
+ title="Maximum edge length (default: auto)",
1139
+ change="trigger('set_custom_preset')",
1140
+ disabled=("selected_options.includes('optim') || hsiz",),
1141
+ )
1142
+ with v3.VCol(cols=6):
1143
+ v3.VTextField(
1144
+ v_model=("hmin",),
1145
+ label="hmin",
1146
+ type="number",
1147
+ min=0.001,
1148
+ step=0.01,
1149
+ density="compact",
1150
+ variant="outlined",
1151
+ hide_details=True,
1152
+ clearable=True,
1153
+ title="Minimum edge length (optional, must be > 0)",
1154
+ change="trigger('set_custom_preset')",
1155
+ disabled=("selected_options.includes('optim') || hsiz",),
1156
+ )
1157
+ with v3.VRow(dense=True, classes="mb-2"):
1158
+ with v3.VCol(cols=6):
1159
+ v3.VTextField(
1160
+ v_model=("hausd",),
1161
+ label="hausd",
1162
+ type="number",
1163
+ min=0.0001,
1164
+ step=0.001,
1165
+ density="compact",
1166
+ variant="outlined",
1167
+ hide_details=True,
1168
+ clearable=True,
1169
+ title="Hausdorff distance for boundary accuracy (default: auto)",
1170
+ change="trigger('set_custom_preset')",
1171
+ disabled=("selected_options.includes('optim')",),
1172
+ )
1173
+ with v3.VCol(cols=6):
1174
+ v3.VTextField(
1175
+ v_model=("hgrad",),
1176
+ label="hgrad",
1177
+ type="number",
1178
+ min=1.01,
1179
+ step=0.1,
1180
+ density="compact",
1181
+ variant="outlined",
1182
+ hide_details=True,
1183
+ clearable=True,
1184
+ title="Gradation (size ratio between adjacent elements, default: 1.3)",
1185
+ change="trigger('set_custom_preset')",
1186
+ disabled=("selected_options.includes('optim')",),
1187
+ )
1188
+ with v3.VRow(dense=True, classes="mb-2"):
1189
+ with v3.VCol(cols=6):
1190
+ v3.VTextField(
1191
+ v_model=("ar",),
1192
+ label="ar",
1193
+ type="number",
1194
+ min=0,
1195
+ max=180,
1196
+ step=5,
1197
+ density="compact",
1198
+ variant="outlined",
1199
+ hide_details=True,
1200
+ clearable=True,
1201
+ title="Angle detection threshold in degrees (default: 45)",
1202
+ )
1203
+
1204
+ def _build_advanced_options_section(self) -> None:
1205
+ """Build advanced optimization options (optim, noinsert, noswap, etc.)."""
1206
+ with html.Div(classes="d-flex align-center mb-2"):
1207
+ v3.VIcon("mdi-cog", size="small", color="warning", classes="mr-2")
1208
+ html.Span("Options", classes="text-subtitle-2 font-weight-medium")
1209
+ with v3.VBtnToggle(
1210
+ v_model=("selected_options",),
1211
+ density="compact",
1212
+ multiple=True,
1213
+ divided=True,
1214
+ style="width: 100%;",
1215
+ ):
1216
+ v3.VBtn(
1217
+ value="optim",
1218
+ text="Optimize Only",
1219
+ size="small",
1220
+ style="flex: 1;",
1221
+ title="ONLY optimize quality, don't change mesh size",
1222
+ )
1223
+ v3.VBtn(
1224
+ value="noinsert",
1225
+ text="No Insert",
1226
+ size="small",
1227
+ style="flex: 1;",
1228
+ title="Disable vertex insertion (no refinement)",
1229
+ )
1230
+ with v3.VBtnToggle(
1231
+ v_model=("selected_options",),
1232
+ density="compact",
1233
+ multiple=True,
1234
+ divided=True,
1235
+ style="width: 100%;",
1236
+ ):
1237
+ v3.VBtn(
1238
+ value="noswap",
1239
+ text="No Swap",
1240
+ size="small",
1241
+ style="flex: 1;",
1242
+ title="Disable edge/face swapping",
1243
+ )
1244
+ v3.VBtn(
1245
+ value="nomove",
1246
+ text="No Move",
1247
+ size="small",
1248
+ style="flex: 1;",
1249
+ title="Keep vertices fixed",
1250
+ )
1251
+ with v3.VBtnToggle(
1252
+ v_model=("selected_options",),
1253
+ density="compact",
1254
+ multiple=True,
1255
+ divided=True,
1256
+ style="width: 100%;",
1257
+ v_show="mesh_kind === 'tetrahedral'",
1258
+ ):
1259
+ v3.VBtn(
1260
+ value="nosurf",
1261
+ text="No Surf",
1262
+ size="small",
1263
+ style="flex: 1;",
1264
+ title="Don't modify surface mesh (3D only)",
1265
+ )
1266
+ v3.VBtn(
1267
+ value="nreg",
1268
+ text="Smooth Normals",
1269
+ size="small",
1270
+ style="flex: 1;",
1271
+ title="Enable normal regularization for smoother surfaces",
1272
+ )
1273
+ # Open boundary option for tetrahedral meshes with internal surfaces
1274
+ with v3.VBtnToggle(
1275
+ v_model=("selected_options",),
1276
+ density="compact",
1277
+ multiple=True,
1278
+ divided=True,
1279
+ style="width: 100%;",
1280
+ v_show="mesh_kind === 'tetrahedral' && mesh_stats?.refs?.boundary_count > 1",
1281
+ ):
1282
+ v3.VBtn(
1283
+ value="opnbdy",
1284
+ text="Open Boundary",
1285
+ size="small",
1286
+ style="flex: 1;",
1287
+ title="Preserve internal surfaces between regions with same ref (slower)",
1288
+ )
1289
+ # Show nreg for surface meshes too (without nosurf)
1290
+ with v3.VBtnToggle(
1291
+ v_model=("selected_options",),
1292
+ density="compact",
1293
+ multiple=True,
1294
+ divided=True,
1295
+ style="width: 100%;",
1296
+ v_show="mesh_kind === 'triangular_surface'",
1297
+ ):
1298
+ v3.VBtn(
1299
+ value="nreg",
1300
+ text="Smooth Normals",
1301
+ size="small",
1302
+ style="flex: 1;",
1303
+ title="Enable normal regularization for smoother surfaces",
1304
+ )
1305
+ v3.VAlert(
1306
+ text="Warning: No Insert + No Swap + No Move disables most improvements",
1307
+ type="warning",
1308
+ density="compact",
1309
+ variant="tonal",
1310
+ v_show=(
1311
+ "selected_options.includes('noinsert') && "
1312
+ "selected_options.includes('noswap') && "
1313
+ "selected_options.includes('nomove')"
1314
+ ),
1315
+ classes="mb-2",
1316
+ )
1317
+ # Advanced settings expansion panel
1318
+ with v3.VExpansionPanels(variant="accordion", classes="mb-3"):
1319
+ with v3.VExpansionPanel():
1320
+ v3.VExpansionPanelTitle("Advanced Settings", classes="text-body-2")
1321
+ with v3.VExpansionPanelText():
1322
+ v3.VTextField(
1323
+ v_model=("mem",),
1324
+ label="Memory limit (MB)",
1325
+ type="number",
1326
+ min=1,
1327
+ step=100,
1328
+ density="compact",
1329
+ variant="outlined",
1330
+ hide_details=True,
1331
+ clearable=True,
1332
+ title="Maximum memory usage in MB. Leave empty for automatic.",
1333
+ )
1334
+
1335
+ def _build_mode_specific_options(self) -> None:
1336
+ """Build mode-specific options (levelset formula, lagrangian, source)."""
1337
+ v3.VTextField(
1338
+ v_model=("levelset_formula",),
1339
+ label=(
1340
+ "use_solution_as_levelset ? "
1341
+ "'Using solution file as levelset' : 'Levelset Formula'",
1342
+ ),
1343
+ density="compact",
1344
+ variant="outlined",
1345
+ hide_details=True,
1346
+ classes="mb-2",
1347
+ v_show="remesh_mode === 'levelset'",
1348
+ disabled=("use_solution_as_levelset",),
1349
+ title="Python expression using x, y, z, np (iso-surface extracted at isovalue)",
1350
+ )
1351
+ v3.VTextField(
1352
+ v_model=("levelset_isovalue",),
1353
+ label="Isovalue (ls)",
1354
+ type="number",
1355
+ step=0.1,
1356
+ density="compact",
1357
+ variant="outlined",
1358
+ hide_details=True,
1359
+ classes="mb-3",
1360
+ v_show="remesh_mode === 'levelset'",
1361
+ title="Iso-surface extraction value (default: 0.0)",
1362
+ )
1363
+ v3.VSlider(
1364
+ v_model=("displacement_scale",),
1365
+ label="Displacement Scale",
1366
+ min=0.01,
1367
+ max=1.0,
1368
+ step=0.01,
1369
+ density="compact",
1370
+ hide_details=True,
1371
+ thumb_label=True,
1372
+ classes="mb-3",
1373
+ v_show="remesh_mode === 'lagrangian'",
1374
+ title="Scale factor for vertex displacement",
1375
+ )
1376
+ html.Div("Remesh from", classes="text-caption text-grey mb-1")
1377
+ with v3.VBtnToggle(
1378
+ v_model=("remesh_source",),
1379
+ density="compact",
1380
+ mandatory=True,
1381
+ divided=True,
1382
+ classes="mb-3",
1383
+ ):
1384
+ v3.VBtn(
1385
+ value="original",
1386
+ text="Original",
1387
+ size="small",
1388
+ title="Remesh from original loaded mesh",
1389
+ )
1390
+ v3.VBtn(
1391
+ value="current",
1392
+ text="Current",
1393
+ size="small",
1394
+ title="Remesh from last result (iterative)",
1395
+ )
1396
+
1397
+ def _build_run_section(self) -> None:
1398
+ """Build run button and result alerts."""
1399
+ v3.VBtn(
1400
+ "Run Remesh",
1401
+ click="trigger('run_remesh')",
1402
+ color="primary",
1403
+ block=True,
1404
+ disabled=("!mesh_loaded || is_remeshing",),
1405
+ loading=("is_remeshing",),
1406
+ prepend_icon="mdi-play",
1407
+ title="Execute remeshing",
1408
+ )
1409
+ v3.VAlert(
1410
+ text="Remesh complete!",
1411
+ type="success",
1412
+ density="compact",
1413
+ variant="tonal",
1414
+ v_show="remesh_result && !remesh_result.error",
1415
+ classes="mt-3",
1416
+ )
1417
+ v3.VAlert(
1418
+ text=("`Error: ${remesh_result?.error}`",),
1419
+ type="error",
1420
+ density="compact",
1421
+ variant="tonal",
1422
+ v_show="remesh_result?.error",
1423
+ classes="mt-3",
1424
+ )
1425
+
1426
+ def _build_content(self) -> None:
1427
+ """Build main content area with 3D viewer."""
1428
+ with v3.VContainer(fluid=True, classes="fill-height pa-0"):
1429
+ with v3.VRow(classes="fill-height ma-0"):
1430
+ # Main viewer column
1431
+ with v3.VCol(classes="fill-height pa-0", style="position: relative;"):
1432
+ # Empty state
1433
+ with (
1434
+ v3.VCard(
1435
+ classes="fill-height",
1436
+ variant="flat",
1437
+ v_show="!mesh_loaded",
1438
+ ),
1439
+ v3.VCardText(
1440
+ classes=(
1441
+ "fill-height d-flex flex-column "
1442
+ "align-center justify-center"
1443
+ ),
1444
+ ),
1445
+ ):
1446
+ v3.VIcon(
1447
+ icon="mdi-cube-outline",
1448
+ size="128",
1449
+ color="grey-lighten-1",
1450
+ )
1451
+ html.Span(
1452
+ "Load a mesh to get started",
1453
+ classes="text-h6 text-grey mt-4",
1454
+ )
1455
+ with v3.VRow(classes="mt-4"):
1456
+ v3.VBtn(
1457
+ "Load Sample",
1458
+ click="trigger('load_sample_mesh', ['sphere'])",
1459
+ color="primary",
1460
+ variant="outlined",
1461
+ prepend_icon="mdi-cube-outline",
1462
+ classes="mx-2",
1463
+ )
1464
+
1465
+ # Initialize plotter
1466
+ if self._plotter is None:
1467
+ self._plotter = pv.Plotter()
1468
+ is_dark = self.state.theme_name == "dark"
1469
+ self._plotter.set_background("#1e1e1e" if is_dark else "white")
1470
+ self._plotter.add_mesh(pv.Sphere(), opacity=0.0)
1471
+ text_color = "white" if is_dark else "black"
1472
+ self._plotter.add_axes(color=text_color)
1473
+ self._render_window = self._plotter.ren_win
1474
+
1475
+ # 3D viewer
1476
+ self._view = vtk_widgets.VtkRemoteView(
1477
+ self._render_window,
1478
+ v_show="mesh_loaded",
1479
+ style="width: 100%; height: 100%;",
1480
+ interactive_ratio=1,
1481
+ )
1482
+
1483
+ self.ctrl.view_update = self._view.update
1484
+ self.ctrl.view_reset_camera = self._view.reset_camera
1485
+
1486
+ # Top-right toolbar overlay
1487
+ self._build_viewer_toolbar()
1488
+
1489
+ def _build_viewer_toolbar(self) -> None:
1490
+ """Build the viewer toolbar overlay."""
1491
+ with v3.VCard(
1492
+ classes="position-absolute",
1493
+ style="top: 8px; right: 8px; z-index: 10;",
1494
+ variant="elevated",
1495
+ v_show="mesh_loaded",
1496
+ ):
1497
+ with v3.VToolbar(density="compact", color="surface"):
1498
+ v3.VSelect(
1499
+ v_model=("show_scalar",),
1500
+ items=("scalar_field_options",),
1501
+ density="compact",
1502
+ variant="outlined",
1503
+ hide_details=True,
1504
+ style="min-width: 160px;",
1505
+ title="Color by scalar field",
1506
+ )
1507
+ v3.VBtn(
1508
+ icon=("show_edges ? 'mdi-grid' : 'mdi-grid-off'",),
1509
+ click="show_edges = !show_edges",
1510
+ title="Toggle edges",
1511
+ variant="text",
1512
+ classes="ml-1",
1513
+ )
1514
+ v3.VBtn(
1515
+ icon=("smooth_shading ? 'mdi-blur' : 'mdi-blur-off'",),
1516
+ click="smooth_shading = !smooth_shading",
1517
+ title="Toggle smooth shading",
1518
+ variant="text",
1519
+ )
1520
+ # Opacity menu
1521
+ with v3.VMenu(close_on_content_click=False):
1522
+ with v3.Template(v_slot_activator="{ props }"):
1523
+ v3.VBtn(
1524
+ icon="mdi-opacity",
1525
+ v_bind="props",
1526
+ title="Opacity",
1527
+ variant="text",
1528
+ )
1529
+ with v3.VCard(classes="pa-2", style="width: 150px;"):
1530
+ v3.VSlider(
1531
+ v_model=("opacity",),
1532
+ min=0.1,
1533
+ max=1.0,
1534
+ step=0.1,
1535
+ density="compact",
1536
+ hide_details=True,
1537
+ thumb_label=True,
1538
+ )
1539
+ # Slice control for tetrahedral meshes
1540
+ with v3.VMenu(
1541
+ v_show="mesh_kind === 'tetrahedral'",
1542
+ close_on_content_click=False,
1543
+ ):
1544
+ with v3.Template(v_slot_activator="{ props }"):
1545
+ v3.VBtn(
1546
+ icon=(
1547
+ "slice_enabled ? 'mdi-box-cutter' : 'mdi-cube-scan'",
1548
+ ),
1549
+ v_bind="props",
1550
+ title="Slice view (see inside tetrahedra)",
1551
+ variant="text",
1552
+ )
1553
+ with v3.VCard(classes="pa-3", style="width: 200px;"):
1554
+ v3.VSwitch(
1555
+ v_model=("slice_enabled",),
1556
+ label="Enable slice",
1557
+ density="compact",
1558
+ hide_details=True,
1559
+ classes="mb-2",
1560
+ )
1561
+ html.Span(
1562
+ "Axis",
1563
+ classes="text-caption text-grey mb-1",
1564
+ )
1565
+ with v3.VBtnToggle(
1566
+ v_model=("slice_axis",),
1567
+ density="compact",
1568
+ mandatory=True,
1569
+ divided=True,
1570
+ classes="mb-3",
1571
+ disabled=("!slice_enabled",),
1572
+ ):
1573
+ v3.VBtn(value=0, text="X", size="small")
1574
+ v3.VBtn(value=1, text="Y", size="small")
1575
+ v3.VBtn(value=2, text="Z", size="small")
1576
+ v3.VSlider(
1577
+ v_model=("slice_threshold",),
1578
+ label="Position",
1579
+ min=0.0,
1580
+ max=1.0,
1581
+ step=0.01,
1582
+ density="compact",
1583
+ hide_details=True,
1584
+ thumb_label=True,
1585
+ disabled=("!slice_enabled",),
1586
+ )
1587
+ # View controls menu
1588
+ with v3.VMenu(close_on_content_click=False):
1589
+ with v3.Template(v_slot_activator="{ props }"):
1590
+ v3.VBtn(
1591
+ icon="mdi-video-3d",
1592
+ v_bind="props",
1593
+ title="Camera views",
1594
+ variant="text",
1595
+ )
1596
+ with v3.VCard(
1597
+ classes="pa-3",
1598
+ style="min-width: 220px;",
1599
+ ):
1600
+ html.Span(
1601
+ "View",
1602
+ classes="text-caption text-grey mb-1",
1603
+ )
1604
+ with v3.VBtnToggle(
1605
+ v_model=("current_view",),
1606
+ density="compact",
1607
+ mandatory=True,
1608
+ divided=True,
1609
+ classes="mb-2",
1610
+ style="width: 100%;",
1611
+ ):
1612
+ v3.VBtn(
1613
+ value="xy",
1614
+ text="+Z",
1615
+ size="small",
1616
+ title="Top (XY plane)",
1617
+ click="trigger('set_view', ['xy'])",
1618
+ )
1619
+ v3.VBtn(
1620
+ value="-xy",
1621
+ text="-Z",
1622
+ size="small",
1623
+ title="Bottom (XY plane)",
1624
+ click="trigger('set_view', ['-xy'])",
1625
+ )
1626
+ v3.VBtn(
1627
+ value="xz",
1628
+ text="+Y",
1629
+ size="small",
1630
+ title="Front (XZ plane)",
1631
+ click="trigger('set_view', ['xz'])",
1632
+ )
1633
+ v3.VBtn(
1634
+ value="-xz",
1635
+ text="-Y",
1636
+ size="small",
1637
+ title="Back (XZ plane)",
1638
+ click="trigger('set_view', ['-xz'])",
1639
+ )
1640
+ with v3.VBtnToggle(
1641
+ v_model=("current_view",),
1642
+ density="compact",
1643
+ mandatory=True,
1644
+ divided=True,
1645
+ classes="mb-3",
1646
+ style="width: 100%;",
1647
+ ):
1648
+ v3.VBtn(
1649
+ value="yz",
1650
+ text="+X",
1651
+ size="small",
1652
+ title="Right (YZ plane)",
1653
+ click="trigger('set_view', ['yz'])",
1654
+ )
1655
+ v3.VBtn(
1656
+ value="-yz",
1657
+ text="-X",
1658
+ size="small",
1659
+ title="Left (YZ plane)",
1660
+ click="trigger('set_view', ['-yz'])",
1661
+ )
1662
+ v3.VBtn(
1663
+ value="isometric",
1664
+ text="ISO",
1665
+ size="small",
1666
+ title="Isometric view",
1667
+ click="trigger('set_view', ['isometric'])",
1668
+ )
1669
+ v3.VDivider(classes="mb-3")
1670
+ v3.VSwitch(
1671
+ v_model=("parallel_projection",),
1672
+ label="Parallel projection",
1673
+ density="compact",
1674
+ hide_details=True,
1675
+ click="trigger('toggle_parallel_projection')",
1676
+ )
1677
+ v3.VBtn(
1678
+ icon="mdi-information-outline",
1679
+ click="info_panel_open = !info_panel_open",
1680
+ title="Toggle info panel",
1681
+ variant="text",
1682
+ )
1683
+
1684
+ def _build_info_panel(self) -> None:
1685
+ """Build the right-side mesh info panel (inside drawer)."""
1686
+ with v3.VCard(
1687
+ classes="fill-height overflow-auto",
1688
+ variant="flat",
1689
+ ):
1690
+ v3.VCardTitle("Mesh Info", classes="text-subtitle-1 py-2")
1691
+ with v3.VCardText(classes="pa-2"):
1692
+ # Geometry section
1693
+ with v3.VList(density="compact"):
1694
+ v3.VListSubheader("Geometry")
1695
+ v3.VListItem(
1696
+ title="Type",
1697
+ subtitle=(
1698
+ "`${mesh_stats?.kind || '-'} (${mesh_stats?.mmg_module || '-'})`",
1699
+ ),
1700
+ )
1701
+ v3.VListItem(
1702
+ title="Vertices",
1703
+ subtitle=(
1704
+ "`${mesh_stats?.vertices?.toLocaleString() || '-'}`",
1705
+ ),
1706
+ )
1707
+ v3.VListItem(
1708
+ title="Elements",
1709
+ subtitle=(
1710
+ "`${mesh_stats?.elements?.toLocaleString() || '-'} "
1711
+ "${mesh_stats?.element_type || ''}`",
1712
+ ),
1713
+ )
1714
+
1715
+ v3.VDivider(classes="my-1")
1716
+
1717
+ # Bounding Box section
1718
+ with v3.VList(density="compact"):
1719
+ v3.VListSubheader("Bounding Box")
1720
+ v3.VListItem(
1721
+ title="Min",
1722
+ subtitle=(
1723
+ "`[${mesh_stats?.bounds?.min?.map(v => v.toFixed(3)).join(', ') || '-'}]`",
1724
+ ),
1725
+ )
1726
+ v3.VListItem(
1727
+ title="Max",
1728
+ subtitle=(
1729
+ "`[${mesh_stats?.bounds?.max?.map(v => v.toFixed(3)).join(', ') || '-'}]`",
1730
+ ),
1731
+ )
1732
+ v3.VListItem(
1733
+ title="Size",
1734
+ subtitle=(
1735
+ "`${mesh_stats?.size?.map(v => v.toFixed(3)).join(' × ') || '-'}`",
1736
+ ),
1737
+ )
1738
+
1739
+ v3.VDivider(classes="my-1")
1740
+
1741
+ # Quality section
1742
+ with v3.VList(density="compact"):
1743
+ v3.VListSubheader("Element Quality (In-Radius Ratio)")
1744
+ v3.VListItem(
1745
+ title="Min / Max",
1746
+ subtitle=(
1747
+ "`${mesh_stats?.quality?.min?.toFixed(4) || '-'} / "
1748
+ "${mesh_stats?.quality?.max?.toFixed(4) || '-'}`",
1749
+ ),
1750
+ )
1751
+ v3.VListItem(
1752
+ title="Mean ± Std",
1753
+ subtitle=(
1754
+ "`${mesh_stats?.quality?.mean?.toFixed(4) || '-'} ± "
1755
+ "${mesh_stats?.quality?.std?.toFixed(4) || '-'}`",
1756
+ ),
1757
+ )
1758
+
1759
+ v3.VDivider(classes="my-1")
1760
+
1761
+ # Edge Length section (for sizing hints)
1762
+ with v3.VList(density="compact"):
1763
+ v3.VListSubheader("Edge Length (for hmin/hmax/hsiz)")
1764
+ v3.VListItem(
1765
+ title="Min / Max",
1766
+ subtitle=(
1767
+ "`${mesh_stats?.edge_length?.min?.toFixed(4) || '-'} / "
1768
+ "${mesh_stats?.edge_length?.max?.toFixed(4) || '-'}`",
1769
+ ),
1770
+ )
1771
+ v3.VListItem(
1772
+ title="Mean / Median",
1773
+ subtitle=(
1774
+ "`${mesh_stats?.edge_length?.mean?.toFixed(4) || '-'} / "
1775
+ "${mesh_stats?.edge_length?.median?.toFixed(4) || '-'}`",
1776
+ ),
1777
+ )
1778
+
1779
+ # Refs section (shown if mesh has refs)
1780
+ with v3.VList(
1781
+ density="compact",
1782
+ v_show="mesh_stats?.refs",
1783
+ ):
1784
+ v3.VDivider(classes="my-1")
1785
+ v3.VListSubheader("References (Material/Boundary IDs)")
1786
+ v3.VListItem(
1787
+ title="Element Refs",
1788
+ subtitle=(
1789
+ "`${mesh_stats?.refs?.element_count || 0} region(s): "
1790
+ "${mesh_stats?.refs?.element_refs?.join(', ') || '-'}`",
1791
+ ),
1792
+ )
1793
+ # Show boundary refs for tetrahedral meshes
1794
+ v3.VListItem(
1795
+ v_show="mesh_stats?.refs?.boundary_refs",
1796
+ title="Boundary Refs",
1797
+ subtitle=(
1798
+ "`${mesh_stats?.refs?.boundary_count || 0} region(s): "
1799
+ "${mesh_stats?.refs?.boundary_refs?.join(', ') || '-'}`",
1800
+ ),
1801
+ )
1802
+
1803
+ # Remesh result section
1804
+ with v3.VCard(
1805
+ variant="tonal",
1806
+ color="success",
1807
+ classes="mt-3",
1808
+ v_show="remesh_result && !remesh_result.error",
1809
+ ):
1810
+ v3.VCardTitle("Remesh Result", classes="text-subtitle-2 py-2")
1811
+ with v3.VCardText(classes="pa-2"):
1812
+ with v3.VList(density="compact", bg_color="transparent"):
1813
+ v3.VListItem(
1814
+ title="Vertices",
1815
+ subtitle=(
1816
+ "`${remesh_result?.vertices_before} → "
1817
+ "${remesh_result?.vertices_after}`",
1818
+ ),
1819
+ )
1820
+ v3.VListItem(
1821
+ title="Elements",
1822
+ subtitle=(
1823
+ "`${remesh_result?.elements_before} → "
1824
+ "${remesh_result?.elements_after}`",
1825
+ ),
1826
+ )
1827
+ v3.VListItem(
1828
+ title="Quality (In-Radius Ratio)",
1829
+ subtitle=(
1830
+ "`${remesh_result?.quality_before} → "
1831
+ "${remesh_result?.quality_after}`",
1832
+ ),
1833
+ )
1834
+ v3.VListItem(
1835
+ title="Duration",
1836
+ subtitle=("`${remesh_result?.duration}`",),
1837
+ )