Skip to content

Commit 1c0890f

Browse files
ZhuochengShangjiayuasuCopilotKontinuation
authored
[GH-2302] add constructor of Geometry to Geography (#2298)
* add constructor of Geometry to Geography * add constructors ST_GeomToGeography * correct GeomToGeography constructors * empty commit to retrigger ci * fix handle Null and duplicates linestrings * clean file format * Update spark/common/src/test/scala/org/apache/sedona/sql/geography/ConstructorsDataFrameAPITest.scala Co-authored-by: Copilot <[email protected]> * Update docs/api/sql/geography/Constructor.md Co-authored-by: Copilot <[email protected]> * Update common/src/main/java/org/apache/sedona/common/geography/Constructors.java Co-authored-by: Copilot <[email protected]> * Remove exception type that does make sense from function signature * Fix the doc --------- Co-authored-by: Jia Yu <[email protected]> Co-authored-by: Copilot <[email protected]> Co-authored-by: Kristin Cowalcijk <[email protected]> Co-authored-by: Jia Yu <[email protected]>
1 parent 6bc52c3 commit 1c0890f

File tree

8 files changed

+386
-4
lines changed

8 files changed

+386
-4
lines changed

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

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,4 +264,200 @@ private static Geometry collectionToGeom(Geography g, GeometryFactory gf) {
264264
}
265265
return gf.createGeometryCollection(gs);
266266
}
267+
268+
public static Geography geomToGeography(Geometry geom) {
269+
if (geom == null) {
270+
return null;
271+
}
272+
Geography geography;
273+
if (geom instanceof Point) {
274+
geography = pointToGeog((Point) geom);
275+
} else if (geom instanceof MultiPoint) {
276+
geography = mPointToGeog((MultiPoint) geom);
277+
} else if (geom instanceof LineString) {
278+
geography = lineToGeog((LineString) geom);
279+
} else if (geom instanceof MultiLineString) {
280+
geography = mLineToGeog((MultiLineString) geom);
281+
} else if (geom instanceof Polygon) {
282+
geography = polyToGeog((Polygon) geom);
283+
} else if (geom instanceof MultiPolygon) {
284+
geography = mPolyToGeog((MultiPolygon) geom);
285+
} else if (geom instanceof GeometryCollection) {
286+
geography = geomCollToGeog((GeometryCollection) geom);
287+
} else {
288+
throw new UnsupportedOperationException(
289+
"Geometry type is not supported: " + geom.getClass().getSimpleName());
290+
}
291+
geography.setSRID(geom.getSRID());
292+
return geography;
293+
}
294+
295+
private static Geography pointToGeog(Point geom) throws IllegalArgumentException {
296+
Coordinate[] pts = geom.getCoordinates();
297+
if (pts.length == 0 || Double.isNaN(pts[0].x) || Double.isNaN(pts[0].y)) {
298+
return new SinglePointGeography(); // Return empty geography
299+
}
300+
double lon = pts[0].x;
301+
double lat = pts[0].y;
302+
303+
// Just create the point directly. No builder needed.
304+
S2Point s2Point = S2LatLng.fromDegrees(lat, lon).toPoint();
305+
return new SinglePointGeography(s2Point);
306+
}
307+
308+
private static Geography mPointToGeog(MultiPoint geom) throws IllegalArgumentException {
309+
Coordinate[] pts = geom.getCoordinates();
310+
List<S2Point> points = toS2Points(pts);
311+
// Build via S2Builder + S2PointVectorLayer
312+
S2Builder builder = new S2Builder.Builder().build();
313+
S2PointVectorLayer layer = new S2PointVectorLayer();
314+
builder.startLayer(layer);
315+
// must call build() before reading out the points
316+
for (S2Point pt : points) {
317+
builder.addPoint(pt);
318+
}
319+
S2Error error = new S2Error();
320+
if (!builder.build(error)) {
321+
throw new IllegalArgumentException("Failed to build S2 point layer: " + error.text());
322+
}
323+
return new PointGeography(layer.getPointVector());
324+
}
325+
326+
private static Geography lineToGeog(LineString geom) throws IllegalArgumentException {
327+
// Build S2 points
328+
List<S2Point> pts = toS2Points(geom.getCoordinates());
329+
if (pts.size() < 2) {
330+
// empty or degenerate → empty single polyline
331+
return new SinglePolylineGeography();
332+
}
333+
334+
S2Builder builder = new S2Builder.Builder().build();
335+
S2PolylineLayer layer = new S2PolylineLayer();
336+
builder.startLayer(layer);
337+
builder.addPolyline(new S2Polyline(pts));
338+
339+
S2Error error = new S2Error();
340+
if (!builder.build(error)) {
341+
throw new IllegalArgumentException("Failed to build S2 polyline: " + error.text());
342+
}
343+
S2Polyline s2poly = layer.getPolyline();
344+
return new SinglePolylineGeography(s2poly);
345+
}
346+
347+
private static Geography mLineToGeog(MultiLineString geom) throws IllegalArgumentException {
348+
S2Builder builder = new S2Builder.Builder().build();
349+
S2PolylineVectorLayer.Options opts =
350+
new S2PolylineVectorLayer.Options()
351+
.setDuplicateEdges(
352+
S2Builder.GraphOptions.DuplicateEdges.MERGE); // reject duplicate segments
353+
S2PolylineVectorLayer vectorLayer = new S2PolylineVectorLayer(opts);
354+
builder.startLayer(vectorLayer);
355+
for (int i = 0; i < geom.getNumGeometries(); i++) {
356+
LineString ls = (LineString) geom.getGeometryN(i);
357+
List<S2Point> pts = toS2Points(ls.getCoordinates());
358+
if (pts.size() >= 2) {
359+
builder.addPolyline(new S2Polyline(pts));
360+
}
361+
// Empty/degenerate parts are skipped
362+
}
363+
S2Error error = new S2Error();
364+
if (!builder.build(error)) {
365+
throw new IllegalArgumentException("Failed to build S2 polyline: " + error.text());
366+
}
367+
return new PolylineGeography(vectorLayer.getPolylines());
368+
}
369+
370+
private static Geography polyToGeog(Polygon geom) throws IllegalArgumentException {
371+
// Construct S2 polygon (parity handles holes automatically)
372+
S2Polygon s2poly = toS2Polygon(geom);
373+
return new PolygonGeography(s2poly);
374+
}
375+
376+
private static Geography mPolyToGeog(MultiPolygon geom) throws IllegalArgumentException {
377+
final List<S2Polygon> polys = new ArrayList<>();
378+
for (int i = 0; i < geom.getNumGeometries(); i++) {
379+
Polygon p = (Polygon) geom.getGeometryN(i);
380+
S2Polygon s2 = toS2Polygon(p);
381+
if (s2 != null && !s2.isEmpty()) polys.add(s2);
382+
}
383+
384+
return new MultiPolygonGeography(
385+
Geography.GeographyKind.MULTIPOLYGON, Collections.unmodifiableList(polys));
386+
}
387+
388+
private static Geography geomCollToGeog(GeometryCollection geom) {
389+
List<Geography> features = new ArrayList<>();
390+
for (int i = 0; i < geom.getNumGeometries(); i++) {
391+
Geometry g = geom.getGeometryN(i);
392+
Geography sub = geomToGeography(g);
393+
if (sub != null) features.add(sub);
394+
}
395+
return new GeographyCollection(features);
396+
}
397+
398+
/** Convert JTS coordinates to S2 points; drops NaNs and consecutive duplicates. */
399+
private static List<S2Point> toS2Points(Coordinate[] coords) throws IllegalArgumentException {
400+
List<S2Point> points = new ArrayList<>(coords.length);
401+
for (int i = 0; i < coords.length; i++) {
402+
double lon = coords[i].x;
403+
double lat = coords[i].y;
404+
// 1. Check for and drop NaNs.
405+
if (Double.isNaN(lat) || Double.isNaN(lon)) {
406+
continue;
407+
}
408+
S2Point s2Point = S2LatLng.fromDegrees(lat, lon).toPoint();
409+
// 2. Check for and drop consecutive duplicates.
410+
if (!points.isEmpty() && points.get(points.size() - 1).equals(s2Point)) {
411+
continue;
412+
}
413+
points.add(s2Point);
414+
}
415+
return points;
416+
}
417+
418+
/** Convert a JTS LinearRing to a normalized S2Loop. Returns null if < 3 distinct vertices. */
419+
private static S2Loop toS2Loop(LinearRing ring) throws IllegalArgumentException {
420+
Coordinate[] coords = ring.getCoordinates();
421+
if (coords == null || coords.length < 4) { // JTS rings usually have first==last
422+
return null;
423+
}
424+
425+
List<S2Point> pts = toS2Points(coords);
426+
427+
if (pts.size() < 3) return null;
428+
if (pts.get(0).equals(pts.get(pts.size() - 1))) {
429+
pts.remove(pts.size() - 1); // Remove duplicate closing point if it exists
430+
}
431+
432+
S2Loop loop = new S2Loop(pts);
433+
loop.normalize(); // ensure area <= 2π; orientation consistent for S2
434+
return loop;
435+
}
436+
437+
private static S2Polygon toS2Polygon(Polygon poly) throws IllegalArgumentException {
438+
List<S2Loop> loops = new ArrayList<>();
439+
S2Loop shell = toS2Loop((LinearRing) poly.getExteriorRing());
440+
if (shell != null) loops.add(shell);
441+
for (int i = 0; i < poly.getNumInteriorRing(); i++) {
442+
S2Loop hole = toS2Loop((LinearRing) poly.getInteriorRingN(i));
443+
if (hole != null) loops.add(hole);
444+
}
445+
if (loops.isEmpty()) return null;
446+
// Now feed those loops into S2Builder + S2PolygonLayer:
447+
S2Builder builder = new S2Builder.Builder().build();
448+
S2PolygonLayer polyLayer = new S2PolygonLayer();
449+
builder.startLayer(polyLayer);
450+
// add shell + holes
451+
for (S2Loop loop : loops) {
452+
builder.addLoop(loop);
453+
}
454+
// build
455+
S2Error error = new S2Error();
456+
if (!builder.build(error)) {
457+
throw new IllegalArgumentException("S2Builder failed: " + error.text());
458+
}
459+
460+
// extract the stitched polygon
461+
return polyLayer.getPolygon(); // even–odd handles holes
462+
}
267463
}

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

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,4 +245,109 @@ public void polygon_threeHoles() throws Exception {
245245
assertEquals(expected, got.toString());
246246
assertEquals(0, got.getSRID());
247247
}
248+
249+
@Test
250+
public void MultiPolygonGeomToGeography() throws Exception {
251+
String wkt =
252+
"MULTIPOLYGON ("
253+
+
254+
// Component A: outer shell + lake
255+
"((10 10, 70 10, 70 70, 10 70, 10 10),"
256+
+ " (20 20, 60 20, 60 60, 20 60, 20 20)),"
257+
+
258+
// Component B: island with a pond
259+
" ((30 30, 50 30, 50 50, 30 50, 30 30),"
260+
+ " (36 36, 44 36, 44 44, 36 44, 36 36))"
261+
+ ")";
262+
Geometry g = new org.locationtech.jts.io.WKTReader().read(wkt);
263+
g.setSRID(4326);
264+
Geography got = Constructors.geomToGeography(g);
265+
String expected = "SRID=4326; " + wkt;
266+
assertEquals(4326, got.getSRID());
267+
org.locationtech.jts.io.WKTWriter wktWriter = new org.locationtech.jts.io.WKTWriter();
268+
wktWriter.setPrecisionModel(new PrecisionModel(PrecisionModel.FIXED));
269+
assertEquals(expected, got.toString());
270+
}
271+
272+
@Test
273+
public void PointGeomToGeography() throws Exception {
274+
Geometry geom = org.apache.sedona.common.Constructors.geomFromWKT("POINT (1 1)", 0);
275+
Geography got = Constructors.geomToGeography(geom);
276+
org.locationtech.jts.io.WKTWriter wktWriter = new org.locationtech.jts.io.WKTWriter();
277+
wktWriter.setPrecisionModel(new PrecisionModel(PrecisionModel.FIXED));
278+
assertEquals(geom.toString(), got.toString());
279+
280+
geom =
281+
org.apache.sedona.common.Constructors.geomFromWKT(
282+
"MULTIPOINT ((10 10), (20 20), (30 30))", 0);
283+
got = Constructors.geomToGeography(geom);
284+
assertEquals(geom.toString(), got.toString());
285+
}
286+
287+
@Test
288+
public void PointGeomToGeographyDuplicate() throws Exception {
289+
Geometry geom =
290+
org.apache.sedona.common.Constructors.geomFromWKT(
291+
"MULTIPOINT ((10 10), (20 20), (20 20), (30 30))", 0);
292+
Geography got = Constructors.geomToGeography(geom);
293+
org.locationtech.jts.io.WKTWriter wktWriter = new org.locationtech.jts.io.WKTWriter();
294+
wktWriter.setPrecisionModel(new PrecisionModel(PrecisionModel.FIXED));
295+
assertEquals("MULTIPOINT ((10 10), (20 20), (30 30))", got.toString());
296+
297+
geom =
298+
org.apache.sedona.common.Constructors.geomFromWKT(
299+
"MULTIPOINT ((10 10), (20 20), (30 30), (20 20), (10 10))", 0);
300+
got = Constructors.geomToGeography(geom);
301+
assertEquals("MULTIPOINT ((10 10), (20 20), (30 30))", got.toString());
302+
}
303+
304+
@Test
305+
public void LineGeomToGeography() throws Exception {
306+
Geometry geom =
307+
org.apache.sedona.common.Constructors.geomFromWKT("LINESTRING (1 2, 3 4, 5 6)", 0);
308+
Geography got = Constructors.geomToGeography(geom);
309+
org.locationtech.jts.io.WKTWriter wktWriter = new org.locationtech.jts.io.WKTWriter();
310+
wktWriter.setPrecisionModel(new PrecisionModel(PrecisionModel.FIXED));
311+
assertEquals(geom.toString(), got.toString());
312+
313+
geom =
314+
org.apache.sedona.common.Constructors.geomFromWKT(
315+
"MULTILINESTRING((1 2, 3 4), (4 5, 6 7))", 0);
316+
got = Constructors.geomToGeography(geom);
317+
assertEquals(geom.toString(), got.toString());
318+
}
319+
320+
@Test
321+
public void LineGeomToGeographyDuplicate() throws Exception {
322+
Geometry geom =
323+
org.apache.sedona.common.Constructors.geomFromWKT("LINESTRING (1 2, 3 4, 3 4, 5 6)", 0);
324+
Geography got = Constructors.geomToGeography(geom);
325+
org.locationtech.jts.io.WKTWriter wktWriter = new org.locationtech.jts.io.WKTWriter();
326+
wktWriter.setPrecisionModel(new PrecisionModel(PrecisionModel.FIXED));
327+
assertEquals("LINESTRING (1 2, 3 4, 5 6)", got.toString());
328+
329+
geom =
330+
org.apache.sedona.common.Constructors.geomFromWKT(
331+
"MULTILINESTRING ((1 2, 3 4), (4 5, 6 7), (1 2, 3 4))", 0);
332+
got = Constructors.geomToGeography(geom);
333+
assertEquals("MULTILINESTRING ((1 2, 3 4), (4 5, 6 7))", got.toString());
334+
335+
geom =
336+
org.apache.sedona.common.Constructors.geomFromWKT(
337+
"MULTILINESTRING ((1 2, 3 4), EMPTY, (4 5, 6 7), (1 2, 3 4))", 0);
338+
got = Constructors.geomToGeography(geom);
339+
assertEquals("MULTILINESTRING ((1 2, 3 4), (4 5, 6 7))", got.toString());
340+
}
341+
342+
@Test
343+
public void CollGeomToGeography() throws Exception {
344+
Geometry geom =
345+
org.apache.sedona.common.Constructors.geomFromWKT(
346+
"GEOMETRYCOLLECTION (POINT (10 20), LINESTRING (30 40, 50 60, 70 80), POLYGON ((10 10, 20 20, 10 20, 10 10)))",
347+
0);
348+
Geography got = Constructors.geomToGeography(geom);
349+
org.locationtech.jts.io.WKTWriter wktWriter = new org.locationtech.jts.io.WKTWriter();
350+
wktWriter.setPrecisionModel(new PrecisionModel(PrecisionModel.FIXED));
351+
assertEquals(geom.toString(), got.toString());
352+
}
248353
}

