capytaine 2.3__cp311-cp311-win_amd64.whl → 3.0.0a1__cp311-cp311-win_amd64.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 (88) hide show
  1. capytaine/__about__.py +7 -2
  2. capytaine/__init__.py +11 -15
  3. capytaine/bem/engines.py +234 -354
  4. capytaine/bem/problems_and_results.py +30 -21
  5. capytaine/bem/solver.py +205 -81
  6. capytaine/bodies/bodies.py +279 -862
  7. capytaine/bodies/dofs.py +136 -9
  8. capytaine/bodies/hydrostatics.py +540 -0
  9. capytaine/bodies/multibodies.py +216 -0
  10. capytaine/green_functions/{libs/Delhommeau_float32.cp311-win_amd64.dll.a → Delhommeau_float32.cp311-win_amd64.dll.a} +0 -0
  11. capytaine/green_functions/Delhommeau_float32.cp311-win_amd64.pyd +0 -0
  12. capytaine/green_functions/{libs/Delhommeau_float64.cp311-win_amd64.dll.a → Delhommeau_float64.cp311-win_amd64.dll.a} +0 -0
  13. capytaine/green_functions/Delhommeau_float64.cp311-win_amd64.pyd +0 -0
  14. capytaine/green_functions/abstract_green_function.py +2 -2
  15. capytaine/green_functions/delhommeau.py +50 -31
  16. capytaine/green_functions/hams.py +19 -13
  17. capytaine/io/legacy.py +3 -103
  18. capytaine/io/xarray.py +15 -10
  19. capytaine/meshes/__init__.py +2 -6
  20. capytaine/meshes/abstract_meshes.py +375 -0
  21. capytaine/meshes/clean.py +302 -0
  22. capytaine/meshes/clip.py +347 -0
  23. capytaine/meshes/export.py +89 -0
  24. capytaine/meshes/geometry.py +244 -394
  25. capytaine/meshes/io.py +433 -0
  26. capytaine/meshes/meshes.py +621 -676
  27. capytaine/meshes/predefined/cylinders.py +22 -56
  28. capytaine/meshes/predefined/rectangles.py +26 -85
  29. capytaine/meshes/predefined/spheres.py +4 -11
  30. capytaine/meshes/quality.py +118 -407
  31. capytaine/meshes/surface_integrals.py +48 -29
  32. capytaine/meshes/symmetric_meshes.py +641 -0
  33. capytaine/meshes/visualization.py +353 -0
  34. capytaine/post_pro/free_surfaces.py +1 -4
  35. capytaine/post_pro/kochin.py +10 -10
  36. capytaine/tools/block_circulant_matrices.py +275 -0
  37. capytaine/tools/lists_of_points.py +2 -2
  38. capytaine/tools/memory_monitor.py +45 -0
  39. capytaine/tools/symbolic_multiplication.py +31 -5
  40. capytaine/tools/timer.py +68 -42
  41. capytaine-3.0.0a1.dist-info/DELVEWHEEL +2 -0
  42. {capytaine-2.3.dist-info → capytaine-3.0.0a1.dist-info}/METADATA +8 -14
  43. capytaine-3.0.0a1.dist-info/RECORD +70 -0
  44. capytaine/bodies/predefined/__init__.py +0 -6
  45. capytaine/bodies/predefined/cylinders.py +0 -151
  46. capytaine/bodies/predefined/rectangles.py +0 -111
  47. capytaine/bodies/predefined/spheres.py +0 -70
  48. capytaine/green_functions/FinGreen3D/.gitignore +0 -1
  49. capytaine/green_functions/FinGreen3D/FinGreen3D.f90 +0 -3589
  50. capytaine/green_functions/FinGreen3D/LICENSE +0 -165
  51. capytaine/green_functions/FinGreen3D/Makefile +0 -16
  52. capytaine/green_functions/FinGreen3D/README.md +0 -24
  53. capytaine/green_functions/FinGreen3D/test_program.f90 +0 -39
  54. capytaine/green_functions/LiangWuNoblesse/.gitignore +0 -1
  55. capytaine/green_functions/LiangWuNoblesse/LICENSE +0 -504
  56. capytaine/green_functions/LiangWuNoblesse/LiangWuNoblesseWaveTerm.f90 +0 -751
  57. capytaine/green_functions/LiangWuNoblesse/Makefile +0 -18
  58. capytaine/green_functions/LiangWuNoblesse/README.md +0 -2
  59. capytaine/green_functions/LiangWuNoblesse/test_program.f90 +0 -28
  60. capytaine/green_functions/libs/Delhommeau_float32.cp311-win_amd64.pyd +0 -0
  61. capytaine/green_functions/libs/Delhommeau_float64.cp311-win_amd64.pyd +0 -0
  62. capytaine/green_functions/libs/__init__.py +0 -0
  63. capytaine/io/mesh_loaders.py +0 -1086
  64. capytaine/io/mesh_writers.py +0 -692
  65. capytaine/io/meshio.py +0 -38
  66. capytaine/matrices/__init__.py +0 -16
  67. capytaine/matrices/block.py +0 -592
  68. capytaine/matrices/block_toeplitz.py +0 -325
  69. capytaine/matrices/builders.py +0 -89
  70. capytaine/matrices/linear_solvers.py +0 -232
  71. capytaine/matrices/low_rank.py +0 -395
  72. capytaine/meshes/clipper.py +0 -465
  73. capytaine/meshes/collections.py +0 -334
  74. capytaine/meshes/mesh_like_protocol.py +0 -37
  75. capytaine/meshes/properties.py +0 -276
  76. capytaine/meshes/quadratures.py +0 -80
  77. capytaine/meshes/symmetric.py +0 -392
  78. capytaine/tools/lru_cache.py +0 -49
  79. capytaine/ui/vtk/__init__.py +0 -3
  80. capytaine/ui/vtk/animation.py +0 -329
  81. capytaine/ui/vtk/body_viewer.py +0 -28
  82. capytaine/ui/vtk/helpers.py +0 -82
  83. capytaine/ui/vtk/mesh_viewer.py +0 -461
  84. capytaine-2.3.dist-info/DELVEWHEEL +0 -2
  85. capytaine-2.3.dist-info/RECORD +0 -97
  86. {capytaine-2.3.dist-info → capytaine-3.0.0a1.dist-info}/LICENSE +0 -0
  87. {capytaine-2.3.dist-info → capytaine-3.0.0a1.dist-info}/WHEEL +0 -0
  88. {capytaine-2.3.dist-info → capytaine-3.0.0a1.dist-info}/entry_points.txt +0 -0
