Coverage for trimesh/transformations.py: 93%

808 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-10-24 01:00 +0000

1# transformations.py 

2 

3# Modified for inclusion in the `trimesh` library 

4# https://github.com/mikedh/trimesh 

5# ----------------------------------------------------------------------- 

6# 

7# Copyright (c) 2006-2017, Christoph Gohlke 

8# Copyright (c) 2006-2017, The Regents of the University of California 

9# Produced at the Laboratory for Fluorescence Dynamics 

10# All rights reserved. 

11# 

12# Redistribution and use in source and binary forms, with or without 

13# modification, are permitted provided that the following conditions are met: 

14# 

15# * Redistributions of source code must retain the above copyright 

16# notice, this list of conditions and the following disclaimer. 

17# * Redistributions in binary form must reproduce the above copyright 

18# notice, this list of conditions and the following disclaimer in the 

19# documentation and/or other materials provided with the distribution. 

20# * Neither the name of the copyright holders nor the names of any 

21# contributors may be used to endorse or promote products derived 

22# from this software without specific prior written permission. 

23# 

24# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 

25# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 

26# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 

27# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE 

28# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 

29# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 

30# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 

31# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 

32# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 

33# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 

34# POSSIBILITY OF SUCH DAMAGE. 

35 

36"""Homogeneous Transformation Matrices and Quaternions. 

37 

38A library for calculating 4x4 matrices for translating, rotating, reflecting, 

39scaling, shearing, projecting, orthogonalizing, and superimposing arrays of 

403D homogeneous coordinates as well as for converting between rotation matrices, 

41Euler angles, and quaternions. Also includes an Arcball control object and 

42functions to decompose transformation matrices. 

43 

44:Author: 

45 `Christoph Gohlke <http://www.lfd.uci.edu/~gohlke/>`_ 

46 

47:Organization: 

48 Laboratory for Fluorescence Dynamics, University of California, Irvine 

49 

50:Version: 2017.02.17 

51 

52Requirements 

53------------ 

54* `CPython 2.7 or 3.4 <http://www.python.org>`_ 

55* `numpy 1.9 <http://www.np.org>`_ 

56* `Transformations.c 2015.03.19 <http://www.lfd.uci.edu/~gohlke/>`_ 

57 (recommended for speedup of some functions) 

58 

59Notes 

60----- 

61The API is not stable yet and is expected to change between revisions. 

62 

63This Python code is not optimized for speed. Refer to the transformations.c 

64module for a faster implementation of some functions. 

65 

66Documentation in HTML format can be generated with epydoc. 

67 

68Matrices (M) can be inverted using np.linalg.inv(M), be concatenated using 

69np.dot(M0, M1), or transform homogeneous coordinate arrays (v) using 

70np.dot(M, v) for shape (4, *) column vectors, respectively 

71np.dot(v, M.T) for shape (*, 4) row vectors ("array of points"). 

72 

73This module follows the "column vectors on the right" and "row major storage" 

74(C contiguous) conventions. The translation components are in the right column 

75of the transformation matrix, i.e. M[:3, 3]. 

76The transpose of the transformation matrices may have to be used to interface 

77with other graphics systems, e.g. with OpenGL's glMultMatrixd(). See also [16]. 

78 

79Calculations are carried out with np.float64 precision. 

80 

81Vector, point, quaternion, and matrix function arguments are expected to be 

82"array like", i.e. tuple, list, or numpy arrays. 

83 

84Return types are numpy arrays unless specified otherwise. 

85 

86Angles are in radians unless specified otherwise. 

87 

88Quaternions w+ix+jy+kz are represented as [w, x, y, z]. 

89 

90A triple of Euler angles can be applied/interpreted in 24 ways, which can 

91be specified using a 4 character string or encoded 4-tuple: 

92 

93 *Axes 4-string*: e.g. 'sxyz' or 'ryxy' 

94 

95 - first character : rotations are applied to 's'tatic or 'r'otating frame 

96 - remaining characters : successive rotation axis 'x', 'y', or 'z' 

97 

98 *Axes 4-tuple*: e.g. (0, 0, 0, 0) or (1, 1, 1, 1) 

99 

100 - inner axis: code of axis ('x':0, 'y':1, 'z':2) of rightmost matrix. 

101 - parity : even (0) if inner axis 'x' is followed by 'y', 'y' is followed 

102 by 'z', or 'z' is followed by 'x'. Otherwise odd (1). 

103 - repetition : first and last axis are same (1) or different (0). 

104 - frame : rotations are applied to static (0) or rotating (1) frame. 

105 

106Other Python packages and modules for 3D transformations and quaternions: 

107 

108* `Transforms3d <https://pypi.python.org/pypi/transforms3d>`_ 

109 includes most code of this module. 

110* `Blender.mathutils <http://www.blender.org/api/blender_python_api>`_ 

111* `numpy-dtypes <https://github.com/numpy/numpy-dtypes>`_ 

112 

113References 

114---------- 

115(1) Matrices and transformations. Ronald Goldman. 

116 In "Graphics Gems I", pp 472-475. Morgan Kaufmann, 1990. 

117(2) More matrices and transformations: shear and pseudo-perspective. 

118 Ronald Goldman. In "Graphics Gems II", pp 320-323. Morgan Kaufmann, 1991. 

119(3) Decomposing a matrix into simple transformations. Spencer Thomas. 

120 In "Graphics Gems II", pp 320-323. Morgan Kaufmann, 1991. 

121(4) Recovering the data from the transformation matrix. Ronald Goldman. 

122 In "Graphics Gems II", pp 324-331. Morgan Kaufmann, 1991. 

123(5) Euler angle conversion. Ken Shoemake. 

124 In "Graphics Gems IV", pp 222-229. Morgan Kaufmann, 1994. 

125(6) Arcball rotation control. Ken Shoemake. 

126 In "Graphics Gems IV", pp 175-192. Morgan Kaufmann, 1994. 

127(7) Representing attitude: Euler angles, unit quaternions, and rotation 

128 vectors. James Diebel. 2006. 

129(8) A discussion of the solution for the best rotation to relate two sets 

130 of vectors. W Kabsch. Acta Cryst. 1978. A34, 827-828. 

131(9) Closed-form solution of absolute orientation using unit quaternions. 

132 BKP Horn. J Opt Soc Am A. 1987. 4(4):629-642. 

133(10) Quaternions. Ken Shoemake. 

134 http://www.sfu.ca/~jwa3/cmpt461/files/quatut.pdf 

135(11) From quaternion to matrix and back. JMP van Waveren. 2005. 

136 http://www.intel.com/cd/ids/developer/asmo-na/eng/293748.htm 

137(12) Uniform random rotations. Ken Shoemake. 

138 In "Graphics Gems III", pp 124-132. Morgan Kaufmann, 1992. 

139(13) Quaternion in molecular modeling. CFF Karney. 

140 J Mol Graph Mod, 25(5):595-604 

141(14) New method for extracting the quaternion from a rotation matrix. 

142 Itzhack Y Bar-Itzhack, J Guid Contr Dynam. 2000. 23(6): 1085-1087. 

143(15) Multiple View Geometry in Computer Vision. Hartley and Zissermann. 

144 Cambridge University Press; 2nd Ed. 2004. Chapter 4, Algorithm 4.7, p 130. 

145(16) Column Vectors vs. Row Vectors. 

146 http://steve.hollasch.net/cgindex/math/matrix/column-vec.html 

147 

148Examples 

149-------- 

150>>> alpha, beta, gamma = 0.123, -1.234, 2.345 

151>>> origin, xaxis, yaxis, zaxis = [0, 0, 0], [1, 0, 0], [0, 1, 0], [0, 0, 1] 

152>>> I = identity_matrix() 

153>>> Rx = rotation_matrix(alpha, xaxis) 

154>>> Ry = rotation_matrix(beta, yaxis) 

155>>> Rz = rotation_matrix(gamma, zaxis) 

156>>> R = concatenate_matrices(Rx, Ry, Rz) 

157>>> euler = euler_from_matrix(R, 'rxyz') 

158>>> np.allclose([alpha, beta, gamma], euler) 

159True 

160>>> Re = euler_matrix(alpha, beta, gamma, 'rxyz') 

161>>> is_same_transform(R, Re) 

162True 

163>>> al, be, ga = euler_from_matrix(Re, 'rxyz') 

164>>> is_same_transform(Re, euler_matrix(al, be, ga, 'rxyz')) 

165True 

166>>> qx = quaternion_about_axis(alpha, xaxis) 

167>>> qy = quaternion_about_axis(beta, yaxis) 

168>>> qz = quaternion_about_axis(gamma, zaxis) 

169>>> q = quaternion_multiply(qx, qy) 

170>>> q = quaternion_multiply(q, qz) 

171>>> Rq = quaternion_matrix(q) 

172>>> is_same_transform(R, Rq) 

173True 

174>>> S = scale_matrix(1.23, origin) 

175>>> T = translation_matrix([1, 2, 3]) 

176>>> Z = shear_matrix(beta, xaxis, origin, zaxis) 

177>>> R = random_rotation_matrix(np.random.rand(3)) 

178>>> M = concatenate_matrices(T, R, Z, S) 

179>>> scale, shear, angles, trans, persp = decompose_matrix(M) 

180>>> np.allclose(scale, 1.23) 

181True 

182>>> np.allclose(trans, [1, 2, 3]) 

183True 

184>>> np.allclose(shear, [0, np.tan(beta), 0]) 

185True 

186>>> is_same_transform(R, euler_matrix(axes='sxyz', *angles)) 

187True 

188>>> M1 = compose_matrix(scale, shear, angles, trans, persp) 

189>>> is_same_transform(M, M1) 

190True 

191>>> v0, v1 = random_vector(3), random_vector(3) 

192>>> M = rotation_matrix(angle_between_vectors(v0, v1), vector_product(v0, v1)) 

193>>> v2 = np.dot(v0, M[:3,:3].T) 

194>>> np.allclose(unit_vector(v1), unit_vector(v2)) 

195True 

196 

197""" 

198 

199import numpy as np 

200from numpy.typing import ArrayLike, NDArray 

201 

202from .typed import Integer, Number, Seed 

203from .util import diagonal_dot, random_generator 

204 

205# a cached immutable identity matrix provides a speedup sometimes 

206_IDENTITY = np.eye(4) 

207_IDENTITY.flags["WRITEABLE"] = False 

208# a flattened 3x3 identity for comparing against raveled rotations 

209_IDENTITY9 = np.eye(3).ravel() 

210_IDENTITY9.flags["WRITEABLE"] = False 

211 

212 

213def identity_matrix(): 

214 """Return 4x4 identity/unit matrix. 

215 

216 >>> I = identity_matrix() 

217 >>> np.allclose(I, np.dot(I, I)) 

218 True 

219 >>> float(np.sum(I)), float(np.trace(I)) 

220 (4.0, 4.0) 

221 >>> np.allclose(I, np.identity(4)) 

222 True 

223 

224 """ 

225 return np.identity(4) 

226 

227 

228def translation_matrix(direction): 

229 """ 

230 Return matrix to translate by direction vector. 

231 

232 >>> v = np.random.random(3) - 0.5 

233 >>> np.allclose(v, translation_matrix(v)[:3, 3]) 

234 True 

235 

236 """ 

237 # are we 2D or 3D 

238 dim = len(direction) 

239 

240 # start with identity matrix 

241 

242 if any("sympy" in str(type(v)) for v in direction): 

243 # if we have been passed input values as sympy.Symbol 

244 from sympy import eye 

245 

246 M = eye(dim + 1) 

247 else: 

248 M = np.eye(dim + 1) 

249 

250 # apply the offset 

251 M[:dim, dim] = direction[:dim] 

252 

253 return M 

254 

255 

256def translation_from_matrix(matrix): 

257 """Return translation vector from translation matrix. 

258 

259 >>> v0 = np.random.random(3) - 0.5 

260 >>> v1 = translation_from_matrix(translation_matrix(v0)) 

261 >>> np.allclose(v0, v1) 

262 True 

263 

264 """ 

265 return np.asarray(matrix)[:3, 3].copy() 

266 

267 

268def reflection_matrix(point, normal): 

269 """Return matrix to mirror at plane defined by point and normal vector. 

270 

271 >>> v0 = np.random.random(4) - 0.5 

272 >>> v0[3] = 1. 

273 >>> v1 = np.random.random(3) - 0.5 

274 >>> R = reflection_matrix(v0, v1) 

275 >>> np.allclose(2, np.trace(R)) 

276 True 

277 >>> np.allclose(v0, np.dot(R, v0)) 

278 True 

279 >>> v2 = v0.copy() 

280 >>> v2[:3] += v1 

281 >>> v3 = v0.copy() 

282 >>> v2[:3] -= v1 

283 >>> np.allclose(v2, np.dot(R, v3)) 

284 True 

285 

286 """ 

