Coverage for src/meshpy/core/rotation.py: 95%

191 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-04-28 04:21 +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.""" 

23 

24import copy as _copy 

25 

26import numpy as _np 

27 

28from meshpy.core.conf import mpy as _mpy 

29 

30 

31def skew_matrix(vector): 

32 """Return the skew matrix for the vector.""" 

33 skew = _np.zeros([3, 3]) 

34 skew[0, 1] = -vector[2] 

35 skew[0, 2] = vector[1] 

36 skew[1, 0] = vector[2] 

37 skew[1, 2] = -vector[0] 

38 skew[2, 0] = -vector[1] 

39 skew[2, 1] = vector[0] 

40 return skew 

41 

42 

43class Rotation: 

44 """A class that represents a rotation of a coordinate system. 

45 

46 Internally the rotations are stored as quaternions. 

47 """ 

48 

49 def __init__(self, *args): 

50 """Initialize the rotation object. 

51 

52 Args 

53 ---- 

54 *args: 

55 - Rotation() 

56 Create a identity rotation. 

57 - Rotation(axis, phi) 

58 Create a rotation around the vector axis with the angle phi. 

59 """ 

60 

61 self.q = _np.zeros(4) 

62 

63 if len(args) == 0: 

64 # Identity element. 

65 self.q[0] = 1 

66 elif len(args) == 2: 

67 # Set from rotation axis and rotation angle. 

68 axis = _np.asarray(args[0]) 

69 phi = args[1] 

70 norm = _np.linalg.norm(axis) 

71 if norm < _mpy.eps_quaternion: 

72 raise ValueError("The rotation axis can not be a zero vector!") 

73 self.q[0] = _np.cos(0.5 * phi) 

74 self.q[1:] = _np.sin(0.5 * phi) * axis / norm 

75 else: 

76 raise ValueError(f"The given arguments {args} are invalid!") 

77 

78 @classmethod 

79 def from_quaternion(cls, q, *, normalized=False): 

80 """Create the object from a quaternion float array (4x1) 

81 

82 Args 

83 ---- 

84 q: Quaternion, q0, qx,qy,qz 

85 normalized: Flag if the input quaternion is normalized. If so, no 

86 normalization is performed which can potentially improve performance. 

87 Skipping the normalization should only be done in very special cases 

88 where we can be sure that the input quaternion is normalized to avoid 

89 error accumulation. 

90 """ 

91 rotation = object.__new__(cls) 

92 if normalized: 

93 rotation.q = _np.array(q) 

94 else: 

95 rotation.q = _np.asarray(q) / _np.linalg.norm(q) 

96 if (not rotation.q.ndim == 1) or (not len(rotation.q) == 4): 

97 raise ValueError("Got quaternion array with unexpected dimensions") 

98 return rotation 

99 

100 @classmethod 

101 def from_rotation_matrix(cls, R): 

102 """Create the object from a rotation matrix. 

103 

104 The code is based on Spurriers algorithm: 

105 R. A. Spurrier (1978): “Comment on “Singularity-free extraction of a quaternion from a 

106 direction-cosine matrix” 

107 """ 

108 

109 q = _np.zeros(4) 

110 trace = _np.trace(R) 

111 values = [R[i, i] for i in range(3)] 

112 values.append(trace) 

113 arg_max = _np.argmax(values) 

114 if arg_max == 3: 

115 q[0] = _np.sqrt(trace + 1) * 0.5 

116 q[1] = (R[2, 1] - R[1, 2]) / (4 * q[0]) 

117 q[2] = (R[0, 2] - R[2, 0]) / (4 * q[0]) 

118 q[3] = (R[1, 0] - R[0, 1]) / (4 * q[0]) 

119 else: 

120 i_index = arg_max 

121 j_index = (i_index + 1) % 3 

122 k_index = (i_index + 2) % 3 

123 q_i = _np.sqrt(R[i_index, i_index] * 0.5 + (1 - trace) * 0.25) 

124 q[0] = (R[k_index, j_index] - R[j_index, k_index]) / (4 * q_i) 

125 q[i_index + 1] = q_i 

126 q[j_index + 1] = (R[j_index, i_index] + R[i_index, j_index]) / (4 * q_i) 

127 q[k_index + 1] = (R[k_index, i_index] + R[i_index, k_index]) / (4 * q_i) 

128 

129 return cls.from_quaternion(q) 

130 

131 @classmethod 

132 def from_basis(cls, t1, t2): 

133 """Create the object from two basis vectors t1, t2. 

