Skip to content

Commit 9ac32e7

Browse files
committed
fix: SO3.RotatedVector returned identity for antipodal vectors
SO3.RotatedVector(v1, v2) computes the rotation axis as v1 x v2. That cross product is zero whenever v1 and v2 are (anti)parallel, and the existing singularity check treated both cases identically -- returning identity regardless. Correct only when v1 and v2 point the same way; when they're antipodal (v2 == -v1), the correct answer is a 180 degree rotation about any axis perpendicular to v1, not identity. Distinguish the two cases via the sign of v1.v2 at the singularity. The antipodal case uses the closed form for a 180 degree rotation about a unit axis u (R = 2*u*u^T - I), with u found by crossing v1 against whichever world axis it's least aligned with. Found while building spatialgeometry's Arrow.FromTo(start, end), which needs exactly this "rotate a reference axis to point along an arbitrary direction" operation and hit the bug immediately for an arrow pointing straight backward relative to its own rest orientation.
1 parent 8b83c1f commit 9ac32e7

2 files changed

Lines changed: 31 additions & 1 deletion

File tree

‎spatialmath/pose3d.py‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -769,7 +769,24 @@ def RotatedVector(cls, v1: ArrayLike3, v2: ArrayLike3, tol=20) -> Self:
769769
v = smb.cross(v1, v2)
770770
s = smb.norm(v)
771771
if abs(s) < tol * np.finfo(float).eps:
772-
return cls(np.eye(3), check=False)
772+
c = np.dot(v1, v2)
773+
if c > 0:
774+
# v1 and v2 already (anti)parallel in the same direction
775+
return cls(np.eye(3), check=False)
776+
# v1 and v2 point in opposite directions -- the formula below
777+
# is singular here too (it divides by s**2), but unlike the
778+
# c > 0 case the answer isn't identity: any 180 degree
779+
# rotation about an axis perpendicular to v1 takes v1 to v2.
780+
# Closed form for a 180 degree rotation about unit axis u:
781+
# R = 2*u*u^T - I (Rodrigues at theta=pi, sin=0, cos=-1).
782+
# Pick u by crossing v1 with whichever world axis it's least
783+
# aligned with, so the cross product is never itself
784+
# degenerate.
785+
axis = np.zeros(3)
786+
axis[np.argmin(np.abs(v1))] = 1.0
787+
u = smb.unitvec(smb.cross(v1, axis))
788+
R = 2 * np.outer(u, u) - np.eye(3)
789+
return cls(R, check=False)
773790
else:
774791
c = np.dot(v1, v2)
775792
V = smb.skew(v)

‎tests/test_pose3d.py‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -726,6 +726,19 @@ def test_rotatedvector(self):
726726
Re = SO3.RotatedVector(v1, v1)
727727
np.testing.assert_almost_equal(Re, np.eye(3))
728728

729+
# Antipodal case: v1 and v2 point in exactly opposite directions.
730+
# The cross product used to find the rotation axis is zero here
731+
# too, same as the parallel case above, but the correct answer is
732+
# a 180 degree flip, not identity -- regression test for a bug
733+
# where this silently returned identity instead.
734+
for v1 in ([1, 0, 0], [0, 1, 0], [0, 0, 1], [1, 2, 3], [-3, 1, 2]):
735+
v1 = unitvec(v1)
736+
v2 = [-x for x in v1]
737+
Re = SO3.RotatedVector(v1, v2)
738+
np.testing.assert_almost_equal(np.asarray(Re * v1).flatten(), v2)
739+
# must actually be a 180 degree rotation, not identity
740+
assert not np.allclose(Re.A, np.eye(3))
741+
729742
R = SO3() # identity matrix case
730743

731744
# Check log and exponential map

0 commit comments

Comments
 (0)