From d8e4911fcd5fd8dbb86f059c0c53824e2b48ddc2 Mon Sep 17 00:00:00 2001 From: "Yoonyoung (Jamie) Cho" Date: Fri, 25 Dec 2020 00:45:02 -0500 Subject: [PATCH] Fixed GLViewWidget Gimbal Lock with Quaternion Parametrization (#909) * Fixed GLViewWidget Gimbal Lock with Quaternion Parametrization * fixed rotation name * fixes extraneous pos argument and restore compatibility * remove redundant getAxisAngle() in rotation (for Qt4 compatibility) * GLViewWidget takes rotationMethod parameter * Avoid merge conflict * Fix docstring Co-authored-by: Ogi Moore --- pyqtgraph/opengl/GLViewWidget.py | 128 ++++++++++++++++++++++--------- 1 file changed, 91 insertions(+), 37 deletions(-) diff --git a/pyqtgraph/opengl/GLViewWidget.py b/pyqtgraph/opengl/GLViewWidget.py index 8dca4ed0..5129704f 100644 --- a/pyqtgraph/opengl/GLViewWidget.py +++ b/pyqtgraph/opengl/GLViewWidget.py @@ -10,18 +10,26 @@ from .. import functions as fn ShareWidget = None class GLViewWidget(QtOpenGL.QGLWidget): - """ - Basic widget for displaying 3D data + + def __init__(self, parent=None, devicePixelRatio=None, rotationMethod='euler'): + """ + Basic widget for displaying 3D data - Rotation/scale controls - Axis/grid display - Export options - High-DPI displays: Qt5 should automatically detect the correct resolution. - For Qt4, specify the ``devicePixelRatio`` argument when initializing the - widget (usually this value is 1-2). - """ - - def __init__(self, parent=None, devicePixelRatio=None): + ================ ============================================================== + **Arguments:** + parent (QObject, optional): Parent QObject. Defaults to None. + devicePixelRatio (float, optional): High-DPI displays Qt5 should automatically + detect the correct resolution. For Qt4, specify the + ``devicePixelRatio`` argument when initializing the widget + (usually this value is 1-2). Defaults to None. + rotationMethod (str): Mechanimsm to drive the rotation method, options are + 'euler' and 'quaternion'. Defaults to 'euler'. + ================ ============================================================== + """ + global ShareWidget if ShareWidget is None: @@ -31,8 +39,21 @@ class GLViewWidget(QtOpenGL.QGLWidget): QtOpenGL.QGLWidget.__init__(self, parent, ShareWidget) self.setFocusPolicy(QtCore.Qt.ClickFocus) + + if rotationMethod not in {"euler", "quaternion"}: + raise RuntimeError("Rotation method should be either 'euler' or 'quaternion'") + self.opts = { - 'devicePixelRatio': devicePixelRatio + 'center': Vector(0,0,0), ## will always appear at the center of the widget + 'rotation' : QtGui.QQuaternion(1,0,0,0), ## camera rotation (quaternion:wxyz) + 'distance': 10.0, ## distance of camera from center + 'fov': 60, ## horizontal field of view in degrees + 'elevation': 30, ## camera's angle of elevation in degrees + 'azimuth': 45, ## camera's azimuthal angle in degrees + ## (rotation around z-axis 0 points along x-axis) + 'viewport': None, ## glViewport params; None == whole widget + 'devicePixelRatio': devicePixelRatio, + 'rotationMethod': rotationMethod } self.reset() self.items = [] @@ -162,8 +183,12 @@ class GLViewWidget(QtOpenGL.QGLWidget): def viewMatrix(self): tr = QtGui.QMatrix4x4() tr.translate( 0.0, 0.0, -self.opts['distance']) - tr.rotate(self.opts['elevation']-90, 1, 0, 0) - tr.rotate(self.opts['azimuth']+90, 0, 0, -1) + if self.opts['rotationMethod'] == 'quaternion': + tr.rotate(self.opts['rotation']) + else: + # default rotation method + tr.rotate(self.opts['elevation']-90, 1, 0, 0) + tr.rotate(self.opts['azimuth']+90, 0, 0, -1) center = self.opts['center'] tr.translate(-center.x(), -center.y(), -center.z()) return tr @@ -251,36 +276,52 @@ class GLViewWidget(QtOpenGL.QGLWidget): glMatrixMode(GL_MODELVIEW) glPopMatrix() - def setCameraPosition(self, pos=None, distance=None, elevation=None, azimuth=None): + def setCameraPosition(self, pos=None, distance=None, elevation=None, azimuth=None, rotation=None): if pos is not None: self.opts['center'] = pos if distance is not None: self.opts['distance'] = distance - if elevation is not None: - self.opts['elevation'] = elevation - if azimuth is not None: - self.opts['azimuth'] = azimuth + if rotation is not None: + # set with quaternion + self.opts['rotation'] = rotation + else: + # set with elevation-azimuth, restored for compatibility + eu = self.opts['rotation'].toEulerAngles() + if azimuth is not None: + eu.setZ(-azimuth-90) + if elevation is not None: + eu.setX(elevation-90) + self.opts['rotation'] = QtGui.QQuaternion.fromEulerAngles(eu) self.update() def cameraPosition(self): """Return current position of camera based on center, dist, elevation, and azimuth""" center = self.opts['center'] dist = self.opts['distance'] - elev = self.opts['elevation'] * np.pi/180. - azim = self.opts['azimuth'] * np.pi/180. - - pos = Vector( - center.x() + dist * np.cos(elev) * np.cos(azim), - center.y() + dist * np.cos(elev) * np.sin(azim), - center.z() + dist * np.sin(elev) - ) - + if self.opts['rotationMethod'] == "quaternion": + pos = center - self.opts['rotation'].rotatedVector( Vector(0,0,dist) ) + else: + # using 'euler' rotation method + elev = self.opts['elevation'] * np.pi / 180 + azim = self.opts['azimuth'] * np.pi / 180 + pos = Vector( + center.x() + dist * np.cos(elev) * np.cos(azim), + center.y() + dist * np.cos(elev) * np.sin(azim), + center.z() + dist * np.sin(elev) + ) return pos def orbit(self, azim, elev): """Orbits the camera around the center position. *azim* and *elev* are given in degrees.""" - self.opts['azimuth'] += azim - self.opts['elevation'] = np.clip(self.opts['elevation'] + elev, -90, 90) + if self.opts['rotationMethod'] == 'quaternion': + q = QtGui.QQuaternion.fromEulerAngles( + elev, -azim, 0 + ) # rx-ry-rz + q *= self.opts['rotation'] + self.opts['rotation'] = q + else: # default euler rotation method + self.opts['azimuth'] += azim + self.opts['elevation'] = np.clip(self.opts['elevation'] + elev, -90, 90) self.update() def pan(self, dx, dy, dz, relative='global'): @@ -326,16 +367,29 @@ class GLViewWidget(QtOpenGL.QGLWidget): self.opts['center'] = self.opts['center'] + xVec * xScale * dx + yVec * xScale * dy + zVec * xScale * dz elif relative == 'view': # pan in plane of camera - elev = np.radians(self.opts['elevation']) - azim = np.radians(self.opts['azimuth']) - fov = np.radians(self.opts['fov']) - dist = (self.opts['center'] - self.cameraPosition()).length() - fov_factor = np.tan(fov / 2) * 2 - scale_factor = dist * fov_factor / self.width() - z = scale_factor * np.cos(elev) * dy - x = scale_factor * (np.sin(azim) * dx - np.sin(elev) * np.cos(azim) * dy) - y = scale_factor * (np.cos(azim) * dx + np.sin(elev) * np.sin(azim) * dy) - self.opts['center'] += QtGui.QVector3D(x, -y, z) + + if self.opts['rotationMethod'] == 'quaternion': + # obtain basis vectors + qc = self.opts['rotation'].conjugated() + xv = qc.rotatedVector( Vector(1,0,0) ) + yv = qc.rotatedVector( Vector(0,1,0) ) + zv = qc.rotatedVector( Vector(0,0,1) ) + + scale_factor = self.pixelSize( self.opts['center'] ) + + # apply translation + self.opts['center'] += scale_factor * (xv*-dx + yv*dy + zv*dz) + else: # use default euler rotation method + elev = np.radians(self.opts['elevation']) + azim = np.radians(self.opts['azimuth']) + fov = np.radians(self.opts['fov']) + dist = (self.opts['center'] - self.camerPosition()).length() + fov_factor = np.tan(fov / 2) * 2 + scale_factor = dist * fov_factor / self.width() + z = scale_factor * np.cos(elev) * dy + x = scale_factor * (np.sin(azim) * dx - np.sin(elev) * np.cos(azim) * dy) + y = scale_factor * (np.cos(azim) * dx + np.sin(elev) * np.sin(azim) * dy) + self.opts['center'] += QtGui.QVector3D(x, -y, z) else: raise ValueError("relative argument must be global, view, or view-upright")