Skip to content

Commit 772e3d5

Browse files
authored
[GH-2830] Adds Geography implementations for ST_Within and ST_DWithin (#2858)
1 parent 442bb90 commit 772e3d5

12 files changed

Lines changed: 650 additions & 38 deletions

File tree

common/src/main/java/org/apache/sedona/common/geography/Functions.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,24 @@ public static boolean equals(Geography g1, Geography g2) {
312312
return pred.S2_equals(toShapeIndex(g1), toShapeIndex(g2), s2Options());
313313
}
314314

315+
/**
316+
* Spherical "distance within" test. Returns true iff the minimum geodesic distance between g1 and
317+
* g2 (in meters) is less than or equal to {@code distanceMeters}.
318+
*/
319+
public static boolean dWithin(Geography g1, Geography g2, double distanceMeters) {
320+
if (g1 == null || g2 == null) return false;
321+
Double d = distance(g1, g2);
322+
return d != null && d <= distanceMeters;
323+
}
324+
325+
/**
326+
* Spherical "within" test. Returns true iff g1 is fully inside g2 on the sphere. OGC convention:
327+
* {@code ST_Within(A, B) == ST_Contains(B, A)}.
328+
*/
329+
public static boolean within(Geography g1, Geography g2) {
330+
return contains(g2, g1);
331+
}
332+
315333
/** Return EWKT for geography object */
316334
public static String asEWKT(Geography geography) {
317335
return geography.toEWKT();

common/src/test/java/org/apache/sedona/common/Geography/FunctionTest.java

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -496,6 +496,129 @@ public void contains_nullHandling() throws ParseException {
496496
assertFalse(Functions.contains(null, g1));
497497
}
498498

499+
// ─── Level 3: ST_DWithin ─────────────────────────────────────────────────
500+
501+
@Test
502+
public void dWithin_twoPointsOneDegreeApart() throws ParseException {
503+
Geography g1 = Constructors.geogFromWKT("POINT (0 0)", 4326);
504+
Geography g2 = Constructors.geogFromWKT("POINT (0 1)", 4326);
505+
// 1° of latitude ≈ 111_195 m on the sphere
506+
assertFalse(Functions.dWithin(g1, g2, 100_000.0));
507+
assertTrue(Functions.dWithin(g1, g2, 200_000.0));
508+
}
509+
510+
@Test
511+
public void dWithin_pointInsidePolygon() throws ParseException {
512+
Geography poly = Constructors.geogFromWKT("POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", 4326);
513+
Geography pt = Constructors.geogFromWKT("POINT (0.5 0.5)", 4326);
514+
// Distance is zero when one contains the other; any positive threshold should pass.
515+
assertTrue(Functions.dWithin(poly, pt, 1.0));
516+
}
517+
518+
@Test
519+
public void dWithin_boundaryInclusive() throws ParseException {
520+
// distance == threshold ⇒ true (inclusive <=)
521+
Geography g1 = Constructors.geogFromWKT("POINT (0 0)", 4326);
522+
Geography g2 = Constructors.geogFromWKT("POINT (0 1)", 4326);
523+
double actual = Functions.distance(g1, g2);
524+
assertTrue(Functions.dWithin(g1, g2, actual));
525+
assertFalse(Functions.dWithin(g1, g2, actual - 1.0));
526+
}
527+
528+
@Test
529+
public void dWithin_antimeridianCrossing() throws ParseException {
530+
// Two points straddling the antimeridian: great-circle distance ~22 km,
531+
// planar distance ~40_000 km — succeeding at 50 km proves we use spherical distance.
532+
Geography g1 = Constructors.geogFromWKT("POINT (179.9 0)", 4326);
533+
Geography g2 = Constructors.geogFromWKT("POINT (-179.9 0)", 4326);
534+
assertTrue(Functions.dWithin(g1, g2, 50_000.0));
535+
}
536+
537+
@Test
538+
public void dWithin_nullHandling() throws ParseException {
539+
Geography g = Constructors.geogFromWKT("POINT (0 0)", 4326);
540+
assertFalse(Functions.dWithin(g, null, 1e6));
541+
assertFalse(Functions.dWithin(null, g, 1e6));
542+
assertFalse(Functions.dWithin(null, null, 1e6));
543+
}
544+
545+
@Test
546+
public void dWithin_reflexiveZeroThreshold() throws ParseException {
547+
// A point is trivially within distance 0 of itself (distance == 0, threshold == 0, <= is
548+
// inclusive).
549+
Geography g = Constructors.geogFromWKT("POINT (10 20)", 4326);
550+
assertTrue(Functions.dWithin(g, g, 0.0));
551+
}
552+
553+
@Test
554+
public void dWithin_negativeDistance() throws ParseException {
555+
// No two geographies can be at a negative geodesic distance, so any negative threshold =>
556+
// false.
557+
Geography g1 = Constructors.geogFromWKT("POINT (0 0)", 4326);
558+
Geography g2 = Constructors.geogFromWKT("POINT (0 0)", 4326);
559+
assertFalse(Functions.dWithin(g1, g2, -1.0));
560+
}
561+
562+
@Test
563+
public void dWithin_nanDistance() throws ParseException {
564+
// NaN threshold => all comparisons are false.
565+
Geography g1 = Constructors.geogFromWKT("POINT (0 0)", 4326);
566+
Geography g2 = Constructors.geogFromWKT("POINT (0 1)", 4326);
567+
assertFalse(Functions.dWithin(g1, g2, Double.NaN));
568+
}
569+
570+
// ─── Level 3: ST_Within ──────────────────────────────────────────────────
571+
572+
@Test
573+
public void within_pointInPolygon() throws ParseException {
574+
Geography pt = Constructors.geogFromWKT("POINT (0.5 0.5)", 4326);
575+
Geography poly = Constructors.geogFromWKT("POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", 4326);
576+
assertTrue(Functions.within(pt, poly));
577+
}
578+
579+
@Test
580+
public void within_pointOutsidePolygon() throws ParseException {
581+
Geography pt = Constructors.geogFromWKT("POINT (2 2)", 4326);
582+
Geography poly = Constructors.geogFromWKT("POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))", 4326);
583+
assertFalse(Functions.within(pt, poly));
584+
}
585+
586+
@Test
587+
public void within_isContainsSwapped() throws ParseException {
588+
// OGC parity: within(A, B) == contains(B, A) for every input pair.
589+
Geography poly = Constructors.geogFromWKT("POLYGON ((0 0, 2 0, 2 2, 0 2, 0 0))", 4326);
590+
Geography inside = Constructors.geogFromWKT("POINT (1 1)", 4326);
591+
Geography outside = Constructors.geogFromWKT("POINT (3 3)", 4326);
592+
assertEquals(Functions.contains(poly, inside), Functions.within(inside, poly));
593+
assertEquals(Functions.contains(poly, outside), Functions.within(outside, poly));
594+
}
595+
596+
@Test
597+
public void within_nullHandling() throws ParseException {
598+
Geography g = Constructors.geogFromWKT("POINT (1 1)", 4326);
599+
assertFalse(Functions.within(g, null));
600+
assertFalse(Functions.within(null, g));
601+
assertFalse(Functions.within(null, null));
602+
}
603+
604+
@Test
605+
public void within_polygonInPolygon() throws ParseException {
606+
Geography inner = Constructors.geogFromWKT("POLYGON ((1 1, 2 1, 2 2, 1 2, 1 1))", 4326);
607+
Geography outer = Constructors.geogFromWKT("POLYGON ((0 0, 3 0, 3 3, 0 3, 0 0))", 4326);
608+
assertTrue(Functions.within(inner, outer));
609+
// Swapped: the outer polygon is NOT within the inner one.
610+
assertFalse(Functions.within(outer, inner));
611+
}
612+
613+
@Test
614+
public void within_overlappingNotContained() throws ParseException {
615+
// Two polygons that intersect but neither is contained in the other.
616+
Geography a = Constructors.geogFromWKT("POLYGON ((0 0, 2 0, 2 2, 0 2, 0 0))", 4326);
617+
Geography b = Constructors.geogFromWKT("POLYGON ((1 1, 3 1, 3 3, 1 3, 1 1))", 4326);
618+
assertFalse(Functions.within(a, b));
619+
assertFalse(Functions.within(b, a));
620+
}
621+
499622
// ─── Level 4: ST_Buffer ──────────────────────────────────────────────────
500623

501624
@Test

docs/api/sql/geography/Geography-Functions.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,4 +53,6 @@ These functions operate on geography type objects.
5353
| [ST_Distance](Geography-Functions/ST_Distance.md) | Double | Return the minimum geodesic distance between two geographies in meters. | v1.9.0 |
5454
| [ST_Length](Geography-Functions/ST_Length.md) | Double | Return the spherical length of a geography in meters, summed along great-circle edges. | v1.9.1 |
5555
| [ST_Contains](Geography-Functions/ST_Contains.md) | Boolean | Test whether geography A fully contains geography B. | v1.9.0 |
56+
| [ST_DWithin](Geography-Functions/ST_DWithin.md) | Boolean | Test whether two geographies are within a given geodesic distance (in meters) of each other. | v1.9.1 |
57+
| [ST_Within](Geography-Functions/ST_Within.md) | Boolean | Test whether geography A is fully within geography B. | v1.9.1 |
5658
| [ST_Equals](Geography-Functions/ST_Equals.md) | Boolean | Test whether two geographies are spatially equal. | v1.9.1 |
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
<!--
2+
Licensed to the Apache Software Foundation (ASF) under one
3+
or more contributor license agreements. See the NOTICE file
4+
distributed with this work for additional information
5+
regarding copyright ownership. The ASF licenses this file
6+
to you under the Apache License, Version 2.0 (the
7+
"License"); you may not use this file except in compliance
8+
with the License. You may obtain a copy of the License at
9+
10+
http://www.apache.org/licenses/LICENSE-2.0
11+
12+
Unless required by applicable law or agreed to in writing,
13+
software distributed under the License is distributed on an
14+
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
KIND, either express or implied. See the License for the
16+
specific language governing permissions and limitations
17+
under the License.
18+
-->
19+
20+
# ST_DWithin
21+
22+
Introduction: Tests whether two geographies are within a given geodesic distance (in meters) of each other on the sphere. The minimum great-circle distance between any two points on the two geographies is compared against the threshold; the test is inclusive (returns true when the minimum distance equals the threshold).
23+
24+
![ST_DWithin returning true](../../../../image/ST_DWithin_geography/ST_DWithin_geography_true.svg "ST_DWithin returning true")
25+
![ST_DWithin returning false](../../../../image/ST_DWithin_geography/ST_DWithin_geography_false.svg "ST_DWithin returning false")
26+
27+
Format:
28+
29+
`ST_DWithin (A: Geography, B: Geography, distance: Double)`
30+
31+
Return type: `Boolean`
32+
33+
Since: `v1.9.1`
34+
35+
SQL Example
36+
37+
```sql
38+
SELECT ST_DWithin(
39+
ST_GeogFromWKT('POINT (0 0)', 4326),
40+
ST_GeogFromWKT('POINT (0 1)', 4326),
41+
200000.0
42+
);
43+
```
44+
45+
Output:
46+
47+
```
48+
true
49+
```
50+
51+
The same pair of points with a tighter threshold:
52+
53+
```sql
54+
SELECT ST_DWithin(
55+
ST_GeogFromWKT('POINT (0 0)', 4326),
56+
ST_GeogFromWKT('POINT (0 1)', 4326),
57+
100000.0
58+
);
59+
```
60+
61+
Output:
62+
63+
```
64+
false
65+
```
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<!--
2+
Licensed to the Apache Software Foundation (ASF) under one
3+
or more contributor license agreements. See the NOTICE file
4+
distributed with this work for additional information
5+
regarding copyright ownership. The ASF licenses this file
6+
to you under the Apache License, Version 2.0 (the
7+
"License"); you may not use this file except in compliance
8+
with the License. You may obtain a copy of the License at
9+
10+
http://www.apache.org/licenses/LICENSE-2.0
11+
12+
Unless required by applicable law or agreed to in writing,
13+
software distributed under the License is distributed on an
14+
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
KIND, either express or implied. See the License for the
16+
specific language governing permissions and limitations
17+
under the License.
18+
-->
19+
20+
# ST_Within
21+
22+
Introduction: Tests whether geography `A` is fully within geography `B` using S2 spherical boolean operations. Returns true when every point of `A`'s interior lies in `B`'s interior. By OGC convention, `ST_Within(A, B)` is equivalent to `ST_Contains(B, A)`, and shares the same boundary semantics.
23+
24+
Boundary semantics on the sphere are inherited from S2's boolean operations and depend on each ring's vertex orientation: along an edge that is "owned" by `B`'s boundary the test returns true, and along the opposite edge it returns false. Do not rely on a specific result for points that lie exactly on `B`'s boundary; for predictable behavior, use a strict interior point or expand `B` slightly with `ST_Buffer` before testing.
25+
26+
![ST_Within returning true](../../../../image/ST_Within_geography/ST_Within_geography_true.svg "ST_Within returning true")
27+
![ST_Within returning false](../../../../image/ST_Within_geography/ST_Within_geography_false.svg "ST_Within returning false")
28+
29+
Format:
30+
31+
`ST_Within (A: Geography, B: Geography)`
32+
33+
Return type: `Boolean`
34+
35+
Since: `v1.9.1`
36+
37+
SQL Example — interior point:
38+
39+
```sql
40+
SELECT ST_Within(
41+
ST_GeogFromWKT('POINT (0.5 0.5)', 4326),
42+
ST_GeogFromWKT('POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))', 4326)
43+
);
44+
```
45+
46+
Output:
47+
48+
```
49+
true
50+
```
Lines changed: 42 additions & 0 deletions
Loading
Lines changed: 42 additions & 0 deletions
Loading

0 commit comments

Comments
 (0)