134 

135 t2 will be orthogonalized on t1, and t3 will be calculated with 

136 the cross product. 

137 """ 

138 

139 t1_normal = t1 / _np.linalg.norm(t1) 

140 t2_ortho = t2 - t1_normal * _np.dot(t1_normal, t2) 

141 t2_normal = t2_ortho / _np.linalg.norm(t2_ortho) 

142 t3_normal = _np.cross(t1_normal, t2_normal) 

143 

144 R = _np.transpose([t1_normal, t2_normal, t3_normal]) 

145 return cls.from_rotation_matrix(R) 

146 

147 @classmethod 

148 def from_rotation_vector(cls, rotation_vector): 

149 """Create the object from a rotation vector.""" 

150 

151 q = _np.zeros(4) 

152 rotation_vector = _np.asarray(rotation_vector) 

153 phi = _np.linalg.norm(rotation_vector) 

154 q[0] = _np.cos(0.5 * phi) 

155 if phi < _mpy.eps_quaternion: 

156 # This is the Taylor series expansion of sin(phi/2)/phi around phi=0 

157 q[1:] = 0.5 * rotation_vector 

158 else: 

159 q[1:] = _np.sin(0.5 * phi) / phi * rotation_vector 

160 return cls.from_quaternion(q) 

161 

162 def check(self): 

163 """Perform all checks for the rotation.""" 

164 self.check_uniqueness() 

165 self.check_quaternion_constraint() 

166 

167 def check_uniqueness(self): 

168 """We always want q0 to be positive -> the range for the rotational 

169 angle is 0 <= phi <= pi.""" 

170 

171 if self.q[0] < 0: 

172 self.q *= -1 

173 

174 def check_quaternion_constraint(self): 

175 """We want to check that q.q = 1.""" 

176 

177 if _np.abs(1 - _np.linalg.norm(self.q)) > _mpy.eps_quaternion: 

178 raise ValueError( 

179 f"The rotation object is corrupted. q.q does not equal 1! q={self.q}" 

180 ) 

181 

182 def get_rotation_matrix(self): 

183 """Return the rotation matrix for this rotation. 

184 

185 (Krenk (3.50)) 

186 """ 

187 q_skew = skew_matrix(self.q[1:]) 

188 R = ( 

189 (self.q[0] ** 2 - _np.dot(self.q[1:], self.q[1:])) * _np.eye(3) 

190 + 2 * self.q[0] * q_skew 

191 + 2 * ([self.q[1:]] * _np.transpose([self.q[1:]])) 

192 ) 

193 

194 return R 

195 

196 def get_quaternion(self): 

197 """Return the quaternion for this rotation, as numpy array (copy).""" 

198 

199 return _np.array(self.q) 

200 

201 def get_rotation_vector(self): 

202 """Return the rotation vector for this object.""" 

203 

204 self.check() 

205 

206 norm = _np.linalg.norm(self.q[1:]) 

207 phi = 2 * _np.arctan2(norm, self.q[0]) 

208 

209 if phi < _mpy.eps_quaternion: 

210 # For small angles return the Taylor series expansion of phi/sin(phi/2) 

211 scale_factor = 2 

212 else: 

213 scale_factor = phi / _np.sin(phi / 2) 

214 if _np.abs(_np.abs(phi) - _np.pi) < _mpy.eps_quaternion: 

215 # For rotations of exactly +-pi, numerical issues might occur, resulting in 

216 # a rotation vector that is non-deterministic. The result is correct, but 

217 # the sign can switch due to different implementation of basic underlying 

218 # math functions. This is especially triggered when using this code with 

219 # different OS. To avoid this, we scale the rotation axis in such a way, 

220 # for a rotation angle of +-pi, the first component of the rotation axis 

221 # that is not 0 is positive. 

222 for i_dir in range(3): 

223 if _np.abs(self.q[1 + i_dir]) > _mpy.eps_quaternion: 

224 if self.q[1 + i_dir] < 0: 

225 scale_factor *= -1 

226 break 

227 return self.q[1:] * scale_factor 

228 

229 def get_transformation_matrix(self): 

230 """Return the transformation matrix for this rotation. 

