capytaine 3.0.0a1__cp310-cp310-macosx_15_0_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 (65) 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 +21 -0
  5. capytaine/__init__.py +32 -0
  6. capytaine/bem/__init__.py +0 -0
  7. capytaine/bem/airy_waves.py +111 -0
  8. capytaine/bem/engines.py +321 -0
  9. capytaine/bem/problems_and_results.py +601 -0
  10. capytaine/bem/solver.py +718 -0
  11. capytaine/bodies/__init__.py +4 -0
  12. capytaine/bodies/bodies.py +630 -0
  13. capytaine/bodies/dofs.py +146 -0
  14. capytaine/bodies/hydrostatics.py +540 -0
  15. capytaine/bodies/multibodies.py +216 -0
  16. capytaine/green_functions/Delhommeau_float32.cpython-310-darwin.so +0 -0
  17. capytaine/green_functions/Delhommeau_float64.cpython-310-darwin.so +0 -0
  18. capytaine/green_functions/__init__.py +2 -0
  19. capytaine/green_functions/abstract_green_function.py +64 -0
  20. capytaine/green_functions/delhommeau.py +522 -0
  21. capytaine/green_functions/hams.py +210 -0
  22. capytaine/io/__init__.py +0 -0
  23. capytaine/io/bemio.py +153 -0
  24. capytaine/io/legacy.py +228 -0
  25. capytaine/io/wamit.py +479 -0
  26. capytaine/io/xarray.py +673 -0
  27. capytaine/meshes/__init__.py +2 -0
  28. capytaine/meshes/abstract_meshes.py +375 -0
  29. capytaine/meshes/clean.py +302 -0
  30. capytaine/meshes/clip.py +347 -0
  31. capytaine/meshes/export.py +89 -0
  32. capytaine/meshes/geometry.py +259 -0
  33. capytaine/meshes/io.py +433 -0
  34. capytaine/meshes/meshes.py +826 -0
  35. capytaine/meshes/predefined/__init__.py +6 -0
  36. capytaine/meshes/predefined/cylinders.py +280 -0
  37. capytaine/meshes/predefined/rectangles.py +202 -0
  38. capytaine/meshes/predefined/spheres.py +55 -0
  39. capytaine/meshes/quality.py +159 -0
  40. capytaine/meshes/surface_integrals.py +82 -0
  41. capytaine/meshes/symmetric_meshes.py +641 -0
  42. capytaine/meshes/visualization.py +353 -0
  43. capytaine/post_pro/__init__.py +6 -0
  44. capytaine/post_pro/free_surfaces.py +85 -0
  45. capytaine/post_pro/impedance.py +92 -0
  46. capytaine/post_pro/kochin.py +54 -0
  47. capytaine/post_pro/rao.py +60 -0
  48. capytaine/tools/__init__.py +0 -0
  49. capytaine/tools/block_circulant_matrices.py +275 -0
  50. capytaine/tools/cache_on_disk.py +26 -0
  51. capytaine/tools/deprecation_handling.py +18 -0
  52. capytaine/tools/lists_of_points.py +52 -0
  53. capytaine/tools/memory_monitor.py +45 -0
  54. capytaine/tools/optional_imports.py +27 -0
  55. capytaine/tools/prony_decomposition.py +150 -0
  56. capytaine/tools/symbolic_multiplication.py +161 -0
  57. capytaine/tools/timer.py +90 -0
  58. capytaine/ui/__init__.py +0 -0
  59. capytaine/ui/cli.py +28 -0
  60. capytaine/ui/rich.py +5 -0
  61. capytaine-3.0.0a1.dist-info/LICENSE +674 -0
  62. capytaine-3.0.0a1.dist-info/METADATA +755 -0
  63. capytaine-3.0.0a1.dist-info/RECORD +65 -0
  64. capytaine-3.0.0a1.dist-info/WHEEL +6 -0
  65. capytaine-3.0.0a1.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,826 @@
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
16
+
17
+ import logging
18
+ from functools import cached_property
19
+ from typing import List, Union, Tuple, Dict, Optional, Literal
20
+
21
+ import numpy as np
22
+
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
37
+
38
+ LOG = logging.getLogger(__name__)
39
+
40
+
41
+ class Mesh(AbstractMesh):
42
+ """Mesh class for representing and manipulating 3D surface meshes.
43
+
44
+ Parameters
45
+ ----------
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.
55
+ name : str, optional
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.
61
+
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
+ """
69
+
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)
86
+
87
+ # --- Faces: process using helper method ---
88
+ self._faces: List[List[int]] = self._process_faces(faces)
89
+
90
+ if faces_metadata is None:
91
+ self.faces_metadata = {}
92
+ else:
93
+ self.faces_metadata = {k: np.asarray(faces_metadata[k]) for k in faces_metadata}
94
+
95
+ for m in self.faces_metadata:
96
+ assert self.faces_metadata[m].shape[0] == len(self._faces)
97
+
98
+ # Optional name
99
+ self.name = str(name) if name is not None else None
100
+
101
+ self.quadrature_method = quadrature_method
102
+
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.
152
+
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(...)"
181
+
182
+ def __repr__(self) -> str:
183
+ return self.__str__()
184
+
185
+ def _repr_pretty_(self, p, cycle):
186
+ p.text(self.__str__())
187
+
188
+ def __rich_repr__(self):
189
+ class CustomRepr:
190
+ def __init__(self, n, kind):
191
+ self.n = n
192
+ self.kind = kind
193
+ def __repr__(self):
194
+ return "[[... {} {} ...]]".format(self.n, self.kind)
195
+ yield "vertices", CustomRepr(self.nb_vertices, "vertices")
196
+ yield "faces", CustomRepr(self.nb_faces, "faces")
197
+ yield "quadrature_method", self.quadrature_method
198
+ yield "name", self.name
199
+
200
+ def show(self, *, backend=None, **kwargs):
201
+ """Visualize the mesh using the specified backend.
202
+
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`
213
+
214
+ Returns
215
+ -------
216
+ object
217
+ Visualization object returned by the backend (e.g., matplotlib figure).
218
+
219
+ Raises
220
+ ------
221
+ NotImplementedError
222
+ If the specified backend is not supported.
223
+ """
224
+ return show_3d(self, backend=backend, **kwargs)
225
+
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.
232
+
233
+ Parameters
234
+ ----------
235
+ arr : np.ndarray
236
+ 2D array of face data
237
+
238
+ Returns
239
+ -------
240
+ bool
241
+ True if the first column appears to be vertex counts
242
+ """
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.
256
+
257
+ Parameters
258
+ ----------
259
+ faces : np.ndarray or list
260
+ The faces data to process.
261
+
262
+ Returns
263
+ -------
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.
272
+ """
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]
278
+ else:
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":
299
+ """
300
+ Create a Mesh instance from a list of faces defined by vertex coordinates.
301
+
302
+ Parameters
303
+ ----------
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.
317
+
318
+ Returns
319
+ -------
320
+ Mesh
321
+ An instance of Mesh with:
322
+ - unique vertices extracted from the input
323
+ - faces defined as indices into the vertex array
324
+ """
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.
350
+
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
376
+
377
+ def export(self, format, **kwargs):
378
+ return export_mesh(self, format, **kwargs)
379
+
380
+ ## INTERFACE FOR BEM SOLVER
381
+
382
+ @cached_property
383
+ def faces_vertices_centers(self) -> np.ndarray:
384
+ """Calculate the center of vertices that form the faces.
385
+
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)
397
+ else:
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)
402
+
403
+ @cached_property
404
+ def faces_normals(self) -> np.ndarray:
405
+ """Normal vectors for each face.
406
+
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)
413
+
414
+ @cached_property
415
+ def faces_areas(self) -> np.ndarray:
416
+ """Surface area of each face.
417
+
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
426
+ def faces_centers(self) -> np.ndarray:
427
+ """Geometric centers of each face.
428
+
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)
435
+
436
+ @cached_property
437
+ def faces_radiuses(self) -> np.ndarray:
438
+ """Radii of each face (circumradius or characteristic size).
439
+
440
+ Returns
441
+ -------
442
+ np.ndarray
443
+ Array of shape (n_faces,) containing the radius of each face.
444
+ """
445
+ return compute_faces_radii(self.vertices, self._faces)
446
+
447
+ @cached_property
448
+ def faces(self) -> np.ndarray:
449
+ """Face connectivity as quadrilateral array.
450
+
451
+ Returns
452
+ -------
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.
461
+ """
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)
464
+
465
+ @cached_property
466
+ def quadrature_points(self) -> Tuple[np.ndarray, np.ndarray]:
467
+ """Quadrature points and weights for numerical integration.
468
+
469
+ Returns
470
+ -------
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.
474
+ """
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
+ )
494
+
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.
497
+
498
+ Parameters
499
+ ----------
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.
509
+ """
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
554
+ else:
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.
571
+
572
+ Parameters
573
+ ----------
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
582
+
583
+ Returns
584
+ -------
585
+ Mesh
586
+ New mesh containing vertices and faces from all meshes.
587
+
588
+ See Also
589
+ --------
590
+ __add__ : Implements the + operator for mesh joining.
591
+ """
592
+ if not all(isinstance(m, Mesh) for m in meshes):
593
+ raise TypeError("Only Mesh instances can be added together.")
594
+
595
+ faces = sum((m.as_list_of_faces() for m in meshes), [])
596
+
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)]
601
+
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)}
604
+
605
+ if all(meshes[0].quadrature_method == m.quadrature_method for m in meshes[1:]):
606
+ quadrature_method = meshes[0].quadrature_method
607
+ else:
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
629
+
630
+ def generate_lid(self, z=0.0, faces_max_radius=None, name=None):
631
+ """
632
+ Return a mesh of the internal free surface of the body.
633
+
634
+ Parameters
635
+ ----------
636
+ z: float, optional
637
+ Vertical position of the lid. Default: 0.0
638
+ faces_max_radius: float, optional
639
+ resolution of the mesh of the lid.
640
+ Default: mean of hull mesh resolution.
641
+ name: str, optional
642
+ A name for the new mesh
643
+
644
+ Returns
645
+ -------
646
+ Mesh
647
+ lid of internal surface
648
+ """
649
+ from capytaine.meshes.predefined.rectangles import mesh_rectangle
650
+
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))
657
+ # Alternatively: could keep only faces below z without proper clipping,
658
+ # and it would work similarly.
659
+
660
+ if clipped_hull_mesh.nb_faces == 0:
661
+ return Mesh(None, None, name=name)
662
+
663
+ x_span = clipped_hull_mesh.vertices[:, 0].max() - clipped_hull_mesh.vertices[:, 0].min()
664
+ y_span = clipped_hull_mesh.vertices[:, 1].max() - clipped_hull_mesh.vertices[:, 1].min()
665
+ x_mean = (clipped_hull_mesh.vertices[:, 0].max() + clipped_hull_mesh.vertices[:, 0].min())/2
666
+ y_mean = (clipped_hull_mesh.vertices[:, 1].max() + clipped_hull_mesh.vertices[:, 1].min())/2
667
+
668
+ if faces_max_radius is None:
669
+ faces_max_radius = np.mean(clipped_hull_mesh.faces_radiuses)
670
+
671
+ candidate_lid_size = (
672
+ max(faces_max_radius/2, 1.1*x_span),
673
+ max(faces_max_radius/2, 1.1*y_span),
674
+ )
675
+ # The size of the lid is at least the characteristic length of a face
676
+
677
+ candidate_lid_mesh = mesh_rectangle(
678
+ size=(candidate_lid_size[1], candidate_lid_size[0]), # TODO Fix: Exchange x and y in mesh_rectangle
679
+ faces_max_radius=faces_max_radius,
680
+ center=(x_mean, y_mean, z),
681
+ normal=(0.0, 0.0, -1.0),
682
+ name="candidate_lid_mesh"
683
+ )
684
+
685
+ candidate_lid_points = candidate_lid_mesh.vertices[:, 0:2]
686
+
687
+ hull_faces = clipped_hull_mesh.vertices[clipped_hull_mesh.faces, 0:2]
688
+ edges_of_hull_faces = hull_faces[:, [1, 2, 3, 0], :] - hull_faces[:, :, :] # Vectors between two consecutive points in a face
689
+ # edges_of_hull_faces.shape = (nb_full_faces, 4, 2)
690
+ lid_points_in_local_coords = candidate_lid_points[:, np.newaxis, np.newaxis, :] - hull_faces[:, :, :]
691
+ # lid_points_in_local_coords.shape = (nb_candidate_lid_points, nb_full_faces, 4, 2)
692
+ side_of_hull_edges = (lid_points_in_local_coords[..., 0] * edges_of_hull_faces[..., 1]
693
+ - lid_points_in_local_coords[..., 1] * edges_of_hull_faces[..., 0])
694
+ # side_of_hull_edges.shape = (nb_candidate_lid_points, nb_full_faces, 4)
695
+ point_is_above_panel = np.all(side_of_hull_edges <= 0, axis=-1) | np.all(side_of_hull_edges >= 0, axis=-1)
696
+ # point_is_above_panel.shape = (nb_candidate_lid_points, nb_full_faces)
697
+
698
+ # For all point in candidate_lid_points, and for all edges of all faces of
699
+ # the hull mesh, check on which side of the edge is the point by using a
700
+ # cross product.
701
+ # If a point on the same side of all edges of a face, then it is inside.
702
+
703
+ nb_panels_below_point = np.sum(point_is_above_panel, axis=-1)
704
+ needs_lid = (nb_panels_below_point % 2 == 1).nonzero()[0]
705
+
706
+ lid_faces = candidate_lid_mesh.faces[np.all(np.isin(candidate_lid_mesh.faces, needs_lid), axis=-1), :]
707
+
708
+ if len(lid_faces) == 0:
709
+ return Mesh(None, None, name=name)
710
+
711
+ lid_mesh = Mesh(candidate_lid_mesh.vertices, lid_faces, name=name, auto_check=False)
712
+ return lid_mesh
713
+
714
+ def extract_lid(self, z=0.0):
715
+ """
716
+ Split the mesh into a mesh of the hull and a mesh of the lid.
717
+ By default, the lid is composed of the horizontal faces on the z=0 plane.
718
+
719
+ Parameters
720
+ ----------
721
+ plane: Plane
722
+ The plane on which to look for lid faces.
723
+
724
+ Returns
725
+ -------
726
+ 2-ple of Mesh
727
+ hull mesh and lid mesh
728
+ """
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
+ ]
738
+ lid_mesh = self.extract_faces(faces_on_plane)
739
+ hull_mesh = self.extract_faces(list(set(range(self.nb_faces)) - set(faces_on_plane)))
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
+ )