Skip to content

Commit 3deefc8

Browse files
committed
Make Line3 relation tests invariant to Plucker scale
Normalize line directions and moments for parallel and intersection checks. Construct common perpendiculars from the nearest point and unit direction cross product, with regression coverage across Plucker scalings. Assisted-by: OpenAI Codex (GPT-6 Sol) Signed-off-by: 李永祺 <doribelove@gmail.com>
1 parent 2de7801 commit 3deefc8

2 files changed

Lines changed: 72 additions & 11 deletions

File tree

‎spatialmath/geom3d.py‎

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -693,7 +693,14 @@ def isparallel(
693693
694694
:seealso: :meth:`__or__` :meth:`intersects`
695695
"""
696-
return bool(np.linalg.norm(np.cross(l1.w, l2.w)) < tol * _eps)
696+
n1 = np.linalg.norm(l1.w)
697+
n2 = np.linalg.norm(l2.w)
698+
if n1 == 0 or n2 == 0:
699+
return True
700+
701+
# Plucker coordinates are homogeneous: compare the angle, not the
702+
# magnitude of the direction vectors.
703+
return bool(np.linalg.norm(np.cross(l1.w / n1, l2.w / n2)) < tol * _eps)
697704

698705
def isintersecting(
699706
l1, l2: Line3, tol: float = 20 # type: ignore
@@ -715,7 +722,15 @@ def isintersecting(
715722
716723
:seealso: :meth:`__xor__` :meth:`intersects` :meth:`isparallel`
717724
"""
718-
return not l1.isparallel(l2, tol=tol) and bool(abs(l1 * l2) < tol * _eps)
725+
if l1.isparallel(l2, tol=tol):
726+
return False
727+
728+
n1 = np.linalg.norm(l1.w)
729+
n2 = np.linalg.norm(l2.w)
730+
# Normalize both moments as well as directions before applying the
731+
# intersection tolerance; each pair (v, w) may be rescaled freely.
732+
reciprocal = np.dot(l1.w / n1, l2.v / n2) + np.dot(l2.w / n2, l1.v / n1)
733+
return bool(abs(reciprocal) < tol * _eps)
719734

720735
def __eq__(l1, l2: Line3) -> bool: # type: ignore pylint: disable=no-self-argument
721736
"""
@@ -1009,16 +1024,12 @@ def commonperp(
10091024
if l1 | l2:
10101025
# no common perpendicular if lines are parallel
10111026
return None
1012-
else:
1013-
# lines are skew or intersecting
1014-
w = np.cross(l1.w, l2.w)
1015-
v = (
1016-
np.cross(l1.v, l2.w)
1017-
- np.cross(l2.v, l1.w)
1018-
+ (l1 * l2) * np.dot(l1.w, l2.w) * base.unitvec(np.cross(l1.w, l2.w))
1019-
)
10201027

1021-
return l1.__class__(v, w)
1028+
# The nearest point lies on both the common perpendicular and l1.
1029+
# Unit directions keep its direction independent of Plucker scaling.
1030+
point, _ = l1.closest_to_line(l2)
1031+
w = np.cross(l1.w / np.linalg.norm(l1.w), l2.w / np.linalg.norm(l2.w))
1032+
return l1.__class__.PointDir(point, w)
10221033

10231034
def __mul__(
10241035
left, right: Line3

‎tests/test_geom3d.py‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,24 @@ def test_parallel(self):
207207
self.assertFalse(L1.isparallel(L3))
208208
self.assertFalse(L1 | L3)
209209

210+
def test_parallel_plucker_scale(self):
211+
for scale1, scale2 in ((1, 1), (1e-8, 1e-8), (-1e-8, 1e-8)):
212+
l1 = Line3.PointDir([0, 0, 0], [scale1, 0, 0])
213+
perpendicular = Line3.PointDir([0, 0, 1], [0, scale2, 0])
214+
parallel = Line3.PointDir([0, 0, 1], [scale2, 0, 0])
215+
216+
self.assertFalse(l1.isparallel(perpendicular))
217+
self.assertFalse(l1 | perpendicular)
218+
self.assertTrue(l1.isparallel(parallel))
219+
self.assertTrue(l1 | parallel)
220+
221+
# A loose angular tolerance must not shrink when Plucker coordinates grow.
222+
l1 = Line3.PointDir([0, 0, 0], [1, 0, 0])
223+
almost_parallel = Line3.PointDir([0, 0, 1], [1, 1e-9, 0])
224+
scaled = Line3.PointDir([0, 0, 1], [1e8, 0.1, 0])
225+
self.assertTrue(l1.isparallel(almost_parallel, tol=1e8))
226+
self.assertTrue(l1.isparallel(scaled, tol=1e8))
227+
210228
def test_intersect(self):
211229
L1 = Line3.PointDir([4, 5, 6], [1, 2, 3])
212230
L2 = Line3.PointDir([5, 5, 6], [1, 2, 3])
@@ -223,6 +241,22 @@ def test_intersect(self):
223241
L3 ^ L4,
224242
)
225243

244+
def test_intersect_plucker_scale(self):
245+
for scale in (1, 1e-8, 1e8):
246+
l1 = Line3.PointDir([0, 0, 0], [scale, 0, 0])
247+
crossing = Line3.PointDir([0, 0, 0], [0, scale, 0])
248+
skew = Line3.PointDir([0, 0, 1e-7], [0, scale, 0])
249+
250+
self.assertTrue(l1.isintersecting(crossing))
251+
self.assertTrue(l1 ^ crossing)
252+
self.assertFalse(l1.isintersecting(skew))
253+
self.assertFalse(l1 ^ skew)
254+
255+
# Tolerance on the miss distance is independent of direction magnitude.
256+
l1 = Line3.PointDir([0, 0, 0], [1e8, 0, 0])
257+
near = Line3.PointDir([0, 0, 1e-8], [0, 1e8, 0])
258+
self.assertTrue(l1.isintersecting(near, tol=1e9))
259+
226260
def test_commonperp(self):
227261
L1 = Line3.PointDir([4, 5, 6], [0, 0, 1])
228262
L2 = Line3.PointDir([6, 5, 6], [0, 1, 0])
@@ -237,6 +271,22 @@ def test_commonperp(self):
237271
self.assertTrue(L ^ L1)
238272
self.assertTrue(L ^ L2)
239273

274+
def test_commonperp_plucker_scale(self):
275+
for scale1, scale2 in ((1, 1), (1e-8, 1e-8), (-1e-8, 1e8)):
276+
l1 = Line3.PointDir([1, 2, 3], [scale1, 0, 0])
277+
l2 = Line3.PointDir([0, 2, 4], [scale2, scale2, 0])
278+
279+
common = l1.commonperp(l2)
280+
self.assertIsNotNone(common)
281+
nt.assert_allclose(common.pp, [0, 2, 0], atol=1e-12)
282+
self.assertAlmostEqual(abs(common.uw[2]), 1)
283+
self.assertTrue(common.isintersecting(l1))
284+
self.assertTrue(common.isintersecting(l2))
285+
286+
nearest, distance = l1.closest_to_line(l2)
287+
nt.assert_allclose(nearest, [0, 2, 3], atol=1e-12)
288+
self.assertAlmostEqual(distance, 1)
289+
240290
def test_line(self):
241291
# mindist
242292
# intersect

0 commit comments

Comments
 (0)