@@ -1,448 +1,159 @@
1
- """Tools for mesh quality and mesh healing.
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.
6
14
 
7
15
  import logging
16
+ from itertools import chain
8
17
 
9
18
  import numpy as np
10
19
 
11
- from capytaine.meshes.geometry import inplace_transformation
12
- from capytaine.meshes.properties import compute_connectivity
13
-
14
20
  LOG = logging.getLogger(__name__)
21
+ SHOWED_PYVISTA_WARNING = False
15
22
 
16
23
 
17
- def merge_duplicates(mesh, atol=1e-8):
18
- """Merges the duplicate vertices of the mesh in place.
19
-
20
- Parameters
21
- ----------
22
- atol : float, optional
23
- Absolute tolerance. default is 1e-8
24
-
25
- Returns
26
- -------
27
- new_id : ndarray
28
- Array of indices that merges the vertices.
24
+ def check_mesh_quality(mesh, *, tol=1e-8):
29
25
  """
30
- uniq, new_id = merge_duplicate_rows(mesh.vertices, atol=atol)
26
+ Perform a set of geometric and metric quality checks on mesh data.
31
27
 
32
- nv_init = mesh.nb_vertices
33
-
34
- # Updating mesh data
35
- mesh.vertices = uniq
36
- mesh.faces = new_id[mesh.faces] # Faces vertices ids are updated here
28
+ Checks performed:
29
+ - Non-coplanar faces
30
+ - Non-convex faces
31
+ - Aspect ratio via PyVista (if available)
32
+ """
33
+ non_coplanar = indices_of_non_coplanar_faces(mesh.vertices, mesh._faces)
34
+ if non_coplanar:
35
+ LOG.warning(f"{len(non_coplanar)} non-coplanar faces detected in {mesh}.")
36
+
37
+ non_convex = indices_of_non_convex_faces(mesh.vertices, mesh._faces)
38
+ if non_convex:
39
+ LOG.warning(f"{len(non_convex)} non-convex faces detected in {mesh}.")
40
+
41
+ try:
42
+ pv_mesh = mesh.export_to_pyvista()
43
+
44
+ try:
45
+ arq = pv_mesh.cell_quality("aspect_ratio").cell_data.get("aspect_ratio")
46
+ except AttributeError: # Older version of PyVista
47
+ arq = pv_mesh.compute_cell_quality("aspect_ratio").cell_data.get("CellQuality")
48
+ if arq is not None:
49
+ ratio_ok = np.sum(arq < 5) / len(arq)
50
+ if ratio_ok < 0.9:
51
+ LOG.info(f"Aspect Ratio of {mesh}:")
52
+ LOG.info(
53
+ f" Min: {np.min(arq):.3f} | Max: {np.max(arq):.3f} | Mean: {np.mean(arq):.3f}"
54
+ )
55
+ LOG.info(f" Elements with AR < 5: {ratio_ok*100:.1f}%")
56
+ LOG.warning(
57
+ "Low quality: more than 10% of elements have aspect ratio higher than 5."
58
+ )
59
+ except ImportError:
60
+ global SHOWED_PYVISTA_WARNING
61
+ if LOG.isEnabledFor(logging.INFO) and not SHOWED_PYVISTA_WARNING:
62
+ LOG.info("PyVista not installed, skipping aspect ratio check.")
63
+ SHOWED_PYVISTA_WARNING = True
37
64
 
