capytaine 2.3__cp39-cp39-macosx_14_0_arm64.whl → 3.0.0a1__cp39-cp39-macosx_14_0_arm64.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 (86) hide show
  1. capytaine/.dylibs/libgcc_s.1.1.dylib +0 -0
  2. capytaine/.dylibs/libgfortran.5.dylib +0 -0
  3. capytaine/.dylibs/libquadmath.0.dylib +0 -0
  4. capytaine/__about__.py +7 -2
  5. capytaine/__init__.py +8 -12
  6. capytaine/bem/engines.py +234 -354
  7. capytaine/bem/problems_and_results.py +30 -21
  8. capytaine/bem/solver.py +205 -81
  9. capytaine/bodies/bodies.py +279 -862
  10. capytaine/bodies/dofs.py +136 -9
  11. capytaine/bodies/hydrostatics.py +540 -0
  12. capytaine/bodies/multibodies.py +216 -0
  13. capytaine/green_functions/{libs/Delhommeau_float32.cpython-39-darwin.so → Delhommeau_float32.cpython-39-darwin.so} +0 -0
  14. capytaine/green_functions/{libs/Delhommeau_float64.cpython-39-darwin.so → Delhommeau_float64.cpython-39-darwin.so} +0 -0
  15. capytaine/green_functions/abstract_green_function.py +2 -2
  16. capytaine/green_functions/delhommeau.py +50 -31
  17. capytaine/green_functions/hams.py +19 -13
  18. capytaine/io/legacy.py +3 -103
  19. capytaine/io/xarray.py +15 -10
  20. capytaine/meshes/__init__.py +2 -6
  21. capytaine/meshes/abstract_meshes.py +375 -0
  22. capytaine/meshes/clean.py +302 -0
  23. capytaine/meshes/clip.py +347 -0
  24. capytaine/meshes/export.py +89 -0
  25. capytaine/meshes/geometry.py +244 -394
  26. capytaine/meshes/io.py +433 -0
  27. capytaine/meshes/meshes.py +621 -676
  28. capytaine/meshes/predefined/cylinders.py +22 -56
  29. capytaine/meshes/predefined/rectangles.py +26 -85
  30. capytaine/meshes/predefined/spheres.py +4 -11
  31. capytaine/meshes/quality.py +118 -407
  32. capytaine/meshes/surface_integrals.py +48 -29
  33. capytaine/meshes/symmetric_meshes.py +641 -0
  34. capytaine/meshes/visualization.py +353 -0
  35. capytaine/post_pro/free_surfaces.py +1 -4
  36. capytaine/post_pro/kochin.py +10 -10
  37. capytaine/tools/block_circulant_matrices.py +275 -0
  38. capytaine/tools/lists_of_points.py +2 -2
  39. capytaine/tools/memory_monitor.py +45 -0
  40. capytaine/tools/symbolic_multiplication.py +31 -5
  41. capytaine/tools/timer.py +68 -42
  42. {capytaine-2.3.dist-info → capytaine-3.0.0a1.dist-info}/METADATA +8 -14
  43. capytaine-3.0.0a1.dist-info/RECORD +65 -0
  44. capytaine-3.0.0a1.dist-info/WHEEL +6 -0
  45. capytaine/bodies/predefined/__init__.py +0 -6
  46. capytaine/bodies/predefined/cylinders.py +0 -151
  47. capytaine/bodies/predefined/rectangles.py +0 -111
  48. capytaine/bodies/predefined/spheres.py +0 -70
  49. capytaine/green_functions/FinGreen3D/.gitignore +0 -1
  50. capytaine/green_functions/FinGreen3D/FinGreen3D.f90 +0 -3589
  51. capytaine/green_functions/FinGreen3D/LICENSE +0 -165
  52. capytaine/green_functions/FinGreen3D/Makefile +0 -16
  53. capytaine/green_functions/FinGreen3D/README.md +0 -24
  54. capytaine/green_functions/FinGreen3D/test_program.f90 +0 -39
  55. capytaine/green_functions/LiangWuNoblesse/.gitignore +0 -1
  56. capytaine/green_functions/LiangWuNoblesse/LICENSE +0 -504
  57. capytaine/green_functions/LiangWuNoblesse/LiangWuNoblesseWaveTerm.f90 +0 -751
  58. capytaine/green_functions/LiangWuNoblesse/Makefile +0 -18
  59. capytaine/green_functions/LiangWuNoblesse/README.md +0 -2
  60. capytaine/green_functions/LiangWuNoblesse/test_program.f90 +0 -28
  61. capytaine/green_functions/libs/__init__.py +0 -0
  62. capytaine/io/mesh_loaders.py +0 -1086
  63. capytaine/io/mesh_writers.py +0 -692
  64. capytaine/io/meshio.py +0 -38
  65. capytaine/matrices/__init__.py +0 -16
  66. capytaine/matrices/block.py +0 -592
  67. capytaine/matrices/block_toeplitz.py +0 -325
  68. capytaine/matrices/builders.py +0 -89
  69. capytaine/matrices/linear_solvers.py +0 -232
  70. capytaine/matrices/low_rank.py +0 -395
  71. capytaine/meshes/clipper.py +0 -465
  72. capytaine/meshes/collections.py +0 -334
  73. capytaine/meshes/mesh_like_protocol.py +0 -37
  74. capytaine/meshes/properties.py +0 -276
  75. capytaine/meshes/quadratures.py +0 -80
  76. capytaine/meshes/symmetric.py +0 -392
  77. capytaine/tools/lru_cache.py +0 -49
  78. capytaine/ui/vtk/__init__.py +0 -3
  79. capytaine/ui/vtk/animation.py +0 -329
  80. capytaine/ui/vtk/body_viewer.py +0 -28
  81. capytaine/ui/vtk/helpers.py +0 -82
  82. capytaine/ui/vtk/mesh_viewer.py +0 -461
  83. capytaine-2.3.dist-info/RECORD +0 -92
  84. capytaine-2.3.dist-info/WHEEL +0 -4
  85. {capytaine-2.3.dist-info → capytaine-3.0.0a1.dist-info}/LICENSE +0 -0
  86. {capytaine-2.3.dist-info → capytaine-3.0.0a1.dist-info}/entry_points.txt +0 -0
@@ -1,81 +1,186 @@
1
- """ This module contains a class to describe the 2D mesh of the surface of a body in a 3D space.
2
- Based on meshmagick <https://github.com/LHEEA/meshmagick> by François Rongère.
3
- """
4
- # Copyright (C) 2017-2019 Matthieu Ancellin, based on the work of François Rongère
5
- # See LICENSE file at <https://github.com/mancellin/capytaine>
1
+ # Copyright 2025 Mews Labs
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ from __future__ import annotations
6
16
 
7
17
  import logging
8
- from itertools import count
18
+ from functools import cached_property
19
+ from typing import List, Union, Tuple, Dict, Optional, Literal
9
20
 
10
21
  import numpy as np
11
22
 
12
- from capytaine.meshes.geometry import Abstract3DObject, ClippableMixin, Plane, inplace_transformation, xOy_Plane
13
- from capytaine.meshes.properties import compute_faces_properties, connected_components, connected_components_of_waterline
14
- from capytaine.meshes.surface_integrals import SurfaceIntegralsMixin
15
- from capytaine.meshes.quality import (merge_duplicates, heal_normals, remove_unused_vertices,
16
- heal_triangles, remove_degenerated_faces)
17
- from capytaine.tools.optional_imports import import_optional_dependency
18
- from capytaine.meshes.quadratures import compute_quadrature_on_faces
23
+ from .abstract_meshes import AbstractMesh
24
+ from .geometry import (
25
+ compute_faces_areas,
26
+ compute_faces_centers,
27
+ compute_faces_normals,
28
+ compute_faces_radii,
29
+ compute_gauss_legendre_2_quadrature,
30
+ get_vertices_face,
31
+ )
32
+ from .clip import clip_faces
33
+ from .clean import clean_mesh
34
+ from .export import export_mesh
35
+ from .quality import _is_valid, check_mesh_quality
36
+ from .visualization import show_3d
19
37
 
20
38
  LOG = logging.getLogger(__name__)
21
39
 
22
40
 