287 normal = unit_vector(normal[:3]) 

288 M = np.identity(4) 

289 M[:3, :3] -= 2.0 * np.outer(normal, normal) 

290 M[:3, 3] = (2.0 * np.dot(point[:3], normal)) * normal 

291 return M 

292 

293 

294def reflection_from_matrix(matrix): 

295 """Return mirror plane point and normal vector from reflection matrix. 

296 

297 >>> v0 = np.random.random(3) - 0.5 

298 >>> v1 = np.random.random(3) - 0.5 

299 >>> M0 = reflection_matrix(v0, v1) 

300 >>> point, normal = reflection_from_matrix(M0) 

301 >>> M1 = reflection_matrix(point, normal) 

302 >>> is_same_transform(M0, M1) 

303 True 

304 

305 """ 

306 M = np.asarray(matrix, dtype=np.float64) 

307 # normal: unit eigenvector corresponding to eigenvalue -1 

308 w, V = np.linalg.eig(M[:3, :3]) 

309 i = np.where(abs(np.real(w) + 1.0) < 1e-8)[0] 

310 if not len(i): 

311 raise ValueError("no unit eigenvector corresponding to eigenvalue -1") 

312 normal = np.real(V[:, i[0]]).squeeze() 

313 # point: any unit eigenvector corresponding to eigenvalue 1 

314 w, V = np.linalg.eig(M) 

315 i = np.where(abs(np.real(w) - 1.0) < 1e-8)[0] 

316 if not len(i): 

317 raise ValueError("no unit eigenvector corresponding to eigenvalue 1") 

318 point = np.real(V[:, i[-1]]).squeeze() 

319 point /= point[3] 

320 return point, normal 

321 

322 

323def rotation_matrix(angle, direction, point=None): 

324 """ 

325 Return matrix to rotate about axis defined by point and 

326 direction. 

327 

328 Parameters 

329 ------------- 

330 angle : float, or sympy.Symbol 

331 Angle, in radians or symbolic angle 

332 direction : (3,) float 

333 Any vector along rotation axis 

334 point : (3, ) float, or None 

335 Origin point of rotation axis 

336 

337 Returns 

338 ------------- 

339 matrix : (4, 4) float, or (4, 4) sympy.Matrix 

340 Homogeneous transformation matrix 

341 

342 Examples 

343 ------------- 

344 >>> R = rotation_matrix(np.pi/2, [0, 0, 1], [1, 0, 0]) 

345 >>> np.allclose(np.dot(R, [0, 0, 0, 1]), [1, -1, 0, 1]) 

346 True 

347 >>> angle = (random.random() - 0.5) * (2*np.pi) 

348 >>> direc = np.random.random(3) - 0.5 

349 >>> point = np.random.random(3) - 0.5 

350 >>> R0 = rotation_matrix(angle, direc, point) 

351 >>> R1 = rotation_matrix(angle-2*np.pi, direc, point) 

352 >>> is_same_transform(R0, R1) 

353 True 

354 >>> R0 = rotation_matrix(angle, direc, point) 

355 >>> R1 = rotation_matrix(-angle, -direc, point) 

356 >>> is_same_transform(R0, R1) 

357 True 

358 >>> I = np.identity(4, np.float64) 

359 >>> np.allclose(I, rotation_matrix(np.pi*2, direc)) 

360 True 

361 >>> np.allclose(2, np.trace(rotation_matrix(np.pi/2,direc,point))) 

362 True 

363 

364 """ 

365 if "sympy" in str(type(angle)): 

366 # special case sympy symbolic angles 

367 import sympy as sp 

368 

369 symbolic = True 

370 sina = sp.sin(angle) 

371 cosa = sp.cos(angle) 

372 else: 

373 symbolic = False 

374 sina = np.sin(angle) 

375 cosa = np.cos(angle) 

376 

377 direction = unit_vector(direction[:3]) 

378 

379 # rotation matrix around unit vector 

380 M = np.diag([cosa, cosa, cosa, 1.0]) 

381 M[:3, :3] += np.outer(direction, direction) * (1.0 - cosa) 

382 

383 direction = direction * sina 

384 M[:3, :3] += np.array( 

385 [ 

386 [0.0, -direction[2], direction[1]], 

387 [direction[2], 0.0, -direction[0]], 

388 [-direction[1], direction[0], 0.0], 

389 ] 

390 ) 

391 

392 # if point is specified, rotation is not around origin 

393 if point is not None: 

394 point = np.asarray(point[:3], dtype=np.float64) 

395 M[:3, 3] = point - np.dot(M[:3, :3], point) 

396 

397 # return symbolic angles as sympy Matrix objects 

398 if symbolic: 

399 return sp.Matrix(M) 

400 

401 return M 

402 

403 

404def rotation_from_matrix(matrix): 

405 """Return rotation angle and axis from rotation matrix. 

406 

407 >>> angle = (random.random() - 0.5) * (2*np.pi) 

408 >>> direc = np.random.random(3) - 0.5 

409 >>> point = np.random.random(3) - 0.5 

410 >>> R0 = rotation_matrix(angle, direc, point) 

411 >>> angle, direc, point = rotation_from_matrix(R0) 

412 >>> R1 = rotation_matrix(angle, direc, point) 

413 >>> is_same_transform(R0, R1) 

414 True 

415 

416 """ 

417 R = np.asarray(matrix, dtype=np.float64) 

418 R33 = R[:3, :3] 

419 # direction: unit eigenvector of R33 corresponding to eigenvalue of 1 

420 w, W = np.linalg.eig(R33.T) 

421 i = np.where(abs(np.real(w) - 1.0) < 1e-8)[0] 

422 if not len(i): 

423 raise ValueError("no unit eigenvector corresponding to eigenvalue 1") 

424 direction = np.real(W[:, i[-1]]).squeeze() 

425 # point: unit eigenvector of R33 corresponding to eigenvalue of 1 

426 w, Q = np.linalg.eig(R) 

427 i = np.where(abs(np.real(w) - 1.0) < 1e-8)[0] 

428 if not len(i): 

429 raise ValueError("no unit eigenvector corresponding to eigenvalue 1") 

430 point = np.real(Q[:, i[-1]]).squeeze() 

431 point /= point[3] 

432 # rotation angle depending on direction 

433 cosa = (np.trace(R33) - 1.0) / 2.0 

434 if abs(direction[2]) > 1e-8: 

435 sina = (R[1, 0] + (cosa - 1.0) * direction[0] * direction[1]) / direction[2] 

436 elif abs(direction[1]) > 1e-8: 

437 sina = (R[0, 2] + (cosa - 1.0) * direction[0] * direction[2]) / direction[1] 

438 else: 

439 sina = (R[2, 1] + (cosa - 1.0) * direction[1] * direction[2]) / direction[0] 

440 angle = np.arctan2(sina, cosa) 

441 return angle, direction, point 

442 

443 

444def scale_matrix(factor, origin=None, direction=None): 

445 """Return matrix to scale by factor around origin in direction. 

446 

447 Use factor -1 for point symmetry. 

448 

449 >>> v = (np.random.rand(4, 5) - 0.5) * 20 

450 >>> v[3] = 1 

451 >>> S = scale_matrix(-1.234) 

452 >>> np.allclose(np.dot(S, v)[:3], -1.234*v[:3]) 

453 True 

454 >>> factor = random.random() * 10 - 5 

455 >>> origin = np.random.random(3) - 0.5 

456 >>> direct = np.random.random(3) - 0.5 

457 >>> S = scale_matrix(factor, origin) 

458 >>> S = scale_matrix(factor, origin, direct) 

459 

460 """ 

461 if direction is None: 

462 # uniform scaling 

463 M = np.diag([factor, factor, factor, 1.0]) 

464 if origin is not None: 

465 M[:3, 3] = origin[:3] 

466 M[:3, 3] *= 1.0 - factor 

467 else: 

468 # nonuniform scaling 

469 direction = unit_vector(direction[:3]) 

470 factor = 1.0 - factor 

471 M = np.identity(4) 

472 M[:3, :3] -= factor * np.outer(direction, direction) 

473 if origin is not None: 

474 M[:3, 3] = (factor * np.dot(origin[:3], direction)) * direction 

475 return M 

476 

477 

478def scale_from_matrix(matrix): 

479 """Return scaling factor, origin and direction from scaling matrix. 

480 

481 >>> factor = random.random() * 10 - 5 

482 >>> origin = np.random.random(3) - 0.5 

483 >>> direct = np.random.random(3) - 0.5 

484 >>> S0 = scale_matrix(factor, origin) 

485 >>> factor, origin, direction = scale_from_matrix(S0) 

486 >>> S1 = scale_matrix(factor, origin, direction) 

487 >>> is_same_transform(S0, S1) 

488 True 

489 >>> S0 = scale_matrix(factor, origin, direct) 

490 >>> factor, origin, direction = scale_from_matrix(S0) 

491 >>> S1 = scale_matrix(factor, origin, direction) 

492 >>> is_same_transform(S0, S1) 

493 True 

494 

495 """ 

496 M = np.asarray(matrix, dtype=np.float64) 

497 M33 = M[:3, :3] 

498 factor = np.trace(M33) - 2.0 

499 try: 

500 # direction: unit eigenvector corresponding to eigenvalue factor 

501 w, V = np.linalg.eig(M33) 

502 i = np.where(abs(np.real(w) - factor) < 1e-8)[0][0] 

503 direction = np.real(V[:, i]).squeeze() 

504 direction /= vector_norm(direction) 

505 except IndexError: 

506 # uniform scaling 

507 factor = (factor + 2.0) / 3.0 

508 direction = None 

509 # origin: any eigenvector corresponding to eigenvalue 1 

510 w, V = np.linalg.eig(M) 

511 i = np.where(abs(np.real(w) - 1.0) < 1e-8)[0] 

512 if not len(i): 

513 raise ValueError("no eigenvector corresponding to eigenvalue 1") 

514 origin = np.real(V[:, i[-1]]).squeeze() 

515 origin /= origin[3] 

516 return factor, origin, direction 

517 

518 

519def projection_matrix(point, normal, direction=None, perspective=None, pseudo=False): 

520 """Return matrix to project onto plane defined by point and normal. 

521 

522 Using either perspective point, projection direction, or none of both. 

523 

524 If pseudo is True, perspective projections will preserve relative depth 

525 such that Perspective = dot(Orthogonal, PseudoPerspective). 

526 

527 >>> P = projection_matrix([0, 0, 0], [1, 0, 0]) 

528 >>> np.allclose(P[1:, 1:], np.identity(4)[1:, 1:]) 

529 True 

530 >>> point = np.random.random(3) - 0.5 

531 >>> normal = np.random.random(3) - 0.5 

532 >>> direct = np.random.random(3) - 0.5 

533 >>> persp = np.random.random(3) - 0.5 

534 >>> P0 = projection_matrix(point, normal) 

535 >>> P1 = projection_matrix(point, normal, direction=direct) 

536 >>> P2 = projection_matrix(point, normal, perspective=persp) 

537 >>> P3 = projection_matrix(point, normal, perspective=persp, pseudo=True) 

538 >>> is_same_transform(P2, np.dot(P0, P3)) 

539 True 

540 >>> P = projection_matrix([3, 0, 0], [1, 1, 0], [1, 0, 0]) 

541 >>> v0 = (np.random.rand(4, 5) - 0.5) * 20 

542 >>> v0[3] = 1 

543 >>> v1 = np.dot(P, v0) 

544 >>> np.allclose(v1[1], v0[1]) 

545 True 

546 >>> np.allclose(v1[0], 3-v1[1]) 

547 True 

548 

549 """ 

550 M = np.identity(4) 

551 point = np.asarray(point[:3], dtype=np.float64) 

552 normal = unit_vector(normal[:3]) 

553 if perspective is not None: 

554 # perspective projection 

555 perspective = np.asarray(perspective[:3], dtype=np.float64) 

556 M[0, 0] = M[1, 1] = M[2, 2] = np.dot(perspective - point, normal) 

557 M[:3, :3] -= np.outer(perspective, normal) 

558 if pseudo: 

559 # preserve relative depth 

560 M[:3, :3] -= np.outer(normal, normal) 

561 M[:3, 3] = np.dot(point, normal) * (perspective + normal) 

562 else: 

563 M[:3, 3] = np.dot(point, normal) * perspective 

564 M[3, :3] = -normal 

565 M[3, 3] = np.dot(perspective, normal) 

566 elif direction is not None: 

567 # parallel projection 

568 direction = np.asarray(direction[:3], dtype=np.float64) 

569 scale = np.dot(direction, normal) 

570 M[:3, :3] -= np.outer(direction, normal) / scale 

571 M[:3, 3] = direction * (np.dot(point, normal) / scale) 

