capytaine 3.0.0a1__cp314-cp314-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.
- capytaine/.dylibs/libgcc_s.1.1.dylib +0 -0
- capytaine/.dylibs/libgfortran.5.dylib +0 -0
- capytaine/.dylibs/libquadmath.0.dylib +0 -0
- capytaine/__about__.py +21 -0
- capytaine/__init__.py +32 -0
- capytaine/bem/__init__.py +0 -0
- capytaine/bem/airy_waves.py +111 -0
- capytaine/bem/engines.py +321 -0
- capytaine/bem/problems_and_results.py +601 -0
- capytaine/bem/solver.py +718 -0
- capytaine/bodies/__init__.py +4 -0
- capytaine/bodies/bodies.py +630 -0
- capytaine/bodies/dofs.py +146 -0
- capytaine/bodies/hydrostatics.py +540 -0
- capytaine/bodies/multibodies.py +216 -0
- capytaine/green_functions/Delhommeau_float32.cpython-314-darwin.so +0 -0
- capytaine/green_functions/Delhommeau_float64.cpython-314-darwin.so +0 -0
- capytaine/green_functions/__init__.py +2 -0
- capytaine/green_functions/abstract_green_function.py +64 -0
- capytaine/green_functions/delhommeau.py +522 -0
- capytaine/green_functions/hams.py +210 -0
- capytaine/io/__init__.py +0 -0
- capytaine/io/bemio.py +153 -0
- capytaine/io/legacy.py +228 -0
- capytaine/io/wamit.py +479 -0
- capytaine/io/xarray.py +673 -0
- capytaine/meshes/__init__.py +2 -0
- capytaine/meshes/abstract_meshes.py +375 -0
- capytaine/meshes/clean.py +302 -0
- capytaine/meshes/clip.py +347 -0
- capytaine/meshes/export.py +89 -0
- capytaine/meshes/geometry.py +259 -0
- capytaine/meshes/io.py +433 -0
- capytaine/meshes/meshes.py +826 -0
- capytaine/meshes/predefined/__init__.py +6 -0
- capytaine/meshes/predefined/cylinders.py +280 -0
- capytaine/meshes/predefined/rectangles.py +202 -0
- capytaine/meshes/predefined/spheres.py +55 -0
- capytaine/meshes/quality.py +159 -0
- capytaine/meshes/surface_integrals.py +82 -0
- capytaine/meshes/symmetric_meshes.py +641 -0
- capytaine/meshes/visualization.py +353 -0
- capytaine/post_pro/__init__.py +6 -0
- capytaine/post_pro/free_surfaces.py +85 -0
- capytaine/post_pro/impedance.py +92 -0
- capytaine/post_pro/kochin.py +54 -0
- capytaine/post_pro/rao.py +60 -0
- capytaine/tools/__init__.py +0 -0
- capytaine/tools/block_circulant_matrices.py +275 -0
- capytaine/tools/cache_on_disk.py +26 -0
- capytaine/tools/deprecation_handling.py +18 -0
- capytaine/tools/lists_of_points.py +52 -0
- capytaine/tools/memory_monitor.py +45 -0
- capytaine/tools/optional_imports.py +27 -0
- capytaine/tools/prony_decomposition.py +150 -0
- capytaine/tools/symbolic_multiplication.py +161 -0
- capytaine/tools/timer.py +90 -0
- capytaine/ui/__init__.py +0 -0
- capytaine/ui/cli.py +28 -0
- capytaine/ui/rich.py +5 -0
- capytaine-3.0.0a1.dist-info/LICENSE +674 -0
- capytaine-3.0.0a1.dist-info/METADATA +755 -0
- capytaine-3.0.0a1.dist-info/RECORD +65 -0
- capytaine-3.0.0a1.dist-info/WHEEL +6 -0
- 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
|
+
)
|