Repository: mikedh/trimesh
Pull request #381 (2019-04-22 13:07:52)

Use pyrender.trackball.Trackball as alternative to Arcball
For https://github.com/mikedh/trimesh/issues/368#issuecomment-485153083

wkentaro: ## Before

![2019-04-22_14-13-15](https://user-images.githubusercontent.com/4310419/56502114-24eaf600-6509-11e9-926e-2775826a3cb0.gif)
![2019-04-22_14-13-32](https://user-images.githubusercontent.com/4310419/56502115-24eaf600-6509-11e9-9091-ec719f42524d.gif)

## After

![2019-04-22_14-12-29](https://user-images.githubusercontent.com/4310419/56502126-2fa58b00-6509-11e9-8d59-ca28a1bef1a3.gif)
![2019-04-22_14-12-52](https://user-images.githubusercontent.com/4310419/56502127-2fa58b00-6509-11e9-98da-88c95d61a879.gif)

wkentaro: ## Example Script

```python
#!/usr/bin/env python

import numpy as np
import trimesh
import trimesh.transformations as tf


mesh = trimesh.load('../models/fuze.obj')
scene = trimesh.Scene()
scene.add_geometry(mesh)
scene.set_camera(angles=[np.deg2rad(90), 0, 0])
scene.show()

scene = trimesh.Scene()
for _ in range(10):
    axis = trimesh.creation.axis(0.01)
    axis.apply_translation(np.random.uniform(-0.3, 0.3, (3,)))
    scene.add_geometry(axis)
scene.show()

```
coveralls: 
[![Coverage Status](https://coveralls.io/builds/22946638/badge)](https://coveralls.io/builds/22946638)

Coverage decreased (-0.5%) to 83.503% when pulling **0ffcaab1ea66a6b0a90874dc136e5846fd69e7a7 on wkentaro:trackball_for_viewer** into **90a5a24fa07a78901896bd0822af75db362900dd on mikedh:master**.


Patch:
diff --git a/trimesh/viewer/trackball.py b/trimesh/viewer/trackball.py
new file mode 100644
index 00000000..8b8a2952
--- /dev/null
+++ b/trimesh/viewer/trackball.py
@@ -0,0 +1,240 @@
+# Copied from https://github.com/mmatl/pyrender/blob/master/pyrender/trackball.py
+
+# MIT License
+#
+# Copyright (c) 2019 Matthew Matl
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+"""Trackball class for 3D manipulation of viewpoints.
+"""
+import numpy as np
+
+import trimesh.transformations as transformations
+
+
+class Trackball(object):
+    """A trackball class for creating camera transforms from mouse movements.
+    """
+    STATE_ROTATE = 0
+    STATE_PAN = 1
+    STATE_ROLL = 2
+    STATE_ZOOM = 3
+
+    def __init__(self, pose, size, scale,
+                 target=np.array([0.0, 0.0, 0.0])):
+        """Initialize a trackball with an initial camera-to-world pose
+        and the given parameters.
+
+        Parameters
+        ----------
+        pose : [4,4]
+            An initial camera-to-world pose for the trackball.
+
+        size : (float, float)
+            The width and height of the camera image in pixels.
+
+        scale : float
+            The diagonal of the scene's bounding box --
+            used for ensuring translation motions are sufficiently
+            fast for differently-sized scenes.
+
+        target : (3,) float
+            The center of the scene in world coordinates.
+            The trackball will revolve around this point.
+        """
+        self._size = np.array(size)
+        self._scale = float(scale)
+
+        self._pose = pose
+        self._n_pose = pose
+
+        self._target = target
+        self._n_target = target
+
+        self._state = Trackball.STATE_ROTATE
+
+    @property
+    def pose(self):
+        """autolab_core.RigidTransform : The current camera-to-world pose.
+        """
+        return self._n_pose
+
+    def set_state(self, state):
+        """Set the state of the trackball in order to change the effect of
+        dragging motions.
+
+        Parameters
+        ----------
+        state : int
+            One of Trackball.STATE_ROTATE, Trackball.STATE_PAN,
+            Trackball.STATE_ROLL, and Trackball.STATE_ZOOM.
+        """
+        self._state = state
+
+    def resize(self, size):
+        """Resize the window.
+
+        Parameters
+        ----------
+        size : (float, float)
+            The new width and height of the camera image in pixels.
+        """
+        self._size = np.array(size)
+
+    def down(self, point):
+        """Record an initial mouse press at a given point.
+
+        Parameters
+        ----------
+        point : (2,) int
+            The x and y pixel coordinates of the mouse press.
+        """
+        self._pdown = np.array(point, dtype=np.float32)
+        self._pose = self._n_pose
+        self._target = self._n_target
+
+    def drag(self, point):
+        """Update the tracball during a drag.
+
+        Parameters
+        ----------
+        point : (2,) int
+            The current x and y pixel coordinates of the mouse during a drag.
+            This will compute a movement for the trackball with the relative
+            motion between this point and the one marked by down().
+        """
+        point = np.array(point, dtype=np.float32)
+        dx, dy = point - self._pdown
+        mindim = 0.3 * np.min(self._size)
+
+        target = self._target
+        x_axis = self._pose[:3,0].flatten()
+        y_axis = self._pose[:3,1].flatten()
+        z_axis = self._pose[:3,2].flatten()
+        eye = self._pose[:3,3].flatten()
+
+        # Interpret drag as a rotation
+        if self._state == Trackball.STATE_ROTATE:
+            x_angle = -dx / mindim
+            x_rot_mat = transformations.rotation_matrix(
+                x_angle, y_axis, target
+            )
+
+            y_angle = dy / mindim
+            y_rot_mat = transformations.rotation_matrix(
+                y_angle, x_axis, target
+            )
+
+            self._n_pose = y_rot_mat.dot(x_rot_mat.dot(self._pose))
+
+        # Interpret drag as a roll about the camera axis
+        elif self._state == Trackball.STATE_ROLL:
+            center = self._size / 2.0
+            v_init = self._pdown - center
+            v_curr = point - center
+            v_init = v_init / np.linalg.norm(v_init)
+            v_curr = v_curr / np.linalg.norm(v_curr)
+
+            theta = (-np.arctan2(v_curr[1], v_curr[0]) +
+                     np.arctan2(v_init[1], v_init[0]))
+
+            rot_mat = transformations.rotation_matrix(theta, z_axis, target)
+
+            self._n_pose = rot_mat.dot(self._pose)
+
+        # Interpret drag as a camera pan in view plane
+        elif self._state == Trackball.STATE_PAN:
+            dx = -dx / (5.0 * mindim) * self._scale
+            dy = -dy / (5.0 * mindim) * self._scale
+
+            translation = dx * x_axis + dy * y_axis
+            self._n_target = self._target + translation
+            t_tf = np.eye(4)
+            t_tf[:3,3] = translation
+            self._n_pose = t_tf.dot(self._pose)
+
+        # Interpret drag as a zoom motion
+        elif self._state == Trackball.STATE_ZOOM:
+            radius = np.linalg.norm(eye - target)
+            ratio = 0.0
+            if dy > 0:
+                ratio = np.exp(abs(dy) / (0.5 * self._size[1])) - 1.0
+            elif dy < 0:
+                ratio = 1.0 - np.exp(dy / (0.5 * (self._size[1])))
+            translation = -np.sign(dy) * ratio * radius * z_axis
+            t_tf = np.eye(4)
+            t_tf[:3,3] = translation
+            self._n_pose = t_tf.dot(self._pose)
+
+    def scroll(self, clicks):
+        """Zoom using a mouse scroll wheel motion.
+
+        Parameters
+        ----------
+        clicks : int
+            The number of clicks. Positive numbers indicate forward wheel
+            movement.
+        """
+        target = self._target
+        ratio = 0.90
+
+        mult = 1.0
+        if clicks > 0:
+            mult = ratio**clicks
+        elif clicks < 0:
+            mult = (1.0 / ratio)**abs(clicks)
+
+        z_axis = self._n_pose[:3,2].flatten()
+        eye = self._n_pose[:3,3].flatten()
+        radius = np.linalg.norm(eye - target)
+        translation = (mult * radius - radius) * z_axis
+        t_tf = np.eye(4)
+        t_tf[:3,3] = translation
+        self._n_pose = t_tf.dot(self._n_pose)
+
+        z_axis = self._pose[:3,2].flatten()
+        eye = self._pose[:3,3].flatten()
+        radius = np.linalg.norm(eye - target)
+        translation = (mult * radius - radius) * z_axis
+        t_tf = np.eye(4)
+        t_tf[:3,3] = translation
+        self._pose = t_tf.dot(self._pose)
+
+    def rotate(self, azimuth, axis=None):
+        """Rotate the trackball about the "Up" axis by azimuth radians.
+
+        Parameters
+        ----------
+        azimuth : float
+            The number of radians to rotate.
+        """
+        target = self._target
+
+        y_axis = self._n_pose[:3,1].flatten()
+        if axis is not None:
+            y_axis = axis
+        x_rot_mat = transformations.rotation_matrix(azimuth, y_axis, target)
+        self._n_pose = x_rot_mat.dot(self._n_pose)
+
+        y_axis = self._pose[:3,1].flatten()
+        if axis is not None:
+            y_axis = axis
+        x_rot_mat = transformations.rotation_matrix(azimuth, y_axis, target)
+        self._pose = x_rot_mat.dot(self._pose)
diff --git a/trimesh/viewer/windowed.py b/trimesh/viewer/windowed.py
index 12c544a4..a6279813 100644
--- a/trimesh/viewer/windowed.py
+++ b/trimesh/viewer/windowed.py
@@ -12,6 +12,7 @@ from ..visual import to_rgba
 from ..util import log, BytesIO
 from ..transformations import Arcball
 from .. import rendering
+from .trackball import Trackball
 import collections
 import platform
 import numpy as np
@@ -73,6 +74,7 @@ class SceneViewer(pyglet.window.Window):
         self.callback = callback
         self.callback_period = callback_period
         self.scene._redraw = self._redraw
+        self._initial_camera_transform = scene.camera.transform
         self.reset_view(flags=flags)
         self.batch = pyglet.graphics.Batch()
         self._smooth = smooth
@@ -202,21 +204,20 @@ class SceneViewer(pyglet.window.Window):
           If any view key passed override the default
           e.g. {'cull': False}
         """
-        self.view = {'cull': True,
-                     'axis': False,
-                     'fullscreen': False,
-                     'wireframe': False,
-                     'translation': np.zeros(3),
-                     'center': self.scene.centroid,
-                     'scale': self.scene.scale,
-                     'ball': Arcball()}
+        self.view = {
+            'cull': True,
+            'axis': False,
+            'fullscreen': False,
+            'wireframe': False,
+            'ball': Trackball(
+                pose=self._initial_camera_transform,
+                size=self.scene.camera.resolution,
+                scale=self.scene.scale,
+                target=self.scene.centroid,
+            ),
+        }
 
         try:
-            # place the arcball (rotation widget) in the center of the view
-            self.view['ball'].place([self.width / 2.0,
-                                     self.height / 2.0],
-                                    (self.width + self.height) / 2.0)
-
             # if any flags are passed override defaults
             if isinstance(flags, dict):
                 for k, v in flags.items():
@@ -429,34 +430,42 @@ class SceneViewer(pyglet.window.Window):
                           .01,
                           self.scene.scale * 5.0)
         gl.glMatrixMode(gl.GL_MODELVIEW)
-        self.view['ball'].place([width / 2, height / 2],
-                                (width + height) / 2)
+
+        self.scene.camera.resolution = (width, height)
+        self.view['ball'].resize(self.scene.camera.resolution)
 
     def on_mouse_press(self, x, y, buttons, modifiers):
         """
         Set the start point of the drag.
         """
-        self.view['ball'].down([x, -y])
+        self.view['ball'].set_state(Trackball.STATE_ROTATE)
+        if (buttons == pyglet.window.mouse.LEFT):
+            ctrl = (modifiers & pyglet.window.key.MOD_CTRL)
+            shift = (modifiers & pyglet.window.key.MOD_SHIFT)
+            if (ctrl and shift):
+                self.view['ball'].set_state(Trackball.STATE_ZOOM)
+            elif shift:
+                self.view['ball'].set_state(Trackball.STATE_ROLL)
+            elif ctrl:
+                self.view['ball'].set_state(Trackball.STATE_PAN)
+        elif (buttons == pyglet.window.mouse.MIDDLE):
+            self.view['ball'].set_state(Trackball.STATE_PAN)
+        elif (buttons == pyglet.window.mouse.RIGHT):
+            self.view['ball'].set_state(Trackball.STATE_ZOOM)
+
+        self.view['ball'].down(np.array([x, y]))
 
     def on_mouse_drag(self, x, y, dx, dy, buttons, modifiers):
         """
         Pan or rotate the view.
         """
-        # left mouse button, with control key down (pan)
-        if ((buttons == pyglet.window.mouse.LEFT) and
-                (modifiers & pyglet.window.key.MOD_CTRL)):
-            delta = [dx / self.width, dy / self.height]
-            self.view['translation'][:2] += delta
-
-        # left mouse button, no modifier keys pressed (rotate)
-        elif (buttons == pyglet.window.mouse.LEFT):
-            self.view['ball'].drag([x, -y])
+        self.view['ball'].drag(np.array([x, y]))
 
     def on_mouse_scroll(self, x, y, dx, dy):
         """
         Zoom the view.
         """
-        self.view['translation'][2] += float(dy) / self.height
+        self.view['ball'].scroll(dy)
 
     def on_key_press(self, symbol, modifiers):
         """
@@ -498,6 +507,9 @@ class SceneViewer(pyglet.window.Window):
         gl.glClear(gl.GL_COLOR_BUFFER_BIT | gl.GL_DEPTH_BUFFER_BIT)
         gl.glLoadIdentity()
 
+        # change camera transform based on the trackball on viewer
+        self.scene.camera.transform = self.view['ball'].pose.copy()
+
         # pull the new camera transform from the scene
         transform_camera = self.scene.graph.get(
             frame_to='world',
@@ -506,12 +518,6 @@ class SceneViewer(pyglet.window.Window):
         # apply the camera transform to the matrix stack
         gl.glMultMatrixf(rendering.matrix_to_gl(transform_camera))
 
-        # dragging the mouse moves the view
-        # but doesn't alter the scene
-        view = view_to_transform(self.view)
-        # add the view transform to the stack
-        gl.glMultMatrixf(rendering.matrix_to_gl(view))
-
         # we want to render fully opaque objects first,
         # followed by objects which have transparency
         node_names = collections.deque(self.scene.graph.nodes_geometry)
@@ -603,18 +609,6 @@ class SceneViewer(pyglet.window.Window):
             colorbuffer.save(filename=file_obj)
 
 
-def view_to_transform(view):
-    """
-    Given a dictionary containing view parameters,
-    calculate a transformation matrix.
-    """
-    transform = view['ball'].matrix()
-    transform[:3, 3] = view['center']
-    transform[:3, 3] -= np.dot(transform[:3, :3], view['center'])
-    transform[:3, 3] += view['translation'] * view['scale'] * 5.0
-    return transform
-
-
 def geometry_hash(geometry):
     """
     Get an MD5 for a geometry object


Test patch:
diff --git a/tests/test_trackball.py b/tests/test_trackball.py
new file mode 100644
index 00000000..6df651b4
--- /dev/null
+++ b/tests/test_trackball.py
@@ -0,0 +1,168 @@
+try:
+    from . import generic as g
+except BaseException:
+    import generic as g
+
+from trimesh.viewer.trackball import Trackball
+
+
+class TrackballTest(g.unittest.TestCase):
+
+    def test_resize(self):
+        trackball = Trackball(
+            pose=g.np.eye(4),
+            size=(640, 480),
+            scale=1.0,
+            target=g.np.array([0, 0, 0])
+        )
+        trackball.down((320, 240))
+        trackball.drag((321, 240))
+        pose1 = trackball.pose
+        trackball.drag((320, 240))
+        trackball.resize((1280, 960))
+        trackball.down((640, 480))
+        trackball.drag((642, 480))
+        pose2 = trackball.pose
+        g.np.testing.assert_allclose(pose1, pose2)
+
+    def test_drag_rotate(self):
+        trackball = Trackball(
+            pose=g.np.eye(4),
+            size=(640, 480),
+            scale=1.0,
+            target=g.np.array([0, 0, 0])
+        )
+
+        # rotates around y-axis
+        trackball.set_state(Trackball.STATE_ROTATE)
+        trackball.down((320, 240))
+        trackball.drag((321, 240))
+        assert trackball.pose[0, 0] < 1
+        assert trackball.pose[0, 1] == 0
+        assert trackball.pose[0, 2] < 0
+        assert trackball.pose[1, 0] == 0
+        assert trackball.pose[1, 1] == 1
+        assert trackball.pose[1, 2] == 0
+        assert trackball.pose[2, 0] > 0
+        assert trackball.pose[2, 1] == 0
+        assert trackball.pose[2, 2] < 1
+        g.np.testing.assert_allclose(trackball.pose[:, 3], [0, 0, 0, 1])
+        g.np.testing.assert_allclose(trackball.pose[3, :], [0, 0, 0, 1])
+        # rotate back
+        trackball.drag((320, 240))
+        g.np.testing.assert_allclose(trackball.pose, g.np.eye(4))
+
+        # rotate around x-axis
+        trackball.drag((320, 241))
+        assert trackball.pose[0, 0] == 1
+        assert trackball.pose[0, 1] == 0
+        assert trackball.pose[0, 2] == 0
+        assert trackball.pose[1, 0] == 0
+        assert trackball.pose[1, 1] < 1
+        assert trackball.pose[1, 2] < 0
+        assert trackball.pose[2, 0] == 0
+        assert trackball.pose[2, 1] > 0
+        assert trackball.pose[2, 2] < 1
+        g.np.testing.assert_allclose(trackball.pose[:, 3], [0, 0, 0, 1])
+        g.np.testing.assert_allclose(trackball.pose[3, :], [0, 0, 0, 1])
+        # rotate back
+        trackball.drag((320, 240))
+        g.np.testing.assert_allclose(trackball.pose, g.np.eye(4))
+
+    def test_drag_roll(self):
+        trackball = Trackball(
+            pose=g.np.eye(4),
+            size=(640, 480),
+            scale=1.0,
+            target=g.np.array([0, 0, 0])
+        )
+
+        # rotates around z-axis
+        trackball.set_state(Trackball.STATE_ROLL)
+        trackball.down((321, 241))
+        trackball.drag((320, 241))
+        assert trackball.pose[0, 0] < 1
+        assert trackball.pose[0, 1] > 0
+        assert trackball.pose[0, 2] == 0
+        assert trackball.pose[1, 0] < 0
+        assert trackball.pose[1, 1] < 1
+        assert trackball.pose[1, 2] == 0
+        assert trackball.pose[2, 0] == 0
+        assert trackball.pose[2, 1] == 0
+        assert trackball.pose[2, 2] == 1
+        g.np.testing.assert_allclose(trackball.pose[:, 3], [0, 0, 0, 1])
+        g.np.testing.assert_allclose(trackball.pose[3, :], [0, 0, 0, 1])
+        # rotate back
+        trackball.drag((321, 241))
+        g.np.testing.assert_allclose(trackball.pose, g.np.eye(4))
+
+    def test_drag_pan(self):
+        trackball = Trackball(
+            pose=g.np.eye(4),
+            size=(640, 480),
+            scale=1.0,
+            target=g.np.array([0, 0, 0])
+        )
+
+        # translate to x
+        trackball.set_state(Trackball.STATE_PAN)
+        trackball.down((321, 241))
+        trackball.drag((320, 241))
+        g.np.testing.assert_allclose(trackball.pose[:3, :3], g.np.eye(3))
+        assert trackball.pose[0, 3] != 0
+        assert trackball.pose[1, 3] == 0
+        assert trackball.pose[2, 3] == 0
+
+    def test_drag_zoom(self):
+        pose = g.trimesh.transformations.translation_matrix([0, 0, 1])
+        trackball = Trackball(
+            pose=pose,
+            size=(640, 480),
+            scale=1.0,
+            target=g.np.array([0, 0, 0])
+        )
+
+        # translate to x
+        trackball.set_state(Trackball.STATE_ZOOM)
+        trackball.down((320, 240))
+        trackball.drag((320, 241))
+        g.np.testing.assert_allclose(trackball.pose[:3, :3], g.np.eye(3))
+        assert trackball.pose[3, 0] == 0
+        assert trackball.pose[3, 1] == 0
+        assert trackball.pose[3, 2] < 1
+        g.np.testing.assert_allclose(trackball.pose[3, :], [0, 0, 0, 1])
+
+    def test_scroll(self):
+        pose = g.trimesh.transformations.translation_matrix([0, 0, 1])
+        trackball = Trackball(
+            pose=pose,
+            size=(640, 480),
+            scale=1.0,
+            target=g.np.array([0, 0, 0])
+        )
+        g.np.testing.assert_allclose(trackball.pose[:3, :3], g.np.eye(3))
+        g.np.testing.assert_allclose(trackball.pose[:3, 3], [0, 0, 1])
+        trackball.scroll(1)
+        g.np.testing.assert_allclose(trackball.pose[:3, :3], g.np.eye(3))
+        g.np.testing.assert_allclose(trackball.pose[:3, 3], [0, 0, 0.9])
+
+    def test_rotate(self):
+        trackball = Trackball(
+            pose=g.np.eye(4),
+            size=(640, 480),
+            scale=1.0,
+            target=g.np.array([0, 0, 0])
+        )
+        # rotates around y-axis
+        trackball.rotate(g.np.deg2rad(1))
+        assert trackball.pose[0, 0] < 1
+        assert trackball.pose[0, 1] == 0
+        assert trackball.pose[0, 2] > 0
+        assert trackball.pose[1, 0] == 0
+        assert trackball.pose[1, 1] == 1
+        assert trackball.pose[1, 2] == 0
+        assert trackball.pose[2, 0] < 0
+        assert trackball.pose[2, 1] == 0
+        assert trackball.pose[2, 2] < 1
+        g.np.testing.assert_allclose(trackball.pose[:, 3], [0, 0, 0, 1])
+        g.np.testing.assert_allclose(trackball.pose[3, :], [0, 0, 0, 1])