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/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
|
+
)
|