23
- class Mesh(ClippableMixin, SurfaceIntegralsMixin, Abstract3DObject):
24
- """A class to handle unstructured 2D meshes in a 3D space.
41
+ class Mesh(AbstractMesh):
42
+ """Mesh class for representing and manipulating 3D surface meshes.
25
43
 
26
44
  Parameters
27
45
  ----------
28
- vertices : array_like of shape (nv, 3)
29
- Array of mesh vertices coordinates.Each line of the array represents one vertex
30
- coordinates
31
- faces : array_like of shape (nf, 4)
32
- Arrays of mesh connectivities for faces. Each line of the array represents indices of
33
- vertices that form the face, expressed in counterclockwise order to ensure outward normals
34
- description.
46
+ vertices : np.ndarray, optional
47
+ Array of mesh vertices coordinates with shape (n_vertices, 3).
48
+ Each row represents one vertex's (x, y, z) coordinates.
49
+ faces : List[List[int]] or np.ndarray, optional
50
+ Array of mesh connectivities for panels. Each row contains indices
51
+ of vertices that form a face (triangles or quads).
52
+ faces_metadata: Dict[str, np.ndarray]
53
+ Some arrays with the same first dimension (should be the number of faces)
54
+ storing some fields defined on all the faces of the mesh.
35
55
  name : str, optional
36
- The name of the mesh. If None, the mesh is given an automatic name based on its internal ID.
37
- quadrature_method: None or str or Quadpy quadrature, optional
38
- The method used to compute quadrature points in each cells.
39
- By default: None, that is a one-point first order scheme is used.
40
- """
41
-
42
- _ids = count(0) # A counter for automatic naming of new meshes.
56
+ Optional name for the mesh instance.
57
+ auto_clean : bool, optional
58
+ Whether to automatically clean the mesh upon initialization. Defaults to True.
59
+ auto_check : bool, optional
60
+ Whether to automatically check mesh quality upon initialization. Defaults to True.
43
61
 
44
- def __init__(self, vertices=None, faces=None, name=None, *, quadrature_method=None):
62
+ Attributes
63
+ ----------
64
+ vertices : np.ndarray
65
+ Array of vertex coordinates with shape (n_vertices, 3).
66
+ name : str or None
67
+ Name of the mesh instance.
68
+ """
45
69
 
46
- if vertices is None or len(vertices) == 0:
47
- vertices = np.zeros((0, 3))
70
+ def __init__(
71
+ self,
72
+ vertices: np.ndarray = None,
73
+ faces: Union[List[List[int]], np.ndarray] = None,
74
+ *,
75
+ faces_metadata: Optional[Dict[str, np.ndarray]] = None,
76
+ quadrature_method: Optional[str] = None,
77
+ name: Optional[str] = None,
78
+ auto_clean: bool = True,
79
+ auto_check: bool = True,
80
+ ):
81
+ # --- Vertices: always a NumPy array with shape (n,3) ---
82
+ if vertices is None:
83
+ self.vertices = np.empty((0, 3), dtype=np.float64)
84
+ else:
85
+ self.vertices = np.array(vertices, dtype=np.float64)
48
86
 
49
- if faces is None or len(faces) == 0:
50
- faces = np.zeros((0, 4))
87
+ # --- Faces: process using helper method ---
88
+ self._faces: List[List[int]] = self._process_faces(faces)
51
89
 
52
- if name is None:
53
- self.name = f'mesh_{next(Mesh._ids)}'
90
+ if faces_metadata is None:
91
+ self.faces_metadata = {}
54
92
  else:
55
- self.name = str(name)
93
+ self.faces_metadata = {k: np.asarray(faces_metadata[k]) for k in faces_metadata}
56
94
 
57
- self.__internals__ = dict()
58
- self.vertices = vertices # Not a direct assignment, goes through the setter method below.
59
- self.faces = faces # Not a direct assignment, goes through the setter method below.
95
+ for m in self.faces_metadata:
96
+ assert self.faces_metadata[m].shape[0] == len(self._faces)
60
97
 
61
- LOG.debug(f"New mesh: {repr(self)}")
98
+ # Optional name
99
+ self.name = str(name) if name is not None else None
62
100
 
63
101
  self.quadrature_method = quadrature_method
64
102
 
65
- def __short_str__(self):
66
- return (f"{self.__class__.__name__}(..., name=\"{self.name}\")")
103
+ # Cleaning/quality (unless mesh is completely empty)
104
+ if not (len(self.vertices) == 0 and len(self._faces) == 0):
105
+ if not _is_valid(vertices, faces):
106
+ raise ValueError(
107
+ "Mesh is invalid: faces contain out-of-bounds or negative indices."
108
+ )
109
+
110
+ if np.any(np.isnan(vertices)):
111
+ raise ValueError(
112
+ "Mesh is invalid: vertices coordinates contains NaN values."
113
+ )
114
+
115
+ if auto_clean:
116
+ self.vertices, self._faces, self.faces_metadata = clean_mesh(
117
+ self.vertices, self._faces, self.faces_metadata, max_iter=5, tol=1e-8
118
+ )
119
+ LOG.debug("Cleaned %s", str(self))
120
+
121
+ if auto_check:
122
+ check_mesh_quality(self)
123
+ LOG.debug("Checked quality of %s", str(self))
124
+
125
+ LOG.debug("New %s", str(self))
126
+
127
+
128
+ ## MAIN METRICS AND DISPLAY
129
+
130
+ @property
131
+ def nb_vertices(self) -> int:
132
+ """Number of vertices in the mesh."""
133
+ return len(self.vertices)
134
+
135
+ @property
136
+ def nb_faces(self) -> int:
137
+ """Number of faces in the mesh."""
138
+ return len(self._faces)
139
+
140
+ @property
141
+ def nb_triangles(self) -> int:
142
+ """Number of triangular faces (3-vertex) in the mesh."""
143
+ return sum(1 for f in self._faces if len(f) == 3)
144
+
145
+ @property
146
+ def nb_quads(self) -> int:
147
+ """Number of quadrilateral faces (4-vertex) in the mesh."""
148
+ return sum(1 for f in self._faces if len(f) == 4)
149
+
150
+ def summary(self):
151
+ """Print a summary of the mesh properties.
67
152
 
68
- def __str__(self):
69
- return (f"{self.__class__.__name__}(vertices=[[... {self.nb_vertices} vertices ...]], "
70
- f"faces=[[... {self.nb_faces} faces ...]], name=\"{self.name}\")")
153
+ Notes
154
+ -----
155
+ Displays the mesh name, vertex count, face count, and bounding box.
156
+ """
157
+ print("Mesh Summary")
158
+ print(f" Name : {self.name}")
159
+ print(f" Vertices count : {self.nb_vertices}")
160
+ print(f" Faces count : {self.nb_faces}")
161
+ print(
162
+ f" Bounding box : {self.vertices.min(axis=0)} to {self.vertices.max(axis=0)}"
163
+ )
164
+ print(f" Metadata keys : {self.faces_metadata.keys()}")
165
+ print(f" Quadrature : {self.quadrature_method}")
166
+
167
+ def __str__(self) -> str:
168
+ return (
169
+ f"Mesh(vertices=[[... {self.nb_vertices} vertices ...]], "
170
+ + f"faces=[[... {self.nb_faces} faces ...]]"
171
+ + (f", quadrature_method={repr(self.quadrature_method)}" if self.quadrature_method is not None else "")
172
+ + (f", name={repr(self.name)}" if self.name is not None else "")
173
+ + ")"
174
+ )
175
+
176
+ def __short_str__(self) -> str:
177
+ if self.name is not None:
178
+ return f"Mesh(..., name={repr(self.name)})"
179
+ else:
180
+ return "Mesh(...)"
71
181
 
72
- def __repr__(self):
73
- # shift = len(self.__class__.__name__) + 1
74
- # vert_str = np.array_repr(self.vertices).replace('\n', '\n' + (shift + 9)*' ')
75
- # faces_str = np.array_repr(self.faces).replace('\n', '\n' + (shift + 6)*' ')
76
- # return f"{self.__class__.__name__}(\n{' '*shift}vertices={vert_str},\n{' '*shift}faces={faces_str}\n{' '*shift}name=\"{self.name}\"\n)"
77
- return (f"{self.__class__.__name__}(vertices=[[... {self.nb_vertices} vertices ...]], "
78
- f"faces=[[... {self.nb_faces} faces ...]], name=\"{self.name}\")")
182
+ def __repr__(self) -> str:
183
+ return self.__str__()
79
184
 
