Skip to content

Commit 736b1ef

Browse files
authored
Fix @turf/nearest-point-on-line endpoint selection and degenerate input cases (#2940)
* @turf/nearest-point-on-line code simplifications Before fixing behavioral bugs, making some non-behavior changing cleanups: - Removing unused distance calculations - Cleaning up tuples and position variable use in main loop - Removing unnecessary conditions and types for intersectPt (it will always exist) - Added return type to magnitude function - Reduced duplicate calculations in return values for nearestPointOnSegment Fix @turf/nearest-point-on-line #2934 issue Changed closest point determination logic to ensure that the correct endpoint is returned in certain cases where the line segment spans more than Pi radians. Specific changes: - Added normalize vector function - Replaced inline coefficient calculations with existing vector functions for readability - Added geometric rationale comments for intermediate steps - Added minimal failing test for #2934 - Replaced closest intersection point logic with dot product version - Replaced closest endpoint distance logic with dot product version (less calculations) Fix @turf/nearest-point-on-line degenerate cases Cross product usage leads to several degenerate edge cases that can lead to spurious failures. This attempts to fix several of them: - Clamp z-value before asin when converting to lng-lat - Capture degenerate zero-vectors in the segment's normal from coincident or antipodal points - Early return from coincident points as this becomes a pt-pt distance - Explicit throw from antipodal points as an infinite number of arcs match - Early return when the target is coincident with the segment's normal, here all points are equidistant so choose the endpoint for consistency @turf/nearest-point-on-line improve test Improve test addressing degenerate line segment cases. These tests were failing in browser but succeeding in Chrome. This test fix monkey patches Node to capture any regressions. * Fix @Turf nearest-point-on-line incorrect endpoints Two additional fixes: - Caught an additional failure case widely separated points could incorrectly return that the intersection point was inside the line segment; added regression test for this case - Elminated throw on antipodal points, instead returning that the point lies directly on the line; added comment in function signature mentioning this behavior * Fix: @turf/nearest-point-on-line failing test After changes, tests in @turf/point-to-polygon-distance were failing. Issue confirmed to be due to single bit floating point difference and updated to new output value. Also fixed partially missing comment. * Fix: @turf/nearest-point-on-line faililng test 9edc247 introduced another floating point precision issue that is fixed here.
1 parent ad10a65 commit 736b1ef

File tree

5 files changed

+189
-93
lines changed

5 files changed

+189
-93
lines changed

packages/turf-nearest-point-on-line/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@
66

77
Returns the nearest point on a line to a given point.
88

9+
If any of the segments in the input line string are antipodal and therefore
10+
have an undefined arc, this function will instead return that the point lies
11+
on the line.
12+
913
### Parameters
1014

1115
* `lines` **([Geometry][1] | [Feature][2]<([LineString][3] | [MultiLineString][4])>)** lines to snap to

packages/turf-nearest-point-on-line/index.ts

Lines changed: 95 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ import { getCoord, getCoords } from "@turf/invariant";
1313
/**
1414
* Returns the nearest point on a line to a given point.
1515
*
16+
* If any of the segments in the input line string are antipodal and therefore
17+
* have an undefined arc, this function will instead return that the point lies
18+
* on the line.
19+
*
1620
* @function
1721
* @param {Geometry|Feature<LineString|MultiLineString>} lines lines to snap to
1822
* @param {Geometry|Feature<Point>|number[]} pt point to snap from
@@ -75,12 +79,10 @@ function nearestPointOnLine<G extends LineString | MultiLineString>(
7579
for (let i = 0; i < coords.length - 1; i++) {
7680
//start - start of current line section
7781
const start: Feature<Point, { dist: number }> = point(coords[i]);
78-
start.properties.dist = distance(pt, start, options);
7982
const startPos = getCoord(start);
8083

8184
//stop - end of current line section
8285
const stop: Feature<Point, { dist: number }> = point(coords[i + 1]);
83-
stop.properties.dist = distance(pt, stop, options);
8486
const stopPos = getCoord(stop);
8587

8688
// sectionLength
@@ -89,40 +91,28 @@ function nearestPointOnLine<G extends LineString | MultiLineString>(
8991
let wasEnd: boolean;
9092

9193
// Short circuit if snap point is start or end position of the line
92-
// segment or if start is equal to stop position.
93-
if (startPos[0] === ptPos[0] && startPos[1] === ptPos[1]) {
94-
[intersectPos, , wasEnd] = [startPos, undefined, false];
95-
} else if (stopPos[0] === ptPos[0] && stopPos[1] === ptPos[1]) {
96-
[intersectPos, , wasEnd] = [stopPos, undefined, true];
97-
} else if (startPos[0] === stopPos[0] && startPos[1] === stopPos[1]) {
98-
[intersectPos, , wasEnd] = [stopPos, undefined, true];
94+
// Test the end position first for consistency in case they are
95+
// coincident
96+
if (stopPos[0] === ptPos[0] && stopPos[1] === ptPos[1]) {
97+
[intersectPos, wasEnd] = [stopPos, true];
98+
} else if (startPos[0] === ptPos[0] && startPos[1] === ptPos[1]) {
99+
[intersectPos, wasEnd] = [startPos, false];
99100
} else {
100101
// Otherwise, find the nearest point the hard way.
101-
[intersectPos, , wasEnd] = nearestPointOnSegment(
102-
start.geometry.coordinates,
103-
stop.geometry.coordinates,
104-
getCoord(pt)
102+
[intersectPos, wasEnd] = nearestPointOnSegment(
103+
startPos,
104+
stopPos,
105+
ptPos
105106
);
106107
}
107-
let intersectPt:
108-
| Feature<
109-
Point,
110-
{ dist: number; multiFeatureIndex: number; location: number }
111-
>
112-
| undefined;
113-
114-
if (intersectPos) {
115-
intersectPt = point(intersectPos, {
116-
dist: distance(pt, intersectPos, options),
117-
multiFeatureIndex: multiFeatureIndex,
118-
location: length + distance(start, intersectPos, options),
119-
});
120-
}
121108

122-
if (
123-
intersectPt &&
124-
intersectPt.properties.dist < closestPt.properties.dist
125-
) {
109+
const intersectPt = point(intersectPos, {
110+
dist: distance(pt, intersectPos, options),
111+
multiFeatureIndex: multiFeatureIndex,
112+
location: length + distance(start, intersectPos, options),
113+
});
114+
115+
if (intersectPt.properties.dist < closestPt.properties.dist) {
126116
closestPt = {
127117
...intersectPt,
128118
properties: {
@@ -143,12 +133,7 @@ function nearestPointOnLine<G extends LineString | MultiLineString>(
143133
return closestPt;
144134
}
145135

146-
/*
147-
* Plan is to externalise these vector functions to a simple third party
148-
* library.
149-
* Possible candidate is @amandaghassaei/vector-math though having some import
150-
* issues.
151-
*/
136+
// A simple Vector3 type for cartesian operations.
152137
type Vector = [number, number, number];
153138

154139
function dot(v1: Vector, v2: Vector): number {
@@ -164,13 +149,13 @@ function cross(v1: Vector, v2: Vector): Vector {
164149
return [v1y * v2z - v1z * v2y, v1z * v2x - v1x * v2z, v1x * v2y - v1y * v2x];
165150
}
166151

167-
function magnitude(v: Vector) {
152+
function magnitude(v: Vector): number {
168153
return Math.sqrt(Math.pow(v[0], 2) + Math.pow(v[1], 2) + Math.pow(v[2], 2));
169154
}
170155

171-
function angle(v1: Vector, v2: Vector): number {
172-
const theta = dot(v1, v2) / (magnitude(v1) * magnitude(v2));
173-
return Math.acos(Math.min(Math.max(theta, -1), 1));
156+
function normalize(v: Vector): Vector {
157+
const mag = magnitude(v);
158+
return [v[0] / mag, v[1] / mag, v[2] / mag];
174159
}
175160

176161
function lngLatToVector(a: Position): Vector {
@@ -185,7 +170,11 @@ function lngLatToVector(a: Position): Vector {
185170

186171
function vectorToLngLat(v: Vector): Position {
187172
const [x, y, z] = v;
188-
const lat = radiansToDegrees(Math.asin(z));
173+
// Clamp the z-value to ensure that is inside the [-1, 1] domain as required
174+
// by asin. Note therefore that this function should only be applied to unit
175+
// vectors so z > 1 should not exist
176+
const zClamp = Math.min(Math.max(z, -1), 1);
177+
const lat = radiansToDegrees(Math.asin(zClamp));
189178
const lng = radiansToDegrees(Math.atan2(y, x));
190179

191180
return [lng, lat];
@@ -195,7 +184,7 @@ function nearestPointOnSegment(
195184
posA: Position, // start point of segment to measure to
196185
posB: Position, // end point of segment to measure to
197186
posC: Position // point to measure from
198-
): [Position, boolean, boolean] {
187+
): [Position, boolean] {
199188
// Based heavily on this article on finding cross track distance to an arc:
200189
// https://gis.stackexchange.com/questions/209540/projecting-cross-track-distance-on-great-circle
201190

@@ -206,62 +195,78 @@ function nearestPointOnSegment(
206195
const B = lngLatToVector(posB); // ... to posB
207196
const C = lngLatToVector(posC); // ... to posC
208197

209-
// Components of target point.
210-
const [Cx, Cy, Cz] = C;
211-
212-
// Calculate coefficients.
213-
const [D, E, F] = cross(A, B);
214-
const a = E * Cz - F * Cy;
215-
const b = F * Cx - D * Cz;
216-
const c = D * Cy - E * Cx;
217-
218-
const f = c * E - b * F;
219-
const g = a * F - c * D;
220-
const h = b * D - a * E;
198+
// The axis (normal vector) of the great circle plane containing the line segment
199+
const segmentAxis = cross(A, B);
200+
201+
// Two degenerate cases exist for the segment axis cross product. The first is
202+
// when vectors are aligned (within the bounds of floating point tolerance).
203+
// The second is where vectors are antipodal (again within the bounds of
204+
// tolerance. Both cases produce a [0, 0, 0] cross product which invalidates
205+
// the rest of the algorithm, but each case must be handled separately:
206+
// - The aligned case indicates coincidence of A and B. therefore this can be
207+
// an early return assuming the closest point is the end (for consistency).
208+
// - The antipodal case is truly degenerate - an infinte number of great
209+
// circles are possible and one will always pass through C. However, given
210+
// that this case is both highly unlikely to occur in practice and that is
211+
// will usually be logically sound to return that the point is on the
212+
// segment, we choose to return the provided point.
213+
if (segmentAxis[0] === 0 && segmentAxis[1] === 0 && segmentAxis[2] === 0) {
214+
if (dot(A, B) > 0) {
215+
return [[...posB], true];
216+
} else {
217+
return [[...posC], false];
218+
}
219+
}
221220

222-
const t = 1 / Math.sqrt(Math.pow(f, 2) + Math.pow(g, 2) + Math.pow(h, 2));
221+
// The axis of the great circle passing through the segment's axis and the
222+
// target point
223+
const targetAxis = cross(segmentAxis, C);
223224

224-
// Vectors to the two points these great circles intersect.
225-
const I1: Vector = [f * t, g * t, h * t];
226-
const I2: Vector = [-1 * f * t, -1 * g * t, -1 * h * t];
225+
// This cross product also has a degenerate case where the segment axis is
226+
// coincident with or antipodal to the target point. In this case the point
227+
// is equidistant to the entire segment. For consistency, we early return the
228+
// endpoint as the matching point.
229+
if (targetAxis[0] === 0 && targetAxis[1] === 0 && targetAxis[2] === 0) {
230+
return [[...posB], true];
231+
}
227232

228-
// Figure out which is the closest intersection to this segment of the great
229-
// circle.
230-
const angleAB = angle(A, B);
231-
const angleAI1 = angle(A, I1);
232-
const angleBI1 = angle(B, I1);
233-
const angleAI2 = angle(A, I2);
234-
const angleBI2 = angle(B, I2);
233+
// The line of intersection between the two great circle planes
234+
const intersectionAxis = cross(targetAxis, segmentAxis);
235235

236-
let I: Vector;
236+
// Vectors to the two points these great circles intersect are the normalized
237+
// intersection and its antipodes
238+
const I1 = normalize(intersectionAxis);
239+
const I2: Vector = [-I1[0], -I1[1], -I1[2]];
237240

238-
if (
239-
(angleAI1 < angleAI2 && angleAI1 < angleBI2) ||
240-
(angleBI1 < angleAI2 && angleBI1 < angleBI2)
241-
) {
242-
I = I1;
243-
} else {
244-
I = I2;
245-
}
241+
// Figure out which is the closest intersection to this segment of the great circle
242+
// Note that for points on a unit sphere, the dot product represents the
243+
// cosine of the angle between the two vectors which monotonically increases
244+
// the closer the two points are together and therefore determines proximity
245+
const I = dot(C, I1) > dot(C, I2) ? I1 : I2;
246246

247247
// I is the closest intersection to the segment, though might not actually be
248-
// ON the segment.
249-
250-
// If angle AI or BI is greater than angleAB, I lies on the circle *beyond* A
251-
// and B so use the closest of A or B as the intersection
252-
if (angle(A, I) > angleAB || angle(B, I) > angleAB) {
253-
if (
254-
distance(vectorToLngLat(I), vectorToLngLat(A)) <=
255-
distance(vectorToLngLat(I), vectorToLngLat(B))
256-
) {
257-
return [vectorToLngLat(A), true, false];
258-
} else {
259-
return [vectorToLngLat(B), false, true];
260-
}
248+
// ON the segment. To test whether the closest intersection lies on the arc or
249+
// not, we do a cross product comparison to check rotation around the unit
250+
// circle defined by the great circle plane.
251+
const segmentAxisNorm = normalize(segmentAxis);
252+
const cmpAI = dot(cross(A, I), segmentAxisNorm);
253+
const cmpIB = dot(cross(I, B), segmentAxisNorm);
254+
255+
// When both comparisons are positive, the rotation from A to I to B is in the
256+
// same direction, implying that I lies between A and B
257+
if (cmpAI >= 0 && cmpIB >= 0) {
258+
return [vectorToLngLat(I), false];
261259
}
262260

263-
// As angleAI nor angleBI don't exceed angleAB, I is on the segment
264-
return [vectorToLngLat(I), false, false];
261+
// Finally process the case where the intersection is not on the segment,
262+
// using the dot product with the original point to find the closest endpoint
263+
if (dot(A, C) > dot(B, C)) {
264+
// Clone position when returning as it is reasonable to not expect structural
265+
// sharing on the returned Position in all return cases
266+
return [[...posA], false];
267+
} else {
268+
return [[...posB], true];
269+
}
265270
}
266271

267272
export { nearestPointOnLine };

packages/turf-nearest-point-on-line/test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
point,
1515
featureCollection,
1616
round,
17+
degreesToRadians,
1718
} from "@turf/helpers";
1819
import { nearestPointOnLine } from "./index.js";
1920

@@ -517,3 +518,89 @@ test("turf-nearest-point-on-line -- issue 2808 redundant point support", (t) =>
517518

518519
t.end();
519520
});
521+
522+
test("turf-nearest-point-on-line -- issue 2934 correct endpoint chosen when in opposite hemisphere", (t) => {
523+
// create a long line where the southern end point should be chosen, but the
524+
// northern endpoint is closer to the projected great circles
525+
const line = lineString([
526+
[25, 88],
527+
[25, -70],
528+
]);
529+
const pt = point([-45, -88]);
530+
const nearest = nearestPointOnLine(line, pt);
531+
532+
t.deepEqual(
533+
truncate(nearest, { precision: 8 }).geometry.coordinates,
534+
[25, -70],
535+
"nearest point is in the Southern Hemisphere"
536+
);
537+
538+
t.end();
539+
});
540+
541+
test("turf-nearest-point-on-line -- correctly reports external intersection for large arcs", (t) => {
542+
// create a test case where the arc is > 2Pi and the closest intersection
543+
// point is far from the arc - this case was failing due to an angular
544+
// comparison only working for small arcs; this test case checks this
545+
// regression
546+
const line = lineString([
547+
[25, 80],
548+
[25, -70],
549+
]);
550+
const pt = point([-88, 13]);
551+
const nearest = nearestPointOnLine(line, pt);
552+
553+
t.deepEqual(
554+
nearest.geometry.coordinates,
555+
[25, 80],
556+
"nearest point is at the northern end"
557+
);
558+
559+
t.end();
560+
});
561+
562+
test("turf-nearest-point-on-line -- issue 2939 nearestPointOnSegment handles tiny segments", (t) => {
563+
// create a test case where the line segment passed is small enough such that
564+
// a cross product used to generate its normal in nearestPointOnSegment ends
565+
// up as [0, 0, 0] but large enough so that the two points are not ===
566+
//
567+
// In v7.2.0 this test case was failing in browser (Chrome 141.0) but
568+
// succeeding on Node (v22.20.0) due to a bit difference in the cosine
569+
// implementation. To reproduce the same effect in Node, we monkey patch
570+
// Math.cos to temporarily return the browser value as it was too difficult to
571+
// find a repro case in node. This monkey patch version fails on v7.2.0 but
572+
// passes with degenerate point fixes.
573+
574+
const lngA = 35.000102519989014;
575+
const lngB = 35.00010251998902;
576+
const line = lineString([
577+
[lngA, 32.00010141921075],
578+
[lngB, 32.00010141921075],
579+
]);
580+
581+
// WARN: Override cos function to give specific results for this test
582+
const originalCos = Math.cos;
583+
Math.cos = (x) => {
584+
// The cosine of lngA gives a different result in the tested browsers than
585+
// it does in node, therefore capturing here for this test run only.
586+
if (x === degreesToRadians(lngA)) {
587+
return originalCos(degreesToRadians(lngB));
588+
} else {
589+
return originalCos(x);
590+
}
591+
};
592+
593+
const pt = point([35.0005, 32.0005]);
594+
const nearest = nearestPointOnLine(line, pt);
595+
596+
// WARN: Reset cos function
597+
Math.cos = originalCos;
598+
599+
t.deepEqual(
600+
nearest.geometry.coordinates,
601+
[35.00010251998902, 32.00010141921075],
602+
"nearest point should be the end point of the line string"
603+
);
604+
605+
t.end();
606+
});

packages/turf-point-to-polygon-distance/test_fixtures/issue_2824.geojson

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -392,7 +392,7 @@
392392
"type": "Feature",
393393
"properties": {
394394
"name": "point1",
395-
"expected_distance": -282.4713222746312,
395+
"expected_distance": -282.47132227384475,
396396
"units": "meters"
397397
},
398398
"geometry": {

packages/turf-point-to-polygon-distance/test_fixtures/multi-polygon.geojson

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"type": "Feature",
66
"properties": {
77
"name": "outer",
8-
"expected_distance": 22.10311139107688,
8+
"expected_distance": 22.10311139186459,
99
"units": "meters"
1010
},
1111
"geometry": {
@@ -53,7 +53,7 @@
5353
"type": "Feature",
5454
"properties": {
5555
"name": "in-hole-close-to-poly-in-hole",
56-
"expected_distance": 16.36078019912375,
56+
"expected_distance": 16.360780198687877,
5757
"units": "meters"
5858
},
5959
"geometry": {

0 commit comments

Comments
 (0)