38
- nv_final = mesh.nb_vertices
39
65
 
40
- LOG.debug("* Merging duplicate vertices that lie in an absolute proximity of %.1E...", atol)
41
- delta_n = nv_init - nv_final
42
- if delta_n == 0:
43
- LOG.debug("\t--> No duplicate vertices have been found")
44
- else:
45
- LOG.debug("\t--> Initial number of vertices : %u", nv_init)
46
- LOG.debug("\t--> Final number of vertices : %u", nv_final)
47
- LOG.debug("\t--> %u vertices have been merged", delta_n)
66
+ def extract_face_vertices(vertices, face):
67
+ return vertices[face]
48
68
 
49
- # if mesh._has_connectivity():
50
- # mesh._remove_connectivity()
51
69
 
52
- return new_id
70
+ def is_non_coplanar(vertices):
71
+ a, b, c, d = vertices
72
+ normal = np.cross(b - a, c - a)
73
+ deviation = np.abs(np.dot(normal, d - a))
74
+ return deviation > 1e-8
53
75
 
54
76
 
55
- def merge_duplicate_rows(arr, atol=1e-8):
56
- """Returns a new node array where close nodes have been merged into one node (following atol).
77
+ def indices_of_non_coplanar_faces(vertices, faces):
78
+ """
79
+ Identify the indices of quadrilateral faces that are not coplanar.
57
80
 
58
81
  Parameters
59
82
  ----------
60
- arr : array_like
61
- array of the coordinates of the mesh's nodes
62
- atol : float, optional
63
- the tolerance used to define nodes that are coincident and
64
- that have to be merged
83
+ vertices : np.ndarray
84
+ Array of vertex coordinates (n_vertices, 3).
85
+ faces : np.ndarray
86
+ Array of face indices (n_faces, 4) or (n_faces, 3).
65
87
 
66
88
  Returns
67
89
  -------