80
185
  def _repr_pretty_(self, p, cycle):
81
186
  p.text(self.__str__())
@@ -89,677 +194,438 @@ class Mesh(ClippableMixin, SurfaceIntegralsMixin, Abstract3DObject):
89
194
  return "[[... {} {} ...]]".format(self.n, self.kind)
90
195
  yield "vertices", CustomRepr(self.nb_vertices, "vertices")
91
196
  yield "faces", CustomRepr(self.nb_faces, "faces")
197
+ yield "quadrature_method", self.quadrature_method
92
198
  yield "name", self.name
93
199
 
94
- @property
95
- def nb_vertices(self) -> int:
96
- """Get the number of vertices in the mesh."""
97
- return self._vertices.shape[0]
200
+ def show(self, *, backend=None, **kwargs):
201
+ """Visualize the mesh using the specified backend.
98
202
 
99
- @property
100
- def vertices(self) -> np.ndarray:
101
- """Get the vertices array coordinate of the mesh."""
102
- return self._vertices
203
+ Parameters
204
+ ----------
205
+ backend : str, optional
206
+ Visualization backend to use. Options are 'pyvista' or 'matplotlib'.
207
+ By default, try several until an installed one is found.
208
+ normal_vectors: bool, optional
209
+ If True, display normal vectors on each face.
210
+ **kwargs
211
+ Additional keyword arguments passed to the visualization backend.
212
+ See :mod:`~capytaine.meshes.visualization`
103
213
 
104
- @vertices.setter
105
- def vertices(self, value) -> None:
106
- self._vertices = np.array(value, dtype=float)
107
- assert self._vertices.shape[1] == 3, \
108
- "Vertices of a mesh should be provided as a sequence of 3-ple."
109
- self.__internals__.clear()
214
+ Returns
215
+ -------
216
+ object
217
+ Visualization object returned by the backend (e.g., matplotlib figure).
110
218
 
111
- @property
112
- def nb_faces(self) -> int:
113
- """Get the number of faces in the mesh."""
114
- return self._faces.shape[0]
219
+ Raises
220
+ ------
221
+ NotImplementedError
222
+ If the specified backend is not supported.
223
+ """
224
+ return show_3d(self, backend=backend, **kwargs)
115
225
 
116
- @property
117
- def faces(self) -> np.ndarray:
118
- """Get the faces connectivity array of the mesh."""
119
- return self._faces
120
-
121
- @faces.setter
122
- def faces(self, faces):
123
- faces = np.array(faces, dtype=int)
124
- assert np.all(faces >= 0), \
125
- "Faces of a mesh should be provided as positive integers (ids of vertices)"
126
- assert faces.shape[1] == 4, \
127
- "Faces of a mesh should be provided as a sequence of 4-ple."
128
- assert len(faces) == 0 or faces.max()+1 <= self.nb_vertices, \
129
- "The array of faces should only reference vertices that are in the mesh."
130
- self._faces = faces
131
- self.__internals__.clear()
132
-
133
- def copy(self, name=None) -> 'Mesh':
134
- """Get a copy of the current mesh instance.
226
+
227
+ ## INITIALISATION
228
+
229
+ @staticmethod
230
+ def _has_leading_count_column(arr: np.ndarray) -> bool:
231
+ """Check if a 2D array has a leading column containing vertex counts.
135
232
 
136
233
  Parameters
137
234
  ----------
138
- name : string, optional
139
- a name for the new mesh
235
+ arr : np.ndarray
236
+ 2D array of face data
140
237
 
141
238
  Returns
142
239
  -------
143
- Mesh
144
- mesh instance copy
240
+ bool
241
+ True if the first column appears to be vertex counts
145
242
  """
146
- from copy import deepcopy
147
- new_mesh = deepcopy(self)
148
- if name is not None:
149
- new_mesh.name = name
150
- return new_mesh
151
-
152
- def merged(self):
153
- """Dummy method to be generalized for collections of meshes."""
154
- return self
155
-
156
- def tree_view(self, **kwargs):
157
- """Dummy method to be generalized for collections of meshes."""
158
- return self.__short_str__()
159
-
160
- def path_to_leaf(self):
161
- """Dummy method to be generalized for collection of meshes."""
162
- return [[]]
163
-
164
- def to_meshmagick(self):
165
- """Convert the Mesh object as a Mesh object from meshmagick.
166
- Mostly for debugging."""
167
- from meshmagick.mesh import Mesh
168
- meshmagick_mesh = Mesh(self.vertices, self.faces, name=self.name)
169
- meshmagick_mesh.heal_mesh()
170
- return meshmagick_mesh
171
-
172
- ##################
173
- # Extract face #
174
- ##################
175
-
176
- def get_face(self, face_id):
177
- """Get the face described by its vertices connectivity.
243
+ if arr.ndim != 2 or arr.shape[1] <= 3:
244
+ return False
245
+
246
+ expected_count = arr.shape[1] - 1
247
+ for row in arr:
248
+ # Check if first value could be a vertex count (3 or 4)
249
+ # and if it matches the expected count (total cols - 1)
250
+ if row[0] != expected_count and row[0] not in [3, 4]:
251
+ return False
252
+ return True
253
+
254
+ def _process_faces(self, faces: List[List[int]] | np.ndarray) -> List[List[int]]:
255
+ """Process the faces input for the Mesh class.
178
256
 
179
257
  Parameters
180
258
  ----------
181
- face_id : int
182
- Face id
259
+ faces : np.ndarray or list
260
+ The faces data to process.
183
261
 
184
262
  Returns
185
263
  -------
186
- ndarray
187
- If the face is a triangle, the array has 3 components, else it has 4 (quadrangle)
264
+ list
265
+ A list of faces, where each face is a list of vertex indices.
266
+
267
+ Notes
268
+ -----
269
+ If the input is a 2D array with a leading column containing face vertex counts
270
+ (e.g., [[3, v1, v2, v3], [4, v1, v2, v3, v4]]), the count column will be
271
+ automatically stripped. This is checked per-row to support mixed triangle/quad meshes.
188
272
  """
189
- if self.is_triangle(face_id):
190
- return self._faces[face_id, :3]
273
+ if faces is None:
274
+ return []
275
+ elif isinstance(faces, list):
276
+ # assume it's already a list of lists of ints
277
+ return [list(f) for f in faces]
191
278
  else:
192
- return self._faces[face_id]
193
-
194
- def extract_one_face(self, id_face):
195
- vertices = self.vertices[self.faces[id_face, :], :]
196
- mesh = Mesh(vertices=vertices, faces=np.array([[0, 1, 2, 3]]), name=f"single_face_from_{self.name}")
197
-
198
- for prop in self.__internals__:
199
- if prop[:4] == "face":
200
- mesh.__internals__[prop] = self.__internals__[prop][[id_face]]
201
-
202
- return mesh
203
-
204
- def extract_faces(self, id_faces_to_extract, return_index=False, name=None):
279
+ # fallback: convert array → nested list
280
+ arr = np.asarray(faces, dtype=int)
281
+
282
+ # Detect & strip a leading "count" column if present
283
+ if self._has_leading_count_column(arr):
284
+ arr = arr[:, 1:]
285
+
286
+ return arr.tolist()
287
+
288
+ @classmethod
289
+ def from_list_of_faces(
290
+ cls,
291
+ list_faces,
292
+ *,
293
+ quadrature_method=None,
294
+ faces_metadata=None,
295
+ name=None,
296
+ auto_clean=True,
297
+ auto_check=True
298
+ ) -> "Mesh":
205
299
  """
206
- Extracts a new mesh from a selection of faces ids
300
+ Create a Mesh instance from a list of faces defined by vertex coordinates.
207
301
 
208
302
  Parameters
209
303
  ----------
210
- id_faces_to_extract : ndarray
211
- Indices of faces that have to be extracted
212
- return_index: bool, optional
213
- Flag to output old indices
214
- name: string, optional
215
- Name for the new mesh
304
+ list_faces : list of list of list of float
305
+ Each face is defined by a list of 3D coordinates. For example:
306
+ [
307
+ [[x1, y1, z1], [x2, y2, z2], [x3, y3, z3]],
308
+ [[x4, y4, z4], [x5, y5, z5], [x6, y6, z6]]
309
+ ]
310
+ faces_metadata: Optional[Dict[str, np.ndarray]]
311
+ name: str, optional
312
+ A name for the new mesh.
313
+ auto_clean : bool, optional
314
+ Whether to automatically clean the mesh upon initialization. Defaults to True.
315
+ auto_check : bool, optional
316
+ Whether to automatically check mesh quality upon initialization. Defaults to True.
216
317
 
217
318
  Returns
218
319
  -------
219
320
  Mesh
220
- A new Mesh instance composed of the extracted faces
321
+ An instance of Mesh with:
322
+ - unique vertices extracted from the input
323
+ - faces defined as indices into the vertex array
221
324
  """
