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
@@ -0,0 +1,790 @@
1
+ """Interactive sizing editor using PyVista.
2
+
3
+ This module provides a visual interface for defining mesh sizing constraints
4
+ by clicking on the mesh to place refinement regions.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from enum import Enum, auto
11
+ from typing import TYPE_CHECKING
12
+
13
+ import numpy as np
14
+ import pyvista as pv
15
+
16
+ from mmgpy.sizing import BoxSize, CylinderSize, PointSize, SizingConstraint, SphereSize
17
+
18
+ if TYPE_CHECKING:
19
+ from numpy.typing import NDArray
20
+
21
+ from mmgpy import Mesh
22
+ from mmgpy._mmgpy import MmgMesh2D, MmgMesh3D, MmgMeshS
23
+
24
+
25
+ class ConstraintMode(Enum):
26
+ """Interaction mode for constraint placement."""
27
+
28
+ NONE = auto()
29
+ SPHERE = auto()
30
+ BOX = auto()
31
+ CYLINDER = auto()
32
+ POINT = auto()
33
+
34
+
35
+ @dataclass
36
+ class ConstraintVisual:
37
+ """Visual representation of a sizing constraint."""
38
+
39
+ constraint: SizingConstraint
40
+ actor: pv.Actor
41
+ label_actor: pv.Actor | None = None
42
+
43
+
44
+ @dataclass
45
+ class EditorState:
46
+ """State for multi-click constraint creation."""
47
+
48
+ mode: ConstraintMode = ConstraintMode.NONE
49
+ first_point: NDArray[np.float64] | None = None
50
+ preview_actor: pv.Actor | None = None
51
+
52
+
53
+ _DEFAULT_SIZE = 0.01
54
+ _DEFAULT_RADIUS = 0.1
55
+ _DEFAULT_NEAR_SIZE = 0.01
56
+ _DEFAULT_FAR_SIZE = 0.1
57
+ _DEFAULT_INFLUENCE_RADIUS = 0.5
58
+
59
+ _SPHERE_COLOR = "red"
60
+ _BOX_COLOR = "blue"
61
+ _CYLINDER_COLOR = "green"
62
+ _POINT_COLOR = "orange"
63
+ _CONSTRAINT_OPACITY = 0.3
64
+ _PREVIEW_OPACITY = 0.5
65
+
66
+ _MIN_SIZE = 0.001
67
+ _MAX_SIZE = 1.0
68
+ _MIN_RADIUS = 0.01
69
+ _MAX_RADIUS = 10.0
70
+
71
+ _DIMS_2D = 2
72
+ _DIMS_3D = 3
73
+
74
+
75
+ class SizingEditor:
76
+ """Interactive editor for mesh sizing constraints.
77
+
78
+ This class provides a PyVista-based GUI for visually defining local
79
+ mesh sizing constraints by clicking on the mesh.
80
+
81
+ Parameters
82
+ ----------
83
+ mesh : Mesh | MmgMesh3D | MmgMesh2D | MmgMeshS
84
+ The mesh to edit sizing for.
85
+
86
+ Attributes
87
+ ----------
88
+ constraints : list[SizingConstraint]
89
+ List of sizing constraints defined through interaction.
90
+
91
+ Examples
92
+ --------
93
+ >>> from mmgpy import Mesh
94
+ >>> from mmgpy.interactive import SizingEditor
95
+ >>>
96
+ >>> mesh = Mesh("model.mesh")
97
+ >>> editor = SizingEditor(mesh)
98
+ >>> editor.add_sphere_tool()
99
+ >>> editor.run()
100
+ >>>
101
+ >>> # Get and apply constraints
102
+ >>> for c in editor.get_constraints():
103
+ ... print(c)
104
+ >>> editor.apply_to_mesh()
105
+
106
+ """
107
+
108
+ def __init__(
109
+ self,
110
+ mesh: Mesh | MmgMesh3D | MmgMesh2D | MmgMeshS,
111
+ ) -> None:
112
+ """Initialize the sizing editor.
113
+
114
+ Parameters
115
+ ----------
116
+ mesh : Mesh | MmgMesh3D | MmgMesh2D | MmgMeshS
117
+ The mesh to edit sizing for.
118
+
119
+ """
120
+ # Wrap raw MmgMesh* objects in a Mesh wrapper
121
+ from mmgpy import Mesh as MeshClass
122
+ from mmgpy import MeshKind
123
+ from mmgpy._mmgpy import MmgMesh2D, MmgMesh3D, MmgMeshS
124
+
125
+ if isinstance(mesh, MmgMesh3D):
126
+ self._mesh: Mesh = MeshClass._from_impl(mesh, MeshKind.TETRAHEDRAL) # noqa: SLF001
127
+ elif isinstance(mesh, MmgMesh2D):
128
+ self._mesh = MeshClass._from_impl(mesh, MeshKind.TRIANGULAR_2D) # noqa: SLF001
129
+ elif isinstance(mesh, MmgMeshS):
130
+ self._mesh = MeshClass._from_impl(mesh, MeshKind.TRIANGULAR_SURFACE) # noqa: SLF001
131
+ else:
132
+ self._mesh = mesh
133
+ self._pv_mesh: pv.PolyData | pv.UnstructuredGrid | None = None
134
+ self._plotter: pv.Plotter | None = None
135
+
136
+ self._constraints: list[SizingConstraint] = []
137
+ self._visuals: list[ConstraintVisual] = []
138
+ self._state = EditorState()
139
+
140
+ self._current_size = _DEFAULT_SIZE
141
+ self._current_radius = _DEFAULT_RADIUS
142
+ self._current_near_size = _DEFAULT_NEAR_SIZE
143
+ self._current_far_size = _DEFAULT_FAR_SIZE
144
+ self._current_influence_radius = _DEFAULT_INFLUENCE_RADIUS
145
+
146
+ self._is_3d = self._detect_mesh_dimension()
147
+
148
+ def _detect_mesh_dimension(self) -> bool:
149
+ """Detect if mesh is 3D (vs 2D planar)."""
150
+ from mmgpy import MeshKind
151
+
152
+ return self._mesh.kind != MeshKind.TRIANGULAR_2D
153
+
154
+ def _get_pyvista_mesh(self) -> pv.PolyData | pv.UnstructuredGrid:
155
+ """Get PyVista representation of the mesh."""
156
+ if self._pv_mesh is None:
157
+ self._pv_mesh = self._mesh.to_pyvista()
158
+ return self._pv_mesh
159
+
160
+ def add_sphere_tool(self) -> SizingEditor:
161
+ """Enable sphere constraint placement mode.
162
+
163
+ In sphere mode, click on the mesh to place the center of a
164
+ spherical refinement region. Use the radius slider to adjust
165
+ the sphere size.
166
+
167
+ Returns
168
+ -------
169
+ SizingEditor
170
+ Self for method chaining.
171
+
172
+ """
173
+ self._state.mode = ConstraintMode.SPHERE
174
+ return self
175
+
176
+ def add_box_tool(self) -> SizingEditor:
177
+ """Enable box constraint placement mode.
178
+
179
+ In box mode, click twice on the mesh to define two opposite
180
+ corners of a box refinement region.
181
+
182
+ Returns
183
+ -------
184
+ SizingEditor
185
+ Self for method chaining.
186
+
187
+ """
188
+ self._state.mode = ConstraintMode.BOX
189
+ return self
190
+
191
+ def add_cylinder_tool(self) -> SizingEditor:
192
+ """Enable cylinder constraint placement mode.
193
+
194
+ In cylinder mode, click twice on the mesh to define the axis
195
+ of a cylindrical refinement region. Use the radius slider to
196
+ adjust the cylinder radius.
197
+
198
+ Returns
199
+ -------
200
+ SizingEditor
201
+ Self for method chaining.
202
+
203
+ Raises
204
+ ------
205
+ TypeError
206
+ If the mesh is 2D (cylinder not supported for 2D meshes).
207
+
208
+ """
209
+ if not self._is_3d:
210
+ msg = "Cylinder tool is not available for 2D meshes"
211
+ raise TypeError(msg)
212
+ self._state.mode = ConstraintMode.CYLINDER
213
+ return self
214
+
215
+ def add_point_tool(self) -> SizingEditor:
216
+ """Enable point-based sizing mode.
217
+
218
+ In point mode, click on the mesh to place a reference point
219
+ for distance-based sizing. Size varies linearly from near_size
220
+ at the point to far_size at influence_radius distance.
221
+
222
+ Returns
223
+ -------
224
+ SizingEditor
225
+ Self for method chaining.
226
+
227
+ """
228
+ self._state.mode = ConstraintMode.POINT
229
+ return self
230
+
231
+ def run(self) -> None:
232
+ """Launch the interactive editor window.
233
+
234
+ Opens a PyVista plotter window with the mesh and interactive
235
+ controls for placing sizing constraints.
236
+
237
+ """
238
+ self._plotter = pv.Plotter(title="mmgpy Sizing Editor")
239
+
240
+ pv_mesh = self._get_pyvista_mesh()
241
+ self._plotter.add_mesh(
242
+ pv_mesh,
243
+ show_edges=True,
244
+ pickable=True,
245
+ name="mesh",
246
+ )
247
+
248
+ self._setup_picking()
249
+ self._setup_sliders()
250
+ self._setup_buttons()
251
+ self._setup_help_panel()
252
+ self._setup_instructions()
253
+
254
+ self._plotter.show()
255
+
256
+ def _setup_help_panel(self) -> None:
257
+ """Set up help text panel."""
258
+ if self._plotter is None:
259
+ return
260
+
261
+ help_text = (
262
+ "HOW TO USE:\n"
263
+ "1. Select a tool (colored buttons, bottom-left)\n"
264
+ "2. Adjust sliders (bottom) for size/radius\n"
265
+ "3. Click on mesh to place constraints\n"
266
+ "4. Close window when done\n"
267
+ "\n"
268
+ "TOOLS:\n"
269
+ " Sphere: Click once for center\n"
270
+ " Box: Click twice for corners\n"
271
+ " Cylinder: Click twice for axis\n"
272
+ " Point: Click for gradual refinement"
273
+ )
274
+ self._plotter.add_text(
275
+ help_text,
276
+ position="upper_left",
277
+ font_size=9,
278
+ name="help_panel",
279
+ )
280
+
281
+ def _setup_picking(self) -> None:
282
+ """Set up point picking callback."""
283
+ if self._plotter is None:
284
+ return
285
+
286
+ self._plotter.enable_point_picking(
287
+ callback=self._on_point_picked,
288
+ show_message=False,
289
+ pickable_window=False,
290
+ left_clicking=True,
291
+ show_point=True,
292
+ point_size=10,
293
+ )
294
+
295
+ def _setup_sliders(self) -> None:
296
+ """Set up slider widgets for parameters."""
297
+ if self._plotter is None:
298
+ return
299
+
300
+ self._plotter.add_slider_widget(
301
+ callback=self._on_size_changed,
302
+ rng=[_MIN_SIZE, _MAX_SIZE],
303
+ value=self._current_size,
304
+ title="Target Size",
305
+ pointa=(0.025, 0.1),
306
+ pointb=(0.31, 0.1),
307
+ style="modern",
308
+ )
309
+
310
+ self._plotter.add_slider_widget(
311
+ callback=self._on_radius_changed,
312
+ rng=[_MIN_RADIUS, _MAX_RADIUS],
313
+ value=self._current_radius,
314
+ title="Radius",
315
+ pointa=(0.35, 0.1),
316
+ pointb=(0.64, 0.1),
317
+ style="modern",
318
+ )
319
+
320
+ self._plotter.add_slider_widget(
321
+ callback=self._on_influence_changed,
322
+ rng=[_MIN_RADIUS, _MAX_RADIUS],
323
+ value=self._current_influence_radius,
324
+ title="Influence Radius",
325
+ pointa=(0.67, 0.1),
326
+ pointb=(0.98, 0.1),
327
+ style="modern",
328
+ )
329
+
330
+ def _setup_buttons(self) -> None:
331
+ """Set up mode selection buttons."""
332
+ if self._plotter is None:
333
+ return
334
+
335
+ button_size = 30
336
+ x_start = 10
337
+ y_start = 10
338
+ spacing = 40
339
+
340
+ self._plotter.add_checkbox_button_widget(
341
+ callback=lambda state: self._set_mode(
342
+ ConstraintMode.SPHERE if state else ConstraintMode.NONE,
343
+ ),
344
+ value=self._state.mode == ConstraintMode.SPHERE,
345
+ position=(x_start, y_start + 3 * spacing),
346
+ size=button_size,
347
+ color_on=_SPHERE_COLOR,
348
+ color_off="white",
349
+ )
350
+ self._plotter.add_text(
351
+ "Sphere",
352
+ position=(x_start + button_size + 5, y_start + 3 * spacing + 5),
353
+ font_size=10,
354
+ name="sphere_label",
355
+ )
356
+
357
+ self._plotter.add_checkbox_button_widget(
358
+ callback=lambda state: self._set_mode(
359
+ ConstraintMode.BOX if state else ConstraintMode.NONE,
360
+ ),
361
+ value=self._state.mode == ConstraintMode.BOX,
362
+ position=(x_start, y_start + 2 * spacing),
363
+ size=button_size,
364
+ color_on=_BOX_COLOR,
365
+ color_off="white",
366
+ )
367
+ self._plotter.add_text(
368
+ "Box",
369
+ position=(x_start + button_size + 5, y_start + 2 * spacing + 5),
370
+ font_size=10,
371
+ name="box_label",
372
+ )
373
+
374
+ if self._is_3d:
375
+ self._plotter.add_checkbox_button_widget(
376
+ callback=lambda state: self._set_mode(
377
+ ConstraintMode.CYLINDER if state else ConstraintMode.NONE,
378
+ ),
379
+ value=self._state.mode == ConstraintMode.CYLINDER,
380
+ position=(x_start, y_start + spacing),
381
+ size=button_size,
382
+ color_on=_CYLINDER_COLOR,
383
+ color_off="white",
384
+ )
385
+ self._plotter.add_text(
386
+ "Cylinder",
387
+ position=(x_start + button_size + 5, y_start + spacing + 5),
388
+ font_size=10,
389
+ name="cylinder_label",
390
+ )
391
+
392
+ self._plotter.add_checkbox_button_widget(
393
+ callback=lambda state: self._set_mode(
394
+ ConstraintMode.POINT if state else ConstraintMode.NONE,
395
+ ),
396
+ value=self._state.mode == ConstraintMode.POINT,
397
+ position=(x_start, y_start),
398
+ size=button_size,
399
+ color_on=_POINT_COLOR,
400
+ color_off="white",
401
+ )
402
+ self._plotter.add_text(
403
+ "Point",
404
+ position=(x_start + button_size + 5, y_start + 5),
405
+ font_size=10,
406
+ name="point_label",
407
+ )
408
+
409
+ def _setup_instructions(self) -> None:
410
+ """Set up instruction text."""
411
+ if self._plotter is None:
412
+ return
413
+
414
+ self._update_instructions()
415
+
416
+ def _get_status_text(self) -> str:
417
+ """Get current status text including mode and constraint count."""
418
+ mode_names = {
419
+ ConstraintMode.NONE: "None",
420
+ ConstraintMode.SPHERE: "SPHERE",
421
+ ConstraintMode.BOX: "BOX",
422
+ ConstraintMode.CYLINDER: "CYLINDER",
423
+ ConstraintMode.POINT: "POINT",
424
+ }
425
+ mode_instructions = {
426
+ ConstraintMode.NONE: "Select a tool below",
427
+ ConstraintMode.SPHERE: "Click mesh to place sphere",
428
+ ConstraintMode.BOX: (
429
+ "Click mesh for second corner"
430
+ if self._state.first_point is not None
431
+ else "Click mesh for first corner"
432
+ ),
433
+ ConstraintMode.CYLINDER: (
434
+ "Click mesh for second axis point"
435
+ if self._state.first_point is not None
436
+ else "Click mesh for first axis point"
437
+ ),
438
+ ConstraintMode.POINT: "Click mesh to place point",
439
+ }
440
+
441
+ mode = mode_names.get(self._state.mode, "")
442
+ instruction = mode_instructions.get(self._state.mode, "")
443
+ count = len(self._constraints)
444
+
445
+ return f"Mode: {mode}\n{instruction}\n\nConstraints: {count}"
446
+
447
+ def _set_mode(self, mode: ConstraintMode) -> None:
448
+ """Set the current interaction mode."""
449
+ self._state.mode = mode
450
+ self._state.first_point = None
451
+ self._clear_preview()
452
+ self._update_instructions()
453
+
454
+ def _update_instructions(self) -> None:
455
+ """Update instruction text based on current mode and state."""
456
+ if self._plotter is None:
457
+ return
458
+
459
+ self._plotter.add_text(
460
+ self._get_status_text(),
461
+ position="upper_right",
462
+ font_size=11,
463
+ name="instructions",
464
+ )
465
+
466
+ def _on_size_changed(self, value: float) -> None:
467
+ """Handle size slider change."""
468
+ self._current_size = value
469
+ self._current_near_size = value
470
+ self._current_far_size = value * 10
471
+
472
+ def _on_radius_changed(self, value: float) -> None:
473
+ """Handle radius slider change."""
474
+ self._current_radius = value
475
+ self._update_preview()
476
+
477
+ def _on_influence_changed(self, value: float) -> None:
478
+ """Handle influence radius slider change."""
479
+ self._current_influence_radius = value
480
+
481
+ def _on_point_picked(self, point: NDArray[np.float64]) -> None:
482
+ """Handle point picking callback."""
483
+ if self._state.mode == ConstraintMode.NONE:
484
+ return
485
+
486
+ point = np.asarray(point, dtype=np.float64)
487
+
488
+ if self._state.mode == ConstraintMode.SPHERE:
489
+ self._add_sphere_constraint(point)
490
+ elif self._state.mode == ConstraintMode.BOX:
491
+ self._handle_box_click(point)
492
+ elif self._state.mode == ConstraintMode.CYLINDER:
493
+ self._handle_cylinder_click(point)
494
+ elif self._state.mode == ConstraintMode.POINT:
495
+ self._add_point_constraint(point)
496
+
497
+ def _add_sphere_constraint(self, center: NDArray[np.float64]) -> None:
498
+ """Add a sphere constraint at the given center."""
499
+ constraint = SphereSize(
500
+ center=center,
501
+ radius=self._current_radius,
502
+ size=self._current_size,
503
+ )
504
+ self._constraints.append(constraint)
505
+ self._visualize_constraint(constraint)
506
+ self._update_instructions()
507
+
508
+ def _handle_box_click(self, point: NDArray[np.float64]) -> None:
509
+ """Handle click in box mode."""
510
+ if self._state.first_point is None:
511
+ self._state.first_point = point
512
+ self._show_preview_point(point, _BOX_COLOR)
513
+ self._update_instructions()
514
+ else:
515
+ self._add_box_constraint(self._state.first_point, point)
516
+ self._state.first_point = None
517
+ self._clear_preview()
518
+ self._update_instructions()
519
+
520
+ def _add_box_constraint(
521
+ self,
522
+ point1: NDArray[np.float64],
523
+ point2: NDArray[np.float64],
524
+ ) -> None:
525
+ """Add a box constraint between two points."""
526
+ min_corner = np.minimum(point1, point2)
527
+ max_corner = np.maximum(point1, point2)
528
+
529
+ if not self._is_3d:
530
+ min_corner = min_corner[:2]
531
+ max_corner = max_corner[:2]
532
+
533
+ constraint = BoxSize(
534
+ bounds=np.array([min_corner, max_corner]),
535
+ size=self._current_size,
536
+ )
537
+ self._constraints.append(constraint)
538
+ self._visualize_constraint(constraint)
539
+
540
+ def _handle_cylinder_click(self, point: NDArray[np.float64]) -> None:
541
+ """Handle click in cylinder mode."""
542
+ if self._state.first_point is None:
543
+ self._state.first_point = point
544
+ self._show_preview_point(point, _CYLINDER_COLOR)
545
+ self._update_instructions()
546
+ else:
547
+ self._add_cylinder_constraint(self._state.first_point, point)
548
+ self._state.first_point = None
549
+ self._clear_preview()
550
+ self._update_instructions()
551
+
552
+ def _add_cylinder_constraint(
553
+ self,
554
+ point1: NDArray[np.float64],
555
+ point2: NDArray[np.float64],
556
+ ) -> None:
557
+ """Add a cylinder constraint between two axis points."""
558
+ constraint = CylinderSize(
559
+ point1=point1,
560
+ point2=point2,
561
+ radius=self._current_radius,
562
+ size=self._current_size,
563
+ )
564
+ self._constraints.append(constraint)
565
+ self._visualize_constraint(constraint)
566
+
567
+ def _add_point_constraint(self, point: NDArray[np.float64]) -> None:
568
+ """Add a point-based sizing constraint."""
569
+ if not self._is_3d:
570
+ point = point[:2]
571
+
572
+ constraint = PointSize(
573
+ point=point,
574
+ near_size=self._current_near_size,
575
+ far_size=self._current_far_size,
576
+ influence_radius=self._current_influence_radius,
577
+ )
578
+ self._constraints.append(constraint)
579
+ self._visualize_constraint(constraint)
580
+ self._update_instructions()
581
+
582
+ def _visualize_constraint(self, constraint: SizingConstraint) -> None:
583
+ """Add visual representation of a constraint."""
584
+ if self._plotter is None:
585
+ return
586
+
587
+ actor: pv.Actor | None = None
588
+
589
+ if isinstance(constraint, SphereSize):
590
+ sphere = pv.Sphere(
591
+ center=constraint.center,
592
+ radius=constraint.radius,
593
+ )
594
+ actor = self._plotter.add_mesh(
595
+ sphere,
596
+ color=_SPHERE_COLOR,
597
+ opacity=_CONSTRAINT_OPACITY,
598
+ name=f"constraint_{len(self._visuals)}",
599
+ )
600
+
601
+ elif isinstance(constraint, BoxSize):
602
+ bounds = constraint.bounds
603
+ if len(bounds[0]) == _DIMS_2D:
604
+ box = pv.Rectangle(
605
+ [
606
+ [bounds[0][0], bounds[0][1], 0],
607
+ [bounds[1][0], bounds[0][1], 0],
608
+ [bounds[1][0], bounds[1][1], 0],
609
+ [bounds[0][0], bounds[1][1], 0],
610
+ ],
611
+ )
612
+ else:
613
+ box = pv.Box(
614
+ bounds=[
615
+ bounds[0][0],
616
+ bounds[1][0],
617
+ bounds[0][1],
618
+ bounds[1][1],
619
+ bounds[0][2],
620
+ bounds[1][2],
621
+ ],
622
+ )
623
+ actor = self._plotter.add_mesh(
624
+ box,
625
+ color=_BOX_COLOR,
626
+ opacity=_CONSTRAINT_OPACITY,
627
+ name=f"constraint_{len(self._visuals)}",
628
+ )
629
+
630
+ elif isinstance(constraint, CylinderSize):
631
+ direction = constraint.point2 - constraint.point1
632
+ height = np.linalg.norm(direction)
633
+ center = (constraint.point1 + constraint.point2) / 2
634
+ cylinder = pv.Cylinder(
635
+ center=center,
636
+ direction=direction,
637
+ radius=constraint.radius,
638
+ height=height,
639
+ )
640
+ actor = self._plotter.add_mesh(
641
+ cylinder,
642
+ color=_CYLINDER_COLOR,
643
+ opacity=_CONSTRAINT_OPACITY,
644
+ name=f"constraint_{len(self._visuals)}",
645
+ )
646
+
647
+ elif isinstance(constraint, PointSize):
648
+ point_3d = (
649
+ constraint.point
650
+ if len(constraint.point) == _DIMS_3D
651
+ else np.array([constraint.point[0], constraint.point[1], 0.0])
652
+ )
653
+ sphere = pv.Sphere(
654
+ center=point_3d,
655
+ radius=constraint.influence_radius,
656
+ )
657
+ actor = self._plotter.add_mesh(
658
+ sphere,
659
+ color=_POINT_COLOR,
660
+ opacity=_CONSTRAINT_OPACITY * 0.5,
661
+ name=f"constraint_{len(self._visuals)}",
662
+ )
663
+
664
+ inner_sphere = pv.Sphere(
665
+ center=point_3d,
666
+ radius=constraint.influence_radius * 0.1,
667
+ )
668
+ self._plotter.add_mesh(
669
+ inner_sphere,
670
+ color=_POINT_COLOR,
671
+ opacity=_CONSTRAINT_OPACITY,
672
+ name=f"constraint_{len(self._visuals)}_inner",
673
+ )
674
+
675
+ if actor is not None:
676
+ self._visuals.append(ConstraintVisual(constraint=constraint, actor=actor))
677
+
678
+ def _show_preview_point(
679
+ self,
680
+ point: NDArray[np.float64],
681
+ color: str,
682
+ ) -> None:
683
+ """Show preview marker at a point."""
684
+ if self._plotter is None:
685
+ return
686
+
687
+ self._clear_preview()
688
+ sphere = pv.Sphere(center=point, radius=self._current_radius * 0.2)
689
+ self._state.preview_actor = self._plotter.add_mesh(
690
+ sphere,
691
+ color=color,
692
+ opacity=_PREVIEW_OPACITY,
693
+ name="preview",
694
+ )
695
+
696
+ def _update_preview(self) -> None:
697
+ """Update preview based on current state."""
698
+ if self._state.first_point is None or self._plotter is None:
699
+ return
700
+
701
+ color = (
702
+ _BOX_COLOR if self._state.mode == ConstraintMode.BOX else _CYLINDER_COLOR
703
+ )
704
+ self._show_preview_point(self._state.first_point, color)
705
+
706
+ def _clear_preview(self) -> None:
707
+ """Clear any preview visualization."""
708
+ if self._plotter is not None and self._state.preview_actor is not None:
709
+ self._plotter.remove_actor(self._state.preview_actor)
710
+ self._state.preview_actor = None
711
+
712
+ def get_constraints(self) -> list[SizingConstraint]:
713
+ """Get the list of defined constraints.
714
+
715
+ Returns
716
+ -------
717
+ list[SizingConstraint]
718
+ List of sizing constraints defined through interaction.
719
+
720
+ """
721
+ return list(self._constraints)
722
+
723
+ def apply_to_mesh(self) -> None:
724
+ """Apply the defined constraints to the mesh.
725
+
726
+ This adds all constraints to the mesh's sizing system, ready
727
+ for the next remesh operation.
728
+
729
+ """
730
+ for constraint in self._constraints:
731
+ if isinstance(constraint, SphereSize):
732
+ self._mesh.set_size_sphere(
733
+ center=constraint.center,
734
+ radius=constraint.radius,
735
+ size=constraint.size,
736
+ )
737
+ elif isinstance(constraint, BoxSize):
738
+ self._mesh.set_size_box(
739
+ bounds=constraint.bounds,
740
+ size=constraint.size,
741
+ )
742
+ elif isinstance(constraint, CylinderSize):
743
+ self._mesh.set_size_cylinder(
744
+ point1=constraint.point1,
745
+ point2=constraint.point2,
746
+ radius=constraint.radius,
747
+ size=constraint.size,
748
+ )
749
+ elif isinstance(constraint, PointSize):
750
+ self._mesh.set_size_from_point(
751
+ point=constraint.point,
752
+ near_size=constraint.near_size,
753
+ far_size=constraint.far_size,
754
+ influence_radius=constraint.influence_radius,
755
+ )
756
+
757
+ def clear_constraints(self) -> None:
758
+ """Clear all defined constraints."""
759
+ self._constraints.clear()
760
+ if self._plotter is not None:
761
+ for visual in self._visuals:
762
+ self._plotter.remove_actor(visual.actor)
763
+ if visual.label_actor is not None:
764
+ self._plotter.remove_actor(visual.label_actor)
765
+ self._visuals.clear()
766
+
767
+ def remove_constraint(self, index: int) -> None:
768
+ """Remove a constraint by index.
769
+
770
+ Parameters
771
+ ----------
772
+ index : int
773
+ Index of the constraint to remove.
774
+
775
+ Raises
776
+ ------
777
+ IndexError
778
+ If index is out of range.
779
+
780
+ """
781
+ if index < 0 or index >= len(self._constraints):
782
+ msg = f"Constraint index {index} out of range"
783
+ raise IndexError(msg)
784
+
785
+ self._constraints.pop(index)
786
+ if self._plotter is not None:
787
+ visual = self._visuals.pop(index)
788
+ self._plotter.remove_actor(visual.actor)
789
+ if visual.label_actor is not None:
790
+ self._plotter.remove_actor(visual.label_actor)