68
- arr : ndarray
69
- array of the coordinates of the mesh's nodes where
70
- every node is different
71
- newID : ndarray
72
- array of the new new vertices IDs
73
- """
74
- # This function is a bottleneck in the clipping routines
75
- # TODO: use np.unique to cluster groups --> acceleration !!
76
-
77
- # atol = pow(10, -decimals)
78
-
79
- arr = np.asarray(arr)
80
-
81
- nv, nbdim = arr.shape
82
-
83
- levels = [0, nv]
84
- iperm = np.arange(nv)
85
-
86
- for dim in range(nbdim):
87
- # Sorting the first dimension
88
- values = arr[:, dim].copy()
89
- if dim > 0:
90
- values = values[iperm]
91
- levels_tmp = []
92
- for (ilevel, istart) in enumerate(levels[:-1]):
93
- istop = levels[ilevel+1]
94
-
95
- if istop-istart > 1:
96
- level_values = values[istart:istop]
97
- iperm_view = iperm[istart:istop]
98
-
99
- iperm_tmp = level_values.argsort()
100
-
101
- level_values[:] = level_values[iperm_tmp]
102
- iperm_view[:] = iperm_view[iperm_tmp]
103
-
104
- levels_tmp.append(istart)
105
- vref = values[istart]
106
-
107
- for idx in range(istart, istop):
108
- cur_val = values[idx]
109
- if np.abs(cur_val - vref) > atol:
110
- levels_tmp.append(idx)
111
- vref = cur_val
112
-
113
- else:
114
- levels_tmp.append(levels[ilevel])
115
- if len(levels_tmp) == nv:
116
- # No duplicate rows
117
- # if verbose:
118
- # LOG.debug "\t -> No duplicate _vertices detected :)"
119
- newID = np.arange(nv)
120
-
121
- levels_tmp.append(nv)
122
- levels = levels_tmp
123
-
124
- else:
125
- # Building the new merged node list
126
- arr_tmp = []
127
- newID = np.arange(nv)
128
- for (ilevel, istart) in enumerate(levels[:-1]):
129
- istop = levels[ilevel+1]
130
-
131
- arr_tmp.append(arr[iperm[istart]])
132
- newID[iperm[list(range(istart, istop))]] = ilevel
133
- arr = np.array(arr_tmp, dtype=float)
134
- # Applying renumbering to cells
135
- # if F is not None:
136
- # for cell in F:
137
- # cell[:] = newID[cell]
138
-
139
- # if verbose:
140
- # nv_new = arr.shape[0]
141
- # LOG.debug "\t -> Initial number of nodes : {:d}".format(nv)
142
- # LOG.debug "\t -> New number of nodes : {:d}".format(nv_new)
143
- # LOG.debug "\t -> {:d} nodes have been merged".format(nv-nv_new)
144
-
145
- # if F is not None:
146
- # if return_index:
147
- # return arr, F, newID
148
- # else:
149
- # return arr, F
150
- # else:
151
- return arr, newID
152
-
153
-
154
- @inplace_transformation
155
- def heal_normals(mesh):
156
- """Heals the mesh's normals orientations so that they have a consistent orientation and try to make them outward.
157
- """
158
- # TODO: return the different groups of a mesh in case it is made of several unrelated groups
159
-
160
- nv = mesh.nb_vertices
161
- nf = mesh.nb_faces
162
- faces = mesh._faces
163
-
164
- # Building connectivities
165
- connectivities = compute_connectivity(mesh)
166
- v_v = connectivities["v_v"]
167
- v_f = connectivities["v_f"]
168
- f_f = connectivities["f_f"]
169
- boundaries = connectivities["boundaries"]
170
-
171
- if len(boundaries) > 0:
172
- mesh_closed = False
173
- else:
174
- mesh_closed = True
175
-
176
- # Flooding the mesh to find inconsistent normals
177
- type_cell = np.zeros(nf, dtype=np.int32)
178
- type_cell[:] = 4
179
- type_cell[mesh.triangles_ids] = 3
180
-
181
- f_vis = np.zeros(nf, dtype=bool)
182
- f_vis[0] = True
183
- stack = [0]
184
- nb_reversed = 0
185
- while 1:
186
- if len(stack) == 0:
187
- if np.any(np.logical_not(f_vis)):
188
- iface = np.where(np.logical_not(f_vis))[0][0]
189
- stack.append(iface)
190
- f_vis[iface] = True
191
- else:
192
- break
193
-
194
- iface = stack.pop()
195
- face = faces[iface]
196
- s1 = set(face)
197
-
198
- for iadj_f in f_f[iface]:
199
- if f_vis[iadj_f]:
200
- continue
201
- f_vis[iadj_f] = True
202
- # Removing the other pointer
203
- f_f[iadj_f].remove(iface) # So as it won't go from iadj_f to iface in the future
204
-
205
- # Shared vertices
206
- adjface = faces[iadj_f]
207
- s2 = set(adjface)
208
- # try:
209
- common_vertices = list(s1 & s2)
210
- if len(common_vertices) == 2:
211
- i_v1, i_v2 = common_vertices
212
- else:
213
- LOG.warning('faces %u and %u have more than 2 vertices in common !', iface, iadj_f)
214
- continue
215
-
216
- # Checking normal consistency
217
- face_ref = np.roll(face[:type_cell[iface]], -np.where(face == i_v1)[0][0])
218
- adj_face_ref = np.roll(adjface[:type_cell[iadj_f]], -np.where(adjface == i_v1)[0][0])
219
-
220
- if face_ref[1] == i_v2:
221
- i = 1
222
- else:
223
- i = -1
224
-
225
- if adj_face_ref[i] == i_v2:
226
- # Reversing normal
227
- nb_reversed += 1
228
- faces[iadj_f] = np.flipud(faces[iadj_f])
229
-
230
- # Appending to the stack
231
- stack.append(iadj_f)
232
-
233
- LOG.debug("* Healing normals to make them consistent and if possible outward")
234
- if nb_reversed > 0:
235
- LOG.debug('\t--> %u faces have been reversed to make normals consistent across the mesh' % (nb_reversed))
236
- else:
237
- LOG.debug("\t--> Normals orientations are consistent")
238
-
239
- mesh._faces = faces
240
-
241
- # Checking if the normals are outward
242
- if mesh_closed:
243
- zmax = np.max(mesh._vertices[:, 2])
244
-
245
- areas = mesh.faces_areas
246
- normals = mesh.faces_normals
247
- centers = mesh.faces_centers
248
- # areas, normals, centers = get_all_faces_properties(vertices, faces)
249
-
250
- hs = (np.array([(centers[:, 2] - zmax) * areas, ] * 3).T * normals).sum(axis=0)
251
-
252
- tol = 1e-9
253
- if np.fabs(hs[0]) > tol or np.fabs(hs[1]) > tol:
254
- LOG.warning("\t--> the mesh does not seem watertight although marked as closed...")
255
-
256
- if hs[2] < 0:
257
- flipped = True
258
- mesh.flip_normals()
259
- else:
260
- flipped = False
261
-
262
- if flipped:
263
- LOG.debug('\t--> Every normals have been reversed to be outward')
264
-
265
- else:
266
- LOG.debug("\t--> Mesh is not closed, Capytaine cannot test if the normals are outward")
267
-
268
- return mesh
269
-
270
-
271
- @inplace_transformation
272
- def remove_unused_vertices(mesh):
273
- """Removes unused vertices in the mesh in place.
274
-
275
- Those are vertices that are not used by any face connectivity.
90
+ list[int]
91
+ List of indices of non-coplanar quadrilateral faces.
276
92
  """
277
- # TODO: implementer return_index !!
278
- nv = mesh.nb_vertices
279
- vertices, faces = mesh._vertices, mesh._faces
280
-
281
- used_v = np.zeros(nv, dtype=bool)
282
- used_v[sum(list(map(list, faces)), [])] = True
283
- nb_used_v = sum(used_v)
284
-
285
- if nb_used_v < nv:
286
- new_id__v = np.arange(nv)
287
- new_id__v[used_v] = np.arange(nb_used_v)
288
- faces = new_id__v[faces]
289
- vertices = vertices[used_v]
290
-
291
- mesh._vertices, mesh._faces = vertices, faces
292
-
293
- LOG.debug("* Removing unused vertices in the mesh:")
294
- if nb_used_v < nv:
295
- unused_v = np.where(np.logical_not(used_v))[0]
296
- vlist_str = '[' + ', '.join(str(iV) for iV in unused_v) + ']'
297
- LOG.debug("\t--> %u unused vertices have been removed" % (nv - nb_used_v))
298
- else:
299
- LOG.debug("\t--> No unused vertices")
93
+ indices = []
94
+ for i, face in enumerate(faces):
95
+ if len(face) != 4:
96
+ continue # skip triangles or degenerate quads
97
+ verts = extract_face_vertices(vertices, face)
98
+ if is_non_coplanar(verts):
99
+ indices.append(i)
100
+ return indices
300
101
 
301
- return mesh
302
102
 
103
+ def is_face_convex(vertices):
104
+ a, b, c, d = vertices
105
+ edges = [b - a, c - b, d - c, a - d]
106
+ normals = [np.cross(edges[i], edges[(i + 1) % 4]) for i in range(4)]
107
+ dot_signs = [np.dot(normals[0], n) for n in normals[1:]]
108
+ return all(s >= -1e-10 for s in dot_signs)
303
109
 
304
- @inplace_transformation
305
- def heal_triangles(mesh):
306
- """Makes the triangle connectivity consistent (in place).
307
110
 