222
- nv = self.nb_vertices
223
-
224
- # Determination of the vertices to keep
225
- vertices_mask = np.zeros(nv, dtype=bool)
226
- vertices_mask[self._faces[id_faces_to_extract].flatten()] = True
227
- id_v = np.arange(nv)[vertices_mask]
325
+ unique_vertices = []
326
+ vertices_map = {}
327
+ indexed_faces = []
328
+
329
+ for face in list_faces:
330
+ indexed_face = []
331
+ for coord in face:
332
+ key = tuple(coord)
333
+ if key not in vertices_map:
334
+ vertices_map[key] = len(unique_vertices)
335
+ unique_vertices.append(coord)
336
+ indexed_face.append(vertices_map[key])
337
+ indexed_faces.append(indexed_face)
338
+
339
+ return cls(
340
+ vertices=np.array(unique_vertices),
341
+ faces=indexed_faces,
342
+ quadrature_method=quadrature_method,
343
+ faces_metadata=faces_metadata,
344
+ name=name
345
+ )
346
+
347
+ def as_list_of_faces(self) -> List[List[List[float]]]:
348
+ """
349
+ Convert the Mesh instance to a list of faces defined by vertex coordinates.
228
350
 
229
- # Building up the vertex array
230
- v_extracted = self._vertices[id_v]
231
- new_id__v = np.arange(nv)
232
- new_id__v[id_v] = np.arange(len(id_v))
351
+ Returns
352
+ -------
353
+ list of list of list of float
354
+ Each face is defined by a list of 3D coordinates. For example:
355
+ [
356
+ [[x1, y1, z1], [x2, y2, z2], [x3, y3, z3]],
357
+ [[x4, y4, z4], [x5, y5, z5], [x6, y6, z6]]
358
+ ]
359
+ """
360
+ list_faces = []
361
+ for face in self._faces:
362
+ face_coords = [self.vertices[idx].tolist() for idx in face]
363
+ list_faces.append(face_coords)
364
+ if len(self.faces_metadata) > 0:
365
+ LOG.debug(f"Dropping metadata of {self} to export as list of faces.")
366
+ return list_faces
367
+
368
+ def as_array_of_faces(self) -> np.ndarray:
369
+ """Similar to as_list_of_faces but returns an array of shape
370
+ (nb_faces, 3, 3) if only triangles, or (nb_faces, 4, 3) otherwise.
371
+ """
372
+ array = self.vertices[self.faces[:, :], :]
373
+ if self.nb_quads == 0:
374
+ array = array[:, :3, :]
375
+ return array
233
376
 
234
- faces_extracted = self._faces[id_faces_to_extract]
235
- faces_extracted = new_id__v[faces_extracted.flatten()].reshape((len(id_faces_to_extract), 4))
377
+ def export(self, format, **kwargs):
378
+ return export_mesh(self, format, **kwargs)
236
379
 
237
- extracted_mesh = Mesh(v_extracted, faces_extracted)
380
+ ## INTERFACE FOR BEM SOLVER
238
381
 
239
- for prop in self.__internals__:
240
- if prop[:4] == "face":
241
- extracted_mesh.__internals__[prop] = self.__internals__[prop][id_faces_to_extract]
382
+ @cached_property
383
+ def faces_vertices_centers(self) -> np.ndarray:
384
+ """Calculate the center of vertices that form the faces.
242
385
 
243
- if name is None:
244
- if self.name is not None and self.name.startswith("mesh_extracted_from_"):
245
- extracted_mesh.name = self.name
386
+ Returns
387
+ -------
388
+ np.ndarray
389
+ Array of shape (n_faces, 3) containing the centroid of each face's vertices.
390
+ """
391
+ centers_vertices = []
392
+ for face in self._faces:
393
+ if face[3] != face[2]:
394
+ a, b, c, d = get_vertices_face(face, self.vertices)
395
+ mean = (a + b + c + d) / 4
396
+ centers_vertices.append(mean)
246
397
  else:
247
- extracted_mesh.name = f"mesh_extracted_from_{self.name}"
248
- else:
249
- extracted_mesh.name = name
250
-
251
- if return_index:
252
- return extracted_mesh, id_v
253
- else:
254
- return extracted_mesh
255
-
256
- def sliced_by_plane(self, plane: Plane):
257
- from capytaine.meshes.collections import CollectionOfMeshes
258
- faces_ids_on_one_side = np.where(plane.distance_to_point(self.faces_centers) < 0)[0]
259
- if len(faces_ids_on_one_side) == 0 or len(faces_ids_on_one_side) == self.nb_faces:
260
- return self.copy()
261
- else:
262
- mesh_part_1 = self.extract_faces(faces_ids_on_one_side)
263
- mesh_part_2 = self.extract_faces(list(set(range(self.nb_faces)) - set(faces_ids_on_one_side)))
264
- return CollectionOfMeshes([mesh_part_1, mesh_part_2],
265
- name=f"{self.name}_splitted_by_{plane}")
398
+ a, b, c = get_vertices_face(face, self.vertices)
399
+ mean = (a + b + c) / 3
400
+ centers_vertices.append(mean)
401
+ return np.array(centers_vertices)
266
402
 
403
+ @cached_property
404
+ def faces_normals(self) -> np.ndarray:
405
+ """Normal vectors for each face.
267
406
 
268
- #####################
269
- # Mean and radius #
270
- #####################
271
-
272
- @property
273
- def center_of_mass_of_nodes(self):
274
- """(Non-weighted) center of mass of the nodes of the mesh."""
275
- if 'center_of_mass_of_nodes' not in self.__internals__:
276
- center_of_mass_of_nodes = np.mean(self.vertices, axis=0)
277
- self.__internals__['center_of_mass_of_nodes'] = center_of_mass_of_nodes
278
- return center_of_mass_of_nodes
279
- return self.__internals__['center_of_mass_of_nodes']
280
-
281
- @property
282
- def diameter_of_nodes(self):
283
- """Maximum distance between two nodes of the mesh."""
284
- if 'diameter_of_nodes' not in self.__internals__:
285
- diameter_of_nodes = 2*np.max(
286
- np.linalg.norm(self.vertices - self.center_of_mass_of_nodes, axis=-1)
287
- )
288
- self.__internals__['diameter_of_nodes'] = diameter_of_nodes
289
- return diameter_of_nodes
290
- return self.__internals__['diameter_of_nodes']
291
-
292
- ######################
293
- # Faces properties #
294
- ######################
407
+ Returns
408
+ -------
409
+ np.ndarray
410
+ Array of shape (n_faces, 3) containing unit normal vectors.
411
+ """
412
+ return compute_faces_normals(self.vertices, self._faces)
295
413
 
296
- @property
414
+ @cached_property
297
415
  def faces_areas(self) -> np.ndarray:
298
- """Get the array of faces areas of the mesh."""
299
- if 'faces_areas' not in self.__internals__:
300
- self.__internals__.update(compute_faces_properties(self))
301
- return self.__internals__['faces_areas']
416
+ """Surface area of each face.
302
417
 
303
- @property
418
+ Returns
419
+ -------
420
+ np.ndarray
421
+ Array of shape (n_faces,) containing the area of each face.
422
+ """
423
+ return compute_faces_areas(self.vertices, self._faces)
424
+
425
+ @cached_property
304
426
  def faces_centers(self) -> np.ndarray:
