Skip to content

Conversation

@bratter
Copy link
Contributor

@bratter bratter commented Nov 1, 2025

Summary

This pull request addresses several issues with @turf/nearest-point-on-line, specifically:

In addition to behavioral fixes, the PR removed some unused calculations and used some cheaper comparisons that avoided trig functions. Overall benchmark performance increased by ~60%.

Potential Discussion Topic

The snippet below shows the catch for degenerate cases. This introduces an explicit exception when the input line segment's points are exactly antipodal. This is highly unlikely to happen in practice as radian conversion tends to throw even simple input cases slightly out at the bit level, and also seems to not be the usage paradigm for this function, but I guess it is at least possible.

I went with the exception, but it also would have been possible to just return the point being exactly on the segment. This is sort of valid as there are infinite great circles through antipodal points, and one of them will also cover the target point. This approach could lead to some strange behavior, but is maybe better than injecting an exception.

Open to changing if consensus is to take the point as on the line.

  if (segmentAxis[0] === 0 && segmentAxis[1] === 0 && segmentAxis[2] === 0) {
    if (dot(A, B) > 0) {
      return [[...posB], true];
    } else {
      throw new Error(
        `Undefined arc segment, line segment endpoints [[${posA}], [${posB}]] are antipodes`
      );
    }
  }

Testing Note

#2939 was reproducible in the browser but not in node due to some floating point issues described in the issue. To make sure there was a working test (that failed without the fix) I monkey patched Math.cos to make the test work. It isn't great, but it works.

Checklist

Please provide the following when creating a PR:

  • Meaningful title, including the name of the package being modified.
  • Summary of the changes.
  • Heads up if this is a breaking change.
  • Any issues this resolves.
  • Inclusion of your details in the contributors field of package.json - you've earned it! 👏
  • Confirmation you've read the steps for preparing a pull request.

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 Turfjs#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 Turfjs#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.
if (dot(A, B) > 0) {
return [[...posB], true];
} else {
throw new Error(
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I fully understand this part.
the bottom line from my perspective is that I don't expect nearest-point-on-line to ever throw.
So if this is caused by ambiguity (if I understand what you wrote above) then I would expect this to return the first one, even if it's not deterministic, I would still prefer that over a method that throws...
I might have completely misunderstood what you wrote, but I also couldn't see a test to over this code flow, so do let me know what I'm missing.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I totally get what you are saying and could go either way. In case its helpful, I'll lay out all the conflicting thought process:

This case should only be hit when the input line segment is made up of two points that are antipodal. Theoretically, for instance, [0, 0] and [180, 0], but practically doesn't quite work like that because of floating point. This is mathematically undefined because the is an infinite number of valid great circles that pass through antipodal points.

So the two options are:

  1. Throw for undefined behavior:
    1. This give stricter mathematical correctness, which is good in some cases but may not be necessary when the focus is pragmatism.
    2. Gives the caller an opportunity to handle in the best way for the application - for instance us picking the right answer may not be the best in animation scenarios where it might cause snapping etc.
    3. Downside is of course that it throws and throwing is always less than ideal, people won't handle it, and might crash the application if not caught.
  2. Assume the point lies on the line:
    1. More permissively, we can simply choose one of the possible great circles. The only one that really makes sense is the one that also passes through the test point, making the point exactly on the line. I don't think there are any other options that would possibly make sense.
    2. This avoids any exceptions in nearest-point-on-line, which as you point out seems like a reasonable expectation.
    3. Determining that the point on the line in this case is not entirely logically unsound, and would likely be a reasonable result in almost all circumstances, especially given this antipodes case is unlikely to occur in practice.
    4. Antipodal points in this kind of method are kind of crazy anyway, and I doubt are seen at all in practice - and if it mattered, you'd think the geometry would include an intermediate point to disambiguate.

So I can see advantages each way. Wanted to drop the more correct version in for the first pass, but definitely open to changing.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If someone needs to know that this might be problematic you could export a method called isAntiPodal to allow testing it if someone is concerned about the results of this method.
This avoid throwing and allows to decide what to do in this case if this interested you.
Another option is simply to call it out in the method docs so that people might know this is a "know limitation".

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense, adding to the docs would work.. I like not throwing. I'll do in the function signature for here now, and then can do in the website docs later.

Noticed another issue with one of the comparisons that I didn't catch before so need to make a further update, so will amend this PR with both fixes shortly.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good!

@HarelM
Copy link

HarelM commented Nov 2, 2025

The math is a bit too complicated for me to dive into, but if this passes all the previous test cases and also the new test cases from the linked issues I believe this is a good improvement.
Thanks for this!
Would be great to have a new version with these fixes.

@bratter bratter marked this pull request as draft November 3, 2025 01:38
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
@bratter
Copy link
Contributor Author

bratter commented Nov 3, 2025

Updated to fix another issue and replace the throw with an early return.

Copy link
Collaborator

@mfedderly mfedderly left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you! One comment seems to have been abandoned but overall looks good.

const [x, y, z] = v;
const lat = radiansToDegrees(Math.asin(z));
// Clamp the z-value to ensure that is inside the [-1, 1] domain as required
// by asin. Note that
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hanging Note that in the comment

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah whoops. I'll fix that when I get a chance and also look at the point-on-polygon tests that failed. These were succeeding when I tried earlier, but are worthwhile checking into due to the floating point funniness.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was able to confirm that the failed tests in turf-point-to-polygon-distance are due to a single bit difference in the latitude of the nearest point between the old version and the new version. See below for an example from the first failing test:

Latitude from the nearest failing point
Old: 52.47479359036524 => 0 10000000100 1010 00111100 11000110 00001001 01001111 01111100 00001100
New: 52.47479359036523 => 0 10000000100 1010 00111100 11000110 00001001 01001111 01111100 00001011

Then as the result is in meters, the single bit difference creeps up. Yay for floating point :-).

Given that a single bit floating point difference is inside even the boundaries of trig functions in different JS engines I see no issue in just changing the expected distance, although it does beg a larger questions as to whether those tests should test with a tolerance like some others do... problem for another time.

I will amend and update the PR.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the detailed readout from this investigation!

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.
@bratter bratter requested a review from mfedderly November 10, 2025 03:28
@HarelM
Copy link

HarelM commented Nov 10, 2025

Nice! Do let me know when a new version is available.

@mfedderly
Copy link
Collaborator

Ah the test failure is just a merge conflict with a new test that was just introduced. Almost certainly a similar issue with the floating point representation. Sorry!

@bratter
Copy link
Contributor Author

bratter commented Nov 11, 2025

Yes indeed - sorry for the messy commit history.

@bratter bratter requested a review from mfedderly November 11, 2025 04:57
@mfedderly mfedderly merged commit 736b1ef into Turfjs:master Nov 11, 2025
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

@turf/nearestPointOnLine Coordinates must contain numbers Bug: nearestPointOnSegment returns incorrect endpoint when in opposite hemispheres

3 participants