Coverage for src/meshpy/core/rotation.py: 96%
196 statements
« prev ^ index » next coverage.py v7.9.0, created at 2025-06-13 04:26 +0000
« prev ^ index » next coverage.py v7.9.0, created at 2025-06-13 04:26 +0000
1# The MIT License (MIT)
2#
3# Copyright (c) 2018-2025 MeshPy Authors
4#
5# Permission is hereby granted, free of charge, to any person obtaining a copy
6# of this software and associated documentation files (the "Software"), to deal
7# in the Software without restriction, including without limitation the rights
8# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9# copies of the Software, and to permit persons to whom the Software is
10# furnished to do so, subject to the following conditions:
11#
12# The above copyright notice and this permission notice shall be included in
13# all copies or substantial portions of the Software.
14#
15# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21# THE SOFTWARE.
22"""This module defines a class that represents a rotation in 3D."""
24import copy as _copy
26import numpy as _np
27import quaternion as _quaternion
29from meshpy.core.conf import mpy as _mpy
32def skew_matrix(vector):
33 """Return the skew matrix for the vector."""
34 skew = _np.zeros([3, 3])
35 skew[0, 1] = -vector[2]
36 skew[0, 2] = vector[1]
37 skew[1, 0] = vector[2]
38 skew[1, 2] = -vector[0]
39 skew[2, 0] = -vector[1]
40 skew[2, 1] = vector[0]
41 return skew
44class Rotation:
45 """A class that represents a rotation of a coordinate system.
47 Internally the rotations are stored as quaternions.
48 """
50 def __init__(self, *args):
51 """Initialize the rotation object.
53 Args
54 ----
55 *args:
56 - Rotation()
57 Create a identity rotation.
58 - Rotation(axis, phi)
59 Create a rotation around the vector axis with the angle phi.
60 """
62 self.q = _np.zeros(4)
64 if len(args) == 0:
65 # Identity element.
66 self.q[0] = 1
67 elif len(args) == 2:
68 # Set from rotation axis and rotation angle.
69 axis = _np.asarray(args[0])
70 phi = args[1]
71 norm = _np.linalg.norm(axis)
72 if norm < _mpy.eps_quaternion:
73 raise ValueError("The rotation axis can not be a zero vector!")
74 self.q[0] = _np.cos(0.5 * phi)
75 self.q[1:] = _np.sin(0.5 * phi) * axis / norm
76 else:
77 raise ValueError(f"The given arguments {args} are invalid!")
79 @classmethod
80 def from_quaternion(cls, q, *, normalized=False):
81 """Create the object from a quaternion float array (4x1)
83 Args
84 ----
85 q: Quaternion, q0, qx,qy,qz
86 normalized: Flag if the input quaternion is normalized. If so, no
87 normalization is performed which can potentially improve performance.
88 Skipping the normalization should only be done in very special cases
89 where we can be sure that the input quaternion is normalized to avoid
90 error accumulation.
91 """
92 if isinstance(q, _quaternion.quaternion):
93 q = _quaternion.as_float_array(q)
95 rotation = object.__new__(cls)
96 if normalized:
97 rotation.q = _np.array(q)
98 else:
99 rotation.q = _np.asarray(q) / _np.linalg.norm(q)
100 if (not rotation.q.ndim == 1) or (not len(rotation.q) == 4):
101 raise ValueError("Got quaternion array with unexpected dimensions")
102 return rotation
104 @classmethod
105 def from_rotation_matrix(cls, R):
106 """Create the object from a rotation matrix.
108 The code is based on Spurriers algorithm:
109 R. A. Spurrier (1978): “Comment on “Singularity-free extraction of a quaternion from a
110 direction-cosine matrix”
111 """
113 q = _np.zeros(4)
114 trace = _np.trace(R)
115 values = [R[i, i] for i in range(3)]
116 values.append(trace)
117 arg_max = _np.argmax(values)
118 if arg_max == 3:
119 q[0] = _np.sqrt(trace + 1) * 0.5
120 q[1] = (R[2, 1] - R[1, 2]) / (4 * q[0])
121 q[2] = (R[0, 2] - R[2, 0]) / (4 * q[0])
122 q[3] = (R[1, 0] - R[0, 1]) / (4 * q[0])
123 else:
124 i_index = arg_max
125 j_index = (i_index + 1) % 3
126 k_index = (i_index + 2) % 3
127 q_i = _np.sqrt(R[i_index, i_index] * 0.5 + (1 - trace) * 0.25)
128 q[0] = (R[k_index, j_index] - R[j_index, k_index]) / (4 * q_i)
129 q[i_index + 1] = q_i
130 q[j_index + 1] = (R[j_index, i_index] + R[i_index, j_index]) / (4 * q_i)
131 q[k_index + 1] = (R[k_index, i_index] + R[i_index, k_index]) / (4 * q_i)
133 return cls.from_quaternion(q)
135 @classmethod
136 def from_basis(cls, t1, t2):
137 """Create the object from two basis vectors t1, t2.
139 t2 will be orthogonalized on t1, and t3 will be calculated with
140 the cross product.
141 """
143 t1_normal = t1 / _np.linalg.norm(t1)
144 t2_ortho = t2 - t1_normal * _np.dot(t1_normal, t2)
145 t2_normal = t2_ortho / _np.linalg.norm(t2_ortho)
146 t3_normal = _np.cross(t1_normal, t2_normal)
148 R = _np.transpose([t1_normal, t2_normal, t3_normal])
149 return cls.from_rotation_matrix(R)
151 @classmethod
152 def from_rotation_vector(cls, rotation_vector):
153 """Create the object from a rotation vector."""
155 q = _np.zeros(4)
156 rotation_vector = _np.asarray(rotation_vector)
157 phi = _np.linalg.norm(rotation_vector)
158 q[0] = _np.cos(0.5 * phi)
159 if phi < _mpy.eps_quaternion:
160 # This is the Taylor series expansion of sin(phi/2)/phi around phi=0
161 q[1:] = 0.5 * rotation_vector
162 else:
163 q[1:] = _np.sin(0.5 * phi) / phi * rotation_vector
164 return cls.from_quaternion(q)
166 def check(self):
167 """Perform all checks for the rotation."""
168 self.check_uniqueness()
169 self.check_quaternion_constraint()
171 def check_uniqueness(self):
172 """We always want q0 to be positive -> the range for the rotational
173 angle is 0 <= phi <= pi."""
175 if self.q[0] < 0:
176 self.q *= -1
178 def check_quaternion_constraint(self):
179 """We want to check that q.q = 1."""
181 if _np.abs(1 - _np.linalg.norm(self.q)) > _mpy.eps_quaternion:
182 raise ValueError(
183 f"The rotation object is corrupted. q.q does not equal 1! q={self.q}"
184 )
186 def get_rotation_matrix(self):
187 """Return the rotation matrix for this rotation.
189 (Krenk (3.50))
190 """
191 q_skew = skew_matrix(self.q[1:])
192 R = (
193 (self.q[0] ** 2 - _np.dot(self.q[1:], self.q[1:])) * _np.eye(3)
194 + 2 * self.q[0] * q_skew
195 + 2 * ([self.q[1:]] * _np.transpose([self.q[1:]]))
196 )
198 return R
200 def get_quaternion(self):
201 """Return the quaternion for this rotation, as numpy array (copy)."""
202 return _np.array(self.q)
204 def get_numpy_quaternion(self):
205 """Return a numpy quaternion object representing this rotation
206 (copy)."""
207 return _quaternion.from_float_array(_np.array(self.q))
209 def get_rotation_vector(self):
210 """Return the rotation vector for this object."""
212 self.check()
214 norm = _np.linalg.norm(self.q[1:])
215 phi = 2 * _np.arctan2(norm, self.q[0])
217 if phi < _mpy.eps_quaternion:
218 # For small angles return the Taylor series expansion of phi/sin(phi/2)
219 scale_factor = 2
220 else:
221 scale_factor = phi / _np.sin(phi / 2)
222 if _np.abs(_np.abs(phi) - _np.pi) < _mpy.eps_quaternion:
223 # For rotations of exactly +-pi, numerical issues might occur, resulting in
224 # a rotation vector that is non-deterministic. The result is correct, but
225 # the sign can switch due to different implementation of basic underlying
226 # math functions. This is especially triggered when using this code with
227 # different OS. To avoid this, we scale the rotation axis in such a way,
228 # for a rotation angle of +-pi, the first component of the rotation axis
229 # that is not 0 is positive.
230 for i_dir in range(3):
231 if _np.abs(self.q[1 + i_dir]) > _mpy.eps_quaternion:
232 if self.q[1 + i_dir] < 0:
233 scale_factor *= -1
234 break
235 return self.q[1:] * scale_factor
237 def get_transformation_matrix(self):
238 """Return the transformation matrix for this rotation.
240 The transformation matrix maps the (infinitesimal)
241 multiplicative rotational increments onto the additive ones.
242 """
244 omega = self.get_rotation_vector()
245 omega_norm = _np.linalg.norm(omega)
247 # We have to take the inverse of the the rotation angle here, therefore,
248 # we have a branch for small angles where the singularity is not present.
249 if omega_norm**2 > _mpy.eps_quaternion:
250 # Taken from Jelenic and Crisfield (1999) Equation (2.5)
251 omega_dir = omega / omega_norm
252 omega_skew = skew_matrix(omega)
253 transformation_matrix = (
254 _np.outer(omega_dir, omega_dir)
255 - 0.5 * omega_skew
256 + 0.5
257 * omega_norm
258 / _np.tan(0.5 * omega_norm)
259 * (_np.identity(3) - _np.outer(omega_dir, omega_dir))
260 )
261 else:
262 # This is the constant part of the Taylor series expansion. If this
263 # function is used with automatic differentiation, higher order
264 # terms have to be added!
265 transformation_matrix = _np.identity(3)
266 return transformation_matrix
268 def get_transformation_matrix_inv(self):
269 """Return the inverse of the transformation matrix for this rotation.
271 The inverse of the transformation matrix maps the
272 (infinitesimal) additive rotational increments onto the
273 multiplicative ones.
274 """
276 omega = self.get_rotation_vector()
277 omega_norm = _np.linalg.norm(omega)
279 # We have to take the inverse of the the rotation angle here, therefore,
280 # we have a branch for small angles where the singularity is not present.
281 if omega_norm**2 > _mpy.eps_quaternion:
282 # Taken from Jelenic and Crisfield (1999) Equation (2.5)
283 omega_dir = omega / omega_norm
284 omega_skew = skew_matrix(omega)
285 transformation_matrix_inverse = (
286 (1.0 - _np.sin(omega_norm) / omega_norm)
287 * _np.outer(omega_dir, omega_dir)
288 + _np.sin(omega_norm) / omega_norm * _np.identity(3)
289 + (1.0 - _np.cos(omega_norm)) / omega_norm**2 * omega_skew
290 )
291 else:
292 # This is the constant part of the Taylor series expansion. If this
293 # function is used with automatic differentiation, higher order
294 # terms have to be added!
295 transformation_matrix_inverse = _np.identity(3)
296 return transformation_matrix_inverse
298 def inv(self):
299 """Return the inverse of this rotation."""
301 tmp_quaternion = self.q.copy()
302 tmp_quaternion[0] *= -1.0
303 return Rotation.from_quaternion(tmp_quaternion)
305 def __mul__(self, other):
306 """Add this rotation to another, or apply it on a vector."""
308 # Check if the other object is also a rotation.
309 if isinstance(other, Rotation):
310 # Get quaternions of the two objects.
311 p = self.q
312 q = other.q
313 # Add the rotations.
314 added_rotation = _np.zeros_like(self.q)
315 added_rotation[0] = p[0] * q[0] - _np.dot(p[1:], q[1:])
316 added_rotation[1:] = p[0] * q[1:] + q[0] * p[1:] + _np.cross(p[1:], q[1:])
317 return Rotation.from_quaternion(added_rotation)
318 elif isinstance(other, (list, _np.ndarray)) and len(other) == 3:
319 # Apply rotation to vector.
320 return _np.dot(self.get_rotation_matrix(), _np.asarray(other))
321 raise NotImplementedError("Error, not implemented, does not make sense anyway!")
323 def __eq__(self, other):
324 """Check if the other rotation is equal to this one."""
326 if isinstance(other, Rotation):
327 return bool(
328 (_np.linalg.norm(self.q - other.q) < _mpy.eps_quaternion)
329 or (_np.linalg.norm(self.q + other.q) < _mpy.eps_quaternion)
330 )
331 else:
332 return object.__eq__(self, other)
334 def copy(self):
335 """Return a copy of this object."""
336 return Rotation.from_quaternion(self.q, normalized=True)
338 def __str__(self):
339 """String representation of object."""
341 self.check()
342 return f"Rotation:\n q0: {self.q[0]}\n q: {self.q[1:]}"
345def add_rotations(rotation_21, rotation_10):
346 """Multiply a rotation onto another.
348 Args
349 ----
350 rotation_10: _np.ndarray
351 Array with the dimensions n x 4 or 4 x 1.
352 The first rotation that is applied.
353 rotation_21: _np.ndarray
354 Array with the dimensions n x 4 or 4 x 1.
355 The second rotation that is applied.
357 Return
358 ----
359 rot_new: _np.ndarray
360 Array with the dimensions n x 4.
361 This array contains the new quaternions.
362 """
364 # Transpose the arrays, to work with the following code.
365 if isinstance(rotation_10, Rotation):
366 rot1 = rotation_10.get_quaternion().transpose()
367 else:
368 rot1 = _np.transpose(rotation_10)
369 if isinstance(rotation_21, Rotation):
370 rot2 = rotation_21.get_quaternion().transpose()
371 else:
372 rot2 = _np.transpose(rotation_21)
374 if rot1.size > rot2.size:
375 rotnew = _np.zeros_like(rot1)
376 else:
377 rotnew = _np.zeros_like(rot2)
379 # Multiply the two rotations (code is taken from /utility/rotation.nb).
380 rotnew[0] = (
381 rot1[0] * rot2[0] - rot1[1] * rot2[1] - rot1[2] * rot2[2] - rot1[3] * rot2[3]
382 )
383 rotnew[1] = (
384 rot1[1] * rot2[0] + rot1[0] * rot2[1] + rot1[3] * rot2[2] - rot1[2] * rot2[3]
385 )
386 rotnew[2] = (
387 rot1[2] * rot2[0] - rot1[3] * rot2[1] + rot1[0] * rot2[2] + rot1[1] * rot2[3]
388 )
389 rotnew[3] = (
390 rot1[3] * rot2[0] + rot1[2] * rot2[1] - rot1[1] * rot2[2] + rot1[0] * rot2[3]
391 )
393 return rotnew.transpose()
396def rotate_coordinates(coordinates, rotation, *, origin=None):
397 """Rotate all given coordinates.
399 Args
400 ----
401 coordinates: _np.array
402 Array of 3D coordinates to be rotated
403 rotation: Rotation, list(quaternions) (nx4)
404 The rotation that will be applied to the coordinates. Can also be an
405 array with a quaternion for each coordinate.
406 origin: 3D vector
407 If this is given, the mesh is rotated about this point. Default is
408 (0,0,0)
409 """
411 if isinstance(rotation, Rotation):
412 rotation = rotation.get_quaternion().transpose()
414 # Check if origin has to be added
415 if origin is None:
416 origin = [0.0, 0.0, 0.0]
418 # New position array
419 coordinates_new = _np.zeros_like(coordinates)
421 # Evaluate the new positions using the numpy data structure
422 # (code is taken from /utility/rotation.nb)
423 rotation = rotation.transpose()
425 q0_q0 = _np.square(rotation[0])
426 q0_q1_2 = 2.0 * rotation[0] * rotation[1]
427 q0_q2_2 = 2.0 * rotation[0] * rotation[2]
428 q0_q3_2 = 2.0 * rotation[0] * rotation[3]
430 q1_q1 = _np.square(rotation[1])
431 q1_q2_2 = 2.0 * rotation[1] * rotation[2]
432 q1_q3_2 = 2.0 * rotation[1] * rotation[3]
434 q2_q2 = _np.square(rotation[2])
435 q2_q3_2 = 2.0 * rotation[2] * rotation[3]
437 q3_q3 = _np.square(rotation[3])
439 coordinates_new[:, 0] = (
440 (q0_q0 + q1_q1 - q2_q2 - q3_q3) * (coordinates[:, 0] - origin[0])
441 + (q1_q2_2 - q0_q3_2) * (coordinates[:, 1] - origin[1])
442 + (q0_q2_2 + q1_q3_2) * (coordinates[:, 2] - origin[2])
443 )
444 coordinates_new[:, 1] = (
445 (q1_q2_2 + q0_q3_2) * (coordinates[:, 0] - origin[0])
446 + (q0_q0 - q1_q1 + q2_q2 - q3_q3) * (coordinates[:, 1] - origin[1])
447 + (-q0_q1_2 + q2_q3_2) * (coordinates[:, 2] - origin[2])
448 )
449 coordinates_new[:, 2] = (
450 (-q0_q2_2 + q1_q3_2) * (coordinates[:, 0] - origin[0])
451 + (q0_q1_2 + q2_q3_2) * (coordinates[:, 1] - origin[1])
452 + (q0_q0 - q1_q1 - q2_q2 + q3_q3) * (coordinates[:, 2] - origin[2])
453 )
455 if origin is not None:
456 coordinates_new += origin
458 return coordinates_new
461def smallest_rotation(q: Rotation, t):
462 """Get the triad that results from the smallest rotation (rotation without
463 twist) from the triad q such that the rotated first basis vector aligns
464 with t. For more details see Christoph Meier's dissertation chapter 2.1.2.
466 Args
467 ----
468 q: Rotation
469 Starting triad.
470 t: Vector in R3
471 Direction of the first basis of the rotated triad.
472 Return
473 ----
474 q_sr: Rotation
475 The triad that results from a smallest rotation.
476 """
478 R_old = q.get_rotation_matrix()
479 g1_old = R_old[:, 0]
480 g1 = _np.asarray(t) / _np.linalg.norm(t)
482 # Quaternion components of relative rotation
483 q_rel = _np.zeros(4)
485 # The scalar quaternion part is cos(alpha/2) this is equal to
486 q_rel[0] = _np.linalg.norm(0.5 * (g1_old + g1))
488 # Vector part of the quaternion is sin(alpha/2)*axis
489 q_rel[1:] = _np.cross(g1_old, g1) / (2.0 * q_rel[0])
491 return Rotation.from_quaternion(q_rel) * q