305
- """Get the array of faces centers of the mesh."""
306
- if 'faces_centers' not in self.__internals__:
307
- self.__internals__.update(compute_faces_properties(self))
308
- return self.__internals__['faces_centers']
427
+ """Geometric centers of each face.
309
428
 
310
- @property
311
- def faces_normals(self) -> np.ndarray:
312
- """Get the array of faces normals of the mesh."""
313
- if 'faces_normals' not in self.__internals__:
314
- self.__internals__.update(compute_faces_properties(self))
315
- return self.__internals__['faces_normals']
429
+ Returns
430
+ -------
431
+ np.ndarray
432
+ Array of shape (n_faces, 3) containing the center point of each face.
433
+ """
434
+ return compute_faces_centers(self.vertices, self._faces)
316
435
 
317
- @property
436
+ @cached_property
318
437
  def faces_radiuses(self) -> np.ndarray:
319
- """Get the array of faces radiuses of the mesh."""
320
- if 'faces_radiuses' not in self.__internals__:
321
- self.__internals__.update(compute_faces_properties(self))
322
- return self.__internals__['faces_radiuses']
323
-
324
- @property
325
- def quadrature_points(self):
326
- if 'quadrature' not in self.__internals__:
327
- self.compute_quadrature(self.quadrature_method)
328
- return self.__internals__['quadrature']
329
-
330
- def compute_quadrature(self, method):
331
- self.heal_triangles()
332
- all_faces = self.vertices[self.faces[:, :], :]
333
- if method is None:
334
- points = self.faces_centers.reshape((self.nb_faces, 1, 3))
335
- weights = self.faces_areas.reshape((self.nb_faces, 1))
336
- else:
337
- points, weights = compute_quadrature_on_faces(all_faces, method)
338
- self.__internals__['quadrature'] = (points, weights)
339
- self.quadrature_method = method
340
- return points, weights
341
-
438
+ """Radii of each face (circumradius or characteristic size).
342
439
 
343
- ###############################
344
- # Triangles and quadrangles #
345
- ###############################
346
-
347
- def is_triangle(self, face_id) -> bool:
348
- """Returns if a face is a triangle
349
-
350
- Parameters
351
- ----------
352
- face_id : int
353
- Face id
440
+ Returns
441
+ -------
442
+ np.ndarray
443
+ Array of shape (n_faces,) containing the radius of each face.
354
444
  """
355
- assert 0 <= face_id < self.nb_faces
356
- return self._faces[face_id, 0] == self._faces[face_id, -1]
357
-
358
- def _compute_triangles_quadrangles(self):
359
- triangle_mask = (self._faces[:, 0] == self._faces[:, -1])
360
- quadrangles_mask = np.invert(triangle_mask)
361
- triangles_quadrangles = {'triangles_ids': np.where(triangle_mask)[0],
362
- 'quadrangles_ids': np.where(quadrangles_mask)[0]}
363
- self.__internals__.update(triangles_quadrangles)
445
+ return compute_faces_radii(self.vertices, self._faces)
364
446
 
365
- @property
366
- def triangles_ids(self) -> np.ndarray:
367
- """Get the array of ids of triangle shaped faces."""
368
- if 'triangles_ids' not in self.__internals__:
369
- self._compute_triangles_quadrangles()
370
- return self.__internals__['triangles_ids']
371
-
372
- @property
373
- def nb_triangles(self) -> int:
374
- """Get the number of triangles in the mesh."""
375
- if 'triangles_ids'not in self.__internals__:
376
- self._compute_triangles_quadrangles()
377
- return len(self.__internals__['triangles_ids'])
378
-
379
- @property
380
- def quadrangles_ids(self) -> np.ndarray:
381
- """Get the array of ids of quadrangle shaped faces."""
382
- if 'triangles_ids' not in self.__internals__:
383
- self._compute_triangles_quadrangles()
384
- return self.__internals__['quadrangles_ids']
385
-
386
- @property
387
- def nb_quadrangles(self) -> int:
388
- """Get the number of quadrangles in the mesh."""
389
- if 'triangles_ids' not in self.__internals__:
390
- self._compute_triangles_quadrangles()
391
- return len(self.__internals__['quadrangles_ids'])
392
-
393
- #############
394
- # Display #
395
- #############
396
-
397
- @property
398
- def axis_aligned_bbox(self):
399
- """Get the axis aligned bounding box of the mesh.
447
+ @cached_property
448
+ def faces(self) -> np.ndarray:
449
+ """Face connectivity as quadrilateral array.
400
450
 
401
451
  Returns
402
452
  -------
403
- tuple
404
- (xmin, xmax, ymin, ymax, zmin, zmax)
453
+ np.ndarray
454
+ Array of shape (n_faces, 4) where triangular faces are padded
455
+ by repeating the last vertex.
456
+
457
+ Notes
458
+ -----
459
+ This property converts all faces to a uniform quad representation
460
+ for compatibility with libraries expecting fixed-width face arrays.
405
461
  """
406
- if self.nb_vertices > 0:
407
- x, y, z = self._vertices.T
408
- return (x.min(), x.max(),
409
- y.min(), y.max(),
410
- z.min(), z.max())
411
- else:
412
- return tuple(np.zeros(6))
462
+ faces_as_quad = [f if len(f) == 4 else f + [f[-1]] for f in self._faces]
463
+ return np.array(faces_as_quad, dtype=int)
413
464
 
414
- @property
415
- def squared_axis_aligned_bbox(self):
416
- """Get a squared axis aligned bounding box of the mesh.
465
+ @cached_property
466
+ def quadrature_points(self) -> Tuple[np.ndarray, np.ndarray]:
467
+ """Quadrature points and weights for numerical integration.
417
468
 
418
469
  Returns
419
470
  -------
420
- tuple
421
- (xmin, xmax, ymin, ymax, zmin, zmax)
422
-
423
- Note
424
- ----
425
- This method differs from `axis_aligned_bbox()` by the fact that
426
- the bounding box that is returned is squared but have the same center as the `axis_aligned_bbox()`.
471
+ tuple[np.ndarray, np.ndarray]
472
+ (points, weights) where points has shape (n_faces, 1, 3) and
473
+ weights has shape (n_faces, 1), using face centers and areas.
427
474
  """
428
- xmin, xmax, ymin, ymax, zmin, zmax = self.axis_aligned_bbox
429
- (x0, y0, z0) = np.array([xmin+xmax, ymin+ymax, zmin+zmax]) * 0.5
430
- d = (np.array([xmax-xmin, ymax-ymin, zmax-zmin]) * 0.5).max()
431
-
432
- return x0-d, x0+d, y0-d, y0+d, z0-d, z0+d
433
-
434
- def show(self, **kwargs):
435
- self.show_vtk(**kwargs)
436
-
437
- def show_vtk(self, **kwargs):
438
- """Shows the mesh in the vtk viewer"""
439
- from capytaine.ui.vtk.mesh_viewer import MeshViewer
440
-
441
- viewer = MeshViewer()
442
- viewer.add_mesh(self, **kwargs)
443
- viewer.show()
444
- viewer.finalize()
475
+ if self.quadrature_method is None:
476
+ return (self.faces_centers.reshape((-1, 1, 3)), self.faces_areas.reshape(-1, 1))
477
+ elif self.quadrature_method == "Gauss-Legendre 2":
478
+ return compute_gauss_legendre_2_quadrature(self.vertices, self.faces)
479
+ else:
480
+ raise ValueError(f"Unknown quadrature_method: {self.quadrature_method}")
481
+
482
+ ## TRANSFORMATIONS
483
+
484
+ def with_quadrature(self, quadrature_method):
485
+ return Mesh(
486
+ self.vertices,
487
+ self.faces,
488
+ faces_metadata=self.faces_metadata,
489
+ quadrature_method=quadrature_method,
490
+ name=self.name,
491
+ auto_clean=False,
492
+ auto_check=False
493
+ )
445
494
 
446
- def show_matplotlib(self, ax=None,
447
- normal_vectors=False, scale_normal_vector=None,
448
- saveas=None, color_field=None, cmap=None,
449
- cbar_label=None,
450
- **kwargs):
451
- """Poor man's viewer with matplotlib.
495
+ def extract_faces(self, faces_id, *, name=None) -> "Mesh":
496
+ """Extract a subset of faces by their indices and return a new Mesh instance.
452
497
 
453
498
  Parameters
454
499
  ----------
455
- ax: matplotlib axis
456
- The 3d axis in which to plot the mesh. If not provided, create a new one.
457
- normal_vectors: bool
458
- If True, print normal vector.
459
- scale_normal_vector: array of shape (nb_faces, )
460
- Scale separately each of the normal vectors.
461
- saveas: str
462
- File path where to save the image.
463
- color_field: array of shape (nb_faces, )
464
- Scalar field to be plot on the mesh (optional).
465
- cmap: matplotlib colormap
466
- Colormap to use for field plotting.
467
- cbar_label: string
468
- Label for colormap
469
-
470
- Other parameters are passed to Poly3DCollection.
500
+ faces_id : array_like
501
+ Indices of faces to extract.
502
+ name: str, optional
503
+ A name for the new mesh
504
+
505
+ Returns
506
+ -------
507
+ Mesh
508
+ New mesh containing only the specified faces.
471
509
  """
