From 54fb3c895f039041e84f65cb76c044c03f47571e Mon Sep 17 00:00:00 2001 From: Huashuo Lei <139048865+wpyaoxueros@users.noreply.github.com> Date: Sun, 18 Oct 2026 03:51:59 +0800 Subject: [PATCH 1/2] Normalize the axis in UnitQuaternion.AngVec --- spatialmath/quaternion.py | 3 ++ tests/test_angvec_nonunit_axis.py | 52 +++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 tests/test_angvec_nonunit_axis.py diff --git a/spatialmath/quaternion.py b/spatialmath/quaternion.py index 29d68de6..98bffcaa 100644 --- a/spatialmath/quaternion.py +++ b/spatialmath/quaternion.py @@ -1551,6 +1551,9 @@ def AngVec( """ v = smb.getvector(v, 3) theta = smb.getunit(theta, unit, vector=False) + if theta == 0: + return cls() + v = smb.unitvec(v) return cls( s=math.cos(theta / 2), v=math.sin(theta / 2) * v, norm=False, check=False ) diff --git a/tests/test_angvec_nonunit_axis.py b/tests/test_angvec_nonunit_axis.py new file mode 100644 index 00000000..8eb3926b --- /dev/null +++ b/tests/test_angvec_nonunit_axis.py @@ -0,0 +1,52 @@ +"""Angle-axis construction preserves unit-quaternion and zero-angle contracts.""" + +import math +import unittest + +import numpy as np +from numpy.testing import assert_allclose + +from spatialmath import UnitQuaternion +from spatialmath.base import angvec2r + + +class TestAngVecNonunitAxis(unittest.TestCase): + def test_unit_norm(self): + assert_allclose(np.linalg.norm(UnitQuaternion.AngVec(0.5, [1, 2, 3]).vec), 1) + + def test_angle_round_trip(self): + theta, axis = UnitQuaternion.AngVec(0.5, [1, 2, 3]).angvec() + assert_allclose(theta, 0.5) + assert_allclose(axis, np.array([1, 2, 3]) / np.sqrt(14)) + + def test_rotation_matrix(self): + assert_allclose( + UnitQuaternion.AngVec(0.5, [1, 2, 3]).R, + angvec2r(0.5, [1, 2, 3]), + atol=1e-12, + ) + + def test_degrees(self): + assert_allclose( + UnitQuaternion.AngVec(90, [0, 0, 4], unit="deg").R, + angvec2r(math.pi / 2, [0, 0, 1]), + atol=1e-12, + ) + + def test_unit_axis_control(self): + assert_allclose( + UnitQuaternion.AngVec(0.5, [1, 0, 0]).vec, + [math.cos(0.25), math.sin(0.25), 0, 0], + ) + + def test_zero_angle_zero_axis(self): + assert_allclose(UnitQuaternion.AngVec(0, [0, 0, 0]).vec, [1, 0, 0, 0]) + + def test_zero_degrees_zero_axis(self): + assert_allclose( + UnitQuaternion.AngVec(0, [0, 0, 0], unit="deg").vec, [1, 0, 0, 0] + ) + + def test_nonzero_angle_requires_nonzero_axis(self): + with self.assertRaises(ValueError): + UnitQuaternion.AngVec(0.5, [0, 0, 0]) From 6940542e7c68dbe310cfecf80de182db8b220cd4 Mon Sep 17 00:00:00 2001 From: Huashuo Lei <139048865+wpyaoxueros@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:07:14 +0800 Subject: [PATCH 2/2] Use the scalar zero tolerance for angle-axis quaternions --- spatialmath/quaternion.py | 7 ++++--- tests/test_angvec_nonunit_axis.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/spatialmath/quaternion.py b/spatialmath/quaternion.py index 98bffcaa..f788d738 100644 --- a/spatialmath/quaternion.py +++ b/spatialmath/quaternion.py @@ -1544,14 +1544,15 @@ def AngVec( >>> print(UQ.AngVec(0, [1,0,0])) >>> print(UQ.AngVec(90, [1,0,0], unit='deg')) - .. note:: :math:`\theta = 0` the result in an identity quaternion, otherwise - ``V`` must have a finite length, ie. :math:`|V| > 0`. + .. note:: If :math:`\theta` is zero within the default tolerance of + :func:`~spatialmath.base.vectors.iszero`, the result is an identity + quaternion. Otherwise, ``v`` must have a nonzero norm. :seealso: :meth:`UnitQuaternion.angvec` :meth:`UnitQuaternion.exp` :func:`~spatialmath.base.transforms3d.angvec2r` """ v = smb.getvector(v, 3) theta = smb.getunit(theta, unit, vector=False) - if theta == 0: + if smb.iszero(theta): return cls() v = smb.unitvec(v) return cls( diff --git a/tests/test_angvec_nonunit_axis.py b/tests/test_angvec_nonunit_axis.py index 8eb3926b..22c748df 100644 --- a/tests/test_angvec_nonunit_axis.py +++ b/tests/test_angvec_nonunit_axis.py @@ -50,3 +50,33 @@ def test_zero_degrees_zero_axis(self): def test_nonzero_angle_requires_nonzero_axis(self): with self.assertRaises(ValueError): UnitQuaternion.AngVec(0.5, [0, 0, 0]) + + def test_near_zero_angle_zero_axis(self): + for theta in [1e-20, -1e-20]: + with self.subTest(theta=theta): + assert_allclose( + UnitQuaternion.AngVec(theta, [0, 0, 0]).vec, [1, 0, 0, 0] + ) + + def test_near_zero_degrees_zero_axis(self): + for theta in [1e-20, -1e-20]: + with self.subTest(theta=theta): + assert_allclose( + UnitQuaternion.AngVec(theta, [0, 0, 0], unit="deg").vec, + [1, 0, 0, 0], + ) + + def test_nonzero_small_angle_requires_nonzero_axis(self): + for theta in [1e-10, -1e-10]: + with self.subTest(theta=theta): + with self.assertRaises(ValueError): + UnitQuaternion.AngVec(theta, [0, 0, 0]) + + def test_nonzero_small_angle_preserves_rotation(self): + for theta in [1e-10, -1e-10]: + with self.subTest(theta=theta): + assert_allclose( + UnitQuaternion.AngVec(theta, [2, 0, 0]).vec, + [math.cos(theta / 2), math.sin(theta / 2), 0, 0], + atol=0, + )