"""Tools to describe geometric objects in 3D.
Based on meshmagick <https://github.com/LHEEA/meshmagick> by François Rongère.
"""
# Copyright (C) 2017-2019 Matthieu Ancellin, based on the work of François Rongère
# See LICENSE file at <https://github.com/mancellin/capytaine>
from abc import ABC, abstractmethod
from capytaine.tools.deprecation_handling import _get_water_depth
import numpy as np
e_x = np.array((1, 0, 0))
e_y = np.array((0, 1, 0))
e_z = np.array((0, 0, 1))
###########################################
# DECORATOR FOR INPLACE TRANSFORMATIONS #
###########################################
##############################
# ABSTRACT 3D OBJECT CLASS #
##############################
[docs]
class Abstract3DObject(ABC):
"""Abstract class for 3d objects that can be transformed in 3d.
The child classes have to define `mirror`, `rotate` and `translate`,
then more routines such as `translate_x` and `translated` are automatically available."""
[docs]
@abstractmethod
def translate(self, vector):
pass
[docs]
@abstractmethod
def rotate(self, axis, angle):
pass
[docs]
@abstractmethod
def mirror(self, plane):
pass
[docs]
@inplace_transformation
def translate_x(self, tx):
return self.translate((tx, 0., 0.))
[docs]
@inplace_transformation
def translate_y(self, ty):
return self.translate((0., ty, 0.))
[docs]
@inplace_transformation
def translate_z(self, tz):
return self.translate((0., 0., tz))
[docs]
@inplace_transformation
def translate_point_to_point(self, point_a, point_b):
return self.translate(np.asarray(point_b) - np.asarray(point_a))
[docs]
@inplace_transformation
def rotate_x(self, thetax):
return self.rotate(Ox_axis, thetax)
[docs]
@inplace_transformation
def rotate_y(self, thetay):
return self.rotate(Oy_axis, thetay)
[docs]
@inplace_transformation
def rotate_z(self, thetaz):
return self.rotate(Oz_axis, thetaz)
[docs]
@inplace_transformation
def rotate_around_center_to_align_vectors(self, center, vec1, vec2):
"""Rotate self such that if vec1 is in self, then it will point in the same direction as vec2."""
vec1 = np.asarray(vec1)
vec2 = np.asarray(vec2)
if parallel_vectors_with_same_direction(vec1, vec2):
return self
else:
if parallel_vectors(vec1, vec2):
if parallel_vectors(vec1, e_x):
axis = Axis(vector=np.cross(vec1, e_y), point=center)
else:
axis = Axis(vector=np.cross(vec1, e_x), point=center)
return self.rotate(axis, np.pi)
else:
axis = Axis(vector=np.cross(vec1, vec2), point=center)
return self.rotate(axis, np.arccos(np.dot(vec1, vec2)))
[docs]
def translated(self, *args, **kwargs):
return self.translate(*args, inplace=False, **kwargs)
[docs]
def rotated(self, *args, **kwargs):
return self.rotate(*args, inplace=False, **kwargs)
[docs]
def mirrored(self, *args, **kwargs):
return self.mirror(*args, inplace=False, **kwargs)
[docs]
def translated_x(self, *args, **kwargs):
return self.translate_x(*args, inplace=False, **kwargs)
[docs]
def translated_y(self, *args, **kwargs):
return self.translate_y(*args, inplace=False, **kwargs)
[docs]
def translated_z(self, *args, **kwargs):
return self.translate_z(*args, inplace=False, **kwargs)
[docs]
def translated_point_to_point(self, *args, **kwargs):
return self.translate_point_to_point(*args, inplace=False, **kwargs)
[docs]
def rotated_x(self, *args, **kwargs):
return self.rotate_x(*args, inplace=False, **kwargs)
[docs]
def rotated_y(self, *args, **kwargs):
return self.rotate_y(*args, inplace=False, **kwargs)
[docs]
def rotated_z(self, *args, **kwargs):
return self.rotate_z(*args, inplace=False, **kwargs)
[docs]
def rotated_around_center_to_align_vectors(self, *args, **kwargs):
return self.rotate_around_center_to_align_vectors(*args, inplace=False, **kwargs)
[docs]
class ClippableMixin(ABC):
"""Abstract base class for object that can be clipped.
The child classes should implement a `clip` method, then this abstract
class will append the new methods `clipped`, `keep_immersed_part` and
`immersed_part`, all based on `clip`.
"""
[docs]
@abstractmethod
def clip(self, plane):
pass
[docs]
def clipped(self, plane, **kwargs):
# Same API as for the other transformations
return self.clip(plane, inplace=False, **kwargs)
[docs]
@inplace_transformation
def keep_immersed_part(self, free_surface=0.0, *, sea_bottom=None, water_depth=None):
self.clip(Plane(normal=(0, 0, 1), point=(0, 0, free_surface)))
water_depth = _get_water_depth(free_surface, water_depth, sea_bottom,
default_water_depth=np.inf)
if water_depth < np.inf:
self.clip(Plane(normal=(0, 0, -1), point=(0, 0, free_surface-water_depth)))
return self
[docs]
def immersed_part(self, free_surface=0.0, *, sea_bottom=None, water_depth=None):
return self.keep_immersed_part(free_surface, inplace=False, name=self.name,
sea_bottom=sea_bottom, water_depth=water_depth)
######################
# HELPER FUNCTIONS #
######################
[docs]
def orthogonal_vectors(vec1, vec2) -> bool:
return np.linalg.norm(vec1 @ vec2) < 1e-6
[docs]
def parallel_vectors(vec1, vec2) -> bool:
return np.linalg.norm(np.cross(vec1, vec2)) < 1e-6
[docs]
def parallel_vectors_with_same_direction(vec1, vec2) -> bool:
return parallel_vectors(vec1, vec2) and np.dot(vec1, vec2) > 0
################
# AXIS CLASS #
################
[docs]
class Axis(Abstract3DObject):
def __init__(self, vector=(1, 0, 0), point=(0, 0, 0)):
assert len(vector) == 3, "Vector of an axis should be given as a 3-ple of values."
assert len(point) == 3, "Point of an axis should be given as a 3-ple of values."
vector = np.array(vector, float)
self.vector = vector / np.linalg.norm(vector)
self.point = np.array(point, float)
def __repr__(self):
return f"Axis(vector={self.vector}, point={self.point})"
def __contains__(self, other_point):
if len(other_point) == 3:
other_point = np.asarray(other_point, dtype=float)
return parallel_vectors(other_point - self.point, self.vector)
else:
raise NotImplementedError
def __eq__(self, other):
if isinstance(self, Axis):
return (self is other) or (self.point in other and parallel_vectors(self.vector, other.vector))
else:
return NotImplemented
[docs]
def is_orthogonal_to(self, other):
if isinstance(other, Plane):
return parallel_vectors(self.vector, other.normal)
elif len(other) == 3: # The other is supposed to be a vector given as a 3-ple
return orthogonal_vectors(self.vector, other)
else:
raise NotImplementedError
[docs]
def is_parallel_to(self, other):
if isinstance(other, Plane):
return orthogonal_vectors(self.vector, other.normal)
elif isinstance(other, Axis):
return parallel_vectors(self.vector, other.vector)
elif len(other) == 3: # The other is supposed to be a vector given as a 3-ple
return parallel_vectors(self.vector, other)
else:
raise NotImplementedError
[docs]
def angle_with_respect_to(self, other_axis: 'Axis') -> float:
"""Angle between two axes."""
return np.arccos(np.dot(self.vector, other_axis.vector))
################################
# Transformation of the axis #
################################
[docs]
def copy(self, name=None):
return Axis(vector=self.vector.copy(), point=self.point.copy())
[docs]
@inplace_transformation
def translate(self, vector):
self.point += vector
return self
[docs]
@inplace_transformation
def rotate(self, axis, angle):
rot_matrix = axis.rotation_matrix(angle)
self.point = rot_matrix @ (self.point - axis.point) + axis.point
self.vector = rot_matrix @ self.vector
return self
[docs]
@inplace_transformation
def mirror(self, plane):
self.point -= 2 * (self.point @ plane.normal - plane.c) * plane.normal
self.vector -= 2 * (self.vector @ plane.normal) * plane.normal
return self
###########
# Other #
###########
[docs]
def rotation_matrix(self, theta):
"""Rotation matrix around the vector according to Rodrigues' formula."""
ux, uy, uz = self.vector
W = np.array([[0, -uz, uy],
[uz, 0, -ux],
[-uy, ux, 0]])
return np.identity(3) + np.sin(theta)*W + 2*np.sin(theta/2)**2 * (W @ W)
[docs]
def rotate_vectors(self, vectors, angle):
vectors = np.asarray(vectors)
return (self.rotation_matrix(angle) @ vectors.T).T
[docs]
def rotate_points(self, points, angle):
points = np.asarray(points)
return self.rotate_vectors(points - self.point, angle) + self.point
Ox_axis = Axis(vector=e_x, point=(0, 0, 0))
Oy_axis = Axis(vector=e_y, point=(0, 0, 0))
Oz_axis = Axis(vector=e_z, point=(0, 0, 0))
#################
# PLANE CLASS #
#################
[docs]
class Plane(Abstract3DObject):
"""3D plane, oriented by the direction of their normal."""
def __init__(self, normal=(0.0, 0.0, 1.0), point=(0.0, 0.0, 0.0)):
normal = np.asarray(normal, dtype=float)
self.normal = normal / np.linalg.norm(normal)
self.point = np.asarray(point, dtype=float)
def __repr__(self):
return f"Plane(normal={self.normal}, point={self.point})"
def __contains__(self, other):
if isinstance(other, Axis):
return other.point in self and orthogonal_vectors(self.normal, other.vector)
elif len(other) == 3:
return orthogonal_vectors(other - self.point, self.normal)
else:
raise NotImplementedError
def __eq__(self, other):
"""Plane are considered equal only when their normal are pointing in the same direction."""
if isinstance(other, Plane):
return ((self is other) or
(other.point in self and parallel_vectors_with_same_direction(self.normal, other.normal)))
else:
return NotImplemented
[docs]
def is_orthogonal_to(self, other):
if isinstance(other, Axis):
return parallel_vectors(self.normal, other.vector)
elif isinstance(other, Plane):
return orthogonal_vectors(self.normal, other.normal)
elif len(other) == 3: # The other is supposed to be a vector given as a 3-ple
return parallel_vectors(self.normal, other)
else:
raise NotImplementedError
@property
def c(self):
"""Distance from plane to origin."""
return np.linalg.norm(self.normal @ self.point)
@property
def s(self):
"""Distance from origin to plane along the normal"""
return np.dot(self.normal, self.point)
#################################
# Transformation of the plane #
#################################
[docs]
def copy(self, name=None):
return Plane(normal=self.normal.copy(), point=self.point.copy())
[docs]
@inplace_transformation
def translate(self, vector):
self.point = self.point + np.asarray(vector)
return self
[docs]
@inplace_transformation
def rotate(self, axis, angle):
rot_matrix = axis.rotation_matrix(angle)
self.point = rot_matrix @ self.point
self.normal = rot_matrix @ self.normal
return self
[docs]
@inplace_transformation
def mirror(self, plane):
self.point -= 2 * (self.point @ plane.normal - plane.c) * plane.normal
self.normal -= 2 * (self.normal @ plane.normal) * plane.normal
return self
###########
# Other #
###########
[docs]
def distance_to_point(self, points):
"""
Return the orthogonal distance of points with respect to the plane.
The distance is counted positively on one side of the plane and negatively on the other.
Parameters
----------
points : ndarray
Array of points coordinates
Returns
-------
dist : ndarray
Array of distances of points with respect to the plane
"""
return np.dot(points, self.normal) - np.dot(self.point, self.normal)
[docs]
def get_edge_intersection(self, p0, p1):
"""
Returns the coordinates of the intersection point between the plane and the edge P0P1.
Parameters
----------
p0 : ndarray
Coordinates of point p0
p1 : ndarray
Coordinates of point P1
Returns
-------
I : ndarray
Coordinates of intersection point
"""
assert len(p0) == 3 and len(p1) == 3
p0n = np.dot(p0, self.normal)
p1n = np.dot(p1, self.normal)
t = (p0n - self.s) / (p0n - p1n)
if t < 0. or t > 1.:
raise RuntimeError('Intersection is outside the edge')
return (1-t) * p0 + t * p1
yOz_Plane = Plane(normal=e_x, point=(0, 0, 0))
xOz_Plane = Plane(normal=e_y, point=(0, 0, 0))
xOy_Plane = Plane(normal=e_z, point=(0, 0, 0))