572 else: 

573 # orthogonal projection 

574 M[:3, :3] -= np.outer(normal, normal) 

575 M[:3, 3] = np.dot(point, normal) * normal 

576 return M 

577 

578 

579def projection_from_matrix(matrix, pseudo=False): 

580 """Return projection plane and perspective point from projection matrix. 

581 

582 Return values are same as arguments for projection_matrix function: 

583 point, normal, direction, perspective, and pseudo. 

584 

585 >>> point = np.random.random(3) - 0.5 

586 >>> normal = np.random.random(3) - 0.5 

587 >>> direct = np.random.random(3) - 0.5 

588 >>> persp = np.random.random(3) - 0.5 

589 >>> P0 = projection_matrix(point, normal) 

590 >>> result = projection_from_matrix(P0) 

591 >>> P1 = projection_matrix(*result) 

592 >>> is_same_transform(P0, P1) 

593 True 

594 >>> P0 = projection_matrix(point, normal, direct) 

595 >>> result = projection_from_matrix(P0) 

596 >>> P1 = projection_matrix(*result) 

597 >>> is_same_transform(P0, P1) 

598 True 

599 >>> P0 = projection_matrix(point, normal, perspective=persp, pseudo=False) 

600 >>> result = projection_from_matrix(P0, pseudo=False) 

601 >>> P1 = projection_matrix(*result) 

602 >>> is_same_transform(P0, P1) 

603 True 

604 >>> P0 = projection_matrix(point, normal, perspective=persp, pseudo=True) 

605 >>> result = projection_from_matrix(P0, pseudo=True) 

606 >>> P1 = projection_matrix(*result) 

607 >>> is_same_transform(P0, P1) 

608 True 

609 

610 """ 

611 M = np.asarray(matrix, dtype=np.float64) 

612 M33 = M[:3, :3] 

613 w, V = np.linalg.eig(M) 

614 i = np.where(abs(np.real(w) - 1.0) < 1e-8)[0] 

615 if not pseudo and len(i): 

616 # point: any eigenvector corresponding to eigenvalue 1 

617 point = np.real(V[:, i[-1]]).squeeze() 

618 point /= point[3] 

619 # direction: unit eigenvector corresponding to eigenvalue 0 

620 w, V = np.linalg.eig(M33) 

621 i = np.where(abs(np.real(w)) < 1e-8)[0] 

622 if not len(i): 

623 raise ValueError("no eigenvector corresponding to eigenvalue 0") 

624 direction = np.real(V[:, i[0]]).squeeze() 

625 direction /= vector_norm(direction) 

626 # normal: unit eigenvector of M33.T corresponding to eigenvalue 0 

627 w, V = np.linalg.eig(M33.T) 

628 i = np.where(abs(np.real(w)) < 1e-8)[0] 

629 if len(i): 

630 # parallel projection 

631 normal = np.real(V[:, i[0]]).squeeze() 

632 normal /= vector_norm(normal) 

633 return point, normal, direction, None, False 

634 else: 

635 # orthogonal projection, where normal equals direction vector 

636 return point, direction, None, None, False 

637 else: 

638 # perspective projection 

639 i = np.where(abs(np.real(w)) > 1e-8)[0] 

640 if not len(i): 

641 raise ValueError("no eigenvector not corresponding to eigenvalue 0") 

642 point = np.real(V[:, i[-1]]).squeeze() 

643 point /= point[3] 

644 normal = -M[3, :3] 

645 perspective = M[:3, 3] / np.dot(point[:3], normal) 

646 if pseudo: 

647 perspective -= normal 

648 return point, normal, None, perspective, pseudo 

649 

650 

651def clip_matrix(left, right, bottom, top, near, far, perspective=False): 

652 """Return matrix to obtain normalized device coordinates from frustum. 

653 

654 The frustum bounds are axis-aligned along x (left, right), 

655 y (bottom, top) and z (near, far). 

656 

657 Normalized device coordinates are in range [-1, 1] if coordinates are 

658 inside the frustum. 

659 

660 If perspective is True the frustum is a truncated pyramid with the 

661 perspective point at origin and direction along z axis, otherwise an 

662 orthographic canonical view volume (a box). 

663 

664 Homogeneous coordinates transformed by the perspective clip matrix 

665 need to be dehomogenized (divided by w coordinate). 

666 

667 >>> frustum = np.random.rand(6) 

668 >>> frustum[1] += frustum[0] 

669 >>> frustum[3] += frustum[2] 

670 >>> frustum[5] += frustum[4] 

671 >>> M = clip_matrix(perspective=False, *frustum) 

672 >>> a = np.dot(M, [frustum[0], frustum[2], frustum[4], 1]) 

673 >>> np.allclose(a, [-1., -1., -1., 1.]) 

674 True 

675 >>> b = np.dot(M, [frustum[1], frustum[3], frustum[5], 1]) 

676 >>> np.allclose(b, [ 1., 1., 1., 1.]) 

677 True 

678 >>> M = clip_matrix(perspective=True, *frustum) 

679 >>> v = np.dot(M, [frustum[0], frustum[2], frustum[4], 1]) 

680 >>> c = v / v[3] 

681 >>> np.allclose(c, [-1., -1., -1., 1.]) 

682 True 

683 >>> v = np.dot(M, [frustum[1], frustum[3], frustum[4], 1]) 

684 >>> d = v / v[3] 

685 >>> np.allclose(d, [ 1., 1., -1., 1.]) 

686 True 

687 

688 """ 

689 if left >= right or bottom >= top or near >= far: 

690 raise ValueError("invalid frustum") 

691 if perspective: 

692 if near <= _EPS: 

693 raise ValueError("invalid frustum: near <= 0") 

694 t = 2.0 * near 

695 M = [ 

696 [t / (left - right), 0.0, (right + left) / (right - left), 0.0], 

697 [0.0, t / (bottom - top), (top + bottom) / (top - bottom), 0.0], 

698 [0.0, 0.0, (far + near) / (near - far), t * far / (far - near)], 

699 [0.0, 0.0, -1.0, 0.0], 

700 ] 

701 else: 

702 M = [ 

703 [2.0 / (right - left), 0.0, 0.0, (right + left) / (left - right)], 

704 [0.0, 2.0 / (top - bottom), 0.0, (top + bottom) / (bottom - top)], 

705 [0.0, 0.0, 2.0 / (far - near), (far + near) / (near - far)], 

706 [0.0, 0.0, 0.0, 1.0], 

707 ] 

708 return np.array(M) 

709 

710 

711def shear_matrix(angle, direction, point, normal): 

712 """Return matrix to shear by angle along direction vector on shear plane. 

713 

714 The shear plane is defined by a point and normal vector. The direction 

715 vector must be orthogonal to the plane's normal vector. 

716 

717 A point P is transformed by the shear matrix into P" such that 

718 the vector P-P" is parallel to the direction vector and its extent is 

719 given by the angle of P-P'-P", where P' is the orthogonal projection 

720 of P onto the shear plane. 

721 

722 >>> angle = (random.random() - 0.5) * 4*np.pi 

723 >>> direct = np.random.random(3) - 0.5 

724 >>> point = np.random.random(3) - 0.5 

725 >>> normal = np.cross(direct, np.random.random(3)) 

726 >>> S = shear_matrix(angle, direct, point, normal) 

727 >>> np.allclose(1, np.linalg.det(S)) 

728 True 

729 

730 """ 

731 normal = unit_vector(normal[:3]) 

732 direction = unit_vector(direction[:3]) 

733 if abs(np.dot(normal, direction)) > 1e-6: 

734 raise ValueError("direction and normal vectors are not orthogonal") 

735 angle = np.tan(angle) 

736 M = np.identity(4) 

737 M[:3, :3] += angle * np.outer(direction, normal) 

738 M[:3, 3] = -angle * np.dot(point[:3], normal) * direction 

739 return M 

740 

741 

742def shear_from_matrix(matrix): 

743 """Return shear angle, direction and plane from shear matrix. 

744 

745 >>> angle = np.pi / 2.0 

746 >>> direct = [0.0, 1.0, 0.0] 

747 >>> point = [0.0, 0.0, 0.0] 

748 >>> normal = np.cross(direct, np.roll(direct,1)) 

749 >>> S0 = shear_matrix(angle, direct, point, normal) 

750 >>> angle, direct, point, normal = shear_from_matrix(S0) 

751 >>> S1 = shear_matrix(angle, direct, point, normal) 

752 >>> is_same_transform(S0, S1) 

753 True 

754 

755 """ 

756 M = np.asarray(matrix, dtype=np.float64) 

757 M33 = M[:3, :3] 

758 # normal: cross independent eigenvectors corresponding to the eigenvalue 1 

759 w, V = np.linalg.eig(M33) 

760 

761 i = np.where(abs(np.real(w) - 1.0) < 1e-4)[0] 

762 if len(i) < 2: 

763 raise ValueError(f"no two linear independent eigenvectors found {w}") 

764 V = np.real(V[:, i]).squeeze().T 

765 lenorm = -1.0 

766 for i0, i1 in ((0, 1), (0, 2), (1, 2)): 

767 n = np.cross(V[i0], V[i1]) 

768 w = vector_norm(n) 

769 if w > lenorm: 

770 lenorm = w 

771 normal = n 

772 normal /= lenorm 

773 # direction and angle 

774 direction = np.dot(M33 - np.identity(3), normal) 

775 angle = vector_norm(direction) 

776 direction /= angle 

777 angle = np.arctan(angle) 

778 # point: eigenvector corresponding to eigenvalue 1 

779 w, V = np.linalg.eig(M) 

780 

781 i = np.where(abs(np.real(w) - 1.0) < 1e-8)[0] 

782 if not len(i): 

783 raise ValueError("no eigenvector corresponding to eigenvalue 1") 

784 point = np.real(V[:, i[-1]]).squeeze() 

785 point /= point[3] 

786 return angle, direction, point, normal 

787 

788 

789def decompose_matrix(matrix): 

790 """Return sequence of transformations from transformation matrix. 

791 

792 matrix : array_like 

793 Non-degenerative homogeneous transformation matrix 

794 

795 Return tuple of: 

796 scale : vector of 3 scaling factors 

797 shear : list of shear factors for x-y, x-z, y-z axes 

798 angles : list of Euler angles about static x, y, z axes 

799 translate : translation vector along x, y, z axes 

800 perspective : perspective partition of matrix 

801 

802 Raise ValueError if matrix is of wrong type or degenerative. 

803 

804 >>> T0 = translation_matrix([1, 2, 3]) 

805 >>> scale, shear, angles, trans, persp = decompose_matrix(T0) 

806 >>> T1 = translation_matrix(trans) 

807 >>> np.allclose(T0, T1) 

808 True 

809 >>> S = scale_matrix(0.123) 

810 >>> scale, shear, angles, trans, persp = decompose_matrix(S) 

811 >>> bool(np.isclose(scale[0], 0.123)) 

812 True 

813 >>> R0 = euler_matrix(1, 2, 3) 

814 >>> scale, shear, angles, trans, persp = decompose_matrix(R0) 

815 >>> R1 = euler_matrix(*angles) 

816 >>> np.allclose(R0, R1) 

817 True 

818 

819 """ 

820 M = np.array(matrix, dtype=np.float64).T 

821 if abs(M[3, 3]) < _EPS: 

822 raise ValueError("M[3, 3] is zero") 

823 M /= M[3, 3] 

824 P = M.copy() 

825 P[:, 3] = 0.0, 0.0, 0.0, 1.0 

826 if not np.linalg.det(P): 

827 raise ValueError("matrix is singular") 

828 

829 scale = np.zeros((3,)) 

830 shear = [0.0, 0.0, 0.0] 

831 angles = [0.0, 0.0, 0.0] 

832 

833 if any(abs(M[:3, 3]) > _EPS): 

834 perspective = np.dot(M[:, 3], np.linalg.inv(P.T)) 

835 M[:, 3] = 0.0, 0.0, 0.0, 1.0 

836 else: 

837 perspective = np.array([0.0, 0.0, 0.0, 1.0]) 

838 

839 translate = M[3, :3].copy() 

840 M[3, :3] = 0.0 

841 

842 row = M[:3, :3].copy() 

843 scale[0] = vector_norm(row[0]) 

844 row[0] /= scale[0] 

845 shear[0] = np.dot(row[0], row[1]) 

846 row[1] -= row[0] * shear[0] 

847 scale[1] = vector_norm(row[1]) 

848 row[1] /= scale[1] 

849 shear[0] /= scale[1] 

850 shear[1] = np.dot(row[0], row[2]) 

851 row[2] -= row[0] * shear[1] 

852 shear[2] = np.dot(row[1], row[2]) 

853 row[2] -= row[1] * shear[2] 