231 

232 The transformation matrix maps the (infinitesimal) 

233 multiplicative rotational increments onto the additive ones. 

234 """ 

235 

236 omega = self.get_rotation_vector() 

237 omega_norm = _np.linalg.norm(omega) 

238 

239 # We have to take the inverse of the the rotation angle here, therefore, 

240 # we have a branch for small angles where the singularity is not present. 

241 if omega_norm**2 > _mpy.eps_quaternion: 

242 # Taken from Jelenic and Crisfield (1999) Equation (2.5) 

243 omega_dir = omega / omega_norm 

244 omega_skew = skew_matrix(omega) 

245 transformation_matrix = ( 

246 _np.outer(omega_dir, omega_dir) 

247 - 0.5 * omega_skew 

248 + 0.5 

249 * omega_norm 

250 / _np.tan(0.5 * omega_norm) 

251 * (_np.identity(3) - _np.outer(omega_dir, omega_dir)) 

252 ) 

253 else: 

254 # This is the constant part of the Taylor series expansion. If this 

255 # function is used with automatic differentiation, higher order 

256 # terms have to be added! 

257 transformation_matrix = _np.identity(3) 

258 return transformation_matrix 

259 

260 def get_transformation_matrix_inv(self): 

261 """Return the inverse of the transformation matrix for this rotation. 

262 

263 The inverse of the transformation matrix maps the 

264 (infinitesimal) additive rotational increments onto the 

265 multiplicative ones. 

266 """ 

267 

268 omega = self.get_rotation_vector() 

269 omega_norm = _np.linalg.norm(omega) 

270 

271 # We have to take the inverse of the the rotation angle here, therefore, 

272 # we have a branch for small angles where the singularity is not present. 

273 if omega_norm**2 > _mpy.eps_quaternion: 

274 # Taken from Jelenic and Crisfield (1999) Equation (2.5) 

275 omega_dir = omega / omega_norm 

276 omega_skew = skew_matrix(omega) 

277 transformation_matrix_inverse = ( 

278 (1.0 - _np.sin(omega_norm) / omega_norm) 

279 * _np.outer(omega_dir, omega_dir) 

280 + _np.sin(omega_norm) / omega_norm * _np.identity(3) 

281 + (1.0 - _np.cos(omega_norm)) / omega_norm**2 * omega_skew 

282 ) 

283 else: 

284 # This is the constant part of the Taylor series expansion. If this 

285 # function is used with automatic differentiation, higher order 

286 # terms have to be added! 

287 transformation_matrix_inverse = _np.identity(3) 

288 return transformation_matrix_inverse 

289 

290 def inv(self): 

291 """Return the inverse of this rotation.""" 

292 

293 tmp_quaternion = self.q.copy() 

294 tmp_quaternion[0] *= -1.0 

295 return Rotation.from_quaternion(tmp_quaternion) 

296 

297 def __mul__(self, other): 

298 """Add this rotation to another, or apply it on a vector.""" 

299 

300 # Check if the other object is also a rotation. 

301 if isinstance(other, Rotation): 

302 # Get quaternions of the two objects. 

303 p = self.q 

304 q = other.q 

305 # Add the rotations. 

306 added_rotation = _np.zeros_like(self.q) 

307 added_rotation[0] = p[0] * q[0] - _np.dot(p[1:], q[1:]) 

308 added_rotation[1:] = p[0] * q[1:] + q[0] * p[1:] + _np.cross(p[1:], q[1:]) 

309 return Rotation.from_quaternion(added_rotation) 

310 elif isinstance(other, (list, _np.ndarray)) and len(other) == 3: 

311 # Apply rotation to vector. 

312 return _np.dot(self.get_rotation_matrix(), _np.asarray(other)) 

313 raise NotImplementedError("Error, not implemented, does not make sense anyway!") 

314 

315 def __eq__(self, other): 

316 """Check if the other rotation is equal to this one.""" 

317 

318 if isinstance(other, Rotation): 

319 return bool( 

320 (_np.linalg.norm(self.q - other.q) < _mpy.eps_quaternion) 

321 or (_np.linalg.norm(self.q + other.q) < _mpy.eps_quaternion) 

322 ) 

323 else: 

324 return object.__eq__(self, other) 

325 

326 def copy(self): 

327 """Return a deep copy of this object.""" 

328 return _copy.deepcopy(self) 

329 

330 def __str__(self): 

331 """String representation of object.""" 

332 

333 self.check() 

334 return f"Rotation:\n q0: {self.q[0]}\n q: {self.q[1:]}" 

335 

336 

337def add_rotations(rotation_21, rotation_10): 

338 """Multiply a rotation onto another. 