docs/api/sql/geography/Constructor.md

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,21 @@ SRID=4326; LINESTRING (0 0, 3 3, 4 4)
143143

144144
## ST_GeogToGeometry
145145

146-
Introduction: Construct a Geometry from a Geography.
146+
Introduction:
147+
148+
This function constructs a planar Geometry object from a Geography. While Sedona makes every effort to preserve the original spatial object, the conversion is not always exact because Geography and Geometry have different underlying models:
149+
150+
* Geography represents shapes on the Earth’s surface (spherical).
151+
* Geometry represents shapes on a flat, Euclidean plane.
152+
153+
This difference can cause certain ambiguities during conversion. For example:
154+
155+
* A polygon in Geography always refers to the region on the Earth’s surface that the ring encloses. When converted to Geometry, however, it becomes unclear whether the polygon is intended to represent the “inside” or its complement (the “outside”) on the sphere.
156+
* Long edges that cross the antimeridian or cover poles may also be represented differently once projected into planar space.
157+
158+
In practice, Sedona preserves the coordinates and ring orientation as closely as possible, but you should be aware that some topological properties (e.g., area, distance) may not match exactly after conversion.
159+
160+
Sedona does not validate or enforce the SRID of the input Geography. Whatever SRID is attached to the Geography will be carried over to the resulting Geometry, even if it is not appropriate for planar interpretation. It is the user’s responsibility to ensure that the Geography’s SRID is meaningful in the target Geometry context.
147161