472
- matplotlib = import_optional_dependency("matplotlib")
473
- import importlib
474
- plt = importlib.import_module("matplotlib.pyplot")
475
- cm = importlib.import_module("matplotlib.cm")
476
-
477
- mpl_toolkits = import_optional_dependency("mpl_toolkits", package_name="matplotlib")
478
- Poly3DCollection = mpl_toolkits.mplot3d.art3d.Poly3DCollection
479
-
480
- default_axis = ax is None
481
- if default_axis:
482
- fig = plt.figure()
483
- ax = fig.add_subplot(111, projection="3d")
484
-
485
- faces = []
486
- for face in self.faces:
487
- vertices = []
488
- for index_vertex in face:
489
- vertices.append(self.vertices[int(index_vertex), :])
490
- faces.append(vertices)
491
-
492
- if color_field is None:
493
- if 'facecolors' not in kwargs:
494
- kwargs['facecolors'] = "yellow"
510
+ if isinstance(faces_id, np.ndarray):
511
+ faces_id = faces_id.ravel()
512
+ all_faces = self.as_list_of_faces()
513
+ selected_faces = [all_faces[i] for i in faces_id]
514
+ return Mesh.from_list_of_faces(
515
+ selected_faces,
516
+ faces_metadata={k: self.faces_metadata[k][selected_faces, ...] for k in self.faces_metadata},
517
+ name=name,
518
+ quadrature_method=self.quadrature_method,
519
+ auto_clean=False,
520
+ auto_check=False
521
+ )
522
+
523
+ def translated(self, shift, *, name=None) -> "Mesh":
524
+ """Return a new Mesh translated along vector-like `shift`."""
525
+ return Mesh(
526
+ vertices=self.vertices + np.asarray(shift),
527
+ faces=self._faces,
528
+ faces_metadata=self.faces_metadata,
529
+ name=name,
530
+ quadrature_method=self.quadrature_method,
531
+ auto_clean=False,
532
+ auto_check=False,
533
+ )
534
+
535
+ def rotated_with_matrix(self, R, *, name=None) -> "Mesh":
536
+ """Return a new Mesh rotated using the provided 3×3 rotation matrix."""
537
+ new_vertices = self.vertices @ R.T
538
+ return Mesh(
539
+ vertices=new_vertices,
540
+ faces=self._faces,
541
+ name=name,
542
+ faces_metadata=self.faces_metadata,
543
+ quadrature_method=self.quadrature_method,
544
+ auto_clean=False,
545
+ auto_check=False,
546
+ )
547
+
548
+ def mirrored(self, plane: Literal['xOz', 'yOz'], *, name=None) -> "Mesh":
549
+ new_vertices = self.vertices.copy()
550
+ if plane == "xOz":
551
+ new_vertices[:, 1] *= -1
552
+ elif plane == "yOz":
553
+ new_vertices[:, 0] *= -1
495
554
  else:
496
- if cmap is None:
497
- cmap = matplotlib.colormaps['coolwarm']
498
- m = cm.ScalarMappable(cmap=cmap)
499
- m.set_array([min(color_field), max(color_field)])
500
- m.set_clim(vmin=min(color_field), vmax=max(color_field))
501
- colors = m.to_rgba(color_field)
502
- kwargs['facecolors'] = colors
503
- if 'edgecolor' not in kwargs:
504
- kwargs['edgecolor'] = 'k'
505
-
506
- ax.add_collection3d(Poly3DCollection(faces, **kwargs))
507
-
508
- if color_field is not None:
509
- cbar = plt.colorbar(m, ax=ax)
510
- if cbar_label is not None:
511
- cbar.set_label(cbar_label)
512
-
513
-
514
-
515
- # Plot normal vectors.
516
- if normal_vectors:
517
- if scale_normal_vector is not None:
518
- vectors = (scale_normal_vector * self.faces_normals.T).T
519
- else:
520
- vectors = self.faces_normals
521
- ax.quiver(*zip(*self.faces_centers), *zip(*vectors), length=0.2)
522
-
523
-
524
- ax.set_xlabel("x")
525
- ax.set_ylabel("y")
526
- ax.set_zlabel("z")
527
-
528
- xmin, xmax, ymin, ymax, zmin, zmax = self.squared_axis_aligned_bbox
529
- ax.set_xlim(xmin, xmax)
530
- ax.set_ylim(ymin, ymax)
531
- ax.set_zlim(zmin, zmax)
532
-
533
- if default_axis:
534
- if saveas is not None:
535
- plt.tight_layout()
536
- plt.savefig(saveas)
537
- else:
538
- plt.show()
539
-
540
- ################################
541
- # Transformation of the mesh #
542
- ################################
543
-
544
- @inplace_transformation
545
- def translate(self, vector) -> 'Mesh':
546
- """Translates the mesh in 3D giving the 3 distances along coordinate axes.
555
+ raise ValueError(f"Unsupported value for plane: {plane}")
556
+ new_faces = [f[::-1] for f in self._faces] # Invert normals
557
+ if name is None and self.name is not None:
558
+ name = f"mirrored_{self.name}"
559
+ return Mesh(
560
+ new_vertices,
561
+ new_faces,
562
+ faces_metadata=self.faces_metadata,
563
+ quadrature_method=self.quadrature_method,
564
+ name=name,
565
+ auto_clean=False,
566
+ auto_check=False
567
+ )
568
+
569
+ def join_meshes(*meshes: List["Mesh"], return_masks=False, name=None) -> "Mesh":
570
+ """Join several meshes and return a new Mesh instance.
547
571
 
548
572
  Parameters
549
573
  ----------
550
- vector : array_like
551
- translation vector
552
- """
553
- vector = np.asarray(vector, dtype=float)
554
- assert vector.shape == (3,), "The translation vector should be given as a 3-ple of values."
555
-
556
- self.vertices += vector
557
-
558
- return self
574
+ meshes: List[Mesh]
575
+ Meshes to be joined
576
+ return_masks: bool, optional
577
+ If True, additionally return a list of numpy masks establishing the
578
+ origin of each face in the new mesh.
579
+ (Default: False)
580
+ name: str, optional
581
+ A name for the new object
559
582
 
560
- @inplace_transformation
561
- def rotate(self, axis, angle) -> 'Mesh':
562
- """Rotate the mesh of a given angle around an axis.
583
+ Returns
584
+ -------
585
+ Mesh
586
+ New mesh containing vertices and faces from all meshes.
563
587
 