854 scale[2] = vector_norm(row[2]) 

855 row[2] /= scale[2] 

856 shear[1:] /= scale[2] 

857 

858 if np.dot(row[0], np.cross(row[1], row[2])) < 0: 

859 np.negative(scale, scale) 

860 np.negative(row, row) 

861 

862 angles[1] = np.arcsin(-row[0, 2]) 

863 if np.cos(angles[1]): 

864 angles[0] = np.arctan2(row[1, 2], row[2, 2]) 

865 angles[2] = np.arctan2(row[0, 1], row[0, 0]) 

866 else: 

867 angles[0] = np.arctan2(-row[2, 1], row[1, 1]) 

868 angles[2] = 0.0 

869 

870 return scale, shear, angles, translate, perspective 

871 

872 

873def compose_matrix(scale=None, shear=None, angles=None, translate=None, perspective=None): 

874 """Return transformation matrix from sequence of transformations. 

875 

876 This is the inverse of the decompose_matrix function. 

877 

878 Sequence of transformations: 

879 scale : vector of 3 scaling factors 

880 shear : list of shear factors for x-y, x-z, y-z axes 

881 angles : list of Euler angles about static x, y, z axes 

882 translate : translation vector along x, y, z axes 

883 perspective : perspective partition of matrix 

884 

885 >>> scale = np.random.random(3) - 0.5 

886 >>> shear = np.random.random(3) - 0.5 

887 >>> angles = (np.random.random(3) - 0.5) * (2*np.pi) 

888 >>> trans = np.random.random(3) - 0.5 

889 >>> persp = np.random.random(4) - 0.5 

890 >>> M0 = compose_matrix(scale, shear, angles, trans, persp) 

891 >>> result = decompose_matrix(M0) 

892 >>> M1 = compose_matrix(*result) 

893 >>> is_same_transform(M0, M1) 

894 True 

895 

896 """ 

897 M = np.identity(4) 

898 if perspective is not None: 

899 P = np.identity(4) 

900 P[3, :] = perspective[:4] 

901 M = np.dot(M, P) 

902 if translate is not None: 

903 T = np.identity(4) 

904 T[:3, 3] = translate[:3] 

905 M = np.dot(M, T) 

906 if angles is not None: 

907 R = euler_matrix(angles[0], angles[1], angles[2], "sxyz") 

908 M = np.dot(M, R) 

909 if shear is not None: 

910 Z = np.identity(4) 

911 Z[1, 2] = shear[2] 

912 Z[0, 2] = shear[1] 

913 Z[0, 1] = shear[0] 

914 M = np.dot(M, Z) 

915 if scale is not None: 

916 S = np.identity(4) 

917 S[0, 0] = scale[0] 

918 S[1, 1] = scale[1] 

919 S[2, 2] = scale[2] 

920 M = np.dot(M, S) 

921 M /= M[3, 3] 

922 return M 

923 

924 

925def orthogonalization_matrix(lengths, angles): 

926 """Return orthogonalization matrix for crystallographic cell coordinates. 

927 

928 Angles are expected in degrees. 

929 

930 The de-orthogonalization matrix is the inverse. 

931 

932 >>> O = orthogonalization_matrix([10, 10, 10], [90, 90, 90]) 

933 >>> np.allclose(O[:3, :3], np.identity(3, float) * 10) 

934 True 

935 >>> O = orthogonalization_matrix([9.8, 12.0, 15.5], [87.2, 80.7, 69.7]) 

936 >>> np.allclose(np.sum(O), 43.063229) 

937 True 

938 

939 """ 

940 a, b, c = lengths 

941 angles = np.radians(angles) 

942 sina, sinb, _ = np.sin(angles) 

943 cosa, cosb, cosg = np.cos(angles) 

944 co = (cosa * cosb - cosg) / (sina * sinb) 

945 return np.array( 

946 [ 

947 [a * sinb * np.sqrt(1.0 - co * co), 0.0, 0.0, 0.0], 

948 [-a * sinb * co, b * sina, 0.0, 0.0], 

949 [a * cosb, b * cosa, c, 0.0], 

950 [0.0, 0.0, 0.0, 1.0], 

951 ] 

952 ) 

953 

954 

955def affine_matrix_from_points(v0, v1, shear=True, scale=True, usesvd=True): 

956 """Return affine transform matrix to register two point sets. 

957 

958 v0 and v1 are shape (ndims, *) arrays of at least ndims non-homogeneous 

959 coordinates, where ndims is the dimensionality of the coordinate space. 

960 

961 If shear is False, a similarity transformation matrix is returned. 

962 If also scale is False, a rigid/Euclidean transformation matrix 

963 is returned. 

964 

965 By default the algorithm by Hartley and Zissermann [15] is used. 

966 If usesvd is True, similarity and Euclidean transformation matrices 

967 are calculated by minimizing the weighted sum of squared deviations 

968 (RMSD) according to the algorithm by Kabsch [8]. 

969 Otherwise, and if ndims is 3, the quaternion based algorithm by Horn [9] 

970 is used, which is slower when using this Python implementation. 

971 

972 The returned matrix performs rotation, translation and uniform scaling 

973 (if specified). 

974 

975 >>> v0 = [[0, 1031, 1031, 0], [0, 0, 1600, 1600]] 

976 >>> v1 = [[675, 826, 826, 677], [55, 52, 281, 277]] 

977 >>> mat = affine_matrix_from_points(v0, v1) 

978 >>> T = translation_matrix(np.random.random(3)-0.5) 

979 >>> R = random_rotation_matrix(np.random.random(3)) 

980 >>> S = scale_matrix(random.random()) 

981 >>> M = concatenate_matrices(T, R, S) 

982 >>> v0 = (np.random.rand(4, 100) - 0.5) * 20 

983 >>> v0[3] = 1 

984 >>> v1 = np.dot(M, v0) 

985 >>> v0[:3] += np.random.normal(0, 1e-8, 300).reshape(3, -1) 

986 >>> M = affine_matrix_from_points(v0[:3], v1[:3]) 

987 >>> check = np.allclose(v1, np.dot(M, v0)) 

988 

989 More examples in superimposition_matrix() 

990 

991 """ 

992 v0 = np.array(v0, dtype=np.float64) 

993 v1 = np.array(v1, dtype=np.float64) 

994 

995 ndims = v0.shape[0] 

996 if ndims < 2 or v0.shape[1] < ndims or v0.shape != v1.shape: 

997 raise ValueError("input arrays are of wrong shape or type") 

998 

999 # move centroids to origin 

1000 t0 = -np.mean(v0, axis=1) 

1001 M0 = np.identity(ndims + 1) 

1002 M0[:ndims, ndims] = t0 

1003 v0 += t0.reshape(ndims, 1) 

1004 t1 = -np.mean(v1, axis=1) 

1005 M1 = np.identity(ndims + 1) 

1006 M1[:ndims, ndims] = t1 

1007 v1 += t1.reshape(ndims, 1) 

1008 

1009 if shear: 

1010 # Affine transformation 

1011 A = np.concatenate((v0, v1), axis=0) 

1012 u, s, vh = np.linalg.svd(A.T) 

1013 vh = vh[:ndims].T 

1014 B = vh[:ndims] 

1015 C = vh[ndims : 2 * ndims] 

1016 t = np.dot(C, np.linalg.pinv(B)) 

1017 t = np.concatenate((t, np.zeros((ndims, 1))), axis=1) 

1018 M = np.vstack((t, ((0.0,) * ndims) + (1.0,))) 

1019 elif usesvd or ndims != 3: 

1020 # Rigid transformation via SVD of covariance matrix 

1021 u, s, vh = np.linalg.svd(np.dot(v1, v0.T)) 

1022 # rotation matrix from SVD orthonormal bases 

1023 R = np.dot(u, vh) 

1024 if np.linalg.det(R) < 0.0: 

1025 # R does not constitute right handed system 

1026 R -= np.outer(u[:, ndims - 1], vh[ndims - 1, :] * 2.0) 

1027 s[-1] *= -1.0 

1028 # homogeneous transformation matrix 

1029 M = np.identity(ndims + 1) 

1030 M[:ndims, :ndims] = R 

1031 else: 

1032 # Rigid transformation matrix via quaternion 

1033 # compute symmetric matrix N 

1034 xx, yy, zz = np.sum(v0 * v1, axis=1) 

1035 xy, yz, zx = np.sum(v0 * np.roll(v1, -1, axis=0), axis=1) 

1036 xz, yx, zy = np.sum(v0 * np.roll(v1, -2, axis=0), axis=1) 

1037 N = [ 

1038 [xx + yy + zz, 0.0, 0.0, 0.0], 

1039 [yz - zy, xx - yy - zz, 0.0, 0.0], 

1040 [zx - xz, xy + yx, yy - xx - zz, 0.0], 

1041 [xy - yx, zx + xz, yz + zy, zz - xx - yy], 

1042 ] 

1043 # quaternion: eigenvector corresponding to most positive eigenvalue 

1044 w, V = np.linalg.eigh(N) 

1045 q = V[:, np.argmax(w)] 

1046 q /= vector_norm(q) # unit quaternion 

1047 # homogeneous transformation matrix 

1048 M = quaternion_matrix(q) 

1049 

1050 if scale and not shear: 

1051 # Affine transformation; scale is ratio of RMS deviations from centroid 

1052 v0 *= v0 

1053 v1 *= v1 

1054 M[:ndims, :ndims] *= np.sqrt(np.sum(v1) / np.sum(v0)) 

1055 

1056 # move centroids back 

1057 M = np.dot(np.linalg.inv(M1), np.dot(M, M0)) 

1058 M /= M[ndims, ndims] 

1059 return M 

1060 

1061 

1062def superimposition_matrix(v0, v1, scale=False, usesvd=True): 

1063 """Return matrix to transform given 3D point set into second point set. 

1064 

1065 v0 and v1 are shape (3, *) or (4, *) arrays of at least 3 points. 

1066 

1067 The parameters scale and usesvd are explained in the more general 

1068 affine_matrix_from_points function. 

1069 

1070 The returned matrix is a similarity or Euclidean transformation matrix. 

1071 This function has a fast C implementation in transformations.c. 

1072 

1073 >>> v0 = np.random.rand(3, 10) 

1074 >>> M = superimposition_matrix(v0, v0) 

1075 >>> np.allclose(M, np.identity(4)) 

1076 True 

1077 >>> R = random_rotation_matrix(np.random.random(3)) 

1078 >>> v0 = [[1,0,0], [0,1,0], [0,0,1], [1,1,1]] 

1079 >>> v1 = np.dot(R, v0) 

1080 >>> M = superimposition_matrix(v0, v1) 

1081 >>> np.allclose(v1, np.dot(M, v0)) 

1082 True 

1083 >>> v0 = (np.random.rand(4, 100) - 0.5) * 20 

1084 >>> v0[3] = 1 

1085 >>> v1 = np.dot(R, v0) 

1086 >>> M = superimposition_matrix(v0, v1) 

1087 >>> np.allclose(v1, np.dot(M, v0)) 

1088 True 

1089 >>> S = scale_matrix(random.random()) 

1090 >>> T = translation_matrix(np.random.random(3)-0.5) 

1091 >>> M = concatenate_matrices(T, R, S) 

1092 >>> v1 = np.dot(M, v0) 

1093 >>> v0[:3] += np.random.normal(0, 1e-9, 300).reshape(3, -1) 

1094 >>> M = superimposition_matrix(v0, v1, scale=True) 

1095 >>> np.allclose(v1, np.dot(M, v0)) 

1096 True 

1097 >>> M = superimposition_matrix(v0, v1, scale=True, usesvd=False) 

1098 >>> np.allclose(v1, np.dot(M, v0)) 

1099 True 

1100 >>> v = np.zeros((4, 100, 3)) 

1101 >>> v[:, :, 0] = v0 

1102 >>> M = superimposition_matrix(v0, v1, scale=True, usesvd=False) 

1103 >>> np.allclose(v1, np.dot(M, v[:, :, 0])) 

1104 True 

1105 

1106 """ 

1107 v0 = np.asarray(v0, dtype=np.float64)[:3] 

1108 v1 = np.asarray(v1, dtype=np.float64)[:3] 

1109 return affine_matrix_from_points(v0, v1, shear=False, scale=scale, usesvd=usesvd) 

1110 

1111 

1112def euler_matrix(ai, aj, ak, axes="sxyz"): 