148162
Format:
149163

@@ -162,3 +176,31 @@ Output:
162176
```
163177
MULTILINESTRING ((90 90, 20 20, 10 40), (40 40, 30 30, 40 20, 30 10))
164178
```
179+
180+
## ST_GeomToGeography
181+
182+
Introduction:
183+
184+
This function constructs a Geography object from a planar Geometry. This function is intended for geometries defined in a Geographic Coordinate Reference System (CRS), most commonly WGS84 (EPSG:4326), where coordinates are expressed in degrees and the longitude/latitude order
185+
186+
If the input Geometry is defined in a projected CRS (e.g., Web Mercator EPSG:3857, UTM zones), the conversion may succeed syntactically, but the resulting Geography will not be meaningful. This is because Geography interprets coordinates on the surface of a sphere, not a flat plane.
187+
188+
Sedona does not validate or enforce the SRID of the input Geometry. Whatever SRID is attached to the Geometry will simply be carried over to the Geography, even if it is inappropriate for spherical interpretation. It is the user’s responsibility to ensure the input Geometry uses a Geographic CRS.
189+
190+
Format:
191+
192+
`ST_GeomToGeography (geom: Geometry)`
193+
194+
Since: `v1.8.0`
195+
196+
SQL example:
197+
198+
```sql
199+
SELECT ST_GeomToGeography(ST_GeomFromWKT('MULTIPOLYGON (((10 10, 70 10, 70 70, 10 70, 10 10), (20 20, 60 20, 60 60, 20 60, 20 20)), ((30 30, 50 30, 50 50, 30 50, 30 30), (36 36, 44 36, 44 44, 36 44, 36 36)))'))
200+
```
201+
202+
Output:
203+
204+
```
205+
MULTIPOLYGON (((10 10, 70 10, 70 70, 10 70, 10 10), (20 20, 60 20, 60 60, 20 60, 20 20)), ((30 30, 50 30, 50 50, 30 50, 30 30), (36 36, 44 36, 44 44, 36 44, 36 36)))
206+
```

