Skip to content

Commit 59f3a3c

Browse files
authored
fix: SO3.RotatedVector returned identity for antipodal vectors (#201)
## Summary `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 (`abs(s) < tol * eps`) treats both cases identically -- returning identity regardless: ```python >>> from spatialmath import SO3 >>> SO3.RotatedVector([0, 0, 1], [0, 0, -1]) # should flip, not be identity 1 0 0 0 1 0 0 0 1 ``` That's correct when `v1` and `v2` point the same way, but wrong when they're antipodal (`v2 == -v1`) -- the correct answer there is a 180° rotation about any axis perpendicular to `v1`, not identity. - [x] Distinguish the two cases via the sign of `v1 . v2` at the singularity. - [x] The antipodal case uses the closed form for a 180° 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 (so the cross product used to find `u` is never itself degenerate). - [x] Existing test coverage (`tests/test_pose3d.py::test_rotatedvector`) only covered the general and exact-same-direction cases -- added the antipodal case there, parametrised over several different `v1` values. Found this while building a `FromTo(start, end)` constructor for an `Arrow` shape in a downstream package, which needs exactly this "rotate a reference axis to point along an arbitrary direction" operation and hit the bug immediately for a direction pointing straight backward relative to the reference axis. ## Test plan - [x] Full suite: `pytest tests/` -- 342 passed - [x] New antipodal-case test passes; confirmed it fails against the old code (returns identity instead of a real rotation) - [x] Confirmed the general-rotation and exact-same-direction cases are unaffected (existing test assertions unchanged)
1 parent c20408d commit 59f3a3c

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
@@ -731,6 +731,19 @@ def test_rotatedvector(self):
731731
Re = SO3.RotatedVector(v1, v1)
732732
np.testing.assert_almost_equal(Re, np.eye(3))
733733

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

736749
# Check log and exponential map

0 commit comments

Comments
 (0)