Skip to content

Commit d05f1fd

Browse files
fix(boolean-contains): reject inner polygon whose edge crosses a conc… (#3104)
* fix(boolean-contains): reject inner polygon whose edge crosses a concave outer polygon booleanContains returned true when every vertex of the inner polygon was inside a concave outer polygon even if one of the inner edges crossed outside through a concavity, because only the inner vertices were tested. Split each inner edge on the outer boundary and reject when a sub-segment midpoint lies strictly in the outer exterior. Closes #2242. * fix(boolean-contains): use scale-independent boundary tolerance --------- Co-authored-by: mfedderly <24275386+mfedderly@users.noreply.github.com>
1 parent 5fd684d commit d05f1fd

2 files changed

Lines changed: 134 additions & 0 deletions

File tree

packages/turf-boolean-contains/index.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -462,17 +462,111 @@ function isPolyInPoly(
462462
return false;
463463
}
464464

465+
const poly1 = getGeom(feature1);
465466
const coords = getGeom(feature2).coordinates;
466467
for (const ring of coords) {
467468
for (const coord of ring) {
468469
if (!booleanPointInPolygon(coord, feature1)) {
469470
return false;
470471
}
471472
}
473+
// Having every vertex inside feature1 is not sufficient when feature1 is concave: an edge of
474+
// feature2 can still cross feature1's exterior (e.g. through a notch) while all its vertices
475+
// remain inside. Split each edge of feature2 on feature1's boundary and reject if any resulting
476+
// sub-segment lies (via its midpoint) strictly in feature1's exterior (#2242).
477+
const segments = splitLineIntoSegmentsOnPolygon(
478+
{ type: "LineString", coordinates: ring },
479+
poly1
480+
);
481+
for (const segment of segments.features) {
482+
const midpoint = getMidpoint(
483+
segment.geometry.coordinates[0],
484+
segment.geometry.coordinates[1]
485+
);
486+
if (
487+
!booleanPointInPolygon(midpoint, feature1) &&
488+
!isPointOnPolygonBoundary(midpoint, poly1)
489+
) {
490+
return false;
491+
}
492+
}
472493
}
473494
return true;
474495
}
475496

497+
// Distance under which a point is treated as lying on a polygon boundary,
498+
// expressed in the coordinate units of the input (degrees for WGS84 lng/lat).
499+
// GeoJSON coordinates are only meaningful to about 6 decimal places, roughly
500+
// 10 cm on the ground, per RFC 7946 section 11.2, so anything closer than this
501+
// to an edge is treated as coincident with it. This is a real distance, unlike
502+
// a raw cross-product epsilon which scales with edge length and coordinate
503+
// magnitude and so is not scale invariant.
504+
// https://datatracker.ietf.org/doc/html/rfc7946#section-11.2
505+
const BOUNDARY_DISTANCE_TOLERANCE = 1e-6;
506+
507+
/**
508+
* Shortest distance from a point to a line segment on the plane, in the
509+
* coordinate units of the inputs.
510+
*
511+
* @private
512+
* @param {Position} point point [x, y]
513+
* @param {Position} start segment start [x, y]
514+
* @param {Position} end segment end [x, y]
515+
* @returns {number} distance from the point to the segment
516+
*/
517+
function pointToSegmentDistance(
518+
point: Position,
519+
start: Position,
520+
end: Position
521+
): number {
522+
const dx = end[0] - start[0];
523+
const dy = end[1] - start[1];
524+
const segmentLengthSquared = dx * dx + dy * dy;
525+
526+
// Project the point onto the segment, clamping to the endpoints. A
527+
// zero-length segment collapses to its shared endpoint (t stays 0).
528+
let t = 0;
529+
if (segmentLengthSquared > 0) {
530+
t =
531+
((point[0] - start[0]) * dx + (point[1] - start[1]) * dy) /
532+
segmentLengthSquared;
533+
t = Math.max(0, Math.min(1, t));
534+
}
535+
536+
const closestX = start[0] + t * dx;
537+
const closestY = start[1] + t * dy;
538+
const offsetX = point[0] - closestX;
539+
const offsetY = point[1] - closestY;
540+
return Math.sqrt(offsetX * offsetX + offsetY * offsetY);
541+
}
542+
543+
/**
544+
* Whether a point lies on the boundary of a polygon, within
545+
* BOUNDARY_DISTANCE_TOLERANCE.
546+
* Used to distinguish edges that run along the polygon boundary (allowed for containment) from
547+
* edges that cross into the polygon's exterior. The tolerance absorbs the floating-point
548+
* error introduced when splitting boundary-coincident edges, while staying far smaller than any
549+
* real excursion into the exterior.
550+
*
551+
* @private
552+
* @param {Position} point point [x, y]
553+
* @param {Polygon} polygon polygon geometry
554+
* @returns {boolean} true if the point lies on any ring of the polygon
555+
*/
556+
function isPointOnPolygonBoundary(point: Position, polygon: Polygon): boolean {
557+
return polygon.coordinates.some((ring) => {
558+
for (let i = 0; i < ring.length - 1; i++) {
559+
if (
560+
pointToSegmentDistance(point, ring[i], ring[i + 1]) <=
561+
BOUNDARY_DISTANCE_TOLERANCE
562+
) {
563+
return true;
564+
}
565+
}
566+
return false;
567+
});
568+
}
569+
476570
function doBBoxOverlap(bbox1: BBox, bbox2: BBox) {
477571
if (bbox1[0] > bbox2[0]) {
478572
return false;
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
{
2+
"type": "FeatureCollection",
3+
"features": [
4+
{
5+
"type": "Feature",
6+
"properties": {},
7+
"geometry": {
8+
"type": "Polygon",
9+
"coordinates": [
10+
[
11+
[0, 0],
12+
[10, 0],
13+
[10, 10],
14+
[6, 10],
15+
[5, 4],
16+
[4, 10],
17+
[0, 10],
18+
[0, 0]
19+
]
20+
]
21+
}
22+
},
23+
{
24+
"type": "Feature",
25+
"properties": {},
26+
"geometry": {
27+
"type": "Polygon",
28+
"coordinates": [
29+
[
30+
[3, 5],
31+
[7, 5],
32+
[7, 7],
33+
[3, 7],
34+
[3, 5]
35+
]
36+
]
37+
}
38+
}
39+
]
40+
}

0 commit comments

Comments
 (0)