564
- Parameters
565
- ----------
566
- axis : Axis
567
- angle : float
588
+ See Also
589
+ --------
590
+ __add__ : Implements the + operator for mesh joining.
568
591
  """
592
+ if not all(isinstance(m, Mesh) for m in meshes):
593
+ raise TypeError("Only Mesh instances can be added together.")
569
594
 
570
- self._vertices = axis.rotate_points(self._vertices, angle)
571
-
572
- return self
595
+ faces = sum((m.as_list_of_faces() for m in meshes), [])
573
596
 
574
- # OTHER
575
- @inplace_transformation
576
- def flip_normals(self) -> 'Mesh':
577
- """Flips every normals of the mesh."""
597
+ if return_masks:
598
+ # Add a temporary metadata to keep track of the origin of each face
599
+ meshes = [m.with_metadata(origin_mesh_index=np.array([i]*m.nb_faces))
600
+ for i, m in enumerate(meshes)]
578
601
 
579
- self._faces = np.fliplr(self._faces)
602
+ faces_metadata = {k: np.concatenate([m.faces_metadata[k] for m in meshes], axis=0)
603
+ for k in AbstractMesh._common_metadata_keys(*meshes)}
580
604
 
581
- return self
582
-
583
- @inplace_transformation
584
- def mirror(self, plane) -> 'Mesh':
585
- """Flip the mesh with respect to a plane.
586
-
587
- Parameters
588
- ----------
589
- plane : Plane
590
- The mirroring plane
591
- """
592
- self.vertices -= 2 * np.outer(np.dot(self.vertices, plane.normal) - plane.c, plane.normal)
593
- self.flip_normals()
594
- return self
595
-
596
- def symmetrized(self, plane):
597
- from capytaine.meshes.symmetric import ReflectionSymmetricMesh
598
- half = self.clipped(plane, name=f"{self.name}_half")
599
- return ReflectionSymmetricMesh(half, plane=plane, name=f"symmetrized_of_{self.name}")
600
-
601
- @inplace_transformation
602
- def clip(self, plane) -> 'Mesh':
603
- from capytaine.meshes.clipper import clip
604
- clipped_self = clip(self, plane=plane)
605
- self.vertices = clipped_self.vertices
606
- self.faces = clipped_self.faces
607
- self._clipping_data = clipped_self._clipping_data
608
- return self
609
-
610
- @inplace_transformation
611
- def triangulate_quadrangles(self) -> 'Mesh':
612
- """Triangulates every quadrangles of the mesh by simple splitting.
613
- Each quadrangle gives two triangles.
614
-
615
- Note
616
- ----
617
- No checking on the triangle quality is done.
618
- """
619
- # Defining both triangles id lists to be generated from quadrangles
620
- t1 = (0, 1, 2)
621
- t2 = (0, 2, 3)
622
-
623
- faces = self._faces
624
-
625
- # Triangulation
626
- new_faces = faces[self.quadrangles_ids].copy()
627
- new_faces[:, :3] = new_faces[:, t1]
628
- new_faces[:, -1] = new_faces[:, 0]
629
-
630
- faces[self.quadrangles_ids, :3] = faces[:, t2][self.quadrangles_ids]
631
- faces[self.quadrangles_ids, -1] = faces[self.quadrangles_ids, 0]
632
-
633
- faces = np.concatenate((faces, new_faces))
634
-
635
- LOG.info('\nTriangulating quadrangles')
636
- if self.nb_quadrangles != 0:
637
- LOG.info('\t-->{:d} quadrangles have been split in triangles'.format(self.nb_quadrangles))
638
-
639
- self._faces = faces
640
-
641
- return self
642
-
643
- ####################
644
- # Combine meshes #
645
- ####################
646
-
647
- def join_meshes(*meshes, name=None):
648
- from capytaine.meshes.collections import CollectionOfMeshes
649
- return CollectionOfMeshes(meshes, name=name).merged()
650
-
651
- def __add__(self, mesh_to_add) -> 'Mesh':
652
- return self.join_meshes(mesh_to_add)
653
-
654
- ####################
655
- # Compare meshes #
656
- ####################
657
- # The objective is to write a mesh as a set of faces in order to check for equality or to
658
- # compute differences of meshes. Each face can be represented as a 4x3 array (4 triplets of
659
- # coordinates).
660
- # However, it is tricky on several aspects:
661
- # * The builtin set class compares the hashes of its objects. Since numpy ndarray are not
662
- # hashable, the 4x3 array can be transformed into a tuple of tuples (which is hashable).
663
- # * Two faces with different numbering of the faces (but the same ordering) are recorded as
664
- # different.
665
- # * Two vertices equal up to machine precision can be recorded as different, due to rounding
666
- # errors.
667
- #
668
- # A possible solution is to define a Face class and a Vertex class with the appropriate __eq__
669
- # and __hash__.
670
- #
671
- # The current implementation below is a rough draft.
672
- # However, the equality shall still be use for testing.
673
-
674
- def as_set_of_faces(self):
675
- return frozenset(frozenset(tuple(vertex) for vertex in face) for face in self.vertices[self.faces])
676
-
677
- @staticmethod
678
- def from_set_of_faces(set_of_faces):
679
- faces = []
680
- vertices = []
681
- for face in set_of_faces:
682
- ids_of_vertices_in_face = []
683
-
684
- for vertex in face:
685
- if vertex not in vertices:
686
- i = len(vertices)
687
- vertices.append(vertex)
688
- else:
689
- i = vertices.index(vertex)
690
- ids_of_vertices_in_face.append(i)
691
-
692
- if len(ids_of_vertices_in_face) == 3:
693
- # Add a fourth node identical to the first one
694
- ids_of_vertices_in_face.append(ids_of_vertices_in_face[0])
695
-
696
- faces.append(ids_of_vertices_in_face)
697
- return Mesh(vertices=vertices, faces=faces)
698
-
699
- def __eq__(self, other):
700
- if not isinstance(other, Mesh):
701
- return NotImplemented
605
+ if all(meshes[0].quadrature_method == m.quadrature_method for m in meshes[1:]):
606
+ quadrature_method = meshes[0].quadrature_method
702
607
  else:
703
- return self.as_set_of_faces() == other.as_set_of_faces()
704
-
705
- def __hash__(self):
706
- if 'hash' not in self.__internals__:
707
- self.__internals__['hash'] = hash(self.as_set_of_faces())
708
- return self.__internals__['hash']
709
-
710
- ##################
711
- # Mesh quality #
712
- ##################
713
-
714
- def merge_duplicates(self, **kwargs):
715
- return merge_duplicates(self, **kwargs)
716
-
717
- def heal_normals(self, **kwargs):
718
- return heal_normals(self, **kwargs)
719
-
720
- def remove_unused_vertices(self, **kwargs):
721
- return remove_unused_vertices(self, **kwargs)
722
-
723
- def heal_triangles(self, **kwargs):
724
- return heal_triangles(self, **kwargs)
725
-
726
- def remove_degenerated_faces(self, **kwargs):
727
- return remove_degenerated_faces(self, **kwargs)
728
-
729
- @inplace_transformation
730
- def heal_mesh(self, closed_mesh=True):
731
- """Heals the mesh for different tests available.
732
-
733
- It applies:
734
-
735
- * Unused vertices removal
736
- * Degenerate faces removal
737
- * Duplicate vertices merging
738
- * Triangles healing
739
- * Normal healing
740
- """
741
- self.remove_unused_vertices()
742
- self.remove_degenerated_faces()
743
- self.merge_duplicates()
744
- self.heal_triangles()
745
- if closed_mesh:
746
- self.heal_normals()
747
- return self
748
-
749
- ##########
750
- # Lids #
751
- ##########
752
-
753
- def lowest_lid_position(self, omega_max, *, g=9.81):
754
- z_lid = 0.0
755
- for comp in connected_components(self):
756
- for ccomp in connected_components_of_waterline(comp):
757
- x_span = ccomp.vertices[:, 0].max() - ccomp.vertices[:, 0].min()
758
- y_span = ccomp.vertices[:, 1].max() - ccomp.vertices[:, 1].min()
759
- p = np.hypot(1/x_span, 1/y_span)
760
- z_lid_comp = -np.arctanh(np.pi*g*p/omega_max**2) / (np.pi * p)
761
- z_lid = min(z_lid, z_lid_comp)
762
- return 0.9*z_lid # Add a small safety margin
608
+ LOG.info("Dropping inconsistent quadrature method when joining meshes")
609
+ quadrature_method = None
610
+
611
+ if name is None and all(m.name is not None for m in meshes):
612
+ name = "+".join([m.name for m in meshes])
613
+
614
+ joined_mesh = Mesh.from_list_of_faces(
615
+ faces,
616
+ quadrature_method=quadrature_method,
617
+ faces_metadata=faces_metadata,
618
+ name=name,
619
+ auto_check=False,
620
+ )
621
+ # If list of faces is trimmed for some reason, metadata will be updated accordingly
622
+
623
+ if return_masks:
624
+ # Extract the temporary metadata
625
+ masks = [joined_mesh.faces_metadata['origin_mesh_index'] == i for i in range(len(meshes))]
626
+ return joined_mesh.without_metadata('origin_mesh_index'), masks
627
+ else:
628
+ return joined_mesh
763
629
 
764
630
  def generate_lid(self, z=0.0, faces_max_radius=None, name=None):
765
631
  """
