Repository: pygfx/pygfx
Pull request #566 (2023-07-14 12:17:50)

Euler and rotation matrix support on Transform
Depends on #565

There are still some improvements to make here; specifically the euler angles are reconstructed via a roundtrip through the matrix, so the inputs are not preserved. Having a stateful euler_x/y/z API would be nice but it requires some more work. I want to do that in a separate PR.


Patch:
diff --git a/pygfx/utils/transform.py b/pygfx/utils/transform.py
index d79aebf..dfd0f0f 100644
--- a/pygfx/utils/transform.py
+++ b/pygfx/utils/transform.py
@@ -179,6 +179,14 @@ class AffineBase:
     def _direction_components(self):
         return (*self._directions,)
 
+    @cached
+    def _rotation_matrix(self):
+        return la.mat_from_quat(self._decomposed[1])
+
+    @cached
+    def _euler(self):
+        return la.quat_to_euler(self._decomposed[1])
+
     def flag_update(self):
         """Signal that this transform has updated."""
         for callback in self.update_callbacks.values():
@@ -240,9 +248,34 @@ class AffineBase:
 
     @property
     def rotation(self) -> np.ndarray:
-        """The orientation of source."""
+        """The orientation of source as a quaternion."""
         return self._decomposed[1]
 
+    @property
+    def rotation_matrix(self) -> np.ndarray:
+        """The orientation of source as a rotation matrix."""
+        return self._rotation_matrix
+
+    @property
+    def euler(self) -> np.ndarray:
+        """The orientation of source as XYZ euler angles."""
+        return self._euler
+
+    @property
+    def euler_x(self) -> float:
+        """The X component of source's orientation as XYZ euler angles."""
+        return self._euler[0]
+
+    @property
+    def euler_y(self) -> float:
+        """The Y component of source's orientation as XYZ euler angles."""
+        return self._euler[1]
+
+    @property
+    def euler_z(self) -> float:
+        """The Z component of source's orientation as XYZ euler angles."""
+        return self._euler[2]
+
     @property
     def scale(self) -> np.ndarray:
         """The scale of source."""
@@ -306,6 +339,26 @@ class AffineBase:
     def rotation(self, value):
         self.matrix = la.mat_compose(self.position, value, self.scale)
 
+    @euler.setter
+    def euler(self, value):
+        self.rotation = la.quat_from_euler(value)
+
+    @rotation_matrix.setter
+    def rotation_matrix(self, value):
+        self.rotation = la.quat_from_mat(value)
+
+    @euler_x.setter
+    def euler_x(self, value):
+        self.euler = (value, 0, 0)
+
+    @euler_y.setter
+    def euler_y(self, value):
+        self.euler = (0, value, 0)
+
+    @euler_z.setter
+    def euler_z(self, value):
+        self.euler = (0, 0, value)
+
     @scale.setter
     def scale(self, value):
         m = la.mat_compose(self.position, self.rotation, value)


Test patch:
diff --git a/tests/objects/test_world_object.py b/tests/objects/test_world_object.py
index 435c451..64566c4 100644
--- a/tests/objects/test_world_object.py
+++ b/tests/objects/test_world_object.py
@@ -441,3 +441,53 @@ def test_scaling_signs_manual_matrix():
     child.local.scale = s2
     npt.assert_array_almost_equal(child.local.scale, s2)
     npt.assert_array_almost_equal(child.world.scale, [-4, 8, 12])
+
+
+def test_rotation_derived():
+    obj = gfx.WorldObject()
+    e1 = np.array([0, np.pi / 2, 0])
+    q1 = la.quat_from_euler(e1, order="XYZ")
+    m1 = la.mat_from_quat(q1)
+
+    obj.local.rotation = q1
+    npt.assert_array_almost_equal(obj.local.rotation, q1)
+    npt.assert_array_almost_equal(obj.local.rotation_matrix, m1)
+    npt.assert_array_almost_equal(obj.local.forward, [1, 0, 0])
+    npt.assert_array_almost_equal(obj.local.right, [0, 0, 1])
+    npt.assert_array_almost_equal(obj.local.up, [0, 1, 0])
+
+    obj.local.rotation_matrix = m1
+    npt.assert_array_almost_equal(obj.local.rotation, q1)
+    npt.assert_array_almost_equal(obj.local.rotation_matrix, m1)
+    npt.assert_array_almost_equal(obj.local.forward, [1, 0, 0])
+    npt.assert_array_almost_equal(obj.local.right, [0, 0, 1])
+    npt.assert_array_almost_equal(obj.local.up, [0, 1, 0])
+
+    obj.local.euler = e1
+    npt.assert_array_almost_equal(obj.local.rotation, q1)
+    npt.assert_array_almost_equal(obj.local.rotation_matrix, m1)
+    npt.assert_array_almost_equal(obj.local.forward, [1, 0, 0])
+    npt.assert_array_almost_equal(obj.local.right, [0, 0, 1])
+    npt.assert_array_almost_equal(obj.local.up, [0, 1, 0])
+
+    obj.local.euler = [0, 0, 0]
+    npt.assert_array_almost_equal(obj.local.rotation, [0, 0, 0, 1])
+    npt.assert_array_almost_equal(obj.local.rotation_matrix, np.eye(4))
+    npt.assert_array_almost_equal(obj.local.forward, [0, 0, 1])
+    npt.assert_array_almost_equal(obj.local.right, [-1, 0, 0])
+    npt.assert_array_almost_equal(obj.local.up, [0, 1, 0])
+
+    obj.local.euler_x = np.pi / 2
+    npt.assert_array_almost_equal(
+        obj.local.rotation, la.quat_from_euler(np.pi / 2, order="X")
+    )
+
+    obj.local.euler_y = np.pi / 2
+    npt.assert_array_almost_equal(
+        obj.local.rotation, la.quat_from_euler(np.pi / 2, order="Y")
+    )
+
+    obj.local.euler_z = np.pi / 2
+    npt.assert_array_almost_equal(
+        obj.local.rotation, la.quat_from_euler(np.pi / 2, order="Z")
+    )