308
- A general face is stored internally as a 4 integer array. It allows to describe indices of a quadrangle's vertices. For triangles, the first index should be equal to the last. This method ensures that this rule is applied everywhere and correct bad triangles description.
111
+ def indices_of_non_convex_faces(vertices, faces):
309
112
  """
310
- faces = mesh._faces
311
-
312
- quads = faces[:, 0] != faces[:, -1]
313
- nquads_init = sum(quads)
314
-
315
- faces[quads] = np.roll(faces[quads], 1, axis=1)
316
- quads = faces[:, 0] != faces[:, -1]
317
-
318
- faces[quads] = np.roll(faces[quads], 1, axis=1)
319
- quads = faces[:, 0] != faces[:, -1]
320
-
321
- faces[quads] = np.roll(faces[quads], 1, axis=1)
322
- quads = faces[:, 0] != faces[:, -1]
323
- nquads_final = sum(quads)
324
-
325
- mesh._faces = faces
326
-
327
- LOG.debug("* Ensuring consistent definition of triangles:")
328
- if nquads_final < nquads_init:
329
- LOG.debug("\t--> %u triangles were described the wrong way and have been corrected" % (
330
- nquads_init - nquads_final))
331
- else:
332
- LOG.debug("\t--> Triangle description is consistent")
333
-
334
- return mesh
335
-
336
-
337
- @inplace_transformation
338
- def remove_degenerated_faces(mesh, rtol=1e-5):
339
- """Removes tiny triangles from the mesh (in place).
340
-
341
- Tiny triangles are those whose area is lower than the mean triangle area in the mesh times the relative
342
- tolerance given.
113
+ Identify indices of quadrilateral faces in the mesh that are not convex.
343
114
 
