From 87b39596146b4eb2f9816004082fbad7b6120e67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9verin=20Lemaignan?= Date: Mon, 19 Dec 2016 21:19:01 +0000 Subject: [PATCH 1/2] [pyassimp] Added missing transformations.py library --- port/PyAssimp/scripts/transformations.py | 1705 ++++++++++++++++++++++ 1 file changed, 1705 insertions(+) create mode 100644 port/PyAssimp/scripts/transformations.py diff --git a/port/PyAssimp/scripts/transformations.py b/port/PyAssimp/scripts/transformations.py new file mode 100644 index 000000000..6d6f19e5b --- /dev/null +++ b/port/PyAssimp/scripts/transformations.py @@ -0,0 +1,1705 @@ +# -*- coding: utf-8 -*- +# transformations.py + +# Copyright (c) 2006, Christoph Gohlke +# Copyright (c) 2006-2009, The Regents of the University of California +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the copyright holders nor the names of any +# contributors may be used to endorse or promote products derived +# from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + +"""Homogeneous Transformation Matrices and Quaternions. + +A library for calculating 4x4 matrices for translating, rotating, reflecting, +scaling, shearing, projecting, orthogonalizing, and superimposing arrays of +3D homogeneous coordinates as well as for converting between rotation matrices, +Euler angles, and quaternions. Also includes an Arcball control object and +functions to decompose transformation matrices. + +:Authors: + `Christoph Gohlke `__, + Laboratory for Fluorescence Dynamics, University of California, Irvine + +:Version: 20090418 + +Requirements +------------ + +* `Python 2.6 `__ +* `Numpy 1.3 `__ +* `transformations.c 20090418 `__ + (optional implementation of some functions in C) + +Notes +----- + +Matrices (M) can be inverted using numpy.linalg.inv(M), concatenated using +numpy.dot(M0, M1), or used to transform homogeneous coordinates (v) using +numpy.dot(M, v) for shape (4, \*) "point of arrays", respectively +numpy.dot(v, M.T) for shape (\*, 4) "array of points". + +Calculations are carried out with numpy.float64 precision. + +This Python implementation is not optimized for speed. + +Vector, point, quaternion, and matrix function arguments are expected to be +"array like", i.e. tuple, list, or numpy arrays. + +Return types are numpy arrays unless specified otherwise. + +Angles are in radians unless specified otherwise. + +Quaternions ix+jy+kz+w are represented as [x, y, z, w]. + +Use the transpose of transformation matrices for OpenGL glMultMatrixd(). + +A triple of Euler angles can be applied/interpreted in 24 ways, which can +be specified using a 4 character string or encoded 4-tuple: + + *Axes 4-string*: e.g. 'sxyz' or 'ryxy' + + - first character : rotations are applied to 's'tatic or 'r'otating frame + - remaining characters : successive rotation axis 'x', 'y', or 'z' + + *Axes 4-tuple*: e.g. (0, 0, 0, 0) or (1, 1, 1, 1) + + - inner axis: code of axis ('x':0, 'y':1, 'z':2) of rightmost matrix. + - parity : even (0) if inner axis 'x' is followed by 'y', 'y' is followed + by 'z', or 'z' is followed by 'x'. Otherwise odd (1). + - repetition : first and last axis are same (1) or different (0). + - frame : rotations are applied to static (0) or rotating (1) frame. + +References +---------- + +(1) Matrices and transformations. Ronald Goldman. + In "Graphics Gems I", pp 472-475. Morgan Kaufmann, 1990. +(2) More matrices and transformations: shear and pseudo-perspective. + Ronald Goldman. In "Graphics Gems II", pp 320-323. Morgan Kaufmann, 1991. +(3) Decomposing a matrix into simple transformations. Spencer Thomas. + In "Graphics Gems II", pp 320-323. Morgan Kaufmann, 1991. +(4) Recovering the data from the transformation matrix. Ronald Goldman. + In "Graphics Gems II", pp 324-331. Morgan Kaufmann, 1991. +(5) Euler angle conversion. Ken Shoemake. + In "Graphics Gems IV", pp 222-229. Morgan Kaufmann, 1994. +(6) Arcball rotation control. Ken Shoemake. + In "Graphics Gems IV", pp 175-192. Morgan Kaufmann, 1994. +(7) Representing attitude: Euler angles, unit quaternions, and rotation + vectors. James Diebel. 2006. +(8) A discussion of the solution for the best rotation to relate two sets + of vectors. W Kabsch. Acta Cryst. 1978. A34, 827-828. +(9) Closed-form solution of absolute orientation using unit quaternions. + BKP Horn. J Opt Soc Am A. 1987. 4(4), 629-642. +(10) Quaternions. Ken Shoemake. + http://www.sfu.ca/~jwa3/cmpt461/files/quatut.pdf +(11) From quaternion to matrix and back. JMP van Waveren. 2005. + http://www.intel.com/cd/ids/developer/asmo-na/eng/293748.htm +(12) Uniform random rotations. Ken Shoemake. + In "Graphics Gems III", pp 124-132. Morgan Kaufmann, 1992. + + +Examples +-------- + +>>> alpha, beta, gamma = 0.123, -1.234, 2.345 +>>> origin, xaxis, yaxis, zaxis = (0, 0, 0), (1, 0, 0), (0, 1, 0), (0, 0, 1) +>>> I = identity_matrix() +>>> Rx = rotation_matrix(alpha, xaxis) +>>> Ry = rotation_matrix(beta, yaxis) +>>> Rz = rotation_matrix(gamma, zaxis) +>>> R = concatenate_matrices(Rx, Ry, Rz) +>>> euler = euler_from_matrix(R, 'rxyz') +>>> numpy.allclose([alpha, beta, gamma], euler) +True +>>> Re = euler_matrix(alpha, beta, gamma, 'rxyz') +>>> is_same_transform(R, Re) +True +>>> al, be, ga = euler_from_matrix(Re, 'rxyz') +>>> is_same_transform(Re, euler_matrix(al, be, ga, 'rxyz')) +True +>>> qx = quaternion_about_axis(alpha, xaxis) +>>> qy = quaternion_about_axis(beta, yaxis) +>>> qz = quaternion_about_axis(gamma, zaxis) +>>> q = quaternion_multiply(qx, qy) +>>> q = quaternion_multiply(q, qz) +>>> Rq = quaternion_matrix(q) +>>> is_same_transform(R, Rq) +True +>>> S = scale_matrix(1.23, origin) +>>> T = translation_matrix((1, 2, 3)) +>>> Z = shear_matrix(beta, xaxis, origin, zaxis) +>>> R = random_rotation_matrix(numpy.random.rand(3)) +>>> M = concatenate_matrices(T, R, Z, S) +>>> scale, shear, angles, trans, persp = decompose_matrix(M) +>>> numpy.allclose(scale, 1.23) +True +>>> numpy.allclose(trans, (1, 2, 3)) +True +>>> numpy.allclose(shear, (0, math.tan(beta), 0)) +True +>>> is_same_transform(R, euler_matrix(axes='sxyz', *angles)) +True +>>> M1 = compose_matrix(scale, shear, angles, trans, persp) +>>> is_same_transform(M, M1) +True + +""" + +from __future__ import division + +import warnings +import math + +import numpy + +# Documentation in HTML format can be generated with Epydoc +__docformat__ = "restructuredtext en" + + +def identity_matrix(): + """Return 4x4 identity/unit matrix. + + >>> I = identity_matrix() + >>> numpy.allclose(I, numpy.dot(I, I)) + True + >>> numpy.sum(I), numpy.trace(I) + (4.0, 4.0) + >>> numpy.allclose(I, numpy.identity(4, dtype=numpy.float64)) + True + + """ + return numpy.identity(4, dtype=numpy.float64) + + +def translation_matrix(direction): + """Return matrix to translate by direction vector. + + >>> v = numpy.random.random(3) - 0.5 + >>> numpy.allclose(v, translation_matrix(v)[:3, 3]) + True + + """ + M = numpy.identity(4) + M[:3, 3] = direction[:3] + return M + + +def translation_from_matrix(matrix): + """Return translation vector from translation matrix. + + >>> v0 = numpy.random.random(3) - 0.5 + >>> v1 = translation_from_matrix(translation_matrix(v0)) + >>> numpy.allclose(v0, v1) + True + + """ + return numpy.array(matrix, copy=False)[:3, 3].copy() + + +def reflection_matrix(point, normal): + """Return matrix to mirror at plane defined by point and normal vector. + + >>> v0 = numpy.random.random(4) - 0.5 + >>> v0[3] = 1.0 + >>> v1 = numpy.random.random(3) - 0.5 + >>> R = reflection_matrix(v0, v1) + >>> numpy.allclose(2., numpy.trace(R)) + True + >>> numpy.allclose(v0, numpy.dot(R, v0)) + True + >>> v2 = v0.copy() + >>> v2[:3] += v1 + >>> v3 = v0.copy() + >>> v2[:3] -= v1 + >>> numpy.allclose(v2, numpy.dot(R, v3)) + True + + """ + normal = unit_vector(normal[:3]) + M = numpy.identity(4) + M[:3, :3] -= 2.0 * numpy.outer(normal, normal) + M[:3, 3] = (2.0 * numpy.dot(point[:3], normal)) * normal + return M + + +def reflection_from_matrix(matrix): + """Return mirror plane point and normal vector from reflection matrix. + + >>> v0 = numpy.random.random(3) - 0.5 + >>> v1 = numpy.random.random(3) - 0.5 + >>> M0 = reflection_matrix(v0, v1) + >>> point, normal = reflection_from_matrix(M0) + >>> M1 = reflection_matrix(point, normal) + >>> is_same_transform(M0, M1) + True + + """ + M = numpy.array(matrix, dtype=numpy.float64, copy=False) + # normal: unit eigenvector corresponding to eigenvalue -1 + l, V = numpy.linalg.eig(M[:3, :3]) + i = numpy.where(abs(numpy.real(l) + 1.0) < 1e-8)[0] + if not len(i): + raise ValueError("no unit eigenvector corresponding to eigenvalue -1") + normal = numpy.real(V[:, i[0]]).squeeze() + # point: any unit eigenvector corresponding to eigenvalue 1 + l, V = numpy.linalg.eig(M) + i = numpy.where(abs(numpy.real(l) - 1.0) < 1e-8)[0] + if not len(i): + raise ValueError("no unit eigenvector corresponding to eigenvalue 1") + point = numpy.real(V[:, i[-1]]).squeeze() + point /= point[3] + return point, normal + + +def rotation_matrix(angle, direction, point=None): + """Return matrix to rotate about axis defined by point and direction. + + >>> angle = (random.random() - 0.5) * (2*math.pi) + >>> direc = numpy.random.random(3) - 0.5 + >>> point = numpy.random.random(3) - 0.5 + >>> R0 = rotation_matrix(angle, direc, point) + >>> R1 = rotation_matrix(angle-2*math.pi, direc, point) + >>> is_same_transform(R0, R1) + True + >>> R0 = rotation_matrix(angle, direc, point) + >>> R1 = rotation_matrix(-angle, -direc, point) + >>> is_same_transform(R0, R1) + True + >>> I = numpy.identity(4, numpy.float64) + >>> numpy.allclose(I, rotation_matrix(math.pi*2, direc)) + True + >>> numpy.allclose(2., numpy.trace(rotation_matrix(math.pi/2, + ... direc, point))) + True + + """ + sina = math.sin(angle) + cosa = math.cos(angle) + direction = unit_vector(direction[:3]) + # rotation matrix around unit vector + R = numpy.array(((cosa, 0.0, 0.0), + (0.0, cosa, 0.0), + (0.0, 0.0, cosa)), dtype=numpy.float64) + R += numpy.outer(direction, direction) * (1.0 - cosa) + direction *= sina + R += numpy.array((( 0.0, -direction[2], direction[1]), + ( direction[2], 0.0, -direction[0]), + (-direction[1], direction[0], 0.0)), + dtype=numpy.float64) + M = numpy.identity(4) + M[:3, :3] = R + if point is not None: + # rotation not around origin + point = numpy.array(point[:3], dtype=numpy.float64, copy=False) + M[:3, 3] = point - numpy.dot(R, point) + return M + + +def rotation_from_matrix(matrix): + """Return rotation angle and axis from rotation matrix. + + >>> angle = (random.random() - 0.5) * (2*math.pi) + >>> direc = numpy.random.random(3) - 0.5 + >>> point = numpy.random.random(3) - 0.5 + >>> R0 = rotation_matrix(angle, direc, point) + >>> angle, direc, point = rotation_from_matrix(R0) + >>> R1 = rotation_matrix(angle, direc, point) + >>> is_same_transform(R0, R1) + True + + """ + R = numpy.array(matrix, dtype=numpy.float64, copy=False) + R33 = R[:3, :3] + # direction: unit eigenvector of R33 corresponding to eigenvalue of 1 + l, W = numpy.linalg.eig(R33.T) + i = numpy.where(abs(numpy.real(l) - 1.0) < 1e-8)[0] + if not len(i): + raise ValueError("no unit eigenvector corresponding to eigenvalue 1") + direction = numpy.real(W[:, i[-1]]).squeeze() + # point: unit eigenvector of R33 corresponding to eigenvalue of 1 + l, Q = numpy.linalg.eig(R) + i = numpy.where(abs(numpy.real(l) - 1.0) < 1e-8)[0] + if not len(i): + raise ValueError("no unit eigenvector corresponding to eigenvalue 1") + point = numpy.real(Q[:, i[-1]]).squeeze() + point /= point[3] + # rotation angle depending on direction + cosa = (numpy.trace(R33) - 1.0) / 2.0 + if abs(direction[2]) > 1e-8: + sina = (R[1, 0] + (cosa-1.0)*direction[0]*direction[1]) / direction[2] + elif abs(direction[1]) > 1e-8: + sina = (R[0, 2] + (cosa-1.0)*direction[0]*direction[2]) / direction[1] + else: + sina = (R[2, 1] + (cosa-1.0)*direction[1]*direction[2]) / direction[0] + angle = math.atan2(sina, cosa) + return angle, direction, point + + +def scale_matrix(factor, origin=None, direction=None): + """Return matrix to scale by factor around origin in direction. + + Use factor -1 for point symmetry. + + >>> v = (numpy.random.rand(4, 5) - 0.5) * 20.0 + >>> v[3] = 1.0 + >>> S = scale_matrix(-1.234) + >>> numpy.allclose(numpy.dot(S, v)[:3], -1.234*v[:3]) + True + >>> factor = random.random() * 10 - 5 + >>> origin = numpy.random.random(3) - 0.5 + >>> direct = numpy.random.random(3) - 0.5 + >>> S = scale_matrix(factor, origin) + >>> S = scale_matrix(factor, origin, direct) + + """ + if direction is None: + # uniform scaling + M = numpy.array(((factor, 0.0, 0.0, 0.0), + (0.0, factor, 0.0, 0.0), + (0.0, 0.0, factor, 0.0), + (0.0, 0.0, 0.0, 1.0)), dtype=numpy.float64) + if origin is not None: + M[:3, 3] = origin[:3] + M[:3, 3] *= 1.0 - factor + else: + # nonuniform scaling + direction = unit_vector(direction[:3]) + factor = 1.0 - factor + M = numpy.identity(4) + M[:3, :3] -= factor * numpy.outer(direction, direction) + if origin is not None: + M[:3, 3] = (factor * numpy.dot(origin[:3], direction)) * direction + return M + + +def scale_from_matrix(matrix): + """Return scaling factor, origin and direction from scaling matrix. + + >>> factor = random.random() * 10 - 5 + >>> origin = numpy.random.random(3) - 0.5 + >>> direct = numpy.random.random(3) - 0.5 + >>> S0 = scale_matrix(factor, origin) + >>> factor, origin, direction = scale_from_matrix(S0) + >>> S1 = scale_matrix(factor, origin, direction) + >>> is_same_transform(S0, S1) + True + >>> S0 = scale_matrix(factor, origin, direct) + >>> factor, origin, direction = scale_from_matrix(S0) + >>> S1 = scale_matrix(factor, origin, direction) + >>> is_same_transform(S0, S1) + True + + """ + M = numpy.array(matrix, dtype=numpy.float64, copy=False) + M33 = M[:3, :3] + factor = numpy.trace(M33) - 2.0 + try: + # direction: unit eigenvector corresponding to eigenvalue factor + l, V = numpy.linalg.eig(M33) + i = numpy.where(abs(numpy.real(l) - factor) < 1e-8)[0][0] + direction = numpy.real(V[:, i]).squeeze() + direction /= vector_norm(direction) + except IndexError: + # uniform scaling + factor = (factor + 2.0) / 3.0 + direction = None + # origin: any eigenvector corresponding to eigenvalue 1 + l, V = numpy.linalg.eig(M) + i = numpy.where(abs(numpy.real(l) - 1.0) < 1e-8)[0] + if not len(i): + raise ValueError("no eigenvector corresponding to eigenvalue 1") + origin = numpy.real(V[:, i[-1]]).squeeze() + origin /= origin[3] + return factor, origin, direction + + +def projection_matrix(point, normal, direction=None, + perspective=None, pseudo=False): + """Return matrix to project onto plane defined by point and normal. + + Using either perspective point, projection direction, or none of both. + + If pseudo is True, perspective projections will preserve relative depth + such that Perspective = dot(Orthogonal, PseudoPerspective). + + >>> P = projection_matrix((0, 0, 0), (1, 0, 0)) + >>> numpy.allclose(P[1:, 1:], numpy.identity(4)[1:, 1:]) + True + >>> point = numpy.random.random(3) - 0.5 + >>> normal = numpy.random.random(3) - 0.5 + >>> direct = numpy.random.random(3) - 0.5 + >>> persp = numpy.random.random(3) - 0.5 + >>> P0 = projection_matrix(point, normal) + >>> P1 = projection_matrix(point, normal, direction=direct) + >>> P2 = projection_matrix(point, normal, perspective=persp) + >>> P3 = projection_matrix(point, normal, perspective=persp, pseudo=True) + >>> is_same_transform(P2, numpy.dot(P0, P3)) + True + >>> P = projection_matrix((3, 0, 0), (1, 1, 0), (1, 0, 0)) + >>> v0 = (numpy.random.rand(4, 5) - 0.5) * 20.0 + >>> v0[3] = 1.0 + >>> v1 = numpy.dot(P, v0) + >>> numpy.allclose(v1[1], v0[1]) + True + >>> numpy.allclose(v1[0], 3.0-v1[1]) + True + + """ + M = numpy.identity(4) + point = numpy.array(point[:3], dtype=numpy.float64, copy=False) + normal = unit_vector(normal[:3]) + if perspective is not None: + # perspective projection + perspective = numpy.array(perspective[:3], dtype=numpy.float64, + copy=False) + M[0, 0] = M[1, 1] = M[2, 2] = numpy.dot(perspective-point, normal) + M[:3, :3] -= numpy.outer(perspective, normal) + if pseudo: + # preserve relative depth + M[:3, :3] -= numpy.outer(normal, normal) + M[:3, 3] = numpy.dot(point, normal) * (perspective+normal) + else: + M[:3, 3] = numpy.dot(point, normal) * perspective + M[3, :3] = -normal + M[3, 3] = numpy.dot(perspective, normal) + elif direction is not None: + # parallel projection + direction = numpy.array(direction[:3], dtype=numpy.float64, copy=False) + scale = numpy.dot(direction, normal) + M[:3, :3] -= numpy.outer(direction, normal) / scale + M[:3, 3] = direction * (numpy.dot(point, normal) / scale) + else: + # orthogonal projection + M[:3, :3] -= numpy.outer(normal, normal) + M[:3, 3] = numpy.dot(point, normal) * normal + return M + + +def projection_from_matrix(matrix, pseudo=False): + """Return projection plane and perspective point from projection matrix. + + Return values are same as arguments for projection_matrix function: + point, normal, direction, perspective, and pseudo. + + >>> point = numpy.random.random(3) - 0.5 + >>> normal = numpy.random.random(3) - 0.5 + >>> direct = numpy.random.random(3) - 0.5 + >>> persp = numpy.random.random(3) - 0.5 + >>> P0 = projection_matrix(point, normal) + >>> result = projection_from_matrix(P0) + >>> P1 = projection_matrix(*result) + >>> is_same_transform(P0, P1) + True + >>> P0 = projection_matrix(point, normal, direct) + >>> result = projection_from_matrix(P0) + >>> P1 = projection_matrix(*result) + >>> is_same_transform(P0, P1) + True + >>> P0 = projection_matrix(point, normal, perspective=persp, pseudo=False) + >>> result = projection_from_matrix(P0, pseudo=False) + >>> P1 = projection_matrix(*result) + >>> is_same_transform(P0, P1) + True + >>> P0 = projection_matrix(point, normal, perspective=persp, pseudo=True) + >>> result = projection_from_matrix(P0, pseudo=True) + >>> P1 = projection_matrix(*result) + >>> is_same_transform(P0, P1) + True + + """ + M = numpy.array(matrix, dtype=numpy.float64, copy=False) + M33 = M[:3, :3] + l, V = numpy.linalg.eig(M) + i = numpy.where(abs(numpy.real(l) - 1.0) < 1e-8)[0] + if not pseudo and len(i): + # point: any eigenvector corresponding to eigenvalue 1 + point = numpy.real(V[:, i[-1]]).squeeze() + point /= point[3] + # direction: unit eigenvector corresponding to eigenvalue 0 + l, V = numpy.linalg.eig(M33) + i = numpy.where(abs(numpy.real(l)) < 1e-8)[0] + if not len(i): + raise ValueError("no eigenvector corresponding to eigenvalue 0") + direction = numpy.real(V[:, i[0]]).squeeze() + direction /= vector_norm(direction) + # normal: unit eigenvector of M33.T corresponding to eigenvalue 0 + l, V = numpy.linalg.eig(M33.T) + i = numpy.where(abs(numpy.real(l)) < 1e-8)[0] + if len(i): + # parallel projection + normal = numpy.real(V[:, i[0]]).squeeze() + normal /= vector_norm(normal) + return point, normal, direction, None, False + else: + # orthogonal projection, where normal equals direction vector + return point, direction, None, None, False + else: + # perspective projection + i = numpy.where(abs(numpy.real(l)) > 1e-8)[0] + if not len(i): + raise ValueError( + "no eigenvector not corresponding to eigenvalue 0") + point = numpy.real(V[:, i[-1]]).squeeze() + point /= point[3] + normal = - M[3, :3] + perspective = M[:3, 3] / numpy.dot(point[:3], normal) + if pseudo: + perspective -= normal + return point, normal, None, perspective, pseudo + + +def clip_matrix(left, right, bottom, top, near, far, perspective=False): + """Return matrix to obtain normalized device coordinates from frustrum. + + The frustrum bounds are axis-aligned along x (left, right), + y (bottom, top) and z (near, far). + + Normalized device coordinates are in range [-1, 1] if coordinates are + inside the frustrum. + + If perspective is True the frustrum is a truncated pyramid with the + perspective point at origin and direction along z axis, otherwise an + orthographic canonical view volume (a box). + + Homogeneous coordinates transformed by the perspective clip matrix + need to be dehomogenized (devided by w coordinate). + + >>> frustrum = numpy.random.rand(6) + >>> frustrum[1] += frustrum[0] + >>> frustrum[3] += frustrum[2] + >>> frustrum[5] += frustrum[4] + >>> M = clip_matrix(*frustrum, perspective=False) + >>> numpy.dot(M, [frustrum[0], frustrum[2], frustrum[4], 1.0]) + array([-1., -1., -1., 1.]) + >>> numpy.dot(M, [frustrum[1], frustrum[3], frustrum[5], 1.0]) + array([ 1., 1., 1., 1.]) + >>> M = clip_matrix(*frustrum, perspective=True) + >>> v = numpy.dot(M, [frustrum[0], frustrum[2], frustrum[4], 1.0]) + >>> v / v[3] + array([-1., -1., -1., 1.]) + >>> v = numpy.dot(M, [frustrum[1], frustrum[3], frustrum[4], 1.0]) + >>> v / v[3] + array([ 1., 1., -1., 1.]) + + """ + if left >= right or bottom >= top or near >= far: + raise ValueError("invalid frustrum") + if perspective: + if near <= _EPS: + raise ValueError("invalid frustrum: near <= 0") + t = 2.0 * near + M = ((-t/(right-left), 0.0, (right+left)/(right-left), 0.0), + (0.0, -t/(top-bottom), (top+bottom)/(top-bottom), 0.0), + (0.0, 0.0, -(far+near)/(far-near), t*far/(far-near)), + (0.0, 0.0, -1.0, 0.0)) + else: + M = ((2.0/(right-left), 0.0, 0.0, (right+left)/(left-right)), + (0.0, 2.0/(top-bottom), 0.0, (top+bottom)/(bottom-top)), + (0.0, 0.0, 2.0/(far-near), (far+near)/(near-far)), + (0.0, 0.0, 0.0, 1.0)) + return numpy.array(M, dtype=numpy.float64) + + +def shear_matrix(angle, direction, point, normal): + """Return matrix to shear by angle along direction vector on shear plane. + + The shear plane is defined by a point and normal vector. The direction + vector must be orthogonal to the plane's normal vector. + + A point P is transformed by the shear matrix into P" such that + the vector P-P" is parallel to the direction vector and its extent is + given by the angle of P-P'-P", where P' is the orthogonal projection + of P onto the shear plane. + + >>> angle = (random.random() - 0.5) * 4*math.pi + >>> direct = numpy.random.random(3) - 0.5 + >>> point = numpy.random.random(3) - 0.5 + >>> normal = numpy.cross(direct, numpy.random.random(3)) + >>> S = shear_matrix(angle, direct, point, normal) + >>> numpy.allclose(1.0, numpy.linalg.det(S)) + True + + """ + normal = unit_vector(normal[:3]) + direction = unit_vector(direction[:3]) + if abs(numpy.dot(normal, direction)) > 1e-6: + raise ValueError("direction and normal vectors are not orthogonal") + angle = math.tan(angle) + M = numpy.identity(4) + M[:3, :3] += angle * numpy.outer(direction, normal) + M[:3, 3] = -angle * numpy.dot(point[:3], normal) * direction + return M + + +def shear_from_matrix(matrix): + """Return shear angle, direction and plane from shear matrix. + + >>> angle = (random.random() - 0.5) * 4*math.pi + >>> direct = numpy.random.random(3) - 0.5 + >>> point = numpy.random.random(3) - 0.5 + >>> normal = numpy.cross(direct, numpy.random.random(3)) + >>> S0 = shear_matrix(angle, direct, point, normal) + >>> angle, direct, point, normal = shear_from_matrix(S0) + >>> S1 = shear_matrix(angle, direct, point, normal) + >>> is_same_transform(S0, S1) + True + + """ + M = numpy.array(matrix, dtype=numpy.float64, copy=False) + M33 = M[:3, :3] + # normal: cross independent eigenvectors corresponding to the eigenvalue 1 + l, V = numpy.linalg.eig(M33) + i = numpy.where(abs(numpy.real(l) - 1.0) < 1e-4)[0] + if len(i) < 2: + raise ValueError("No two linear independent eigenvectors found %s" % l) + V = numpy.real(V[:, i]).squeeze().T + lenorm = -1.0 + for i0, i1 in ((0, 1), (0, 2), (1, 2)): + n = numpy.cross(V[i0], V[i1]) + l = vector_norm(n) + if l > lenorm: + lenorm = l + normal = n + normal /= lenorm + # direction and angle + direction = numpy.dot(M33 - numpy.identity(3), normal) + angle = vector_norm(direction) + direction /= angle + angle = math.atan(angle) + # point: eigenvector corresponding to eigenvalue 1 + l, V = numpy.linalg.eig(M) + i = numpy.where(abs(numpy.real(l) - 1.0) < 1e-8)[0] + if not len(i): + raise ValueError("no eigenvector corresponding to eigenvalue 1") + point = numpy.real(V[:, i[-1]]).squeeze() + point /= point[3] + return angle, direction, point, normal + + +def decompose_matrix(matrix): + """Return sequence of transformations from transformation matrix. + + matrix : array_like + Non-degenerative homogeneous transformation matrix + + Return tuple of: + scale : vector of 3 scaling factors + shear : list of shear factors for x-y, x-z, y-z axes + angles : list of Euler angles about static x, y, z axes + translate : translation vector along x, y, z axes + perspective : perspective partition of matrix + + Raise ValueError if matrix is of wrong type or degenerative. + + >>> T0 = translation_matrix((1, 2, 3)) + >>> scale, shear, angles, trans, persp = decompose_matrix(T0) + >>> T1 = translation_matrix(trans) + >>> numpy.allclose(T0, T1) + True + >>> S = scale_matrix(0.123) + >>> scale, shear, angles, trans, persp = decompose_matrix(S) + >>> scale[0] + 0.123 + >>> R0 = euler_matrix(1, 2, 3) + >>> scale, shear, angles, trans, persp = decompose_matrix(R0) + >>> R1 = euler_matrix(*angles) + >>> numpy.allclose(R0, R1) + True + + """ + M = numpy.array(matrix, dtype=numpy.float64, copy=True).T + if abs(M[3, 3]) < _EPS: + raise ValueError("M[3, 3] is zero") + M /= M[3, 3] + P = M.copy() + P[:, 3] = 0, 0, 0, 1 + if not numpy.linalg.det(P): + raise ValueError("Matrix is singular") + + scale = numpy.zeros((3, ), dtype=numpy.float64) + shear = [0, 0, 0] + angles = [0, 0, 0] + + if any(abs(M[:3, 3]) > _EPS): + perspective = numpy.dot(M[:, 3], numpy.linalg.inv(P.T)) + M[:, 3] = 0, 0, 0, 1 + else: + perspective = numpy.array((0, 0, 0, 1), dtype=numpy.float64) + + translate = M[3, :3].copy() + M[3, :3] = 0 + + row = M[:3, :3].copy() + scale[0] = vector_norm(row[0]) + row[0] /= scale[0] + shear[0] = numpy.dot(row[0], row[1]) + row[1] -= row[0] * shear[0] + scale[1] = vector_norm(row[1]) + row[1] /= scale[1] + shear[0] /= scale[1] + shear[1] = numpy.dot(row[0], row[2]) + row[2] -= row[0] * shear[1] + shear[2] = numpy.dot(row[1], row[2]) + row[2] -= row[1] * shear[2] + scale[2] = vector_norm(row[2]) + row[2] /= scale[2] + shear[1:] /= scale[2] + + if numpy.dot(row[0], numpy.cross(row[1], row[2])) < 0: + scale *= -1 + row *= -1 + + angles[1] = math.asin(-row[0, 2]) + if math.cos(angles[1]): + angles[0] = math.atan2(row[1, 2], row[2, 2]) + angles[2] = math.atan2(row[0, 1], row[0, 0]) + else: + #angles[0] = math.atan2(row[1, 0], row[1, 1]) + angles[0] = math.atan2(-row[2, 1], row[1, 1]) + angles[2] = 0.0 + + return scale, shear, angles, translate, perspective + + +def compose_matrix(scale=None, shear=None, angles=None, translate=None, + perspective=None): + """Return transformation matrix from sequence of transformations. + + This is the inverse of the decompose_matrix function. + + Sequence of transformations: + scale : vector of 3 scaling factors + shear : list of shear factors for x-y, x-z, y-z axes + angles : list of Euler angles about static x, y, z axes + translate : translation vector along x, y, z axes + perspective : perspective partition of matrix + + >>> scale = numpy.random.random(3) - 0.5 + >>> shear = numpy.random.random(3) - 0.5 + >>> angles = (numpy.random.random(3) - 0.5) * (2*math.pi) + >>> trans = numpy.random.random(3) - 0.5 + >>> persp = numpy.random.random(4) - 0.5 + >>> M0 = compose_matrix(scale, shear, angles, trans, persp) + >>> result = decompose_matrix(M0) + >>> M1 = compose_matrix(*result) + >>> is_same_transform(M0, M1) + True + + """ + M = numpy.identity(4) + if perspective is not None: + P = numpy.identity(4) + P[3, :] = perspective[:4] + M = numpy.dot(M, P) + if translate is not None: + T = numpy.identity(4) + T[:3, 3] = translate[:3] + M = numpy.dot(M, T) + if angles is not None: + R = euler_matrix(angles[0], angles[1], angles[2], 'sxyz') + M = numpy.dot(M, R) + if shear is not None: + Z = numpy.identity(4) + Z[1, 2] = shear[2] + Z[0, 2] = shear[1] + Z[0, 1] = shear[0] + M = numpy.dot(M, Z) + if scale is not None: + S = numpy.identity(4) + S[0, 0] = scale[0] + S[1, 1] = scale[1] + S[2, 2] = scale[2] + M = numpy.dot(M, S) + M /= M[3, 3] + return M + + +def orthogonalization_matrix(lengths, angles): + """Return orthogonalization matrix for crystallographic cell coordinates. + + Angles are expected in degrees. + + The de-orthogonalization matrix is the inverse. + + >>> O = orthogonalization_matrix((10., 10., 10.), (90., 90., 90.)) + >>> numpy.allclose(O[:3, :3], numpy.identity(3, float) * 10) + True + >>> O = orthogonalization_matrix([9.8, 12.0, 15.5], [87.2, 80.7, 69.7]) + >>> numpy.allclose(numpy.sum(O), 43.063229) + True + + """ + a, b, c = lengths + angles = numpy.radians(angles) + sina, sinb, _ = numpy.sin(angles) + cosa, cosb, cosg = numpy.cos(angles) + co = (cosa * cosb - cosg) / (sina * sinb) + return numpy.array(( + ( a*sinb*math.sqrt(1.0-co*co), 0.0, 0.0, 0.0), + (-a*sinb*co, b*sina, 0.0, 0.0), + ( a*cosb, b*cosa, c, 0.0), + ( 0.0, 0.0, 0.0, 1.0)), + dtype=numpy.float64) + + +def superimposition_matrix(v0, v1, scaling=False, usesvd=True): + """Return matrix to transform given vector set into second vector set. + + v0 and v1 are shape (3, \*) or (4, \*) arrays of at least 3 vectors. + + If usesvd is True, the weighted sum of squared deviations (RMSD) is + minimized according to the algorithm by W. Kabsch [8]. Otherwise the + quaternion based algorithm by B. Horn [9] is used (slower when using + this Python implementation). + + The returned matrix performs rotation, translation and uniform scaling + (if specified). + + >>> v0 = numpy.random.rand(3, 10) + >>> M = superimposition_matrix(v0, v0) + >>> numpy.allclose(M, numpy.identity(4)) + True + >>> R = random_rotation_matrix(numpy.random.random(3)) + >>> v0 = ((1,0,0), (0,1,0), (0,0,1), (1,1,1)) + >>> v1 = numpy.dot(R, v0) + >>> M = superimposition_matrix(v0, v1) + >>> numpy.allclose(v1, numpy.dot(M, v0)) + True + >>> v0 = (numpy.random.rand(4, 100) - 0.5) * 20.0 + >>> v0[3] = 1.0 + >>> v1 = numpy.dot(R, v0) + >>> M = superimposition_matrix(v0, v1) + >>> numpy.allclose(v1, numpy.dot(M, v0)) + True + >>> S = scale_matrix(random.random()) + >>> T = translation_matrix(numpy.random.random(3)-0.5) + >>> M = concatenate_matrices(T, R, S) + >>> v1 = numpy.dot(M, v0) + >>> v0[:3] += numpy.random.normal(0.0, 1e-9, 300).reshape(3, -1) + >>> M = superimposition_matrix(v0, v1, scaling=True) + >>> numpy.allclose(v1, numpy.dot(M, v0)) + True + >>> M = superimposition_matrix(v0, v1, scaling=True, usesvd=False) + >>> numpy.allclose(v1, numpy.dot(M, v0)) + True + >>> v = numpy.empty((4, 100, 3), dtype=numpy.float64) + >>> v[:, :, 0] = v0 + >>> M = superimposition_matrix(v0, v1, scaling=True, usesvd=False) + >>> numpy.allclose(v1, numpy.dot(M, v[:, :, 0])) + True + + """ + v0 = numpy.array(v0, dtype=numpy.float64, copy=False)[:3] + v1 = numpy.array(v1, dtype=numpy.float64, copy=False)[:3] + + if v0.shape != v1.shape or v0.shape[1] < 3: + raise ValueError("Vector sets are of wrong shape or type.") + + # move centroids to origin + t0 = numpy.mean(v0, axis=1) + t1 = numpy.mean(v1, axis=1) + v0 = v0 - t0.reshape(3, 1) + v1 = v1 - t1.reshape(3, 1) + + if usesvd: + # Singular Value Decomposition of covariance matrix + u, s, vh = numpy.linalg.svd(numpy.dot(v1, v0.T)) + # rotation matrix from SVD orthonormal bases + R = numpy.dot(u, vh) + if numpy.linalg.det(R) < 0.0: + # R does not constitute right handed system + R -= numpy.outer(u[:, 2], vh[2, :]*2.0) + s[-1] *= -1.0 + # homogeneous transformation matrix + M = numpy.identity(4) + M[:3, :3] = R + else: + # compute symmetric matrix N + xx, yy, zz = numpy.sum(v0 * v1, axis=1) + xy, yz, zx = numpy.sum(v0 * numpy.roll(v1, -1, axis=0), axis=1) + xz, yx, zy = numpy.sum(v0 * numpy.roll(v1, -2, axis=0), axis=1) + N = ((xx+yy+zz, yz-zy, zx-xz, xy-yx), + (yz-zy, xx-yy-zz, xy+yx, zx+xz), + (zx-xz, xy+yx, -xx+yy-zz, yz+zy), + (xy-yx, zx+xz, yz+zy, -xx-yy+zz)) + # quaternion: eigenvector corresponding to most positive eigenvalue + l, V = numpy.linalg.eig(N) + q = V[:, numpy.argmax(l)] + q /= vector_norm(q) # unit quaternion + q = numpy.roll(q, -1) # move w component to end + # homogeneous transformation matrix + M = quaternion_matrix(q) + + # scale: ratio of rms deviations from centroid + if scaling: + v0 *= v0 + v1 *= v1 + M[:3, :3] *= math.sqrt(numpy.sum(v1) / numpy.sum(v0)) + + # translation + M[:3, 3] = t1 + T = numpy.identity(4) + T[:3, 3] = -t0 + M = numpy.dot(M, T) + return M + + +def euler_matrix(ai, aj, ak, axes='sxyz'): + """Return homogeneous rotation matrix from Euler angles and axis sequence. + + ai, aj, ak : Euler's roll, pitch and yaw angles + axes : One of 24 axis sequences as string or encoded tuple + + >>> R = euler_matrix(1, 2, 3, 'syxz') + >>> numpy.allclose(numpy.sum(R[0]), -1.34786452) + True + >>> R = euler_matrix(1, 2, 3, (0, 1, 0, 1)) + >>> numpy.allclose(numpy.sum(R[0]), -0.383436184) + True + >>> ai, aj, ak = (4.0*math.pi) * (numpy.random.random(3) - 0.5) + >>> for axes in _AXES2TUPLE.keys(): + ... R = euler_matrix(ai, aj, ak, axes) + >>> for axes in _TUPLE2AXES.keys(): + ... R = euler_matrix(ai, aj, ak, axes) + + """ + try: + firstaxis, parity, repetition, frame = _AXES2TUPLE[axes] + except (AttributeError, KeyError): + _ = _TUPLE2AXES[axes] + firstaxis, parity, repetition, frame = axes + + i = firstaxis + j = _NEXT_AXIS[i+parity] + k = _NEXT_AXIS[i-parity+1] + + if frame: + ai, ak = ak, ai + if parity: + ai, aj, ak = -ai, -aj, -ak + + si, sj, sk = math.sin(ai), math.sin(aj), math.sin(ak) + ci, cj, ck = math.cos(ai), math.cos(aj), math.cos(ak) + cc, cs = ci*ck, ci*sk + sc, ss = si*ck, si*sk + + M = numpy.identity(4) + if repetition: + M[i, i] = cj + M[i, j] = sj*si + M[i, k] = sj*ci + M[j, i] = sj*sk + M[j, j] = -cj*ss+cc + M[j, k] = -cj*cs-sc + M[k, i] = -sj*ck + M[k, j] = cj*sc+cs + M[k, k] = cj*cc-ss + else: + M[i, i] = cj*ck + M[i, j] = sj*sc-cs + M[i, k] = sj*cc+ss + M[j, i] = cj*sk + M[j, j] = sj*ss+cc + M[j, k] = sj*cs-sc + M[k, i] = -sj + M[k, j] = cj*si + M[k, k] = cj*ci + return M + + +def euler_from_matrix(matrix, axes='sxyz'): + """Return Euler angles from rotation matrix for specified axis sequence. + + axes : One of 24 axis sequences as string or encoded tuple + + Note that many Euler angle triplets can describe one matrix. + + >>> R0 = euler_matrix(1, 2, 3, 'syxz') + >>> al, be, ga = euler_from_matrix(R0, 'syxz') + >>> R1 = euler_matrix(al, be, ga, 'syxz') + >>> numpy.allclose(R0, R1) + True + >>> angles = (4.0*math.pi) * (numpy.random.random(3) - 0.5) + >>> for axes in _AXES2TUPLE.keys(): + ... R0 = euler_matrix(axes=axes, *angles) + ... R1 = euler_matrix(axes=axes, *euler_from_matrix(R0, axes)) + ... if not numpy.allclose(R0, R1): print axes, "failed" + + """ + try: + firstaxis, parity, repetition, frame = _AXES2TUPLE[axes.lower()] + except (AttributeError, KeyError): + _ = _TUPLE2AXES[axes] + firstaxis, parity, repetition, frame = axes + + i = firstaxis + j = _NEXT_AXIS[i+parity] + k = _NEXT_AXIS[i-parity+1] + + M = numpy.array(matrix, dtype=numpy.float64, copy=False)[:3, :3] + if repetition: + sy = math.sqrt(M[i, j]*M[i, j] + M[i, k]*M[i, k]) + if sy > _EPS: + ax = math.atan2( M[i, j], M[i, k]) + ay = math.atan2( sy, M[i, i]) + az = math.atan2( M[j, i], -M[k, i]) + else: + ax = math.atan2(-M[j, k], M[j, j]) + ay = math.atan2( sy, M[i, i]) + az = 0.0 + else: + cy = math.sqrt(M[i, i]*M[i, i] + M[j, i]*M[j, i]) + if cy > _EPS: + ax = math.atan2( M[k, j], M[k, k]) + ay = math.atan2(-M[k, i], cy) + az = math.atan2( M[j, i], M[i, i]) + else: + ax = math.atan2(-M[j, k], M[j, j]) + ay = math.atan2(-M[k, i], cy) + az = 0.0 + + if parity: + ax, ay, az = -ax, -ay, -az + if frame: + ax, az = az, ax + return ax, ay, az + + +def euler_from_quaternion(quaternion, axes='sxyz'): + """Return Euler angles from quaternion for specified axis sequence. + + >>> angles = euler_from_quaternion([0.06146124, 0, 0, 0.99810947]) + >>> numpy.allclose(angles, [0.123, 0, 0]) + True + + """ + return euler_from_matrix(quaternion_matrix(quaternion), axes) + + +def quaternion_from_euler(ai, aj, ak, axes='sxyz'): + """Return quaternion from Euler angles and axis sequence. + + ai, aj, ak : Euler's roll, pitch and yaw angles + axes : One of 24 axis sequences as string or encoded tuple + + >>> q = quaternion_from_euler(1, 2, 3, 'ryxz') + >>> numpy.allclose(q, [0.310622, -0.718287, 0.444435, 0.435953]) + True + + """ + try: + firstaxis, parity, repetition, frame = _AXES2TUPLE[axes.lower()] + except (AttributeError, KeyError): + _ = _TUPLE2AXES[axes] + firstaxis, parity, repetition, frame = axes + + i = firstaxis + j = _NEXT_AXIS[i+parity] + k = _NEXT_AXIS[i-parity+1] + + if frame: + ai, ak = ak, ai + if parity: + aj = -aj + + ai /= 2.0 + aj /= 2.0 + ak /= 2.0 + ci = math.cos(ai) + si = math.sin(ai) + cj = math.cos(aj) + sj = math.sin(aj) + ck = math.cos(ak) + sk = math.sin(ak) + cc = ci*ck + cs = ci*sk + sc = si*ck + ss = si*sk + + quaternion = numpy.empty((4, ), dtype=numpy.float64) + if repetition: + quaternion[i] = cj*(cs + sc) + quaternion[j] = sj*(cc + ss) + quaternion[k] = sj*(cs - sc) + quaternion[3] = cj*(cc - ss) + else: + quaternion[i] = cj*sc - sj*cs + quaternion[j] = cj*ss + sj*cc + quaternion[k] = cj*cs - sj*sc + quaternion[3] = cj*cc + sj*ss + if parity: + quaternion[j] *= -1 + + return quaternion + + +def quaternion_about_axis(angle, axis): + """Return quaternion for rotation about axis. + + >>> q = quaternion_about_axis(0.123, (1, 0, 0)) + >>> numpy.allclose(q, [0.06146124, 0, 0, 0.99810947]) + True + + """ + quaternion = numpy.zeros((4, ), dtype=numpy.float64) + quaternion[:3] = axis[:3] + qlen = vector_norm(quaternion) + if qlen > _EPS: + quaternion *= math.sin(angle/2.0) / qlen + quaternion[3] = math.cos(angle/2.0) + return quaternion + + +def quaternion_matrix(quaternion): + """Return homogeneous rotation matrix from quaternion. + + >>> R = quaternion_matrix([0.06146124, 0, 0, 0.99810947]) + >>> numpy.allclose(R, rotation_matrix(0.123, (1, 0, 0))) + True + + """ + q = numpy.array(quaternion[:4], dtype=numpy.float64, copy=True) + nq = numpy.dot(q, q) + if nq < _EPS: + return numpy.identity(4) + q *= math.sqrt(2.0 / nq) + q = numpy.outer(q, q) + return numpy.array(( + (1.0-q[1, 1]-q[2, 2], q[0, 1]-q[2, 3], q[0, 2]+q[1, 3], 0.0), + ( q[0, 1]+q[2, 3], 1.0-q[0, 0]-q[2, 2], q[1, 2]-q[0, 3], 0.0), + ( q[0, 2]-q[1, 3], q[1, 2]+q[0, 3], 1.0-q[0, 0]-q[1, 1], 0.0), + ( 0.0, 0.0, 0.0, 1.0) + ), dtype=numpy.float64) + + +def quaternion_from_matrix(matrix): + """Return quaternion from rotation matrix. + + >>> R = rotation_matrix(0.123, (1, 2, 3)) + >>> q = quaternion_from_matrix(R) + >>> numpy.allclose(q, [0.0164262, 0.0328524, 0.0492786, 0.9981095]) + True + + """ + q = numpy.empty((4, ), dtype=numpy.float64) + M = numpy.array(matrix, dtype=numpy.float64, copy=False)[:4, :4] + t = numpy.trace(M) + if t > M[3, 3]: + q[3] = t + q[2] = M[1, 0] - M[0, 1] + q[1] = M[0, 2] - M[2, 0] + q[0] = M[2, 1] - M[1, 2] + else: + i, j, k = 0, 1, 2 + if M[1, 1] > M[0, 0]: + i, j, k = 1, 2, 0 + if M[2, 2] > M[i, i]: + i, j, k = 2, 0, 1 + t = M[i, i] - (M[j, j] + M[k, k]) + M[3, 3] + q[i] = t + q[j] = M[i, j] + M[j, i] + q[k] = M[k, i] + M[i, k] + q[3] = M[k, j] - M[j, k] + q *= 0.5 / math.sqrt(t * M[3, 3]) + return q + + +def quaternion_multiply(quaternion1, quaternion0): + """Return multiplication of two quaternions. + + >>> q = quaternion_multiply([1, -2, 3, 4], [-5, 6, 7, 8]) + >>> numpy.allclose(q, [-44, -14, 48, 28]) + True + + """ + x0, y0, z0, w0 = quaternion0 + x1, y1, z1, w1 = quaternion1 + return numpy.array(( + x1*w0 + y1*z0 - z1*y0 + w1*x0, + -x1*z0 + y1*w0 + z1*x0 + w1*y0, + x1*y0 - y1*x0 + z1*w0 + w1*z0, + -x1*x0 - y1*y0 - z1*z0 + w1*w0), dtype=numpy.float64) + + +def quaternion_conjugate(quaternion): + """Return conjugate of quaternion. + + >>> q0 = random_quaternion() + >>> q1 = quaternion_conjugate(q0) + >>> q1[3] == q0[3] and all(q1[:3] == -q0[:3]) + True + + """ + return numpy.array((-quaternion[0], -quaternion[1], + -quaternion[2], quaternion[3]), dtype=numpy.float64) + + +def quaternion_inverse(quaternion): + """Return inverse of quaternion. + + >>> q0 = random_quaternion() + >>> q1 = quaternion_inverse(q0) + >>> numpy.allclose(quaternion_multiply(q0, q1), [0, 0, 0, 1]) + True + + """ + return quaternion_conjugate(quaternion) / numpy.dot(quaternion, quaternion) + + +def quaternion_slerp(quat0, quat1, fraction, spin=0, shortestpath=True): + """Return spherical linear interpolation between two quaternions. + + >>> q0 = random_quaternion() + >>> q1 = random_quaternion() + >>> q = quaternion_slerp(q0, q1, 0.0) + >>> numpy.allclose(q, q0) + True + >>> q = quaternion_slerp(q0, q1, 1.0, 1) + >>> numpy.allclose(q, q1) + True + >>> q = quaternion_slerp(q0, q1, 0.5) + >>> angle = math.acos(numpy.dot(q0, q)) + >>> numpy.allclose(2.0, math.acos(numpy.dot(q0, q1)) / angle) or \ + numpy.allclose(2.0, math.acos(-numpy.dot(q0, q1)) / angle) + True + + """ + q0 = unit_vector(quat0[:4]) + q1 = unit_vector(quat1[:4]) + if fraction == 0.0: + return q0 + elif fraction == 1.0: + return q1 + d = numpy.dot(q0, q1) + if abs(abs(d) - 1.0) < _EPS: + return q0 + if shortestpath and d < 0.0: + # invert rotation + d = -d + q1 *= -1.0 + angle = math.acos(d) + spin * math.pi + if abs(angle) < _EPS: + return q0 + isin = 1.0 / math.sin(angle) + q0 *= math.sin((1.0 - fraction) * angle) * isin + q1 *= math.sin(fraction * angle) * isin + q0 += q1 + return q0 + + +def random_quaternion(rand=None): + """Return uniform random unit quaternion. + + rand: array like or None + Three independent random variables that are uniformly distributed + between 0 and 1. + + >>> q = random_quaternion() + >>> numpy.allclose(1.0, vector_norm(q)) + True + >>> q = random_quaternion(numpy.random.random(3)) + >>> q.shape + (4,) + + """ + if rand is None: + rand = numpy.random.rand(3) + else: + assert len(rand) == 3 + r1 = numpy.sqrt(1.0 - rand[0]) + r2 = numpy.sqrt(rand[0]) + pi2 = math.pi * 2.0 + t1 = pi2 * rand[1] + t2 = pi2 * rand[2] + return numpy.array((numpy.sin(t1)*r1, + numpy.cos(t1)*r1, + numpy.sin(t2)*r2, + numpy.cos(t2)*r2), dtype=numpy.float64) + + +def random_rotation_matrix(rand=None): + """Return uniform random rotation matrix. + + rnd: array like + Three independent random variables that are uniformly distributed + between 0 and 1 for each returned quaternion. + + >>> R = random_rotation_matrix() + >>> numpy.allclose(numpy.dot(R.T, R), numpy.identity(4)) + True + + """ + return quaternion_matrix(random_quaternion(rand)) + + +class Arcball(object): + """Virtual Trackball Control. + + >>> ball = Arcball() + >>> ball = Arcball(initial=numpy.identity(4)) + >>> ball.place([320, 320], 320) + >>> ball.down([500, 250]) + >>> ball.drag([475, 275]) + >>> R = ball.matrix() + >>> numpy.allclose(numpy.sum(R), 3.90583455) + True + >>> ball = Arcball(initial=[0, 0, 0, 1]) + >>> ball.place([320, 320], 320) + >>> ball.setaxes([1,1,0], [-1, 1, 0]) + >>> ball.setconstrain(True) + >>> ball.down([400, 200]) + >>> ball.drag([200, 400]) + >>> R = ball.matrix() + >>> numpy.allclose(numpy.sum(R), 0.2055924) + True + >>> ball.next() + + """ + + def __init__(self, initial=None): + """Initialize virtual trackball control. + + initial : quaternion or rotation matrix + + """ + self._axis = None + self._axes = None + self._radius = 1.0 + self._center = [0.0, 0.0] + self._vdown = numpy.array([0, 0, 1], dtype=numpy.float64) + self._constrain = False + + if initial is None: + self._qdown = numpy.array([0, 0, 0, 1], dtype=numpy.float64) + else: + initial = numpy.array(initial, dtype=numpy.float64) + if initial.shape == (4, 4): + self._qdown = quaternion_from_matrix(initial) + elif initial.shape == (4, ): + initial /= vector_norm(initial) + self._qdown = initial + else: + raise ValueError("initial not a quaternion or matrix.") + + self._qnow = self._qpre = self._qdown + + def place(self, center, radius): + """Place Arcball, e.g. when window size changes. + + center : sequence[2] + Window coordinates of trackball center. + radius : float + Radius of trackball in window coordinates. + + """ + self._radius = float(radius) + self._center[0] = center[0] + self._center[1] = center[1] + + def setaxes(self, *axes): + """Set axes to constrain rotations.""" + if axes is None: + self._axes = None + else: + self._axes = [unit_vector(axis) for axis in axes] + + def setconstrain(self, constrain): + """Set state of constrain to axis mode.""" + self._constrain = constrain == True + + def getconstrain(self): + """Return state of constrain to axis mode.""" + return self._constrain + + def down(self, point): + """Set initial cursor window coordinates and pick constrain-axis.""" + self._vdown = arcball_map_to_sphere(point, self._center, self._radius) + self._qdown = self._qpre = self._qnow + + if self._constrain and self._axes is not None: + self._axis = arcball_nearest_axis(self._vdown, self._axes) + self._vdown = arcball_constrain_to_axis(self._vdown, self._axis) + else: + self._axis = None + + def drag(self, point): + """Update current cursor window coordinates.""" + vnow = arcball_map_to_sphere(point, self._center, self._radius) + + if self._axis is not None: + vnow = arcball_constrain_to_axis(vnow, self._axis) + + self._qpre = self._qnow + + t = numpy.cross(self._vdown, vnow) + if numpy.dot(t, t) < _EPS: + self._qnow = self._qdown + else: + q = [t[0], t[1], t[2], numpy.dot(self._vdown, vnow)] + self._qnow = quaternion_multiply(q, self._qdown) + + def next(self, acceleration=0.0): + """Continue rotation in direction of last drag.""" + q = quaternion_slerp(self._qpre, self._qnow, 2.0+acceleration, False) + self._qpre, self._qnow = self._qnow, q + + def matrix(self): + """Return homogeneous rotation matrix.""" + return quaternion_matrix(self._qnow) + + +def arcball_map_to_sphere(point, center, radius): + """Return unit sphere coordinates from window coordinates.""" + v = numpy.array(((point[0] - center[0]) / radius, + (center[1] - point[1]) / radius, + 0.0), dtype=numpy.float64) + n = v[0]*v[0] + v[1]*v[1] + if n > 1.0: + v /= math.sqrt(n) # position outside of sphere + else: + v[2] = math.sqrt(1.0 - n) + return v + + +def arcball_constrain_to_axis(point, axis): + """Return sphere point perpendicular to axis.""" + v = numpy.array(point, dtype=numpy.float64, copy=True) + a = numpy.array(axis, dtype=numpy.float64, copy=True) + v -= a * numpy.dot(a, v) # on plane + n = vector_norm(v) + if n > _EPS: + if v[2] < 0.0: + v *= -1.0 + v /= n + return v + if a[2] == 1.0: + return numpy.array([1, 0, 0], dtype=numpy.float64) + return unit_vector([-a[1], a[0], 0]) + + +def arcball_nearest_axis(point, axes): + """Return axis, which arc is nearest to point.""" + point = numpy.array(point, dtype=numpy.float64, copy=False) + nearest = None + mx = -1.0 + for axis in axes: + t = numpy.dot(arcball_constrain_to_axis(point, axis), point) + if t > mx: + nearest = axis + mx = t + return nearest + + +# epsilon for testing whether a number is close to zero +_EPS = numpy.finfo(float).eps * 4.0 + +# axis sequences for Euler angles +_NEXT_AXIS = [1, 2, 0, 1] + +# map axes strings to/from tuples of inner axis, parity, repetition, frame +_AXES2TUPLE = { + 'sxyz': (0, 0, 0, 0), 'sxyx': (0, 0, 1, 0), 'sxzy': (0, 1, 0, 0), + 'sxzx': (0, 1, 1, 0), 'syzx': (1, 0, 0, 0), 'syzy': (1, 0, 1, 0), + 'syxz': (1, 1, 0, 0), 'syxy': (1, 1, 1, 0), 'szxy': (2, 0, 0, 0), + 'szxz': (2, 0, 1, 0), 'szyx': (2, 1, 0, 0), 'szyz': (2, 1, 1, 0), + 'rzyx': (0, 0, 0, 1), 'rxyx': (0, 0, 1, 1), 'ryzx': (0, 1, 0, 1), + 'rxzx': (0, 1, 1, 1), 'rxzy': (1, 0, 0, 1), 'ryzy': (1, 0, 1, 1), + 'rzxy': (1, 1, 0, 1), 'ryxy': (1, 1, 1, 1), 'ryxz': (2, 0, 0, 1), + 'rzxz': (2, 0, 1, 1), 'rxyz': (2, 1, 0, 1), 'rzyz': (2, 1, 1, 1)} + +_TUPLE2AXES = dict((v, k) for k, v in _AXES2TUPLE.items()) + +# helper functions + +def vector_norm(data, axis=None, out=None): + """Return length, i.e. eucledian norm, of ndarray along axis. + + >>> v = numpy.random.random(3) + >>> n = vector_norm(v) + >>> numpy.allclose(n, numpy.linalg.norm(v)) + True + >>> v = numpy.random.rand(6, 5, 3) + >>> n = vector_norm(v, axis=-1) + >>> numpy.allclose(n, numpy.sqrt(numpy.sum(v*v, axis=2))) + True + >>> n = vector_norm(v, axis=1) + >>> numpy.allclose(n, numpy.sqrt(numpy.sum(v*v, axis=1))) + True + >>> v = numpy.random.rand(5, 4, 3) + >>> n = numpy.empty((5, 3), dtype=numpy.float64) + >>> vector_norm(v, axis=1, out=n) + >>> numpy.allclose(n, numpy.sqrt(numpy.sum(v*v, axis=1))) + True + >>> vector_norm([]) + 0.0 + >>> vector_norm([1.0]) + 1.0 + + """ + data = numpy.array(data, dtype=numpy.float64, copy=True) + if out is None: + if data.ndim == 1: + return math.sqrt(numpy.dot(data, data)) + data *= data + out = numpy.atleast_1d(numpy.sum(data, axis=axis)) + numpy.sqrt(out, out) + return out + else: + data *= data + numpy.sum(data, axis=axis, out=out) + numpy.sqrt(out, out) + + +def unit_vector(data, axis=None, out=None): + """Return ndarray normalized by length, i.e. eucledian norm, along axis. + + >>> v0 = numpy.random.random(3) + >>> v1 = unit_vector(v0) + >>> numpy.allclose(v1, v0 / numpy.linalg.norm(v0)) + True + >>> v0 = numpy.random.rand(5, 4, 3) + >>> v1 = unit_vector(v0, axis=-1) + >>> v2 = v0 / numpy.expand_dims(numpy.sqrt(numpy.sum(v0*v0, axis=2)), 2) + >>> numpy.allclose(v1, v2) + True + >>> v1 = unit_vector(v0, axis=1) + >>> v2 = v0 / numpy.expand_dims(numpy.sqrt(numpy.sum(v0*v0, axis=1)), 1) + >>> numpy.allclose(v1, v2) + True + >>> v1 = numpy.empty((5, 4, 3), dtype=numpy.float64) + >>> unit_vector(v0, axis=1, out=v1) + >>> numpy.allclose(v1, v2) + True + >>> list(unit_vector([])) + [] + >>> list(unit_vector([1.0])) + [1.0] + + """ + if out is None: + data = numpy.array(data, dtype=numpy.float64, copy=True) + if data.ndim == 1: + data /= math.sqrt(numpy.dot(data, data)) + return data + else: + if out is not data: + out[:] = numpy.array(data, copy=False) + data = out + length = numpy.atleast_1d(numpy.sum(data*data, axis)) + numpy.sqrt(length, length) + if axis is not None: + length = numpy.expand_dims(length, axis) + data /= length + if out is None: + return data + + +def random_vector(size): + """Return array of random doubles in the half-open interval [0.0, 1.0). + + >>> v = random_vector(10000) + >>> numpy.all(v >= 0.0) and numpy.all(v < 1.0) + True + >>> v0 = random_vector(10) + >>> v1 = random_vector(10) + >>> numpy.any(v0 == v1) + False + + """ + return numpy.random.random(size) + + +def inverse_matrix(matrix): + """Return inverse of square transformation matrix. + + >>> M0 = random_rotation_matrix() + >>> M1 = inverse_matrix(M0.T) + >>> numpy.allclose(M1, numpy.linalg.inv(M0.T)) + True + >>> for size in range(1, 7): + ... M0 = numpy.random.rand(size, size) + ... M1 = inverse_matrix(M0) + ... if not numpy.allclose(M1, numpy.linalg.inv(M0)): print size + + """ + return numpy.linalg.inv(matrix) + + +def concatenate_matrices(*matrices): + """Return concatenation of series of transformation matrices. + + >>> M = numpy.random.rand(16).reshape((4, 4)) - 0.5 + >>> numpy.allclose(M, concatenate_matrices(M)) + True + >>> numpy.allclose(numpy.dot(M, M.T), concatenate_matrices(M, M.T)) + True + + """ + M = numpy.identity(4) + for i in matrices: + M = numpy.dot(M, i) + return M + + +def is_same_transform(matrix0, matrix1): + """Return True if two matrices perform same transformation. + + >>> is_same_transform(numpy.identity(4), numpy.identity(4)) + True + >>> is_same_transform(numpy.identity(4), random_rotation_matrix()) + False + + """ + matrix0 = numpy.array(matrix0, dtype=numpy.float64, copy=True) + matrix0 /= matrix0[3, 3] + matrix1 = numpy.array(matrix1, dtype=numpy.float64, copy=True) + matrix1 /= matrix1[3, 3] + return numpy.allclose(matrix0, matrix1) + + +def _import_module(module_name, warn=True, prefix='_py_', ignore='_'): + """Try import all public attributes from module into global namespace. + + Existing attributes with name clashes are renamed with prefix. + Attributes starting with underscore are ignored by default. + + Return True on successful import. + + """ + try: + module = __import__(module_name) + except ImportError: + if warn: + warnings.warn("Failed to import module " + module_name) + else: + for attr in dir(module): + if ignore and attr.startswith(ignore): + continue + if prefix: + if attr in globals(): + globals()[prefix + attr] = globals()[attr] + elif warn: + warnings.warn("No Python implementation of " + attr) + globals()[attr] = getattr(module, attr) + return True From 06fc901b7eefdd3fd9b0ed2e7dcf811d97afb719 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9verin=20Lemaignan?= Date: Mon, 19 Dec 2016 21:19:55 +0000 Subject: [PATCH 2/2] [pyassimp] Updated README --- port/PyAssimp/3d_viewer_screenshot.png | Bin 0 -> 51610 bytes port/PyAssimp/README.md | 43 ++++++++++++++++++------- 2 files changed, 32 insertions(+), 11 deletions(-) create mode 100644 port/PyAssimp/3d_viewer_screenshot.png diff --git a/port/PyAssimp/3d_viewer_screenshot.png b/port/PyAssimp/3d_viewer_screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..2031faf5a3f3c45e3f9f7ff7cd14906ef1e63ca9 GIT binary patch literal 51610 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%0AGcK1pII;oqJw>?PqxDt}0 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 S+$;g`pM&jI<_&9B!v6t5hU{Aa literal 0 HcmV?d00001 diff --git a/port/PyAssimp/README.md b/port/PyAssimp/README.md index 201d3ed7d..0000f9386 100644 --- a/port/PyAssimp/README.md +++ b/port/PyAssimp/README.md @@ -11,14 +11,32 @@ Note that pyassimp is not complete. Many ASSIMP features are missing. USAGE ----- -To get started with pyAssimp, examine the `sample.py` script in `scripts/`, -which illustrates the basic usage. All Assimp data structures are wrapped using -ctypes. All the data+length fields in Assimp's data structures (such as -`aiMesh::mNumVertices`, `aiMesh::mVertices`) are replaced by simple python -lists, so you can call len() on them to get their respective size and access -members using []. +### Complete example: 3D viewer -For example, to load a file named 'hello.3ds' and print the first +`pyassimp` comes with a simple 3D viewer that shows how to load and display a 3D +model using a shader-based OpenGL pipeline. + +![Screenshot](3d_viewer_screenshot.png) + +To use it, from within `/port/PyAssimp`: + +``` +$ cd scripts +$ python ./3D-viewer +``` + +You can use this code as starting point in your applications. + +### Writing your own code + +To get started with `pyassimp`, examine the simpler `sample.py` script in `scripts/`, +which illustrates the basic usage. All Assimp data structures are wrapped using +`ctypes`. All the data+length fields in Assimp's data structures (such as +`aiMesh::mNumVertices`, `aiMesh::mVertices`) are replaced by simple python +lists, so you can call `len()` on them to get their respective size and access +members using `[]`. + +For example, to load a file named `hello.3ds` and print the first vertex of the first mesh, you would do (proper error handling substituted by assertions ...): @@ -58,16 +76,19 @@ INSTALL Install `pyassimp` by running: -> python setup.py install +``` +$ python setup.py install +``` PyAssimp requires a assimp dynamic library (`DLL` on windows, -`.so` on linux :-) in order to work. The default search directories +`.so` on linux, `.dynlib` on macOS) in order to work. The default search directories are: - the current directory -- on linux additionally: `/usr/lib` and `/usr/local/lib` +- on linux additionally: `/usr/lib`, `/usr/local/lib`, + `/usr/lib/x86_64-linux-gnu` -To build that library, refer to the Assimp master INSTALL +To build that library, refer to the Assimp master `INSTALL` instructions. To look in more places, edit `./pyassimp/helper.py`. There's an `additional_dirs` list waiting for your entries.