spark/common/src/main/scala/org/apache/sedona/sql/UDF/Catalog.scala

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import org.apache.spark.sql.expressions.Aggregator
2222
import org.apache.spark.sql.sedona_sql.expressions.collect.ST_Collect
2323
import org.apache.spark.sql.sedona_sql.expressions.raster._
2424
import org.apache.spark.sql.sedona_sql.expressions._
25-
import org.apache.spark.sql.sedona_sql.expressions.geography.{ST_GeogCollFromText, ST_GeogFromEWKB, ST_GeogFromEWKT, ST_GeogFromGeoHash, ST_GeogFromText, ST_GeogFromWKB, ST_GeogFromWKT, ST_GeogToGeometry}
25+
import org.apache.spark.sql.sedona_sql.expressions.geography.{ST_GeogCollFromText, ST_GeogFromEWKB, ST_GeogFromEWKT, ST_GeogFromGeoHash, ST_GeogFromText, ST_GeogFromWKB, ST_GeogFromWKT, ST_GeogToGeometry, ST_GeomToGeography}
2626
import org.locationtech.jts.geom.Geometry
2727
import org.locationtech.jts.operation.buffer.BufferParameters
2828

@@ -353,7 +353,8 @@ object Catalog extends AbstractCatalog {
353353
function[ST_GLocal](),
354354
function[ST_BinaryDistanceBandColumn](),
355355
function[ST_WeightedDistanceBandColumn](),
356-
function[ST_GeogToGeometry]())
356+
function[ST_GeogToGeometry](),
357+
function[ST_GeomToGeography]())
357358

358359
val aggregateExpressions: Seq[Aggregator[Geometry, _, _]] =
359360
Seq(new ST_Envelope_Aggr, new ST_Intersection_Aggr, new ST_Union_Aggr())

0 commit comments

Comments
 (0)