Coverage for trimesh/viewer/trackball.py: 92%
106 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-24 04:40 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-24 04:40 +0000
1# Copied from
2# https://github.com/mmatl/pyrender/blob/master/pyrender/trackball.py
4# MIT License
5#
6# Copyright (c) 2019 Matthew Matl
7#
8# Permission is hereby granted, free of charge, to any person obtaining a copy
9# of this software and associated documentation files (the "Software"), to deal
10# in the Software without restriction, including without limitation the rights
11# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
12# copies of the Software, and to permit persons to whom the Software is
13# furnished to do so, subject to the following conditions:
14#
15# The above copyright notice and this permission notice shall be included in all
16# copies or substantial portions of the Software.
17#
18# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
21# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
24# SOFTWARE.
26"""Trackball class for 3D manipulation of viewpoints."""
28import numpy as np
30from .. import transformations
33class Trackball:
34 """A trackball class for creating camera transforms from mouse movements."""
36 STATE_ROTATE = 0
37 STATE_PAN = 1
38 STATE_ROLL = 2
39 STATE_ZOOM = 3
41 def __init__(self, pose, size, scale, target=None):
42 """Initialize a trackball with an initial camera-to-world pose
43 and the given parameters.
45 Parameters
46 ----------
47 pose : [4,4]
48 An initial camera-to-world pose for the trackball.
50 size : (float, float)
51 The width and height of the camera image in pixels.
53 scale : float
54 The diagonal of the scene's bounding box --
55 used for ensuring translation motions are sufficiently
56 fast for differently-sized scenes.
58 target : (3,) float
59 The center of the scene in world coordinates.
60 The trackball will revolve around this point.
61 """
62 self._size = np.array(size)
63 self._scale = float(scale)
65 self._pose = pose
66 self._n_pose = pose
68 if target is None:
69 self._target = np.array([0.0, 0.0, 0.0])
70 self._n_target = np.array([0.0, 0.0, 0.0])
71 else:
72 self._target = target
73 self._n_target = target
75 self._state = Trackball.STATE_ROTATE
77 @property
78 def pose(self):
79 """autolab_core.RigidTransform : The current camera-to-world pose."""
80 return self._n_pose
82 def set_state(self, state):
83 """Set the state of the trackball in order to change the effect of
84 dragging motions.
86 Parameters
87 ----------
88 state : int
89 One of Trackball.STATE_ROTATE, Trackball.STATE_PAN,
90 Trackball.STATE_ROLL, and Trackball.STATE_ZOOM.
91 """
92 self._state = state
94 def resize(self, size):
95 """Resize the window.
97 Parameters
98 ----------
99 size : (float, float)
100 The new width and height of the camera image in pixels.
101 """
102 self._size = np.array(size)
104 def down(self, point):
105 """Record an initial mouse press at a given point.
107 Parameters
108 ----------
109 point : (2,) int
110 The x and y pixel coordinates of the mouse press.
111 """
112 self._pdown = np.array(point, dtype=np.float32)
113 self._pose = self._n_pose
114 self._target = self._n_target
116 def drag(self, point):
117 """Update the tracball during a drag.
119 Parameters
120 ----------
121 point : (2,) int
122 The current x and y pixel coordinates of the mouse during a drag.
123 This will compute a movement for the trackball with the relative
124 motion between this point and the one marked by down().
125 """
126 point = np.array(point, dtype=np.float32)
127 # get the "down" point defaulting to current point making
128 # this a no-op if the "down" event didn't trigger for some reason
129 dx, dy = point - getattr(self, "_pdown", point)
130 mindim = 0.3 * np.min(self._size)
132 target = self._target
133 x_axis = self._pose[:3, 0].flatten()
134 y_axis = self._pose[:3, 1].flatten()
135 z_axis = self._pose[:3, 2].flatten()
136 eye = self._pose[:3, 3].flatten()
138 # Interpret drag as a rotation
139 if self._state == Trackball.STATE_ROTATE:
140 x_angle = -dx / mindim
141 x_rot_mat = transformations.rotation_matrix(x_angle, y_axis, target)
143 y_angle = dy / mindim
144 y_rot_mat = transformations.rotation_matrix(y_angle, x_axis, target)
146 self._n_pose = y_rot_mat.dot(x_rot_mat.dot(self._pose))
148 # Interpret drag as a roll about the camera axis
149 elif self._state == Trackball.STATE_ROLL:
150 center = self._size / 2.0
151 v_init = self._pdown - center
152 v_curr = point - center
153 v_init = v_init / np.linalg.norm(v_init)
154 v_curr = v_curr / np.linalg.norm(v_curr)
156 theta = -np.arctan2(v_curr[1], v_curr[0]) + np.arctan2(v_init[1], v_init[0])
158 rot_mat = transformations.rotation_matrix(theta, z_axis, target)
160 self._n_pose = rot_mat.dot(self._pose)
162 # Interpret drag as a camera pan in view plane
163 elif self._state == Trackball.STATE_PAN:
164 dx = -dx / (5.0 * mindim) * self._scale
165 dy = -dy / (5.0 * mindim) * self._scale
167 translation = dx * x_axis + dy * y_axis
168 self._n_target = self._target + translation
169 t_tf = np.eye(4)
170 t_tf[:3, 3] = translation
171 self._n_pose = t_tf.dot(self._pose)
173 # Interpret drag as a zoom motion
174 elif self._state == Trackball.STATE_ZOOM:
175 radius = np.linalg.norm(eye - target)
176 ratio = 0.0
177 if dy > 0:
178 ratio = np.exp(abs(dy) / (0.5 * self._size[1])) - 1.0
179 elif dy < 0:
180 ratio = 1.0 - np.exp(dy / (0.5 * (self._size[1])))
181 translation = -np.sign(dy) * ratio * radius * z_axis
182 t_tf = np.eye(4)
183 t_tf[:3, 3] = translation
184 self._n_pose = t_tf.dot(self._pose)
186 def scroll(self, clicks):
187 """Zoom using a mouse scroll wheel motion.
189 Parameters
190 ----------
191 clicks : int
192 The number of clicks. Positive numbers indicate forward wheel
193 movement.
194 """
195 target = self._target
196 ratio = 0.90
198 mult = 1.0
199 if clicks > 0:
200 mult = ratio**clicks
201 elif clicks < 0:
202 mult = (1.0 / ratio) ** abs(clicks)
204 z_axis = self._n_pose[:3, 2].flatten()
205 eye = self._n_pose[:3, 3].flatten()
206 radius = np.linalg.norm(eye - target)
207 translation = (mult * radius - radius) * z_axis
208 t_tf = np.eye(4)
209 t_tf[:3, 3] = translation
210 self._n_pose = t_tf.dot(self._n_pose)
212 z_axis = self._pose[:3, 2].flatten()
213 eye = self._pose[:3, 3].flatten()
214 radius = np.linalg.norm(eye - target)
215 translation = (mult * radius - radius) * z_axis
216 t_tf = np.eye(4)
217 t_tf[:3, 3] = translation
218 self._pose = t_tf.dot(self._pose)
220 def rotate(self, azimuth, axis=None):
221 """Rotate the trackball about the "Up" axis by azimuth radians.
223 Parameters
224 ----------
225 azimuth : float
226 The number of radians to rotate.
227 """
228 target = self._target
230 y_axis = self._n_pose[:3, 1].flatten()
231 if axis is not None:
232 y_axis = axis
233 x_rot_mat = transformations.rotation_matrix(azimuth, y_axis, target)
234 self._n_pose = x_rot_mat.dot(self._n_pose)
236 y_axis = self._pose[:3, 1].flatten()
237 if axis is not None:
238 y_axis = axis
239 x_rot_mat = transformations.rotation_matrix(azimuth, y_axis, target)
240 self._pose = x_rot_mat.dot(self._pose)