339 

340 Args 

341 ---- 

342 rotation_10: _np.ndarray 

343 Array with the dimensions n x 4 or 4 x 1. 

344 The first rotation that is applied. 

345 rotation_21: _np.ndarray 

346 Array with the dimensions n x 4 or 4 x 1. 

347 The second rotation that is applied. 

348 

349 Return 

350 ---- 

351 rot_new: _np.ndarray 

352 Array with the dimensions n x 4. 

353 This array contains the new quaternions. 

354 """ 

355 

356 # Transpose the arrays, to work with the following code. 

357 if isinstance(rotation_10, Rotation): 

358 rot1 = rotation_10.get_quaternion().transpose() 

359 else: 

360 rot1 = _np.transpose(rotation_10) 

361 if isinstance(rotation_21, Rotation): 

362 rot2 = rotation_21.get_quaternion().transpose() 

363 else: 

364 rot2 = _np.transpose(rotation_21) 

365 

366 if rot1.size > rot2.size: 

367 rotnew = _np.zeros_like(rot1) 

368 else: 

369 rotnew = _np.zeros_like(rot2) 

370 

371 # Multiply the two rotations (code is taken from /utility/rotation.nb). 

372 rotnew[0] = ( 

373 rot1[0] * rot2[0] - rot1[1] * rot2[1] - rot1[2] * rot2[2] - rot1[3] * rot2[3] 

374 ) 

375 rotnew[1] = ( 

376 rot1[1] * rot2[0] + rot1[0] * rot2[1] + rot1[3] * rot2[2] - rot1[2] * rot2[3] 

377 ) 

378 rotnew[2] = ( 

379 rot1[2] * rot2[0] - rot1[3] * rot2[1] + rot1[0] * rot2[2] + rot1[1] * rot2[3] 

380 ) 

381 rotnew[3] = ( 

382 rot1[3] * rot2[0] + rot1[2] * rot2[1] - rot1[1] * rot2[2] + rot1[0] * rot2[3] 

383 ) 

384 

385 return rotnew.transpose() 

386 

387 

388def rotate_coordinates(coordinates, rotation, *, origin=None): 

389 """Rotate all given coordinates. 

390 

391 Args 

392 ---- 

393 coordinates: _np.array 

394 Array of 3D coordinates to be rotated 

395 rotation: Rotation, list(quaternions) (nx4) 

396 The rotation that will be applied to the coordinates. Can also be an 

397 array with a quaternion for each coordinate. 

398 origin: 3D vector 