1113 """Return homogeneous rotation matrix from Euler angles and axis sequence. 

1114 

1115 ai, aj, ak : Euler's roll, pitch and yaw angles 

1116 axes : One of 24 axis sequences as string or encoded tuple 

1117 

1118 >>> R = euler_matrix(1, 2, 3, 'syxz') 

1119 >>> np.allclose(np.sum(R[0]), -1.34786452) 

1120 True 

1121 >>> R = euler_matrix(1, 2, 3, (0, 1, 0, 1)) 

1122 >>> np.allclose(np.sum(R[0]), -0.383436184) 

1123 True 

1124 >>> ai, aj, ak = (4*np.pi) * (np.random.random(3) - 0.5) 

1125 >>> for axes in _AXES2TUPLE.keys(): 

1126 ... R = euler_matrix(ai, aj, ak, axes) 

1127 >>> for axes in _TUPLE2AXES.keys(): 

1128 ... R = euler_matrix(ai, aj, ak, axes) 

1129 

1130 """ 

1131 try: 

1132 firstaxis, parity, repetition, frame = _AXES2TUPLE[axes] 

1133 except (AttributeError, KeyError): 

1134 _TUPLE2AXES[axes] # validation 

1135 firstaxis, parity, repetition, frame = axes 

1136 

1137 i = firstaxis 

1138 j = _NEXT_AXIS[i + parity] 

1139 k = _NEXT_AXIS[i - parity + 1] 

1140 

1141 if frame: 

1142 ai, ak = ak, ai 

1143 if parity: 

1144 ai, aj, ak = -ai, -aj, -ak 

1145 

1146 if "sympy" in str(type(ai)): 

1147 # if we have been passed input values as sympy.Symbol 

1148 # use symbolic cosine and identity matrix 

1149 from sympy import cos, eye, sin 

1150 

1151 M = eye(4) 

1152 else: 

1153 sin, cos = np.sin, np.cos 

1154 M = np.eye(4) 

1155 

1156 si, sj, sk = sin(ai), sin(aj), sin(ak) 

1157 ci, cj, ck = cos(ai), cos(aj), cos(ak) 

1158 cc, cs = ci * ck, ci * sk 

1159 sc, ss = si * ck, si * sk 

1160 

1161 if repetition: 

1162 M[i, i] = cj 

1163 M[i, j] = sj * si 

1164 M[i, k] = sj * ci 

1165 M[j, i] = sj * sk 

1166 M[j, j] = -cj * ss + cc 

1167 M[j, k] = -cj * cs - sc 

1168 M[k, i] = -sj * ck 

1169 M[k, j] = cj * sc + cs 

1170 M[k, k] = cj * cc - ss 

1171 else: 

1172 M[i, i] = cj * ck 

1173 M[i, j] = sj * sc - cs 

1174 M[i, k] = sj * cc + ss 

1175 M[j, i] = cj * sk 

1176 M[j, j] = sj * ss + cc 

1177 M[j, k] = sj * cs - sc 

1178 M[k, i] = -sj 

1179 M[k, j] = cj * si 

1180 M[k, k] = cj * ci 

1181 return M 

1182 

1183 

1184def euler_from_matrix(matrix, axes="sxyz"): 

1185 """Return Euler angles from rotation matrix for specified axis sequence. 

1186 

1187 axes : One of 24 axis sequences as string or encoded tuple 

1188 

1189 Note that many Euler angle triplets can describe one matrix. 

1190 

1191 >>> R0 = euler_matrix(1, 2, 3, 'syxz') 

1192 >>> al, be, ga = euler_from_matrix(R0, 'syxz') 

1193 >>> R1 = euler_matrix(al, be, ga, 'syxz') 

1194 >>> np.allclose(R0, R1) 

1195 True 

1196 >>> angles = (4*np.pi) * (np.random.random(3) - 0.5) 

1197 >>> for axes in _AXES2TUPLE.keys(): 

1198 ... R0 = euler_matrix(axes=axes, *angles) 

1199 ... R1 = euler_matrix(axes=axes, *euler_from_matrix(R0, axes)) 

1200 ... if not np.allclose(R0, R1): print(axes, "failed") 

1201 

1202 """ 

1203 try: 

1204 firstaxis, parity, repetition, frame = _AXES2TUPLE[axes.lower()] 

1205 except (AttributeError, KeyError): 

1206 _TUPLE2AXES[axes] # validation 

1207 firstaxis, parity, repetition, frame = axes 

1208 

1209 i = firstaxis 

1210 j = _NEXT_AXIS[i + parity] 

1211 k = _NEXT_AXIS[i - parity + 1] 

1212 

1213 M = np.asarray(matrix, dtype=np.float64)[:3, :3] 

1214 if repetition: 

1215 sy = np.sqrt(M[i, j] * M[i, j] + M[i, k] * M[i, k]) 

1216 if sy > _EPS: 

1217 ax = np.arctan2(M[i, j], M[i, k]) 

1218 ay = np.arctan2(sy, M[i, i]) 

1219 az = np.arctan2(M[j, i], -M[k, i]) 

1220 else: 

1221 ax = np.arctan2(-M[j, k], M[j, j]) 

1222 ay = np.arctan2(sy, M[i, i]) 

1223 az = 0.0 

1224 else: 

1225 cy = np.sqrt(M[i, i] * M[i, i] + M[j, i] * M[j, i]) 

1226 if cy > _EPS: 

1227 ax = np.arctan2(M[k, j], M[k, k]) 

1228 ay = np.arctan2(-M[k, i], cy) 

1229 az = np.arctan2(M[j, i], M[i, i]) 

1230 else: 

1231 ax = np.arctan2(-M[j, k], M[j, j]) 

1232 ay = np.arctan2(-M[k, i], cy) 

1233 az = 0.0 

1234 

1235 if parity: 

1236 ax, ay, az = -ax, -ay, -az 

1237 if frame: 

1238 ax, az = az, ax 

1239 return ax, ay, az 

1240 

1241 

1242def euler_from_quaternion(quaternion, axes="sxyz"): 

1243 """Return Euler angles from quaternion for specified axis sequence. 

1244 

1245 >>> angles = euler_from_quaternion([0.99810947, 0.06146124, 0, 0]) 

1246 >>> np.allclose(angles, [0.123, 0, 0]) 

1247 True 

1248 

1249 """ 

1250 return euler_from_matrix(quaternion_matrix(quaternion), axes) 

1251 

1252 

1253def quaternion_from_euler(ai, aj, ak, axes="sxyz"): 

1254 """Return quaternion from Euler angles and axis sequence. 

1255 

1256 ai, aj, ak : Euler's roll, pitch and yaw angles 

1257 axes : One of 24 axis sequences as string or encoded tuple 

1258 

1259 >>> q = quaternion_from_euler(1, 2, 3, 'ryxz') 

1260 >>> np.allclose(q, [0.435953, 0.310622, -0.718287, 0.444435]) 

1261 True 

1262 

1263 """ 

1264 try: 

1265 firstaxis, parity, repetition, frame = _AXES2TUPLE[axes.lower()] 

1266 except (AttributeError, KeyError): 

1267 _TUPLE2AXES[axes] # validation 

1268 firstaxis, parity, repetition, frame = axes 

1269 

1270 i = firstaxis + 1 

1271 j = _NEXT_AXIS[i + parity - 1] + 1 

1272 k = _NEXT_AXIS[i - parity] + 1 

1273 

1274 if frame: 

1275 ai, ak = ak, ai 

1276 if parity: 

1277 aj = -aj 

1278 

1279 ai /= 2.0 

1280 aj /= 2.0 

1281 ak /= 2.0 

1282 ci = np.cos(ai) 

1283 si = np.sin(ai) 

1284 cj = np.cos(aj) 

1285 sj = np.sin(aj) 

1286 ck = np.cos(ak) 

1287 sk = np.sin(ak) 

1288 cc = ci * ck 

1289 cs = ci * sk 

1290 sc = si * ck 

1291 ss = si * sk 

1292 

1293 q = np.zeros((4,)) 

1294 if repetition: 

1295 q[0] = cj * (cc - ss) 

1296 q[i] = cj * (cs + sc) 

1297 q[j] = sj * (cc + ss) 

1298 q[k] = sj * (cs - sc) 

1299 else: 

1300 q[0] = cj * cc + sj * ss 

1301 q[i] = cj * sc - sj * cs 

1302 q[j] = cj * ss + sj * cc 

1303 q[k] = cj * cs - sj * sc 

1304 if parity: 

1305 q[j] *= -1.0 

1306 

1307 return q 

1308 

1309 

1310def quaternion_about_axis(angle, axis): 

1311 """Return quaternion for rotation about axis. 

1312 

1313 >>> q = quaternion_about_axis(0.123, [1, 0, 0]) 

1314 >>> np.allclose(q, [0.99810947, 0.06146124, 0, 0]) 

1315 True 

1316 

1317 """ 

1318 q = np.array([0.0, axis[0], axis[1], axis[2]]) 

1319 qlen = vector_norm(q) 

1320 if qlen > _EPS: 

1321 q *= np.sin(angle / 2.0) / qlen 

1322 q[0] = np.cos(angle / 2.0) 

1323 return q 

1324 

1325 

1326def quaternion_matrix(quaternion): 

1327 """ 

1328 Return a homogeneous rotation matrix from quaternion. 

1329 

1330 >>> M = quaternion_matrix([0.99810947, 0.06146124, 0, 0]) 

1331 >>> np.allclose(M, rotation_matrix(0.123, [1, 0, 0])) 

1332 True 

1333 >>> M = quaternion_matrix([1, 0, 0, 0]) 

1334 >>> np.allclose(M, np.identity(4)) 

1335 True 

1336 >>> M = quaternion_matrix([0, 1, 0, 0]) 

1337 >>> np.allclose(M, np.diag([1, -1, -1, 1])) 

1338 True 

1339 >>> M = quaternion_matrix([[1, 0, 0, 0],[0, 1, 0, 0]]) 

1340 >>> np.allclose(M, np.array([np.identity(4), np.diag([1, -1, -1, 1])])) 

1341 True 

1342 

1343 

1344 """ 

1345 q = np.array(quaternion, dtype=np.float64).reshape((-1, 4)) 

1346 n = diagonal_dot(q, q) 

1347 # how many entries do we have 

1348 num_qs = len(n) 

1349 identities = n < _EPS 

1350 q[~identities, :] *= np.sqrt(2.0 / n[~identities, None]) 

1351 q = q[:, None, :] * q[:, :, None] 

1352 

1353 # store the result 

1354 ret = np.zeros((num_qs, 4, 4)) 

1355 

1356 # pack the values into the result 

1357 ret[:, 0, 0] = 1.0 - q[:, 2, 2] - q[:, 3, 3] 

1358 ret[:, 0, 1] = q[:, 1, 2] - q[:, 3, 0] 

1359 ret[:, 0, 2] = q[:, 1, 3] + q[:, 2, 0] 

1360 ret[:, 1, 0] = q[:, 1, 2] + q[:, 3, 0] 

1361 ret[:, 1, 1] = 1.0 - q[:, 1, 1] - q[:, 3, 3] 

1362 ret[:, 1, 2] = q[:, 2, 3] - q[:, 1, 0] 

1363 ret[:, 2, 0] = q[:, 1, 3] - q[:, 2, 0] 

1364 ret[:, 2, 1] = q[:, 2, 3] + q[:, 1, 0] 

1365 ret[:, 2, 2] = 1.0 - q[:, 1, 1] - q[:, 2, 2] 

1366 ret[:, 3, 3] = 1.0 

1367 # set any identities 

1368 ret[identities] = np.eye(4)[None, ...] 

1369 

1370 return ret.squeeze() 

1371 

1372 

1373def quaternion_from_matrix(matrix, isprecise=False): 

1374 """Return quaternion from rotation matrix. 

1375 

1376 If isprecise is True, the input matrix is assumed to be a precise rotation 

1377 matrix and a faster algorithm is used. 

1378 

1379 >>> q = quaternion_from_matrix(np.identity(4), True) 

1380 >>> np.allclose(q, [1, 0, 0, 0]) 

1381 True 

1382 >>> q = quaternion_from_matrix(np.diag([1, -1, -1, 1])) 

1383 >>> np.allclose(q, [0, 1, 0, 0]) or np.allclose(q, [0, -1, 0, 0]) 

1384 True 

1385 >>> R = rotation_matrix(0.123, (1, 2, 3)) 

1386 >>> q = quaternion_from_matrix(R, True) 

1387 >>> np.allclose(q, [0.9981095, 0.0164262, 0.0328524, 0.0492786]) 

1388 True 

1389 >>> R = [[-0.545, 0.797, 0.260, 0], [0.733, 0.603, -0.313, 0], 

1390 ... [-0.407, 0.021, -0.913, 0], [0, 0, 0, 1]] 

1391 >>> q = quaternion_from_matrix(R) 

1392 >>> np.allclose(q, [0.19069, 0.43736, 0.87485, -0.083611]) 

1393 True 

1394 >>> R = [[0.395, 0.362, 0.843, 0], [-0.626, 0.796, -0.056, 0], 

1395 ... [-0.677, -0.498, 0.529, 0], [0, 0, 0, 1]] 

1396 >>> q = quaternion_from_matrix(R) 

1397 >>> np.allclose(q, [0.82336615, -0.13610694, 0.46344705, -0.29792603]) 

1398 True 

1399 >>> R = random_rotation_matrix() 

1400 >>> q = quaternion_from_matrix(R) 

1401 >>> is_same_transform(R, quaternion_matrix(q)) 

1402 True 

1403 >>> is_same_quaternion(quaternion_from_matrix(R, isprecise=False), 

1404 ... quaternion_from_matrix(R, isprecise=True)) 

1405 True 

1406 >>> R = euler_matrix(0.0, 0.0, np.pi/2.0) 

1407 >>> is_same_quaternion(quaternion_from_matrix(R, isprecise=False), 

1408 ... quaternion_from_matrix(R, isprecise=True)) 

1409 True 

1410 

1411 """ 

