Source code for capytaine.meshes.abstract_meshes

# Copyright 2025 Mews Labs
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from __future__ import annotations

import logging
from abc import ABC, abstractmethod
from functools import cached_property, lru_cache
from typing import Literal, Tuple

import numpy as np

from capytaine.meshes.surface_integrals import SurfaceIntegralsMixin
from capytaine.tools.deprecation_handling import _get_water_depth
from capytaine.meshes.geometry import connected_components, connected_components_of_waterline

LOG = logging.getLogger(__name__)

[docs] class AbstractMesh(SurfaceIntegralsMixin, ABC): @property @abstractmethod def nb_vertices(self) -> int: ... @property @abstractmethod def nb_faces(self) -> int: ... @property @abstractmethod def faces_normals(self) -> np.ndarray: ... @property @abstractmethod def faces_areas(self) -> np.ndarray: ... @property @abstractmethod def faces_centers(self) -> np.ndarray: ... @property @abstractmethod def faces_radiuses(self) -> np.ndarray: ... @property @abstractmethod def faces(self) -> np.ndarray: ... @property @abstractmethod def quadrature_points(self) -> np.ndarray: ... @cached_property def z_span(self) -> Tuple[float, float]: return (self.vertices[:, 2].min(), self.vertices[:, 2].max()) @abstractmethod def __str__(self) -> str: ... @abstractmethod def __short_str__(self) -> str: ...
[docs] @abstractmethod def with_quadrature(self, quadrature_method): ...
[docs] @abstractmethod def extract_faces(self, faces_id, *, name=None) -> AbstractMesh: ...
[docs] @abstractmethod def translated(self, shift, *, name=None) -> AbstractMesh: ...
[docs] def translated_x(self, dx: float, *, name=None) -> AbstractMesh: """Return a new Mesh translated in the x-direction along `dx`.""" return self.translated([dx, 0.0, 0.0], name=name)
[docs] def translated_y(self, dy: float, *, name=None) -> AbstractMesh: """Return a new Mesh translated in the y-direction along `dy`.""" return self.translated([0.0, dy, 0.0], name=name)
[docs] def translated_z(self, dz: float, *, name=None) -> AbstractMesh: """Return a new Mesh translated in the z-direction along `dz`.""" return self.translated([0.0, 0.0, dz], name=name)
[docs] @abstractmethod def rotated_with_matrix(self, R, *, name=None) -> AbstractMesh: ...
[docs] def rotated_x(self, angle: float, *, name=None) -> AbstractMesh: """Return a new Mesh rotated around the x-axis using the provided rotation angle in radians""" c, s = np.cos(angle), np.sin(angle) R = np.array([[1, 0, 0], [0, c, -s], [0, s, c]]) return self.rotated_with_matrix(R, name=name)
[docs] def rotated_y(self, angle: float, *, name=None) -> AbstractMesh: """Return a new Mesh rotated around the y-axis using the provided rotation angle in radians""" c, s = np.cos(angle), np.sin(angle) R = np.array([[c, 0, s], [0, 1, 0], [-s, 0, c]]) return self.rotated_with_matrix(R, name=name)
[docs] def rotated_z(self, angle: float, *, name=None) -> AbstractMesh: """Return a new Mesh rotated around the z-axis using the provided rotation angle in radians""" c, s = np.cos(angle), np.sin(angle) R = np.array([[c, -s, 0], [s, c, 0], [0, 0, 1]]) return self.rotated_with_matrix(R, name=name)
[docs] def rotated_such_that_vectors_are_aligned(self, a, b, *, eps=1e-8, name=None) -> AbstractMesh: a = np.asarray(a, dtype=float) b = np.asarray(b, dtype=float) # Normalize input vectors a_norm = np.linalg.norm(a) b_norm = np.linalg.norm(b) if a_norm < eps or b_norm < eps: raise ValueError("Input vectors must be non-zero") a_hat = a / a_norm b_hat = b / b_norm # Cross and dot products v = np.cross(a_hat, b_hat) c = np.dot(a_hat, b_hat) s = np.linalg.norm(v) # Case 1: vectors are already aligned if s < eps and c > 0: return self.copy(name=name) # Case 2: vectors are opposite if s < eps and c < 0: # Find an arbitrary orthogonal vector # Prefer axis least aligned with a_hat axis = np.array([1.0, 0.0, 0.0]) if abs(a_hat[0]) > abs(a_hat[1]): axis = np.array([0.0, 1.0, 0.0]) axis = axis - a_hat * np.dot(a_hat, axis) axis /= np.linalg.norm(axis) # Rotation by pi around axis K = np.array([[0, -axis[2], axis[1]], [axis[2], 0, -axis[0]], [-axis[1], axis[0], 0]]) return self.rotated_with_matrix(np.eye(3) + 2 * K @ K, name=name) # General case: Rodrigues' rotation formula K = np.array([[0, -v[2], v[1]], [v[2], 0, -v[0]], [-v[1], v[0], 0]]) R = np.eye(3) + K + K @ K * ((1 - c) / (s ** 2)) return self.rotated_with_matrix(R, name=name)
[docs] def mirrored(self, plane: Literal['xOz', 'yOz'], *, name=None) -> AbstractMesh: ...
[docs] @abstractmethod def join_meshes(*meshes, return_masks=False, name=None) -> AbstractMesh: ...
def _common_metadata_keys(*meshes): metadata_keys = [set(m.faces_metadata.keys()) for m in meshes] common_metadata_keys = set.intersection(*metadata_keys) lost_metadata_keys = set.union(*metadata_keys) - common_metadata_keys if len(lost_metadata_keys) > 0: LOG.warning(f'The following metadata have been dropped when joining meshes: {lost_metadata_keys}') return common_metadata_keys def __add__(self, other: AbstractMesh) -> AbstractMesh: """Combine two meshes using the + operator. Parameters ---------- other : Mesh Another mesh to combine with this one. Returns ------- Mesh New mesh containing vertices and faces from both meshes. """ if self.name is not None or other.name is not None: name = f"{self.name}+{other.name}" else: name = None return self.join_meshes(other, name=name)
[docs] def lowest_lid_position(self, omega_max, *, g=9.81): z_lid = 0.0 for comp in connected_components(self): for ccomp in connected_components_of_waterline(comp): x_span = ccomp.vertices[:, 0].max() - ccomp.vertices[:, 0].min() y_span = ccomp.vertices[:, 1].max() - ccomp.vertices[:, 1].min() p = np.hypot(1/x_span, 1/y_span) z_lid_comp = -np.arctanh(np.pi*g*p/omega_max**2) / (np.pi * p) z_lid = min(z_lid, z_lid_comp) return 0.9*z_lid # Add a small safety margin
[docs] @abstractmethod def generate_lid(self, z=0.0, faces_max_radius=None, name=None): ...
[docs] @abstractmethod def extract_lid(self, z=0.0): ...
[docs] @abstractmethod def with_normal_vector_going_down(self, **kwargs) -> AbstractMesh: ...
[docs] @abstractmethod def copy(self) -> AbstractMesh: ...
[docs] def with_metadata(self, **new_metadata) -> AbstractMesh: faces_metadata = self.faces_metadata.copy() for k, v in new_metadata.items(): faces_metadata[k] = v return self.copy(faces_metadata=faces_metadata)
[docs] def pop_metadata(self, metadata_name) -> Tuple[AbstractMesh, np.ndarray]: faces_metadata = self.faces_metadata.copy() data = faces_metadata.pop(metadata_name) return self.copy(faces_metadata=faces_metadata), data
[docs] def without_metadata(self, *metadata_names) -> AbstractMesh: faces_metadata = self.faces_metadata.copy() for k in metadata_names: del faces_metadata[k] return self.copy(faces_metadata=faces_metadata)
[docs] def without_any_metadata(self) -> AbstractMesh: return self.copy(faces_metadata={})
[docs] @abstractmethod def merged(self) -> AbstractMesh: ...
[docs] @abstractmethod def clipped(self, *, origin, normal, name=None) -> AbstractMesh: ...
[docs] def extract_wedge(self, n: int, axis: str = "z") -> AbstractMesh: """Extract a wedge (angular sector) from the mesh for rotational symmetry. Extracts a 1/n sector of the mesh by clipping at angular boundaries. This creates proper faces at the wedge boundaries for clean reconstruction. Parameters ---------- n : int The rotation order. The wedge will span 360/n degrees. axis : str, optional Only "z" is currently supported. Returns ------- Mesh A new mesh containing the wedge sector with proper boundary faces. Examples -------- Extract 1/3 of a sphere (120-degree wedge): >>> sphere = mesh_sphere(radius=1.0, resolution=(12, 12)) >>> wedge = sphere.extract_wedge(n=3) >>> wedge.nb_faces # Approximately 1/3 of sphere.nb_faces """ if axis != "z": raise NotImplementedError( f"Only 'z' axis is currently supported, got '{axis}'" ) if n < 2: raise ValueError(f"Rotation order must be >= 2, got {n}") # Wedge angle in radians wedge_angle = 2 * np.pi / n # First clip: keep the half with y >= 0 (theta in [0, pi]) # This corresponds to the plane y=0, keeping positive y side origin = np.array([0.0, 0.0, 0.0]) normal_1 = np.array([0.0, -1.0, 0.0]) # Keep y >= 0 wedge = self.clipped(origin=origin, normal=normal_1) # Second clip: create the wedge boundary at angle = wedge_angle # The plane passes through the z-axis and has a normal perpendicular to the boundary # For a wedge from theta=0 to theta=wedge_angle, we need to keep theta <= wedge_angle # Normal vector points outward from the wedge (to reject the side we don't want) # At angle theta, the outward normal is [-sin(theta), cos(theta), 0] normal_2 = np.array([-np.sin(wedge_angle), np.cos(wedge_angle), 0.0]) wedge = wedge.clipped(origin=origin, normal=normal_2, name=f"{self.name}_wedge_n{n}") return wedge
[docs] @lru_cache def immersed_part(self, free_surface=0.0, *, sea_bottom=None, water_depth=None) -> AbstractMesh: """ Clip the mesh to keep only the part below the free surface. Parameters ---------- free_surface: float The :math:`z` coordinate of the free surface (default: 0.0) water_depth: Optional[float] The water depth, as a positive value (default: infinity) Returns ------- Mesh A new Mesh instance that has been clipped. """ water_depth = _get_water_depth(free_surface, water_depth, sea_bottom, default_water_depth=np.inf) if (free_surface - water_depth <= self.z_span[0] and self.z_span[1] <= free_surface): # Already clipped return self # Shortcut for performance clipped = self.clipped(origin=(0, 0, 0), normal=(0, 0, 1)) if water_depth < np.inf: clipped = clipped.clipped(origin=(0, 0, free_surface-water_depth), normal=(0, 0, -1)) return clipped
[docs] @abstractmethod def show(self, *, backend=None, **kwargs): ...
[docs] def show_pyvista(self, **kwargs): """ Equivalent to show(backend="pyvista"). See also :func:`~capytaine.meshes.visualization.show_pyvista` """ return self.show(backend="pyvista", **kwargs)
[docs] def show_matplotlib(self, **kwargs): """ Equivalent to show(backend="matplotlib"). See also :func:`~capytaine.meshes.visualization.show_matplotlib` """ return self.show(backend="matplotlib", **kwargs)
[docs] @abstractmethod def export(self, format, **kwargs): ...
[docs] def export_to_pyvista(self, **kwargs): return self.export(format="pyvista", **kwargs)
[docs] def export_to_xarray(self, **kwargs): return self.export(format="xarray", **kwargs)
[docs] def export_to_meshio(self, **kwargs): return self.export(format="meshio", **kwargs)
[docs] def export_to_trimesh(self, **kwargs): return self.export(format="trimesh", **kwargs)