399 If this is given, the mesh is rotated about this point. Default is 

400 (0,0,0) 

401 """ 

402 

403 if isinstance(rotation, Rotation): 

404 rotation = rotation.get_quaternion().transpose() 

405 

406 # Check if origin has to be added 

407 if origin is None: 

408 origin = [0.0, 0.0, 0.0] 

409 

410 # New position array 

411 coordinates_new = _np.zeros_like(coordinates) 

412 

413 # Evaluate the new positions using the numpy data structure 

414 # (code is taken from /utility/rotation.nb) 

415 rotation = rotation.transpose() 

416 

417 q0_q0 = _np.square(rotation[0]) 

418 q0_q1_2 = 2.0 * rotation[0] * rotation[1] 

419 q0_q2_2 = 2.0 * rotation[0] * rotation[2] 

420 q0_q3_2 = 2.0 * rotation[0] * rotation[3] 

421 

422 q1_q1 = _np.square(rotation[1]) 

423 q1_q2_2 = 2.0 * rotation[1] * rotation[2] 

424 q1_q3_2 = 2.0 * rotation[1] * rotation[3] 

425 

426 q2_q2 = _np.square(rotation[2]) 

427 q2_q3_2 = 2.0 * rotation[2] * rotation[3] 

428 

429 q3_q3 = _np.square(rotation[3]) 

430 

431 coordinates_new[:, 0] = ( 

432 (q0_q0 + q1_q1 - q2_q2 - q3_q3) * (coordinates[:, 0] - origin[0]) 

433 + (q1_q2_2 - q0_q3_2) * (coordinates[:, 1] - origin[1]) 

434 + (q0_q2_2 + q1_q3_2) * (coordinates[:, 2] - origin[2]) 

435 ) 

436 coordinates_new[:, 1] = ( 

437 (q1_q2_2 + q0_q3_2) * (coordinates[:, 0] - origin[0]) 

438 + (q0_q0 - q1_q1 + q2_q2 - q3_q3) * (coordinates[:, 1] - origin[1]) 

439 + (-q0_q1_2 + q2_q3_2) * (coordinates[:, 2] - origin[2]) 

440 ) 

441 coordinates_new[:, 2] = ( 

442 (-q0_q2_2 + q1_q3_2) * (coordinates[:, 0] - origin[0]) 

443 + (q0_q1_2 + q2_q3_2) * (coordinates[:, 1] - origin[1]) 

444 + (q0_q0 - q1_q1 - q2_q2 + q3_q3) * (coordinates[:, 2] - origin[2]) 

445 ) 

446 

447 if origin is not None: 

448 coordinates_new += origin 

449 

450 return coordinates_new 

451 

452 

453def smallest_rotation(q: Rotation, t): 

454 """Get the triad that results from the smallest rotation (rotation without 

455 twist) from the triad q such that the rotated first basis vector aligns 

456 with t. For more details see Christoph Meier's dissertation chapter 2.1.2. 

457 

458 Args 

459 ---- 

460 q: Rotation 

461 Starting triad. 

462 t: Vector in R3 

463 Direction of the first basis of the rotated triad. 

464 Return 

465 ---- 

466 q_sr: Rotation 

467 The triad that results from a smallest rotation. 

468 """ 

469 

470 R_old = q.get_rotation_matrix() 

471 g1_old = R_old[:, 0] 

472 g1 = _np.asarray(t) / _np.linalg.norm(t) 

473 

474 # Quaternion components of relative rotation 

475 q_rel = _np.zeros(4) 

476 

477 # The scalar quaternion part is cos(alpha/2) this is equal to 

478 q_rel[0] = _np.linalg.norm(0.5 * (g1_old + g1)) 

479 

480 # Vector part of the quaternion is sin(alpha/2)*axis 

481 q_rel[1:] = _np.cross(g1_old, g1) / (2.0 * q_rel[0]) 

482 

483 return Rotation.from_quaternion(q_rel) * q