1412 M = np.asarray(matrix, dtype=np.float64)[:4, :4] 

1413 if isprecise: 

1414 q = np.zeros((4,)) 

1415 t = np.trace(M) 

1416 if t > M[3, 3]: 

1417 q[0] = t 

1418 q[3] = M[1, 0] - M[0, 1] 

1419 q[2] = M[0, 2] - M[2, 0] 

1420 q[1] = M[2, 1] - M[1, 2] 

1421 else: 

1422 i, j, k = 0, 1, 2 

1423 if M[1, 1] > M[0, 0]: 

1424 i, j, k = 1, 2, 0 

1425 if M[2, 2] > M[i, i]: 

1426 i, j, k = 2, 0, 1 

1427 t = M[i, i] - (M[j, j] + M[k, k]) + M[3, 3] 

1428 q[i] = t 

1429 q[j] = M[i, j] + M[j, i] 

1430 q[k] = M[k, i] + M[i, k] 

1431 q[3] = M[k, j] - M[j, k] 

1432 q = q[[3, 0, 1, 2]] 

1433 q *= 0.5 / np.sqrt(t * M[3, 3]) 

1434 else: 

1435 m00 = M[0, 0] 

1436 m01 = M[0, 1] 

1437 m02 = M[0, 2] 

1438 m10 = M[1, 0] 

1439 m11 = M[1, 1] 

1440 m12 = M[1, 2] 

1441 m20 = M[2, 0] 

1442 m21 = M[2, 1] 

1443 m22 = M[2, 2] 

1444 # symmetric matrix K 

1445 K = np.array( 

1446 [ 

1447 [m00 - m11 - m22, 0.0, 0.0, 0.0], 

1448 [m01 + m10, m11 - m00 - m22, 0.0, 0.0], 

1449 [m02 + m20, m12 + m21, m22 - m00 - m11, 0.0], 

1450 [m21 - m12, m02 - m20, m10 - m01, m00 + m11 + m22], 

1451 ] 

1452 ) 

1453 K /= 3.0 

1454 # quaternion is eigenvector of K that corresponds to largest eigenvalue 

1455 w, V = np.linalg.eigh(K) 

1456 q = V[[3, 0, 1, 2], np.argmax(w)] 

1457 if q[0] < 0.0: 

1458 np.negative(q, q) 

1459 return q 

1460 

1461 

1462def quaternion_multiply(quaternion1, quaternion0): 

1463 """Return multiplication of two quaternions. 

1464 

1465 >>> q = quaternion_multiply([4, 1, -2, 3], [8, -5, 6, 7]) 

1466 >>> np.allclose(q, [28, -44, -14, 48]) 

1467 True 

1468 

1469 """ 

1470 w0, x0, y0, z0 = quaternion0 

1471 w1, x1, y1, z1 = quaternion1 

1472 return np.array( 

1473 [ 

1474 -x1 * x0 - y1 * y0 - z1 * z0 + w1 * w0, 

1475 x1 * w0 + y1 * z0 - z1 * y0 + w1 * x0, 

1476 -x1 * z0 + y1 * w0 + z1 * x0 + w1 * y0, 

1477 x1 * y0 - y1 * x0 + z1 * w0 + w1 * z0, 

1478 ], 

1479 dtype=np.float64, 

1480 ) 

1481 

1482 

1483def quaternion_conjugate(quaternion): 

1484 """Return conjugate of quaternion. 

1485 

1486 >>> q0 = random_quaternion() 

1487 >>> q1 = quaternion_conjugate(q0) 

1488 >>> q1[0] == q0[0] and all(q1[1:] == -q0[1:]) 

1489 True 

1490 

1491 """ 

1492 q = np.array(quaternion, dtype=np.float64) 

1493 np.negative(q[1:], q[1:]) 

1494 return q 

1495 

1496 

1497def quaternion_inverse(quaternion): 

1498 """Return inverse of quaternion. 

1499 

1500 >>> q0 = random_quaternion() 

1501 >>> q1 = quaternion_inverse(q0) 

1502 >>> np.allclose(quaternion_multiply(q0, q1), [1, 0, 0, 0]) 

1503 True 

1504 

1505 """ 

1506 q = np.array(quaternion, dtype=np.float64) 

1507 np.negative(q[1:], q[1:]) 

1508 return q / np.dot(q, q) 

1509 

1510 

1511def quaternion_real(quaternion): 

1512 """Return real part of quaternion. 

1513 

1514 >>> quaternion_real([3, 0, 1, 2]) 

1515 3.0 

1516 

1517 """ 

1518 return float(quaternion[0]) 

1519 

1520 

1521def quaternion_imag(quaternion): 

1522 """Return imaginary part of quaternion. 

1523 

1524 >>> quaternion_imag([3, 0, 1, 2]) 

1525 array([0., 1., 2.]) 

1526 

1527 """ 

1528 return np.array(quaternion[1:4], dtype=np.float64) 

1529 

1530 

1531def quaternion_slerp(quat0, quat1, fraction, spin=0, shortestpath=True): 

1532 """Return spherical linear interpolation between two quaternions. 

1533 

1534 >>> q0 = random_quaternion() 

1535 >>> q1 = random_quaternion() 

1536 >>> q = quaternion_slerp(q0, q1, 0) 

1537 >>> np.allclose(q, q0) 

1538 True 

1539 >>> q = quaternion_slerp(q0, q1, 1, 1) 

1540 >>> np.allclose(q, q1) 

1541 True 

1542 >>> q = quaternion_slerp(q0, q1, 0.5) 

1543 >>> angle = np.arccos(np.dot(q0, q)) 

1544 >>> np.allclose(2, np.arccos(np.dot(q0, q1)) / angle) or \ 

1545 np.allclose(2, np.arccos(-np.dot(q0, q1)) / angle) 

1546 True 

1547 

1548 """ 

1549 q0 = unit_vector(quat0[:4]) 

1550 q1 = unit_vector(quat1[:4]) 

1551 if fraction == 0.0: 

1552 return q0 

1553 elif fraction == 1.0: 

1554 return q1 

1555 d = np.dot(q0, q1) 

1556 if abs(abs(d) - 1.0) < _EPS: 

1557 return q0 

1558 if shortestpath and d < 0.0: 

1559 # invert rotation 

1560 d = -d 

1561 np.negative(q1, q1) 

1562 angle = np.arccos(d) + spin * np.pi 

1563 if abs(angle) < _EPS: 

1564 return q0 

1565 isin = 1.0 / np.sin(angle) 

1566 q0 *= np.sin((1.0 - fraction) * angle) * isin 

1567 q1 *= np.sin(fraction * angle) * isin 

1568 q0 += q1 

1569 return q0 

1570 

1571 

1572def random_quaternion(rand=None, num=1, seed: Seed = None): 

1573 """Return uniform random unit quaternion. 

1574 

1575 rand: array like or None 

1576 Three independent random variables that are uniformly distributed 

1577 between 0 and 1. 

1578 seed : None or int 

1579 Seed for deterministic results, otherwise OS entropy. 

1580 

1581 >>> q = random_quaternion() 

1582 >>> np.allclose(1, vector_norm(q)) 

1583 True 

1584 >>> q = random_quaternion(num=10) 

1585 >>> np.allclose(1, vector_norm(q, axis=1)) 

1586 True 

1587 >>> q = random_quaternion(random_vector(3)) 

1588 >>> len(q.shape), q.shape[0]==4 

1589 (1, True) 

1590 >>> bool(np.allclose(random_quaternion(seed=0), random_quaternion(seed=0))) 

1591 True 

1592 

1593 """ 

1594 if rand is None: 

1595 rand = random_generator(seed).random(3 * num).reshape((3, -1)) 

1596 else: 

1597 assert rand.shape[0] == 3 

1598 r1 = np.sqrt(1.0 - rand[0]) 

1599 r2 = np.sqrt(rand[0]) 

1600 pi2 = np.pi * 2.0 

1601 t1 = pi2 * rand[1] 

1602 t2 = pi2 * rand[2] 

1603 return np.array( 

1604 [np.cos(t2) * r2, np.sin(t1) * r1, np.cos(t1) * r1, np.sin(t2) * r2] 

1605 ).T.squeeze() 

1606 

1607 

1608def random_rotation_matrix( 

1609 rand: ArrayLike | None = None, 

1610 num: Integer = 1, 

1611 translate: Number | None = None, 

1612 seed: Seed = None, 

1613): 

1614 """ 

1615 Return uniform random rotation matrix. 

1616 

1617 Parameters 

1618 ------------ 

1619 rand : (3,) 

1620 Three independent random variables that are uniformly distributed 

1621 between 0 and 1 for each returned quaternion. 

1622 num 

1623 Number of matrices to return. 

1624 translate 

1625 If passed the rotation matrix will include translation 

1626 that is random and between positive and negative half this value. 

1627 seed 

1628 Seed for deterministic results, otherwise OS entropy. 

1629 

1630 >>> R = random_rotation_matrix() 

1631 >>> np.allclose(np.dot(R.T, R), np.identity(4)) 

1632 True 

1633 >>> R = random_rotation_matrix(num=10) 

1634 >>> np.allclose(np.einsum('...ji,...jk->...ik', R, R), np.identity(4)) 

1635 True 

1636 >>> bool(np.allclose(random_rotation_matrix(seed=0, translate=10), 

1637 ... random_rotation_matrix(seed=0, translate=10))) 

1638 True 

1639 

1640 """ 

1641 # thread one generator through so a passed `seed` doesn't hand the 

1642 # translation the same values as the quaternion below 

1643 random = random_generator(seed) 

1644 matrix = quaternion_matrix(random_quaternion(rand=rand, num=num, seed=random)) 

1645 if translate: 

1646 # apply random translation with the order of magnitude requested 

1647 matrix[:3, 3] = (random.random(3) - 0.5) * float(translate) 

1648 

1649 return matrix 

1650 

1651 

1652class Arcball: 

1653 """Virtual Trackball Control. 

1654 

1655 >>> ball = Arcball() 

1656 >>> ball = Arcball(initial=np.identity(4)) 

1657 >>> ball.place([320, 320], 320) 

1658 >>> ball.down([500, 250]) 

1659 >>> ball.drag([475, 275]) 

1660 >>> R = ball.matrix() 

1661 >>> np.allclose(np.sum(R), 3.90583455) 

1662 True 

1663 >>> ball = Arcball(initial=[1, 0, 0, 0]) 

1664 >>> ball.place([320, 320], 320) 

1665 >>> ball.setaxes([1, 1, 0], [-1, 1, 0]) 

1666 >>> ball.constrain = True 

1667 >>> ball.down([400, 200]) 

1668 >>> ball.drag([200, 400]) 

1669 >>> R = ball.matrix() 

1670 >>> np.allclose(np.sum(R), 0.2055924) 

1671 True 

1672 >>> ball.next() 

1673 

1674 """ 

1675 

1676 def __init__(self, initial=None): 

1677 """Initialize virtual trackball control. 

1678 

1679 initial : quaternion or rotation matrix 

1680 

1681 """ 

1682 self._axis = None 

1683 self._axes = None 

1684 self._radius = 1.0 

1685 self._center = [0.0, 0.0] 

1686 self._vdown = np.array([0.0, 0.0, 1.0]) 

1687 self._constrain = False 

1688 if initial is None: 

1689 self._qdown = np.array([1.0, 0.0, 0.0, 0.0]) 

1690 else: 

1691 initial = np.array(initial, dtype=np.float64) 

1692 if initial.shape == (4, 4): 

1693 self._qdown = quaternion_from_matrix(initial) 

1694 elif initial.shape == (4,): 

1695 initial /= vector_norm(initial) 

1696 self._qdown = initial 

1697 else: 

1698 raise ValueError("initial not a quaternion or matrix") 

1699 self._qnow = self._qpre = self._qdown 

1700 

1701 def place(self, center, radius): 

