Skip to content

Commit 61a6c4f

Browse files
authored
Fix Line3 distance for parallel and non-unit directions (#224)
`Line3.distance()` raises `NameError` for parallel lines and can return incorrect distances for skew or intersecting lines. For example: ```python from spatialmath import Line3 a = Line3.PointDir([0, 0, 0], [1, 0, 0]) b = Line3.PointDir([0, 0, 2], [0.6, 0.8, 0]) print(a.distance(b)) # before: 2.5; expected: 2.0 ``` The old expression divides by the squared cross-product norm and uses a reciprocal product that mixes unit directions with unnormalized moments. Scaling directions can therefore change the result; a translated intersecting pair can also return a nonzero distance. Compute the displacement between principal points and project it onto the common normal of the unit directions. For parallel directions, use the perpendicular component of that displacement and return a scalar. Apply the existing `tol` parameter to the parallel-direction test. Initial validation on base `f6a572c6e75e2a7e3c06e632bc34153941e00404`: - Added regressions with the original implementation: **21 failed, 18 passed** in `tests/test_geom3d.py`; with the fix: **39 passed**. - Full `tests` suite: baseline **346 passed, 3 skipped**; fixed **367 passed, 3 skipped**, on Python 3.10/NumPy 2.2.6 and Python 3.12/NumPy 2.5.3. Existing plotting/deprecation warnings remain. - Separate seeded validation: **4,800 scalar comparisons per environment** against closest points computed by `numpy.linalg.lstsq`, including parallel/intersecting/skew lines, sign/scale changes, rigid transformations and operand reversal. - Pinned pre-commit hooks on both changed files, `git diff --check`, wheel and sdist builds passed. The branch is now rebased onto upstream `2de7801` (current head `9600087`). On this updated tree, the complete `tests` suite passes with **368 passed, 3 skipped** on Python 3.10.12, and the four pre-commit hooks on both changed files and `git diff --check` pass. The newer upstream commit adds a separate spatial-vector regression, accounting for one more passing test than the initial run. GitHub Actions has since run: all **10 platform/Python unittest jobs**, the aggregate test check, and Codecov passed. The Sphinx documentation build succeeded with 22 warnings, but its final `gh-pages` push failed with HTTP 403 because `github-actions[bot]` lacks write permission for this fork PR. This patch changes only `spatialmath/geom3d.py` and `tests/test_geom3d.py`. AI assistance: the implementation, tests and this description were prepared with Codex; the local validation commands were executed locally, and the multi-platform results above come from GitHub Actions.
1 parent 3899801 commit 61a6c4f

2 files changed

Lines changed: 41 additions & 15 deletions

File tree

‎spatialmath/geom3d.py‎

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -836,7 +836,7 @@ def distance(
836836
837837
:param l2: Second line
838838
:type l2: ``Line3``
839-
:param tol: Tolerance in multiples of eps, defaults to 20
839+
:param tol: Parallel-direction tolerance in multiples of eps, defaults to 20
840840
:type tol: float, optional
841841
:return: Closest distance between lines
842842
:rtype: float
@@ -847,20 +847,16 @@ def distance(
847847
848848
:seealso: :meth:`closest_to_line`
849849
"""
850-
if l1 | l2:
851-
# lines are parallel
852-
l = np.cross(
853-
l1.w, l1.v - l2.v * np.dot(l1.w, l2.w) / dot(l2.w, l2.w)
854-
) / np.linalg.norm(l1.w)
855-
else:
856-
# lines are not parallel
857-
if abs(l1 * l2) < tol * _eps:
858-
# lines intersect at a point
859-
l = 0
860-
else:
861-
# lines don't intersect, find closest distance
862-
l = abs(l1 * l2) / np.linalg.norm(np.cross(l1.w, l2.w)) ** 2
863-
return l
850+
w1, w2 = l1.uw, l2.uw
851+
normal = np.cross(w1, w2)
852+
normal_length = np.linalg.norm(normal)
853+
delta = l2.pp - l1.pp
854+
if normal_length <= tol * _eps:
855+
# Parallel lines: remove the component along the common direction.
856+
return float(np.linalg.norm(np.cross(delta, w1)))
857+
# The displacement along the common normal is the shortest distance.
858+
# Unit directions make the result independent of Plucker scaling.
859+
return float(abs(np.dot(delta, normal)) / normal_length)
864860

865861
def closest_to_line(
866862
l1, l2: Line3 # type:ignore

‎tests/test_geom3d.py‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,36 @@
1717
import matplotlib.pyplot as plt
1818

1919

20+
@pytest.mark.parametrize(
21+
"p1, w1, p2, w2, expected",
22+
[
23+
([0, 0, 0], [1, 0, 0], [0, 3, 4], [1, 0, 0], 5),
24+
([1, 2, 3], [1, 0, 0], [4, 2, 3], [-1, 0, 0], 0),
25+
([1, 2, 3], [2, 0, 0], [1, 2, 3], [0, 5, 0], 0),
26+
([0, 0, 0], [1, 0, 0], [0, 0, 2], [0.6, 0.8, 0], 2),
27+
([1, 2, 3], [2, 0, 0], [1, 2, 5], [3, 4, 0], 2),
28+
],
29+
ids=["parallel", "coincident", "intersecting", "skew-unit", "skew-scaled"],
30+
)
31+
@pytest.mark.parametrize("scale1, scale2", [(1, 1), (2, 5), (-2, 0.25), (1e-8, 1e-8)])
32+
def test_line_distance(p1, w1, p2, w2, expected, scale1, scale2):
33+
# Scaling either set of Plucker coordinates does not change the line.
34+
line1 = Line3.PointDir(p1, np.array(w1) * scale1)
35+
line2 = Line3.PointDir(p2, np.array(w2) * scale2)
36+
for first, second in [(line1, line2), (line2, line1)]:
37+
distance = first.distance(second)
38+
assert np.isscalar(distance)
39+
assert distance == pytest.approx(expected, abs=1e-12)
40+
41+
42+
def test_line_distance_parallel_tolerance():
43+
line1 = Line3.PointDir([0, 0, 0], [1, 0, 0])
44+
line2 = Line3.PointDir([0, 1, 2], [1, 1e-8, 0])
45+
assert line1.distance(line2) == pytest.approx(2)
46+
# A larger angular tolerance treats the directions as parallel.
47+
assert line1.distance(line2, tol=1e8) == pytest.approx(np.sqrt(5))
48+
49+
2050
class Line3Test(unittest.TestCase):
2151
# Primitives
2252
def test_constructor1(self):

0 commit comments

Comments
 (0)