344
115
  Parameters
345
116
  ----------
346
- rtol : float, optional
347
- Positive relative tolerance
348
- """
117
+ mesh : Mesh
118
+ The input mesh containing faces and vertices.
349
119
 
350
- assert 0 < rtol
120
+ Returns
121
+ -------
122
+ list[int]
123
+ List of indices of non-convex quadrilateral faces.
124
+ """
125
+ indices = []
126
+ for i, face in enumerate(faces):
127
+ if len(face) != 4:
128
+ continue
351
129
 
352
- # TODO: implementer un retour d'index des faces extraites
353
- areas = mesh.faces_areas
354
- area_threshold = areas.mean() * float(rtol)
130
+ verts = extract_face_vertices(vertices, face)
355
131
 
356
- # Detecting faces that have null area
357
- faces = mesh._faces[np.logical_not(areas < area_threshold)]
358
- nb_removed = mesh.nb_faces - faces.shape[0]
359
- LOG.debug('* Removing degenerated faces')
360
- if nb_removed > 0:
361
- LOG.debug('\t-->%u degenerated faces have been removed' % nb_removed)
362
- else:
363
- LOG.debug('\t--> No degenerated faces')
132
+ if not is_face_convex(verts):
133
+ indices.append(i)
364
134
 
365
- mesh._faces = faces
135
+ return indices
366
136
 
367
- return mesh
368
137
 
138
+ def _is_valid(vertices, faces):
139
+ """
140
+ Check that every face index is valid for the given vertices.
141
+ Now: an empty face-list is considered valid (as long as vertices exist).
142
+ """
143
+ # If you have no vertices at all, only accept if also no faces
144
+ if len(vertices) == 0:
145
+ return len(faces) == 0
369
146
 