1702 """Place Arcball, e.g. when window size changes. 

1703 

1704 center : sequence[2] 

1705 Window coordinates of trackball center. 

1706 radius : float 

1707 Radius of trackball in window coordinates. 

1708 

1709 """ 

1710 self._radius = float(radius) 

1711 self._center[0] = center[0] 

1712 self._center[1] = center[1] 

1713 

1714 def setaxes(self, *axes): 

1715 """Set axes to constrain rotations.""" 

1716 if axes is None: 

1717 self._axes = None 

1718 else: 

1719 self._axes = [unit_vector(axis) for axis in axes] 

1720 

1721 @property 

1722 def constrain(self): 

1723 """Return state of constrain to axis mode.""" 

1724 return self._constrain 

1725 

1726 @constrain.setter 

1727 def constrain(self, value): 

1728 """Set state of constrain to axis mode.""" 

1729 self._constrain = bool(value) 

1730 

1731 def down(self, point): 

1732 """Set initial cursor window coordinates and pick constrain-axis.""" 

1733 self._vdown = arcball_map_to_sphere(point, self._center, self._radius) 

1734 self._qdown = self._qpre = self._qnow 

1735 if self._constrain and self._axes is not None: 

1736 self._axis = arcball_nearest_axis(self._vdown, self._axes) 

1737 self._vdown = arcball_constrain_to_axis(self._vdown, self._axis) 

1738 else: 

1739 self._axis = None 

1740 

1741 def drag(self, point): 

1742 """Update current cursor window coordinates.""" 

1743 vnow = arcball_map_to_sphere(point, self._center, self._radius) 

1744 if self._axis is not None: 

1745 vnow = arcball_constrain_to_axis(vnow, self._axis) 

1746 self._qpre = self._qnow 

1747 t = np.cross(self._vdown, vnow) 

1748 if np.dot(t, t) < _EPS: 

1749 self._qnow = self._qdown 

1750 else: 

1751 q = [np.dot(self._vdown, vnow), t[0], t[1], t[2]] 

1752 self._qnow = quaternion_multiply(q, self._qdown) 

1753 

1754 def next(self, acceleration=0.0): 

1755 """Continue rotation in direction of last drag.""" 

1756 q = quaternion_slerp(self._qpre, self._qnow, 2.0 + acceleration, False) 

1757 self._qpre, self._qnow = self._qnow, q 

1758 

1759 def matrix(self): 

1760 """Return homogeneous rotation matrix.""" 

1761 return quaternion_matrix(self._qnow) 

1762 

1763 

1764def arcball_map_to_sphere(point, center, radius): 

1765 """Return unit sphere coordinates from window coordinates.""" 

1766 v0 = (point[0] - center[0]) / radius 

1767 v1 = (center[1] - point[1]) / radius 

1768 n = v0 * v0 + v1 * v1 

1769 if n > 1.0: 

1770 # position outside of sphere 

1771 n = np.sqrt(n) 

1772 return np.array([v0 / n, v1 / n, 0.0]) 

1773 else: 

1774 return np.array([v0, v1, np.sqrt(1.0 - n)]) 

1775 

1776 

1777def arcball_constrain_to_axis(point, axis): 

1778 """Return sphere point perpendicular to axis.""" 

1779 v = np.array(point, dtype=np.float64) 

1780 a = np.array(axis, dtype=np.float64) 

1781 v -= a * np.dot(a, v) # on plane 

1782 n = vector_norm(v) 

1783 if n > _EPS: 

1784 if v[2] < 0.0: 

1785 np.negative(v, v) 

1786 v /= n 

1787 return v 

1788 if a[2] == 1.0: 

1789 return np.array([1.0, 0.0, 0.0]) 

1790 return unit_vector([-a[1], a[0], 0.0]) 

1791 

1792 

1793def arcball_nearest_axis(point, axes): 

1794 """Return axis, which arc is nearest to point.""" 

1795 point = np.asarray(point, dtype=np.float64) 

1796 nearest = None 

1797 mx = -1.0 

1798 for axis in axes: 

1799 t = np.dot(arcball_constrain_to_axis(point, axis), point) 

1800 if t > mx: 

1801 nearest = axis 

1802 mx = t 

1803 return nearest 

1804 

1805 

1806# epsilon for testing whether a number is close to zero 

1807_EPS = np.finfo(float).eps * 4.0 

1808 

1809# axis sequences for Euler angles 

1810_NEXT_AXIS = [1, 2, 0, 1] 

1811 

1812# map axes strings to/from tuples of inner axis, parity, repetition, frame 

1813_AXES2TUPLE = { 

1814 "sxyz": (0, 0, 0, 0), 

1815 "sxyx": (0, 0, 1, 0), 

1816 "sxzy": (0, 1, 0, 0), 

1817 "sxzx": (0, 1, 1, 0), 

1818 "syzx": (1, 0, 0, 0), 

1819 "syzy": (1, 0, 1, 0), 

1820 "syxz": (1, 1, 0, 0), 

1821 "syxy": (1, 1, 1, 0), 

1822 "szxy": (2, 0, 0, 0), 

1823 "szxz": (2, 0, 1, 0), 

1824 "szyx": (2, 1, 0, 0), 

1825 "szyz": (2, 1, 1, 0), 

1826 "rzyx": (0, 0, 0, 1), 

1827 "rxyx": (0, 0, 1, 1), 

1828 "ryzx": (0, 1, 0, 1), 

1829 "rxzx": (0, 1, 1, 1), 

1830 "rxzy": (1, 0, 0, 1), 

1831 "ryzy": (1, 0, 1, 1), 

1832 "rzxy": (1, 1, 0, 1), 

1833 "ryxy": (1, 1, 1, 1), 

1834 "ryxz": (2, 0, 0, 1), 

1835 "rzxz": (2, 0, 1, 1), 

1836 "rxyz": (2, 1, 0, 1), 

1837 "rzyz": (2, 1, 1, 1), 

1838} 

1839 

1840_TUPLE2AXES = {v: k for k, v in _AXES2TUPLE.items()} 

1841 

1842 

1843def vector_norm(data, axis=None, out=None): 

1844 """Return length, i.e. Euclidean norm, of ndarray along axis. 

1845 

1846 >>> v = np.random.random(3) 

1847 >>> n = vector_norm(v) 

1848 >>> np.allclose(n, np.linalg.norm(v)) 

1849 True 

1850 >>> v = np.random.rand(6, 5, 3) 

1851 >>> n = vector_norm(v, axis=-1) 

1852 >>> np.allclose(n, np.sqrt(np.sum(v*v, axis=2))) 

1853 True 

1854 >>> n = vector_norm(v, axis=1) 

1855 >>> np.allclose(n, np.sqrt(np.sum(v*v, axis=1))) 

1856 True 

1857 >>> v = np.random.rand(5, 4, 3) 

1858 >>> n = np.zeros((5, 3)) 

1859 >>> vector_norm(v, axis=1, out=n) 

1860 >>> np.allclose(n, np.sqrt(np.sum(v*v, axis=1))) 

1861 True 

1862 >>> float(vector_norm([])) 

1863 0.0 

1864 >>> float(vector_norm([1])) 

1865 1.0 

1866 

1867 """ 

1868 data = np.array(data, dtype=np.float64) 

1869 if out is None: 

1870 if data.ndim == 1: 

1871 return np.sqrt(np.dot(data, data)) 

1872 data *= data 

1873 out = np.atleast_1d(np.sum(data, axis=axis)) 

1874 np.sqrt(out, out) 

1875 return out 

1876 else: 

1877 data *= data 

1878 np.sum(data, axis=axis, out=out) 

1879 np.sqrt(out, out) 

1880 

1881 

1882def unit_vector(data, axis=None, out=None): 

1883 """Return ndarray normalized by length, i.e. Euclidean norm, along axis. 

1884 

1885 >>> v0 = np.random.random(3) 

1886 >>> v1 = unit_vector(v0) 

1887 >>> np.allclose(v1, v0 / np.linalg.norm(v0)) 

1888 True 

1889 >>> v0 = np.random.rand(5, 4, 3) 

1890 >>> v1 = unit_vector(v0, axis=-1) 

1891 >>> v2 = v0 / np.expand_dims(np.sqrt(np.sum(v0*v0, axis=2)), 2) 

1892 >>> np.allclose(v1, v2) 

1893 True 

1894 >>> v1 = unit_vector(v0, axis=1) 

1895 >>> v2 = v0 / np.expand_dims(np.sqrt(np.sum(v0*v0, axis=1)), 1) 

1896 >>> np.allclose(v1, v2) 

1897 True 

1898 >>> v1 = np.zeros((5, 4, 3)) 

1899 >>> unit_vector(v0, axis=1, out=v1) 

1900 >>> np.allclose(v1, v2) 

1901 True 

1902 >>> list(unit_vector([])) 

1903 [] 

1904 >>> [float(i) for i in unit_vector([1])] 

1905 [1.0] 

1906 

1907 """ 

1908 if out is None: 

1909 data = np.array(data, dtype=np.float64) 

1910 if data.ndim == 1: 

1911 data /= np.sqrt(np.dot(data, data)) 

1912 return data 

1913 else: 

1914 if out is not data: 

1915 out[:] = np.asarray(data) 

1916 data = out 

1917 length = np.atleast_1d(np.sum(data * data, axis)) 

1918 np.sqrt(length, length) 

1919 if axis is not None: 

1920 length = np.expand_dims(length, axis) 

1921 data /= length 

1922 if out is None: 

1923 return data 

1924 

1925 

1926def random_vector(size, seed: Seed = None): 

1927 """Return array of random doubles in the half-open interval [0.0, 1.0). 

1928 

1929 >>> v = random_vector(10000) 

1930 >>> bool(np.all(v >= 0) and np.all(v < 1)) 

1931 True 

1932 >>> v0 = random_vector(10) 

1933 >>> v1 = random_vector(10) 

1934 >>> bool(np.any(v0 == v1)) 

1935 False 

1936 >>> bool(np.allclose(random_vector(10, seed=0), random_vector(10, seed=0))) 

1937 True 

1938 

1939 """ 

1940 return random_generator(seed).random(size) 

1941 

1942 

1943def vector_product(v0, v1, axis=0): 

1944 """Return vector perpendicular to vectors. 

1945 

1946 >>> v = vector_product([2, 0, 0], [0, 3, 0]) 

1947 >>> np.allclose(v, [0, 0, 6]) 

1948 True 

1949 >>> v0 = [[2, 0, 0, 2], [0, 2, 0, 2], [0, 0, 2, 2]] 

1950 >>> v1 = [[3], [0], [0]] 

1951 >>> v = vector_product(v0, v1) 

1952 >>> np.allclose(v, [[0, 0, 0, 0], [0, 0, 6, 6], [0, -6, 0, -6]]) 

1953 True 

1954 >>> v0 = [[2, 0, 0], [2, 0, 0], [0, 2, 0], [2, 0, 0]] 

1955 >>> v1 = [[0, 3, 0], [0, 0, 3], [0, 0, 3], [3, 3, 3]] 

1956 >>> v = vector_product(v0, v1, axis=1) 

1957 >>> np.allclose(v, [[0, 0, 6], [0, -6, 0], [6, 0, 0], [0, -6, 6]]) 

1958 True 

1959 

1960 """ 

1961 return np.cross(v0, v1, axis=axis) 

1962 

1963 

1964def angle_between_vectors(v0, v1, directed=True, axis=0): 

1965 """Return angle between vectors. 

1966 

1967 If directed is False, the input vectors are interpreted as undirected axes, 

1968 i.e. the maximum angle is pi/2. 

1969 

1970 >>> a = angle_between_vectors([1, -2, 3], [-1, 2, -3]) 

1971 >>> np.allclose(a, np.pi) 

1972 True 

1973 >>> a = angle_between_vectors([1, -2, 3], [-1, 2, -3], directed=False) 

1974 >>> np.allclose(a, 0) 

1975 True 

1976 >>> v0 = [[2, 0, 0, 2], [0, 2, 0, 2], [0, 0, 2, 2]] 

1977 >>> v1 = [[3], [0], [0]] 

1978 >>> a = angle_between_vectors(v0, v1) 

1979 >>> np.allclose(a, [0, 1.5708, 1.5708, 0.95532]) 

1980 True 

1981 >>> v0 = [[2, 0, 0], [2, 0, 0], [0, 2, 0], [2, 0, 0]] 

1982 >>> v1 = [[0, 3, 0], [0, 0, 3], [0, 0, 3], [3, 3, 3]] 

1983 >>> a = angle_between_vectors(v0, v1, axis=1) 

1984 >>> np.allclose(a, [1.5708, 1.5708, 1.5708, 0.95532]) 

1985 True 

1986 

1987 """ 

1988 v0 = np.asarray(v0, dtype=np.float64) 

1989 v1 = np.asarray(v1, dtype=np.float64) 

