Solution
I came up with this:
var A := start.basis.get_rotation_quat()
var B := end.basis.get_rotation_quat()
var R := A.inverse() * B
print(R.get_euler())
It is also recommended to always normalize quaternions to minimize cumulative floating point errors. But I believe it is not a problem here.
Explanation
The idea is that you have a rotation quaternion A that after some rotation R becomes B. In other words:
B = A * R
And if we solve for R, we get this:
B = A * R
B * R.inverse() = A * R * R.inverse()
B * R.inverse() = A
B.inverse() * B * R.inverse() = B.inverse() * A
R.inverse() = B.inverse() * A
R = (B.inverse() * A).inverse()
R = A.inverse() * B
Testing
This is what I came up for testing it:
tool
extends MeshInstance
func _enter_tree() -> void:
set_notify_transform(true)
func _notification(what: int) -> void:
if what == NOTIFICATION_TRANSFORM_CHANGED:
var parent := get_parent() as Spatial
var end := global_transform.basis.get_rotation_quat()
var start := parent.global_transform.basis.get_rotation_quat()
var A := start
var B := end
var R := A.inverse() * B
var S := transform.basis.get_rotation_quat()
if R.w < 0: R = -R
if S.w < 0: S = -S
if not S.is_equal_approx(R):
prints("FAIL", rotation_degrees, R, S)
Attach it to a MeshInstance child of an Spatial.
The code will run on the editor. It enables a notification for any change in its Transform. When it get the notification, it computes the quaternion from its global_transform and its parent global_transform. Rotate it around and see it if prints.
By the way, I had initially forgot that there are two ways to represent the same rotation with quaternions. So it wasn't passing the test. These lines fix it:
if R.w < 0: R = -R
if S.w < 0: S = -S
No, normalize does not fix this. And the lack of normalize didn't cause the test to fail either.
I also tested that you don't need to do that to get the correct value from get_euler. I mean, like this it works correctly:
var end := global_transform.basis.get_rotation_quat()
var start := parent.global_transform.basis.get_rotation_quat()
var A := start
var B := end
var R := A.inverse() * B
var S := transform.basis.get_rotation_quat()
if not S.get_euler().is_equal_approx(R.get_euler()):
prints("FAIL", rotation_degrees, R, S)
Similarly it works correctly with Transform directly, like this:
var end := global_transform
var start := parent.global_transform
var A := start
var B := end
var R := A.affine_inverse() * B
var S := transform
if not S.is_equal_approx(R):
prints("FAIL", rotation_degrees, R, S)