370
- def print_quality(mesh):
371
- """Returns data on the mesh quality.
372
- Needs to be tested...
147
+ # If you have vertices but zero faces → that's fine
148
+ if len(faces) == 0:
149
+ return True
373
150
 
374
- It uses VTK and is reproduced from
375
- http://vtk.org/gitweb?p=VTK.git;a=blob;f=Filters/Verdict/Testing/Python/MeshQuality.py
376
- """
377
- # This function is reproduced from
378
- # http://vtk.org/gitweb?p=VTK.git;a=blob;f=Filters/Verdict/Testing/Python/MeshQuality.py
379
- polydata = mesh._vtk_polydata()
380
- quality = vtk.vtkMeshQuality()
381
- quality.SetInputData(polydata)
382
-
383
- def DumpQualityStats(iq, arrayname):
384
- an = iq.GetOutput().GetFieldData().GetArray(arrayname)
385
- cardinality = an.GetComponent(0, 4)
386
- range = list()
387
- range.append(an.GetComponent(0, 0))
388
- range.append(an.GetComponent(0, 2))
389
- average = an.GetComponent(0, 1)
390
- stdDev = math.sqrt(math.fabs(an.GetComponent(0, 3)))
391
- outStr = '%s%g%s%g\n%s%g%s%g' % (
392
- ' range: ', range[0], ' - ', range[1],
393
- ' average: ', average, ' , standard deviation: ', stdDev)
394
- return outStr
395
-
396
- # Here we define the various mesh types and labels for output.
397
- meshTypes = [
398
- ['Triangle', 'Triangle',
399
- [['QualityMeasureToArea', ' Area Ratio:'],
400
- ['QualityMeasureToEdgeRatio', ' Edge Ratio:'],
401
- ['QualityMeasureToAspectRatio', ' Aspect Ratio:'],
402
- ['QualityMeasureToRadiusRatio', ' Radius Ratio:'],
403
- ['QualityMeasureToAspectFrobenius', ' Frobenius Norm:'],
404
- ['QualityMeasureToMinAngle', ' Minimal Angle:']
405
- ]
406
- ],
407
-
408
- ['Quad', 'Quadrilateral',
409
- [['QualityMeasureToArea', ' Area Ratio:'],
410
- ['QualityMeasureToEdgeRatio', ' Edge Ratio:'],
411
- ['QualityMeasureToAspectRatio', ' Aspect Ratio:'],
412
- ['QualityMeasureToRadiusRatio', ' Radius Ratio:'],
413
- ['QualityMeasureToMedAspectFrobenius',
414
- ' Average Frobenius Norm:'],
415
- ['QualityMeasureToMaxAspectFrobenius',
416
- ' Maximal Frobenius Norm:'],
417
- ['QualityMeasureToMinAngle', ' Minimal Angle:']
418
- ]
419
- ]
420
- ]
421
- res = ''
422
- if polydata.GetNumberOfCells() > 0:
423
- for meshType in meshTypes:
424
- res += '\n%s%s' % (meshType[1], ' quality of the mesh ')
425
- quality.Update()
426
- an = quality.GetOutput().GetFieldData().GetArray('Mesh ' + meshType[1] + ' Quality')
427
- cardinality = an.GetComponent(0, 4)
428
-
429
- res = ''.join((res, '(%u elements):\n' % cardinality))
430
-
431
- # res += '('+str(cardinality) +meshType[1]+'):\n'
432
-
433
- for measure in meshType[2]:
434
- eval('quality.Set' + meshType[0] + measure[0] + '()')
435
- quality.Update()
436
- res += '\n%s\n%s' % (
437
- measure[1],
438
- DumpQualityStats(quality, 'Mesh ' + meshType[1] + ' Quality')
439
- )
440
- res += '\n'
151
+ # Otherwise, flatten all face‐indices and check bounds
152
+ all_idx = list(chain.from_iterable(faces))
153
+ if not all_idx:
154
+ return True
441
155
 
442
- info = """\n\nDefinition of the different quality measures is given
443
- in the verdict library manual :
444
- http://www.vtk.org/Wiki/images/6/6b/VerdictManual-revA.pdf\n"""
156
+ if min(all_idx) < 0 or max(all_idx) >= len(vertices):
157
+ return False
445
158
 
446
- res += info
447
- print(res)
448
- return
159
+ return True
@@ -1,63 +1,82 @@
1
- """Tools for surface integrals and hydrostatics."""
2
- # Copyright (C) 2017-2022 Matthieu Ancellin
3
- # See LICENSE file at <https://github.com/capytaine/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.
4
14
 
5
15
  from abc import ABC
16
+ from typing import Tuple, Union
17
+
6
18
  import numpy as np
7
19
 