1990 dot = np.sum(v0 * v1, axis=axis) 

1991 dot /= vector_norm(v0, axis=axis) * vector_norm(v1, axis=axis) 

1992 

1993 # clip off floating point error to avoid `nan` in the arccos` 

1994 dot = np.clip(dot, -1.0, 1.0) 

1995 return np.arccos(dot if directed else np.fabs(dot)) 

1996 

1997 

1998def inverse_matrix(matrix): 

1999 """Return inverse of square transformation matrix. 

2000 

2001 >>> M0 = random_rotation_matrix() 

2002 >>> M1 = inverse_matrix(M0.T) 

2003 >>> np.allclose(M1, np.linalg.inv(M0.T)) 

2004 True 

2005 >>> for size in range(1, 7): 

2006 ... M0 = np.random.rand(size, size) 

2007 ... M1 = inverse_matrix(M0) 

2008 ... if not np.allclose(M1, np.linalg.inv(M0)): print(size) 

2009 

2010 """ 

2011 return np.linalg.inv(matrix) 

2012 

2013 

2014def concatenate_matrices(*matrices): 

2015 """Return concatenation of series of transformation matrices. 

2016 

2017 >>> M = np.random.rand(16).reshape((4, 4)) - 0.5 

2018 >>> np.allclose(M, concatenate_matrices(M)) 

2019 True 

2020 >>> np.allclose(np.dot(M, M.T), concatenate_matrices(M, M.T)) 

2021 True 

2022 

2023 """ 

2024 M = np.identity(4) 

2025 for i in matrices: 

2026 M = np.dot(M, i) 

2027 return M 

2028 

2029 

2030def is_same_transform(matrix0, matrix1): 

2031 """Return True if two matrices perform same transformation. 

2032 

2033 >>> is_same_transform(np.identity(4), np.identity(4)) 

2034 True 

2035 >>> is_same_transform(np.identity(4), random_rotation_matrix()) 

2036 False 

2037 

2038 """ 

2039 matrix0 = np.array(matrix0, dtype=np.float64) 

2040 matrix0 /= matrix0[3, 3] 

2041 matrix1 = np.array(matrix1, dtype=np.float64) 

2042 matrix1 /= matrix1[3, 3] 

2043 return np.allclose(matrix0, matrix1) 

2044 

2045 

2046def is_same_quaternion(q0, q1): 

2047 """Return True if two quaternions are equal.""" 

2048 q0 = np.array(q0) 

2049 q1 = np.array(q1) 

2050 return np.allclose(q0, q1) or np.allclose(q0, -q1) 

2051 

2052 

2053def transform_around(matrix, point): 

2054 """ 

2055 Given a transformation matrix, apply its rotation 

2056 around a point in space. 

2057 

2058 Parameters 

2059 ---------- 

2060 matrix: (4,4) or (3, 3) float, transformation matrix 

2061 point: (3,) or (2,) float, point in space 

2062 

2063 Returns 

2064 --------- 

2065 result: (4,4) transformation matrix 

2066 """ 

2067 point = np.asanyarray(point) 

2068 matrix = np.asanyarray(matrix) 

2069 dim = len(point) 

2070 if matrix.shape != (dim + 1, dim + 1): 

2071 raise ValueError("matrix must be (d+1, d+1)") 

2072 

2073 translate = np.eye(dim + 1) 

2074 translate[:dim, dim] = -point 

2075 result = np.dot(matrix, translate) 

2076 translate[:dim, dim] = point 

2077 result = np.dot(translate, result) 

2078 

2079 return result 

2080 

2081 

2082def planar_matrix(offset=None, theta=None, point=None, scale=None): 

2083 """ 

2084 2D homogeonous transformation matrix. 

2085 

2086 Parameters 

2087 ---------- 

2088 offset : (2,) float 

2089 XY offset 

2090 theta : float 

2091 Rotation around Z in radians 

2092 point : (2, ) float 

2093 Point to rotate around 

2094 scale : (2,) float or None 

2095 Scale to apply 

2096 

2097 Returns 

2098 ---------- 

2099 matrix : (3, 3) flat 

2100 Homogeneous 2D transformation matrix 

2101 """ 

2102 if offset is None: 

2103 offset = [0.0, 0.0] 

2104 if theta is None: 

2105 theta = 0.0 

2106 offset = np.asanyarray(offset, dtype=np.float64) 

2107 theta = float(theta) 

2108 if not np.isfinite(theta): 

2109 raise ValueError("theta must be finite angle!") 

2110 if offset.shape != (2,): 

2111 raise ValueError("offset must be length 2!") 

2112 

2113 T = np.eye(3, dtype=np.float64) 

2114 s = np.sin(theta) 

2115 c = np.cos(theta) 

2116 

2117 T[0, :2] = [c, s] 

2118 T[1, :2] = [-s, c] 

2119 T[:2, 2] = offset 

2120 

2121 if point is not None: 

2122 T = transform_around(matrix=T, point=point) 

2123 

2124 if scale is not None: 

2125 S = np.eye(3) 

2126 S[:2, :2] *= scale 

2127 T = np.dot(S, T) 

2128 

2129 return T 

2130 

2131 

2132def planar_matrix_to_3D(matrix_2D): 

2133 """ 

2134 Given a 2D homogeneous rotation matrix convert it to a 3D rotation 

2135 matrix that is rotating around the Z axis 

2136 

2137 Parameters 

2138 ---------- 

2139 matrix_2D: (3,3) float, homogeneous 2D rotation matrix 

2140 

2141 Returns 

2142 ---------- 

2143 matrix_3D: (4,4) float, homogeneous 3D rotation matrix 

2144 """ 

2145 

2146 matrix_2D = np.asanyarray(matrix_2D, dtype=np.float64) 

2147 if matrix_2D.shape != (3, 3): 

2148 raise ValueError("Homogeneous 2D transformation matrix required!") 

2149 

2150 matrix_3D = np.eye(4) 

2151 # translation 

2152 matrix_3D[:2, 3] = matrix_2D[:2, 2] 

2153 # rotation from 2D to around Z 

2154 matrix_3D[:2, :2] = matrix_2D[:2, :2] 

2155 

2156 return matrix_3D 

2157 

2158 

2159def spherical_matrix(theta, phi, axes="sxyz"): 

2160 """ 

2161 Give a spherical coordinate vector, find the rotation that will 

2162 transform a [0,0,1] vector to those coordinates 

2163 

2164 Parameters 

2165 ----------- 

2166 theta: float, rotation angle in radians 

2167 phi: float, rotation angle in radians 

2168 

2169 Returns 

2170 ---------- 

2171 matrix: (4,4) rotation matrix where the following will 

2172 be a cartesian vector in the direction of the 

2173 input spherical coordinates: 

2174 np.dot(matrix, [0,0,1,0]) 

2175 

2176 """ 

2177 result = euler_matrix(0.0, phi, theta, axes=axes) 

2178 return result 

2179 

2180 

2181def transform_points( 

2182 points: ArrayLike, matrix: ArrayLike, translate: bool = True 

2183) -> NDArray[np.float64]: 

2184 """ 

2185 Returns points rotated by a homogeneous 

2186 transformation matrix. 

2187 

2188 If points are (n, 2) matrix must be (3, 3) 

2189 If points are (n, 3) matrix must be (4, 4) 

2190 

2191 Parameters 

2192 ---------- 

2193 points : (n, dim) float 

2194 Points where `dim` is 2 or 3. 

2195 matrix : (3, 3) or (4, 4) float 

2196 Homogeneous rotation matrix. 

2197 translate : bool 

2198 Apply translation from matrix or not. 

2199 

2200 Returns 

2201 ---------- 

2202 transformed : (n, dim) float 

2203 Transformed points. 

2204 """ 

2205 points = np.asanyarray(points, dtype=np.float64) 

2206 if len(points) == 0 or matrix is None: 

2207 return points.copy() 

2208 

2209 # check the matrix against the points 

2210 matrix = np.asanyarray(matrix, dtype=np.float64) 

2211 # shorthand the shape 

2212 count, dim = points.shape 

2213 

2214 # quickly check to see if we've been passed an identity matrix 

2215 if np.abs(matrix - _IDENTITY[: dim + 1, : dim + 1]).max() < 1e-8: 

2216 return np.ascontiguousarray(points.copy()) 

2217 

2218 if translate: 

2219 # apply translation and rotation 

2220 stack = np.column_stack((points, np.ones(count))) 

2221 return np.dot(matrix, stack.T).T[:, :dim] 

2222 

2223 # only apply the rotation 

2224 return np.dot(matrix[:dim, :dim], points.T).T 

2225 

2226 

2227def fix_rigid(matrix, max_deviance=1e-5): 

2228 """ 

2229 If a homogeneous transformation matrix is *almost* a rigid 

2230 transform but many matrix-multiplies have accumulated some 

2231 floating point error try to restore the matrix using SVD. 

2232 

2233 Parameters 

2234 ----------- 

2235 matrix : (4, 4) or (3, 3) float 

2236 Homogeneous transformation matrix. 

2237 max_deviance : float 

2238 Do not alter the matrix if it is not rigid by more 

2239 than this amount. 

2240 

2241 Returns 

2242 ---------- 

2243 repaired : (4, 4) or (3, 3) float 

2244 Repaired homogeneous transformation matrix 

2245 """ 

2246 dim = matrix.shape[0] - 1 

2247 check = np.abs( 

2248 np.dot(matrix[:dim, :dim], matrix[:dim, :dim].T) - _IDENTITY[:dim, :dim] 

2249 ).max() 

2250 # if the matrix differs by more than float-zero and less 

2251 # than the threshold try to repair the matrix with SVD 

2252 if check > 1e-13 and check < max_deviance: 

2253 # reconstruct the rotation from the SVD 

2254 U, _, V = np.linalg.svd(matrix[:dim, :dim]) 

2255 repaired = np.eye(dim + 1) 

2256 repaired[:dim, :dim] = np.dot(U, V) 

2257 # copy in the translation 

2258 repaired[:dim, dim] = matrix[:dim, dim] 

2259 # should be within tolerance of the original matrix 

2260 assert np.allclose(repaired, matrix, atol=max_deviance) 

2261 return repaired 

2262 

2263 return matrix 

2264 

2265 

2266def is_rigid(matrix, epsilon=1e-8): 

2267 """ 

2268 Check to make sure a homogeonous transformation 

2269 matrix is a rigid transform. 

2270 

2271 Parameters 

2272 ----------- 

2273 matrix : (4, 4) float 

2274 A transformation matrix 

2275 

2276 Returns 

2277 ----------- 

2278 check : bool 

2279 True if matrix is a a transform with only 

2280 translation and rotation. 

2281 """ 

2282 

2283 matrix = np.asanyarray(matrix, dtype=np.float64) 

2284 

2285 if matrix.shape != (4, 4): 

2286 return False 

2287 

2288 # rows of a rotation dot to the identity 

2289 check = np.dot(matrix[:3, :3], matrix[:3, :3].T).ravel() - _IDENTITY9 

2290 # make sure last row has no perspective 

2291 bottom = matrix[3] - _IDENTITY[3] 

2292 

2293 # squared norms are a single dot and avoid `abs` and `ptp` 

2294 return np.dot(bottom, bottom) < epsilon**2 and np.dot(check, check) < epsilon**2 

2295 

2296 

2297def scale_and_translate(scale=None, translate=None): 

2298 """ 

2299 Optimized version of `compose_matrix` for just 

2300 scaling then translating. 

2301 

2302 Scalar args are broadcast to arrays of shape (3,) 

2303 

2304 Parameters 

2305 -------------- 

2306 scale : float or (3,) float 

2307 Scale factor 

2308 translate : float or (3,) float 

2309 Translation 

2310 """ 

2311 M = np.eye(4) 

2312 if np.any(scale != 1): 

2313 M[:3, :3] *= scale 

2314 if translate is not None: 

2315 M[:3, 3] = translate 

2316 return M 

2317 

2318 

2319def flips_winding(matrix): 

2320 """ 

2321 Check to see if a matrix will invert triangles. 

2322 

2323 Parameters 

2324 ------------- 

2325 matrix : (4, 4) float 

2326 Homogeneous transformation matrix 

2327 

2328 Returns 

2329 -------------- 

2330 flip : bool 

2331 True if matrix will flip winding of triangles. 

2332 """ 

2333 # get input as numpy array 

2334 matrix = np.asanyarray(matrix, dtype=np.float64) 

2335 if matrix.shape not in ((3, 3), (4, 4)): 

2336 raise ValueError(f"matrix must be (3, 3) or (4, 4), not {matrix.shape!s}") 

2337 # a transform flips winding exactly when its determinant is negative 

2338 return bool(np.linalg.det(matrix[:3, :3]) < 0.0)