@@ -782,12 +648,17 @@ class Mesh(ClippableMixin, SurfaceIntegralsMixin, Abstract3DObject):
782
648
  """
783
649
  from capytaine.meshes.predefined.rectangles import mesh_rectangle
784
650
 
785
- clipped_hull_mesh = self.clipped(Plane(normal=(0, 0, 1), point=(0, 0, z)))
651
+ LOG.debug(f"Generating lid for {self.__str__()}")
652
+
653
+ if name is None and self.name is not None:
654
+ name = "lid for {}".format(self.name)
655
+
656
+ clipped_hull_mesh = self.clipped(normal=(0, 0, 1), origin=(0, 0, z))
786
657
  # Alternatively: could keep only faces below z without proper clipping,
787
658
  # and it would work similarly.
788
659
 
789
660
  if clipped_hull_mesh.nb_faces == 0:
790
- return Mesh(None, None, name="lid for {}".format(self.name))
661
+ return Mesh(None, None, name=name)
791
662
 
792
663
  x_span = clipped_hull_mesh.vertices[:, 0].max() - clipped_hull_mesh.vertices[:, 0].min()
793
664
  y_span = clipped_hull_mesh.vertices[:, 1].max() - clipped_hull_mesh.vertices[:, 1].min()
@@ -808,6 +679,7 @@ class Mesh(ClippableMixin, SurfaceIntegralsMixin, Abstract3DObject):
808
679
  faces_max_radius=faces_max_radius,
809
680
  center=(x_mean, y_mean, z),
810
681
  normal=(0.0, 0.0, -1.0),
682
+ name="candidate_lid_mesh"
811
683
  )
812
684
 
813
685
  candidate_lid_points = candidate_lid_mesh.vertices[:, 0:2]
@@ -833,34 +705,13 @@ class Mesh(ClippableMixin, SurfaceIntegralsMixin, Abstract3DObject):
833
705
 
834
706
  lid_faces = candidate_lid_mesh.faces[np.all(np.isin(candidate_lid_mesh.faces, needs_lid), axis=-1), :]
835
707
 
836
- if name is None:
837
- name = "lid for {}".format(self.name)
838
-
839
708
  if len(lid_faces) == 0:
840
709
  return Mesh(None, None, name=name)
841
710
 
842
- lid_mesh = Mesh(candidate_lid_mesh.vertices, lid_faces, name=name)
843
- lid_mesh.heal_mesh()
844
-
711
+ lid_mesh = Mesh(candidate_lid_mesh.vertices, lid_faces, name=name, auto_check=False)
845
712
  return lid_mesh
846
713
 
847
- @inplace_transformation
848
- def with_normal_vector_going_down(self):
849
- # For lid meshes for irregular frequencies removal
850
- if np.allclose(self.faces_normals[:, 2], np.ones((self.nb_faces,))):
851
- # The mesh is horizontal with normal vectors going up
852
- LOG.warning(f"Inverting the direction of the normal vectors of {self} to be downward.")
853
- self.faces = self.faces[:, ::-1]
854
- else:
855
- return self
856
-
857
- def _face_on_plane(self, i_face, plane):
858
- return (
859
- self.faces_centers[i_face, :] in plane
860
- and plane.is_orthogonal_to(self.faces_normals[i_face, :])
861
- )
862
-
863
- def extract_lid(self, plane=xOy_Plane):
714
+ def extract_lid(self, z=0.0):
864
715
  """
865
716
  Split the mesh into a mesh of the hull and a mesh of the lid.
866
717
  By default, the lid is composed of the horizontal faces on the z=0 plane.
@@ -875,7 +726,101 @@ class Mesh(ClippableMixin, SurfaceIntegralsMixin, Abstract3DObject):
875
726
  2-ple of Mesh
876
727
  hull mesh and lid mesh
877
728
  """
878
- faces_on_plane = [i_face for i_face in range(self.nb_faces) if self._face_on_plane(i_face, plane)]
729
+ def is_on_plane(i_face):
730
+ return np.isclose(self.faces_centers[i_face, 2], z) and (\
731
+ np.allclose(self.faces_normals[i_face, :], np.array([0.0, 0.0, 1.0])) or \
732
+ np.allclose(self.faces_normals[i_face, :], np.array([0.0, 0.0, -1.0]))
733
+ )
734
+
735
+ faces_on_plane = [
736
+ i_face for i_face in range(self.nb_faces) if is_on_plane(i_face)
737
+ ]
879
738
  lid_mesh = self.extract_faces(faces_on_plane)
880
739
  hull_mesh = self.extract_faces(list(set(range(self.nb_faces)) - set(faces_on_plane)))
881
740
  return hull_mesh, lid_mesh
741
+
742
+ def with_normal_vector_going_down(self, **kwargs) -> "Mesh":
743
+ # Kwargs are for backward compatibility with former inplace implementation of this.
744
+ # It could be removed in the final release.
745
+ """Ensure normal vectors point downward (negative z-direction).
746
+
747
+ Returns
748
+ -------
749
+ Mesh
750
+ Self if normals already point down, otherwise modifies face orientation.
751
+
752
+ Notes
753
+ -----
754
+ Used for lid meshes to avoid irregular frequency issues by ensuring
755
+ consistent normal vector direction.
756
+ """
757
+ # For lid meshes for irregular frequencies removal
758
+ if np.allclose(self.faces_normals[:, 2], np.ones((self.nb_faces,))):
759
+ # The mesh is horizontal with normal vectors going up
760
+ LOG.warning(
761
+ f"Inverting the direction of the normal vectors of {self} to be downward."
762
+ )
763
+ return Mesh(
764
+ vertices=self.vertices,
765
+ faces=self.faces[:, ::-1],
766
+ faces_metadata=self.faces_metadata,
767
+ quadrature_method=self.quadrature_method,
768
+ name=self.name,
769
+ auto_clean=False,
770
+ auto_check=False,
771
+ )
772
+ else:
773
+ return self
774
+
775
+ def copy(self, *, faces_metadata=None, name=None) -> Mesh:
776
+ # No-op for backward compatibility
777
+ if faces_metadata is None:
778
+ faces_metadata = self.faces_metadata.copy()
779
+ if name is None:
780
+ name = self.name
781
+ return Mesh(
782
+ vertices=self.vertices,
783
+ faces=self._faces,
784
+ faces_metadata=faces_metadata,
785
+ quadrature_method=self.quadrature_method,
786
+ name=name,
787
+ auto_clean=False,
788
+ auto_check=False
789
+ )
790
+
791
+ def merged(self, *, name=None) -> Mesh:
792
+ # No-op to be extended to symmetries
793
+ return self.copy(name=name)
794
+
795
+ def clipped(self, *, origin, normal, name=None) -> "Mesh":
796
+ """
797
+ Clip the mesh by a plane defined by `origin` and `normal`.
798
+
799
+ Parameters
800
+ ----------
801
+ origin : np.ndarray
802
+ The point in space where the clipping plane intersects (3D point).
803
+ normal : np.ndarray
804
+ The normal vector defining the orientation of the clipping plane.
805
+ name: Optional[str]
806
+ A name for the newly created mesh
807
+
808
+ Returns
809
+ -------
810
+ Mesh
811
+ A new Mesh instance that has been clipped.
812
+ """
813
+ LOG.debug(f"Clipping {self.__str__()} with origin={origin} and normal={normal}")
814
+ new_vertices, new_faces, face_parent = \
815
+ clip_faces(self.vertices, self._faces, normal, origin)
816
+ new_metadata = {k: self.faces_metadata[k][face_parent] for k in self.faces_metadata}
817
+ if name is None and self.name is not None:
818
+ name = f"{self.name}_clipped"
819
+ return Mesh(
820
+ vertices=new_vertices,
821
+ faces=new_faces,
822
+ faces_metadata=new_metadata,
823
+ quadrature_method=self.quadrature_method,
824
+ name=name,
825
+ auto_check=False,
826
+ )