8
20
  class SurfaceIntegralsMixin(ABC):
9
- # These methods need to be defined for both Mesh and CollectionOfMeshes with the exact same definitions.
10
- # To avoid redunduncy, they are defined here in a mixin inherited by both Mesh and CollectionOfMeshes.
21
+ # Defines some methods inherited by AbstractMesh.
22
+ # There are located in this other module just to make the code more tidy.
11
23
 
12
24
  def surface_integral(self, data, **kwargs):
13
25
  """Returns integral of given data along wet surface area."""
14
26
  return np.sum(data * self.faces_areas, **kwargs)
15
27
 
16
- def waterplane_integral(self, data, **kwargs):
17
- """Returns integral of given data along water plane area."""
18
- return self.surface_integral(self.faces_normals[:,2] * data, **kwargs)
19
-
20
28
  @property
21
- def wet_surface_area(self):
29
+ def wet_surface_area(self) -> float:
22
30
  """Returns wet surface area."""
23
- return self.surface_integral(1)
31
+ return self.immersed_part().surface_integral(1)
24
32
 
25
33
  @property
26
- def volumes(self):
27
- """Returns volumes using x, y, z components of the mesh."""
34
+ def volumes(self) -> Tuple[float, float, float]:
35
+ """Returns volumes using x, y, z components of the mesh.
36
+ Should be the same for a regular mesh."""
28
37
  norm_coord = self.faces_normals * self.faces_centers
29
- return self.surface_integral(norm_coord.T, axis=1)
38
+ return tuple(self.surface_integral(norm_coord.T, axis=1))
30
39
 
31
40
  @property
32
- def volume(self):
41
+ def volume(self) -> float:
33
42
  """Returns volume of the mesh."""
34
43
  return np.mean(self.volumes)
35
44
 
36
- def disp_mass(self, *, rho=1000):
37
- return rho * self.volume
45
+ def waterplane_integral(self, data, **kwargs):
46
+ """Returns integral of given data along water plane area."""
47
+ immersed_self = self.immersed_part()
48
+ return immersed_self.surface_integral(immersed_self.faces_normals[:,2] * data, **kwargs)
49
+
50
+ @property
51
+ def disp_volume(self) -> float:
52
+ return self.immersed_part().volume
53
+
54
+ def disp_mass(self, *, rho=1000) -> float:
55
+ return rho * self.disp_volume
38
56
 
39
57
  @property
40
- def center_of_buoyancy(self):
58
+ def center_of_buoyancy(self) -> np.ndarray:
41
59
  """Returns center of buoyancy of the mesh."""
42
- coords_sq_norm = self.faces_normals * self.faces_centers**2
43
- return self.surface_integral(coords_sq_norm.T, axis=1) / (2*self.volume)
60
+ immersed_self = self.immersed_part()
61
+ coords_sq_norm = immersed_self.faces_normals * immersed_self.faces_centers**2
62
+ return immersed_self.surface_integral(coords_sq_norm.T, axis=1) / (2*immersed_self.volume)
44
63
 
45
64
  @property
46
- def waterplane_area(self):
65
+ def waterplane_area(self) -> float:
47
66
  """Returns water plane area of the mesh."""
48
- waterplane_area = -self.waterplane_integral(1)
49
- return waterplane_area
67
+ immersed_self = self.immersed_part()
68
+ return -immersed_self.waterplane_integral(1)
50
69
 
51
70
  @property
52
- def waterplane_center(self):
71
+ def waterplane_center(self) -> Union[None, np.ndarray]:
53
72
  """Returns water plane center of the mesh.
54
-
55
- Note: Returns None if the mesh is full submerged.
73
+ Returns None if the mesh is full submerged.
56
74
  """
57
- waterplane_area = self.waterplane_area
75
+ immersed_self = self.immersed_part()
76
+ waterplane_area = immersed_self.waterplane_area
58
77
  if abs(waterplane_area) < 1e-10:
59
78
  return None
60
79
  else:
61
- waterplane_center = -self.waterplane_integral(
62
- self.faces_centers.T, axis=1) / waterplane_area
80
+ waterplane_center = -immersed_self.waterplane_integral(
81
+ immersed_self.faces_centers.T, axis=1) / waterplane_area
63
82
  return waterplane_center[:-1]