From 7a49ef708a8f9ca65bb2066fe60b1b9298178e6a Mon Sep 17 00:00:00 2001 From: Furqaan Khan <46216254+furqaankhan@users.noreply.github.com> Date: Mon, 25 Mar 2024 23:45:06 -0400 Subject: [PATCH] [SEDONA-484] [SEDONA-492] Implement ST_IsPolygonCW and ST_ForcePolygonCW (#1286) * feat: add ST_IsPolygonCW and ST_ForcePolygonCW * fix: snowflake tests and add spark dataframe api tests * fix: snowflake tests * fix: snowflake tests * fix: snowflake tests * fix: bug and add tests for MultiPolygon and Non-polygonal geometries * fix: snowflake tests * fix: snowflake tests * docs: add docs for all 3 apis * fix: snowflake test 1 * fix: snowflake test 2 * fix: lint issues --- .../org/apache/sedona/common/Functions.java | 86 +++++++++++++++++++ .../apache/sedona/common/FunctionsTest.java | 45 ++++++++++ docs/api/flink/Function.md | 40 +++++++++ docs/api/snowflake/vector-data/Function.md | 36 ++++++++ docs/api/sql/Function.md | 40 +++++++++ .../java/org/apache/sedona/flink/Catalog.java | 2 + .../sedona/flink/expressions/Functions.java | 16 ++++ .../org/apache/sedona/flink/FunctionTest.java | 19 ++++ python/sedona/sql/st_functions.py | 24 ++++++ python/tests/sql/test_dataframe_api.py | 4 + python/tests/sql/test_function.py | 13 +++ .../snowflake/snowsql/TestFunctions.java | 18 ++++ .../snowflake/snowsql/TestFunctionsV2.java | 18 ++++ .../apache/sedona/snowflake/snowsql/UDFs.java | 16 ++++ .../sedona/snowflake/snowsql/UDFsV2.java | 16 ++++ .../org/apache/sedona/sql/UDF/Catalog.scala | 2 + .../sedona_sql/expressions/Functions.scala | 14 +++ .../sedona_sql/expressions/st_functions.scala | 6 ++ .../sedona/sql/dataFrameAPITestScala.scala | 17 ++++ .../apache/sedona/sql/functionTestScala.scala | 16 ++++ 20 files changed, 448 insertions(+) diff --git a/common/src/main/java/org/apache/sedona/common/Functions.java b/common/src/main/java/org/apache/sedona/common/Functions.java index 73959a7075..ca1dd9f0b3 100644 --- a/common/src/main/java/org/apache/sedona/common/Functions.java +++ b/common/src/main/java/org/apache/sedona/common/Functions.java @@ -810,6 +810,92 @@ public static Geometry lineInterpolatePoint(Geometry geom, double fraction) { return GEOMETRY_FACTORY.createPoint(interPoint); } + /** + * Forces a Polygon/MultiPolygon to use clockwise orientation for the exterior ring and a counter-clockwise for the interior ring(s). + * @param geom + * @return a clockwise orientated (Multi)Polygon + */ + public static Geometry forcePolygonCW(Geometry geom) { + if (isPolygonCW(geom)) { + return geom; + } + + if (geom instanceof Polygon) { + return transformCW((Polygon) geom); + + } else if (geom instanceof MultiPolygon) { + List polygons = new ArrayList<>(); + for (int i = 0; i < geom.getNumGeometries(); i++) { + Polygon polygon = (Polygon) geom.getGeometryN(i); + polygons.add((Polygon) transformCW(polygon)); + } + + return new GeometryFactory().createMultiPolygon(polygons.toArray(new Polygon[0])); + + } + // Non-polygonal geometries are returned unchanged + return geom; + } + + private static Geometry transformCW(Polygon polygon) { + LinearRing exteriorRing = polygon.getExteriorRing(); + LinearRing exteriorRingEnforced = transformCW(exteriorRing, true); + + List interiorRings = new ArrayList<>(); + for (int i = 0; i < polygon.getNumInteriorRing(); i++) { + interiorRings.add(transformCW(polygon.getInteriorRingN(i), false)); + } + + return new GeometryFactory(polygon.getPrecisionModel(), polygon.getSRID()) + .createPolygon(exteriorRingEnforced, interiorRings.toArray(new LinearRing[0])); + } + + private static LinearRing transformCW(LinearRing ring, boolean isExteriorRing) { + boolean isRingClockwise = !Orientation.isCCW(ring.getCoordinateSequence()); + + LinearRing enforcedRing; + if (isExteriorRing) { + enforcedRing = isRingClockwise ? (LinearRing) ring.copy() : ring.reverse(); + } else { + enforcedRing = isRingClockwise ? ring.reverse() : (LinearRing) ring.copy(); + } + return enforcedRing; + } + + /** + * This function accepts Polygon and MultiPolygon, if any other type is provided then it will return false. + * If the exterior ring is clockwise and the interior ring(s) are counter-clockwise then returns true, otherwise false. + * @param geom Polygon or MultiPolygon + * @return + */ + public static boolean isPolygonCW(Geometry geom) { + if (geom instanceof MultiPolygon) { + MultiPolygon multiPolygon = (MultiPolygon) geom; + + boolean arePolygonsCW = checkIfPolygonCW((Polygon) multiPolygon.getGeometryN(0)); + for (int i = 1; i < multiPolygon.getNumGeometries(); i++) { + arePolygonsCW = arePolygonsCW && checkIfPolygonCW((Polygon) multiPolygon.getGeometryN(i)); + } + return arePolygonsCW; + } else if (geom instanceof Polygon) { + return checkIfPolygonCW((Polygon) geom); + } + // False for remaining geometry types + return false; + } + + private static boolean checkIfPolygonCW(Polygon geom) { + LinearRing exteriorRing = geom.getExteriorRing(); + boolean isExteriorRingCW = !Orientation.isCCW(exteriorRing.getCoordinateSequence()); + + boolean isInteriorRingCW = Orientation.isCCW(geom.getInteriorRingN(0).getCoordinateSequence()); + for (int i = 1; i < geom.getNumInteriorRing(); i++) { + isInteriorRingCW = isInteriorRingCW && Orientation.isCCW(geom.getInteriorRingN(i).getCoordinateSequence()); + } + + return isExteriorRingCW && isInteriorRingCW; + } + public static double lineLocatePoint(Geometry geom, Geometry point) { double length = geom.getLength(); diff --git a/common/src/test/java/org/apache/sedona/common/FunctionsTest.java b/common/src/test/java/org/apache/sedona/common/FunctionsTest.java index 7ff16e6452..5b2841e363 100644 --- a/common/src/test/java/org/apache/sedona/common/FunctionsTest.java +++ b/common/src/test/java/org/apache/sedona/common/FunctionsTest.java @@ -690,6 +690,34 @@ public void geometricMedian() throws Exception { } @Test + public void testForcePolygonCW() throws ParseException { + Geometry polyCCW = Constructors.geomFromWKT("POLYGON ((20 35, 10 30, 10 10, 30 5, 45 20, 20 35),(30 20, 20 15, 20 25, 30 20))", 0); + String actual = Functions.asWKT(Functions.forcePolygonCW(polyCCW)); + String expected = "POLYGON ((20 35, 45 20, 30 5, 10 10, 10 30, 20 35), (30 20, 20 25, 20 15, 30 20))"; + assertEquals(expected, actual); + + // both exterior ring and interior ring are counter-clockwise + polyCCW = Constructors.geomFromWKT("POLYGON ((20 35, 10 30, 10 10, 30 5, 45 20, 20 35),(30 20, 20 25, 20 15, 30 20))", 0); + actual = Functions.asWKT(Functions.forcePolygonCW(polyCCW)); + expected = "POLYGON ((20 35, 45 20, 30 5, 10 10, 10 30, 20 35), (30 20, 20 25, 20 15, 30 20))"; + assertEquals(expected, actual); + + Geometry mPoly = Constructors.geomFromWKT("MULTIPOLYGON (((20 35, 10 30, 10 10, 30 5, 45 20, 20 35),(30 20, 20 25, 20 15, 30 20)), ((40 40, 20 45, 45 30, 40 40)))", 0); + actual = Functions.asWKT(Functions.forcePolygonCW(mPoly)); + expected = "MULTIPOLYGON (((20 35, 45 20, 30 5, 10 10, 10 30, 20 35), (30 20, 20 25, 20 15, 30 20)), ((40 40, 45 30, 20 45, 40 40)))"; + assertEquals(expected, actual); + + mPoly = Constructors.geomFromWKT("MULTIPOLYGON (((0 0, 10 0, 10 10, 0 10, 0 0), (2 2, 2 8, 8 8, 8 2, 2 2), (4 4, 4 6, 6 6, 6 4, 4 4), (6 6, 6 8, 8 8, 8 6, 6 6), (3 3, 3 4, 4 4, 4 3, 3 3), (5 5, 5 6, 6 6, 6 5, 5 5), (7 7, 7 8, 8 8, 8 7, 7 7)))", 0); + actual = Functions.asWKT(Functions.forcePolygonCW(mPoly)); + expected = "MULTIPOLYGON (((0 0, 0 10, 10 10, 10 0, 0 0), (2 2, 8 2, 8 8, 2 8, 2 2), (4 4, 6 4, 6 6, 4 6, 4 4), (6 6, 8 6, 8 8, 6 8, 6 6), (3 3, 4 3, 4 4, 3 4, 3 3), (5 5, 6 5, 6 6, 5 6, 5 5), (7 7, 8 7, 8 8, 7 8, 7 7)))"; + assertEquals(expected, actual); + + Geometry nonPoly = Constructors.geomFromWKT("POINT (45 20)", 0); + actual = Functions.asWKT(Functions.forcePolygonCW(nonPoly)); + expected = Functions.asWKT(nonPoly); + assertEquals(expected, actual); + } + public void testForcePolygonCCW() throws ParseException { Geometry polyCW = Constructors.geomFromWKT("POLYGON ((20 35, 45 20, 30 5, 10 10, 10 30, 20 35), (30 20, 20 25, 20 15, 30 20))", 0); String actual = Functions.asWKT(Functions.forcePolygonCCW(polyCW)); @@ -719,6 +747,23 @@ public void testForcePolygonCCW() throws ParseException { } @Test + public void testIsPolygonCW() throws ParseException { + Geometry polyCCW = Constructors.geomFromWKT("POLYGON ((20 35, 10 30, 10 10, 30 5, 45 20, 20 35),(30 20, 20 15, 20 25, 30 20))", 0); + assertFalse(Functions.isPolygonCW(polyCCW)); + + Geometry polyCW = Constructors.geomFromWKT("POLYGON ((20 35, 45 20, 30 5, 10 10, 10 30, 20 35), (30 20, 20 25, 20 15, 30 20))", 0); + assertTrue(Functions.isPolygonCW(polyCW)); + + Geometry mPoly = Constructors.geomFromWKT("MULTIPOLYGON (((0 0, 0 10, 10 10, 10 0, 0 0), (2 2, 8 2, 8 8, 2 8, 2 2), (4 4, 6 4, 6 6, 4 6, 4 4), (6 6, 8 6, 8 8, 6 8, 6 6), (3 3, 4 3, 4 4, 3 4, 3 3), (5 5, 6 5, 6 6, 5 6, 5 5), (7 7, 8 7, 8 8, 7 8, 7 7)))", 0); + assertTrue(Functions.isPolygonCW(mPoly)); + + Geometry point = Constructors.geomFromWKT("POINT (45 20)", 0); + assertFalse(Functions.isPolygonCW(point)); + + Geometry lineClosed = Constructors.geomFromWKT("LINESTRING (30 20, 20 25, 20 15, 30 20)", 0); + assertFalse(Functions.isPolygonCW(lineClosed)); + } + public void testIsPolygonCCW() throws ParseException { Geometry polyCCW = Constructors.geomFromWKT("POLYGON ((20 35, 10 30, 10 10, 30 5, 45 20, 20 35),(30 20, 20 15, 20 25, 30 20))", 0); assertTrue(Functions.isPolygonCCW(polyCCW)); diff --git a/docs/api/flink/Function.md b/docs/api/flink/Function.md index 7838d53c1c..89d0a05793 100644 --- a/docs/api/flink/Function.md +++ b/docs/api/flink/Function.md @@ -1241,6 +1241,26 @@ Output: POLYGON ((20 35, 10 30, 10 10, 30 5, 45 20, 20 35), (30 20, 20 15, 20 25, 30 20)) ``` +## ST_ForcePolygonCW + +Introduction: For (Multi)Polygon geometries, this function sets the exterior ring orientation to clockwise and interior rings to counter-clockwise orientation. Non-polygonal geometries are returned unchanged. + +Format: `ST_ForcePolygonCW(geom: Geometry)` + +Since: `v1.6.0` + +SQL Example: + +```sql +SELECT ST_AsText(ST_ForcePolygonCW(ST_GeomFromText('POLYGON ((20 35, 10 30, 10 10, 30 5, 45 20, 20 35),(30 20, 20 15, 20 25, 30 20))'))) +``` + +Output: + +``` +POLYGON ((20 35, 45 20, 30 5, 10 10, 10 30, 20 35), (30 20, 20 25, 20 15, 30 20)) +``` + ## ST_FrechetDistance Introduction: Computes and returns discrete [Frechet Distance](https://en.wikipedia.org/wiki/Fr%C3%A9chet_distance) between the given two geometries, @@ -1670,6 +1690,26 @@ Output: true ``` +## ST_IsPolygonCW + +Introduction: Returns true if all polygonal components in the input geometry have their exterior rings oriented counter-clockwise and interior rings oriented clockwise. + +Format: `ST_IsPolygonCW(geom: Geometry)` + +Since: `v1.6.0` + +SQL Example: + +```sql +SELECT ST_IsPolygonCW(ST_GeomFromWKT('POLYGON ((20 35, 45 20, 30 5, 10 10, 10 30, 20 35), (30 20, 20 25, 20 15, 30 20))')) +``` + +Output: + +``` +true +``` + ## ST_IsRing Introduction: RETURN true if LINESTRING is ST_IsClosed and ST_IsSimple. diff --git a/docs/api/snowflake/vector-data/Function.md b/docs/api/snowflake/vector-data/Function.md index 71f618cbf1..636a77b1a0 100644 --- a/docs/api/snowflake/vector-data/Function.md +++ b/docs/api/snowflake/vector-data/Function.md @@ -960,6 +960,24 @@ Output: POLYGON ((20 35, 10 30, 10 10, 30 5, 45 20, 20 35), (30 20, 20 15, 20 25, 30 20)) ``` +## ST_ForcePolygonCW + +Introduction: For (Multi)Polygon geometries, this function sets the exterior ring orientation to clockwise and interior rings to counter-clockwise orientation. Non-polygonal geometries are returned unchanged. + +Format: `ST_ForcePolygonCW(geom: Geometry)` + +SQL Example: + +```sql +SELECT ST_AsText(ST_ForcePolygonCW(ST_GeomFromText('POLYGON ((20 35, 10 30, 10 10, 30 5, 45 20, 20 35),(30 20, 20 15, 20 25, 30 20))'))) +``` + +Output: + +``` +POLYGON ((20 35, 45 20, 30 5, 10 10, 10 30, 20 35), (30 20, 20 25, 20 15, 30 20)) +``` + ## ST_FrechetDistance Introduction: Computes and returns discrete [Frechet Distance](https://en.wikipedia.org/wiki/Fr%C3%A9chet_distance) between the given two geometries, @@ -1204,6 +1222,24 @@ Output: true ``` +## ST_IsPolygonCW + +Introduction: Returns true if all polygonal components in the input geometry have their exterior rings oriented counter-clockwise and interior rings oriented clockwise. + +Format: `ST_IsPolygonCW(geom: Geometry)` + +SQL Example: + +```sql +SELECT ST_IsPolygonCW(ST_GeomFromWKT('POLYGON ((20 35, 45 20, 30 5, 10 10, 10 30, 20 35), (30 20, 20 25, 20 15, 30 20))')) +``` + +Output: + +``` +true +``` + ## ST_IsRing Introduction: RETURN true if LINESTRING is ST_IsClosed and ST_IsSimple. diff --git a/docs/api/sql/Function.md b/docs/api/sql/Function.md index d815871a85..88969c3169 100644 --- a/docs/api/sql/Function.md +++ b/docs/api/sql/Function.md @@ -1248,6 +1248,26 @@ Output: POLYGON ((20 35, 10 30, 10 10, 30 5, 45 20, 20 35), (30 20, 20 15, 20 25, 30 20)) ``` +## ST_ForcePolygonCW + +Introduction: For (Multi)Polygon geometries, this function sets the exterior ring orientation to clockwise and interior rings to counter-clockwise orientation. Non-polygonal geometries are returned unchanged. + +Format: `ST_ForcePolygonCW(geom: Geometry)` + +Since: `v1.6.0` + +SQL Example: + +```sql +SELECT ST_AsText(ST_ForcePolygonCW(ST_GeomFromText('POLYGON ((20 35, 10 30, 10 10, 30 5, 45 20, 20 35),(30 20, 20 15, 20 25, 30 20))'))) +``` + +Output: + +``` +POLYGON ((20 35, 45 20, 30 5, 10 10, 10 30, 20 35), (30 20, 20 25, 20 15, 30 20)) +``` + ## ST_FrechetDistance Introduction: Computes and returns discrete [Frechet Distance](https://en.wikipedia.org/wiki/Fr%C3%A9chet_distance) between the given two geometries, @@ -1678,6 +1698,26 @@ Output: true ``` +## ST_IsPolygonCW + +Introduction: Returns true if all polygonal components in the input geometry have their exterior rings oriented counter-clockwise and interior rings oriented clockwise. + +Format: `ST_IsPolygonCW(geom: Geometry)` + +Since: `v1.6.0` + +SQL Example: + +```sql +SELECT ST_IsPolygonCW(ST_GeomFromWKT('POLYGON ((20 35, 45 20, 30 5, 10 10, 10 30, 20 35), (30 20, 20 25, 20 15, 30 20))')) +``` + +Output: + +``` +true +``` + ## ST_IsRing Introduction: RETURN true if LINESTRING is ST_IsClosed and ST_IsSimple. diff --git a/flink/src/main/java/org/apache/sedona/flink/Catalog.java b/flink/src/main/java/org/apache/sedona/flink/Catalog.java index 49d891c75d..66f23b1823 100644 --- a/flink/src/main/java/org/apache/sedona/flink/Catalog.java +++ b/flink/src/main/java/org/apache/sedona/flink/Catalog.java @@ -110,6 +110,7 @@ public static UserDefinedFunction[] getFuncs() { new Functions.ST_SetSRID(), new Functions.ST_SRID(), new Functions.ST_IsClosed(), + new Functions.ST_IsPolygonCW(), new Functions.ST_IsRing(), new Functions.ST_IsSimple(), new Functions.ST_IsValid(), @@ -139,6 +140,7 @@ public static UserDefinedFunction[] getFuncs() { new Functions.ST_GeometricMedian(), new Functions.ST_NumPoints(), new Functions.ST_Force3D(), + new Functions.ST_ForcePolygonCW(), new Functions.ST_NRings(), new Functions.ST_IsPolygonCCW(), new Functions.ST_ForcePolygonCCW(), diff --git a/flink/src/main/java/org/apache/sedona/flink/expressions/Functions.java b/flink/src/main/java/org/apache/sedona/flink/expressions/Functions.java index 150ac9a161..cb3ba79a45 100644 --- a/flink/src/main/java/org/apache/sedona/flink/expressions/Functions.java +++ b/flink/src/main/java/org/apache/sedona/flink/expressions/Functions.java @@ -628,6 +628,14 @@ public boolean eval(@DataTypeHint(value = "RAW", bridgedTo = org.locationtech.jt } } + public static class ST_IsPolygonCW extends ScalarFunction { + @DataTypeHint("Boolean") + public boolean eval(@DataTypeHint(value = "RAW", bridgedTo = org.locationtech.jts.geom.Geometry.class) Object o) { + Geometry geom = (Geometry) o; + return org.apache.sedona.common.Functions.isPolygonCW(geom); + } + } + public static class ST_IsRing extends ScalarFunction { @DataTypeHint("Boolean") public boolean eval(@DataTypeHint(value = "RAW", bridgedTo = org.locationtech.jts.geom.Geometry.class) Object o) { @@ -999,6 +1007,14 @@ public Geometry eval(@DataTypeHint(value = "RAW", bridgedTo = org.locationtech.j } } + public static class ST_ForcePolygonCW extends ScalarFunction { + @DataTypeHint(value = "RAW", bridgedTo = org.locationtech.jts.geom.Geometry.class) + public Geometry eval(@DataTypeHint(value = "RAW", bridgedTo = org.locationtech.jts.geom.Geometry.class) Object o) { + Geometry geometry = (Geometry) o; + return org.apache.sedona.common.Functions.forcePolygonCW(geometry); + } + } + public static class ST_NRings extends ScalarFunction { @DataTypeHint(value = "Integer") public int eval(@DataTypeHint(value = "RAW", bridgedTo = org.locationtech.jts.geom.Geometry.class) Object o) throws Exception { diff --git a/flink/src/test/java/org/apache/sedona/flink/FunctionTest.java b/flink/src/test/java/org/apache/sedona/flink/FunctionTest.java index d6bd22f0fd..e18b65a75a 100644 --- a/flink/src/test/java/org/apache/sedona/flink/FunctionTest.java +++ b/flink/src/test/java/org/apache/sedona/flink/FunctionTest.java @@ -1187,6 +1187,25 @@ public void testForce3DDefaultValue() { assertEquals(expectedDims, actual); } + @Test + public void testForcePolygonCW() { + Table polyTable = tableEnv.sqlQuery("SELECT ST_ForcePolygonCW(ST_GeomFromWKT('POLYGON ((20 35, 10 30, 10 10, 30 5, 45 20, 20 35),(30 20, 20 15, 20 25, 30 20))')) AS polyCW"); + String actual = (String) first(polyTable.select(call(Functions.ST_AsText.class.getSimpleName(), $("polyCW")))).getField(0); + String expected = "POLYGON ((20 35, 45 20, 30 5, 10 10, 10 30, 20 35), (30 20, 20 25, 20 15, 30 20))"; + assertEquals(expected, actual); + } + + @Test + public void testIsPolygonCW() { + Table polyTable = tableEnv.sqlQuery("SELECT ST_GeomFromWKT('POLYGON ((20 35, 10 30, 10 10, 30 5, 45 20, 20 35),(30 20, 20 15, 20 25, 30 20))') AS polyCCW"); + boolean actual = (boolean) first(polyTable.select(call(Functions.ST_IsPolygonCW.class.getSimpleName(), $("polyCCW")))).getField(0); + assertFalse(actual); + + polyTable = tableEnv.sqlQuery("SELECT ST_GeomFromWKT('POLYGON ((20 35, 45 20, 30 5, 10 10, 10 30, 20 35), (30 20, 20 25, 20 15, 30 20))') AS polyCW"); + actual = (boolean) first(polyTable.select(call(Functions.ST_IsPolygonCW.class.getSimpleName(), $("polyCW")))).getField(0); + assertTrue(actual); + } + @Test public void testNRings() { Integer expected = 1; diff --git a/python/sedona/sql/st_functions.py b/python/sedona/sql/st_functions.py index 27e43dc985..3228790407 100644 --- a/python/sedona/sql/st_functions.py +++ b/python/sedona/sql/st_functions.py @@ -72,6 +72,7 @@ "ST_Intersection", "ST_IsClosed", "ST_IsEmpty", + "ST_IsPolygonCW", "ST_IsRing", "ST_IsSimple", "ST_IsValid", @@ -126,6 +127,7 @@ "ST_ZMin", "ST_NumPoints", "ST_Force3D", + "ST_ForcePolygonCW", "ST_NRings", "ST_Translate", "ST_VoronoiPolygons", @@ -809,6 +811,19 @@ def ST_IsEmpty(geometry: ColumnOrName) -> Column: return _call_st_function("ST_IsEmpty", geometry) +@validate_argument_types +def ST_IsPolygonCW(geometry: ColumnOrName) -> Column: + """Check if the Polygon or MultiPolygon use a clockwise orientation for exterior ring and counter-clockwise + orientation for interior ring. + + :param geometry: Geometry column to check. + :type geometry: ColumnOrName + :return: True if the geometry is empty and False otherwise as a boolean column. + :rtype: Column + """ + return _call_st_function("ST_IsPolygonCW", geometry) + + @validate_argument_types def ST_IsRing(line_string: ColumnOrName) -> Column: """Check if a linestring geometry is both closed and simple. @@ -1528,6 +1543,15 @@ def ST_Force3D(geometry: ColumnOrName, zValue: Optional[Union[ColumnOrName, floa args = (geometry, zValue) return _call_st_function("ST_Force3D", args) +@validate_argument_types +def ST_ForcePolygonCW(geometry: ColumnOrName) -> Column: + """ + Returns + :param geometry: Geometry column to change orientation + :return: Clockwise oriented geometry + """ + return _call_st_function("ST_ForcePolygonCW", geometry) + @validate_argument_types def ST_NRings(geometry: ColumnOrName) -> Column: """ diff --git a/python/tests/sql/test_dataframe_api.py b/python/tests/sql/test_dataframe_api.py index c6a3dcf8ac..8a6e4d4cb5 100644 --- a/python/tests/sql/test_dataframe_api.py +++ b/python/tests/sql/test_dataframe_api.py @@ -102,6 +102,7 @@ (stf.ST_FlipCoordinates, ("point",), "point_geom", "", "POINT (1 0)"), (stf.ST_Force_2D, ("point",), "point_geom", "", "POINT (0 1)"), (stf.ST_Force3D, ("point", 1.0), "point_geom", "", "POINT Z (0 1 1)"), + (stf.ST_ForcePolygonCW, ("geom",), "geom_with_hole", "", "POLYGON ((0 0, 3 3, 3 0, 0 0), (1 1, 2 1, 2 2, 1 1))"), (stf.ST_ForcePolygonCCW, ("geom",), "geom_with_hole", "", "POLYGON ((0 0, 3 0, 3 3, 0 0), (1 1, 2 2, 2 1, 1 1))"), (stf.ST_FrechetDistance, ("point", "line",), "point_and_line", "", 5.0990195135927845), (stf.ST_GeometricMedian, ("multipoint",), "multipoint_geom", "", "POINT (22.500002656424286 21.250001168173426)"), @@ -113,6 +114,7 @@ (stf.ST_IsCollection, ("geom",), "geom_collection", "", True), (stf.ST_IsClosed, ("geom",), "closed_linestring_geom", "", True), (stf.ST_IsEmpty, ("geom",), "empty_geom", "", True), + (stf.ST_IsPolygonCW, ("geom",), "geom_with_hole", "", False), (stf.ST_IsPolygonCCW, ("geom",), "geom_with_hole", "", True), (stf.ST_IsRing, ("line",), "linestring_geom", "", False), (stf.ST_IsSimple, ("geom",), "triangle_geom", "", True), @@ -259,6 +261,7 @@ (stf.ST_ExteriorRing, (None,)), (stf.ST_FlipCoordinates, (None,)), (stf.ST_Force_2D, (None,)), + (stf.ST_ForcePolygonCW, (None,)), (stf.ST_ForcePolygonCCW, (None,)), (stf.ST_GeometryN, (None, 0)), (stf.ST_GeometryN, ("", None)), @@ -271,6 +274,7 @@ (stf.ST_Intersection, ("", None)), (stf.ST_IsClosed, (None,)), (stf.ST_IsEmpty, (None,)), + (stf.ST_IsPolygonCW, (None,)), (stf.ST_IsPolygonCCW, (None,)), (stf.ST_IsRing, (None,)), (stf.ST_IsSimple, (None,)), diff --git a/python/tests/sql/test_function.py b/python/tests/sql/test_function.py index 6087aab2bf..2c90eda632 100644 --- a/python/tests/sql/test_function.py +++ b/python/tests/sql/test_function.py @@ -836,6 +836,13 @@ def test_st_remove_point(self): for actual, expected in result_and_expected: assert (actual == expected) + def test_isPolygonCW(self): + actual = self.spark.sql("SELECT ST_IsPolygonCW(ST_GeomFromWKT('POLYGON ((20 35, 10 30, 10 10, 30 5, 45 20, 20 35),(30 20, 20 15, 20 25, 30 20))'))").take(1)[0][0] + assert actual == False + + actual = self.spark.sql("SELECT ST_IsPolygonCW(ST_GeomFromWKT('POLYGON ((20 35, 45 20, 30 5, 10 10, 10 30, 20 35), (30 20, 20 25, 20 15, 30 20))'))").take(1)[0][0] + assert actual == True + def test_st_is_ring(self): result_and_expected = [ [self.calculate_st_is_ring("LINESTRING(0 0, 0 1, 1 0, 1 1, 0 0)"), False], @@ -1331,6 +1338,12 @@ def test_force3D(self): actual = actualDf.selectExpr("ST_NDims(geom)").take(1)[0][0] assert expected == actual + def test_forcePolygonCW(self): + actualDf = self.spark.sql("SELECT ST_ForcePolygonCW(ST_GeomFromWKT('POLYGON ((20 35, 10 30, 10 10, 30 5, 45 20, 20 35),(30 20, 20 15, 20 25, 30 20))')) AS polyCW") + actual = actualDf.selectExpr("ST_AsText(polyCW)").take(1)[0][0] + expected = "POLYGON ((20 35, 45 20, 30 5, 10 10, 10 30, 20 35), (30 20, 20 25, 20 15, 30 20))" + assert expected == actual + def test_nRings(self): expected = 1 actualDf = self.spark.sql("SELECT ST_GeomFromText('POLYGON ((1 0, 1 1, 2 1, 2 0, 1 0))') AS geom") diff --git a/snowflake-tester/src/test/java/org/apache/sedona/snowflake/snowsql/TestFunctions.java b/snowflake-tester/src/test/java/org/apache/sedona/snowflake/snowsql/TestFunctions.java index 3218a54f24..0ba9b1b990 100644 --- a/snowflake-tester/src/test/java/org/apache/sedona/snowflake/snowsql/TestFunctions.java +++ b/snowflake-tester/src/test/java/org/apache/sedona/snowflake/snowsql/TestFunctions.java @@ -1003,6 +1003,24 @@ public void test_ST_Force3D() { ); } + @Test + public void test_ST_ForcePolygonCW() { + registerUDF("ST_ForcePolygonCW", byte[].class); + verifySqlSingleRes( + "SELECT sedona.ST_AsText(sedona.ST_ForcePolygonCW(sedona.ST_GeomFromWKT('POLYGON ((20 35, 10 30, 10 10, 30 5, 45 20, 20 35),(30 20, 20 15, 20 25, 30 20))')))", + "POLYGON ((20 35, 45 20, 30 5, 10 10, 10 30, 20 35), (30 20, 20 25, 20 15, 30 20))" + ); + } + + @Test + public void test_ST_IsPolygonCW() { + registerUDF("ST_IsPolygonCW", byte[].class); + verifySqlSingleRes( + "SELECT sedona.ST_IsPolygonCW(sedona.ST_GeomFromWKT('POLYGON ((20 35, 45 20, 30 5, 10 10, 10 30, 20 35), (30 20, 20 25, 20 15, 30 20))'))", + true + ); + } + @Test public void test_ST_LengthSpheroid() { registerUDF("ST_LengthSpheroid", byte[].class); diff --git a/snowflake-tester/src/test/java/org/apache/sedona/snowflake/snowsql/TestFunctionsV2.java b/snowflake-tester/src/test/java/org/apache/sedona/snowflake/snowsql/TestFunctionsV2.java index 1933d7680e..b37c02cc10 100644 --- a/snowflake-tester/src/test/java/org/apache/sedona/snowflake/snowsql/TestFunctionsV2.java +++ b/snowflake-tester/src/test/java/org/apache/sedona/snowflake/snowsql/TestFunctionsV2.java @@ -964,6 +964,24 @@ public void test_ST_Force3D() { ); } + @Test + public void test_ST_ForcePolygonCW() { + registerUDFV2("ST_ForcePolygonCW", String.class); + verifySqlSingleRes( + "SELECT ST_AsText(sedona.ST_ForcePolygonCW(ST_GeomFromWKT('POLYGON ((20 35, 10 30, 10 10, 30 5, 45 20, 20 35),(30 20, 20 15, 20 25, 30 20))')))", + "POLYGON((20 35,45 20,30 5,10 10,10 30,20 35),(30 20,20 25,20 15,30 20))" + ); + } + + @Test + public void test_ST_IsPolygonCW() { + registerUDFV2("ST_IsPolygonCW", String.class); + verifySqlSingleRes( + "SELECT sedona.ST_IsPolygonCW(ST_GeomFromWKT('POLYGON ((20 35, 45 20, 30 5, 10 10, 10 30, 20 35), (30 20, 20 25, 20 15, 30 20))'))", + true + ); + } + @Test public void test_ST_LengthSpheroid() { registerUDFV2("ST_LengthSpheroid", String.class); diff --git a/snowflake/src/main/java/org/apache/sedona/snowflake/snowsql/UDFs.java b/snowflake/src/main/java/org/apache/sedona/snowflake/snowsql/UDFs.java index 24f668586a..df9c4a1093 100644 --- a/snowflake/src/main/java/org/apache/sedona/snowflake/snowsql/UDFs.java +++ b/snowflake/src/main/java/org/apache/sedona/snowflake/snowsql/UDFs.java @@ -596,6 +596,13 @@ public static boolean ST_IsEmpty(byte[] geometry) { ); } + @UDFAnnotations.ParamMeta(argNames = {"geometry"}) + public static boolean ST_IsPolygonCW(byte[] geom) { + return Functions.isPolygonCW( + GeometrySerde.deserialize(geom) + ); + } + @UDFAnnotations.ParamMeta(argNames = {"geometry"}) public static boolean ST_IsPolygonCCW(byte[] geom) { return Functions.isPolygonCCW( @@ -1314,6 +1321,15 @@ public static byte[] ST_Force3D(byte[] geom) { ); } + @UDFAnnotations.ParamMeta(argNames = {"geom"}) + public static byte[] ST_ForcePolygonCW(byte[] geom) { + return GeometrySerde.serialize( + Functions.forcePolygonCW( + GeometrySerde.deserialize(geom) + ) + ); + } + @UDFAnnotations.ParamMeta(argNames = {"geom"}) public static byte[] ST_ForcePolygonCCW(byte[] geom) { return GeometrySerde.serialize( diff --git a/snowflake/src/main/java/org/apache/sedona/snowflake/snowsql/UDFsV2.java b/snowflake/src/main/java/org/apache/sedona/snowflake/snowsql/UDFsV2.java index e43469b183..177d7b00b8 100644 --- a/snowflake/src/main/java/org/apache/sedona/snowflake/snowsql/UDFsV2.java +++ b/snowflake/src/main/java/org/apache/sedona/snowflake/snowsql/UDFsV2.java @@ -553,6 +553,13 @@ public static boolean ST_IsPolygonCCW(String geom) { ); } + @UDFAnnotations.ParamMeta(argNames = {"geometry"}, argTypes = {"Geometry"}) + public static boolean ST_IsPolygonCW(String geom) { + return Functions.isPolygonCW( + GeometrySerde.deserGeoJson(geom) + ); + } + @UDFAnnotations.ParamMeta(argNames = {"geometry"}, argTypes = {"Geometry"}) public static boolean ST_IsRing(String geometry) { return Functions.isRing( @@ -1157,6 +1164,15 @@ public static String ST_Force3D(String geom) { ); } + @UDFAnnotations.ParamMeta(argNames = {"geom"}, argTypes = {"Geometry"}, returnTypes = "Geometry") + public static String ST_ForcePolygonCW(String geom) { + return GeometrySerde.serGeoJson( + Functions.forcePolygonCW( + GeometrySerde.deserGeoJson(geom) + ) + ); + } + @UDFAnnotations.ParamMeta(argNames = {"geom"}, argTypes = {"Geometry"}, returnTypes = "Geometry") public static String ST_ForcePolygonCCW(String geom) { return GeometrySerde.serGeoJson( diff --git a/spark/common/src/main/scala/org/apache/sedona/sql/UDF/Catalog.scala b/spark/common/src/main/scala/org/apache/sedona/sql/UDF/Catalog.scala index cc4a9bfb27..e727e08090 100644 --- a/spark/common/src/main/scala/org/apache/sedona/sql/UDF/Catalog.scala +++ b/spark/common/src/main/scala/org/apache/sedona/sql/UDF/Catalog.scala @@ -128,6 +128,7 @@ object Catalog { function[ST_AddPoint](-1), function[ST_RemovePoint](-1), function[ST_SetPoint](), + function[ST_IsPolygonCW](), function[ST_IsRing](), function[ST_IsPolygonCCW](), function[ST_ForcePolygonCCW](), @@ -149,6 +150,7 @@ object Catalog { function[ST_PointN](), function[ST_AsEWKT](), function[ST_Force_2D](), + function[ST_ForcePolygonCW](), function[ST_ZMax](), function[ST_ZMin](), function[ST_YMax](), diff --git a/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/Functions.scala b/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/Functions.scala index a3b985c36b..b2d864705c 100644 --- a/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/Functions.scala +++ b/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/Functions.scala @@ -653,6 +653,13 @@ case class ST_ClosestPoint(inputExpressions: Seq[Expression]) } } +case class ST_IsPolygonCW(inputExpressions: Seq[Expression]) + extends InferredExpression(Functions.isPolygonCW _) { + protected def withNewChildrenInternal(newChildren: IndexedSeq[Expression]) = { + copy(inputExpressions = newChildren) + } +} + case class ST_IsRing(inputExpressions: Seq[Expression]) extends InferredExpression(ST_IsRing.isRing _) { @@ -1101,6 +1108,13 @@ case class ST_Force3D(inputExpressions: Seq[Expression]) } } +case class ST_ForcePolygonCW(inputExpressions: Seq[Expression]) + extends InferredExpression(Functions.forcePolygonCW _) { + protected def withNewChildrenInternal(newChildren: IndexedSeq[Expression]) = { + copy(inputExpressions = newChildren) + } +} + case class ST_NRings(inputExpressions: Seq[Expression]) extends InferredExpression(Functions.nRings _) { protected def withNewChildrenInternal(newChildren: IndexedSeq[Expression]) = { diff --git a/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/st_functions.scala b/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/st_functions.scala index f320802a49..ff879dca42 100644 --- a/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/st_functions.scala +++ b/spark/common/src/main/scala/org/apache/spark/sql/sedona_sql/expressions/st_functions.scala @@ -173,6 +173,9 @@ object st_functions extends DataFrameAPI { def ST_IsEmpty(geometry: Column): Column = wrapExpression[ST_IsEmpty](geometry) def ST_IsEmpty(geometry: String): Column = wrapExpression[ST_IsEmpty](geometry) + def ST_IsPolygonCW(geometry: Column): Column = wrapExpression[ST_IsPolygonCW](geometry) + def ST_IsPolygonCW(geometry: String): Column = wrapExpression[ST_IsPolygonCW](geometry) + def ST_IsRing(lineString: Column): Column = wrapExpression[ST_IsRing](lineString) def ST_IsRing(lineString: String): Column = wrapExpression[ST_IsRing](lineString) @@ -386,6 +389,9 @@ object st_functions extends DataFrameAPI { def ST_Force3D(geometry: String, zValue: Double): Column = wrapExpression[ST_Force3D](geometry, zValue) + def ST_ForcePolygonCW(geometry: Column): Column = wrapExpression[ST_ForcePolygonCW](geometry) + def ST_ForcePolygonCW(geometry: String): Column = wrapExpression[ST_ForcePolygonCW](geometry) + def ST_NRings(geometry: Column): Column = wrapExpression[ST_NRings](geometry) def ST_NRings(geometry: String): Column = wrapExpression[ST_NRings](geometry) diff --git a/spark/common/src/test/scala/org/apache/sedona/sql/dataFrameAPITestScala.scala b/spark/common/src/test/scala/org/apache/sedona/sql/dataFrameAPITestScala.scala index 1bf0047cc0..58966761da 100644 --- a/spark/common/src/test/scala/org/apache/sedona/sql/dataFrameAPITestScala.scala +++ b/spark/common/src/test/scala/org/apache/sedona/sql/dataFrameAPITestScala.scala @@ -1187,6 +1187,23 @@ class dataFrameAPITestScala extends TestBaseScala { assertEquals(expectedGeomDefaultValue, wktWriter.write(actualGeomDefaultValue)) } + it("Passed ST_ForcePolygonCW") { + val baseDf = sparkSession.sql("SELECT ST_GeomFromWKT('POLYGON ((20 35, 10 30, 10 10, 30 5, 45 20, 20 35),(30 20, 20 15, 20 25, 30 20))') AS poly") + val actual = baseDf.select(ST_AsText(ST_ForcePolygonCW("poly"))).take(1)(0).get(0).asInstanceOf[String] + val expected = "POLYGON ((20 35, 45 20, 30 5, 10 10, 10 30, 20 35), (30 20, 20 25, 20 15, 30 20))" + assertEquals(expected, actual) + } + + it("Passed ST_IsPolygonCW") { + var baseDf = sparkSession.sql("SELECT ST_GeomFromWKT('POLYGON ((20 35, 10 30, 10 10, 30 5, 45 20, 20 35),(30 20, 20 15, 20 25, 30 20))') as poly") + var actual = baseDf.select(ST_IsPolygonCW("poly")).take(1)(0).get(0).asInstanceOf[Boolean] + assertFalse(actual) + + baseDf = sparkSession.sql("SELECT ST_GeomFromWKT('POLYGON ((20 35, 45 20, 30 5, 10 10, 10 30, 20 35), (30 20, 20 25, 20 15, 30 20))') as poly") + actual = baseDf.select(ST_IsPolygonCW("poly")).take(1)(0).get(0).asInstanceOf[Boolean] + assertTrue(actual) + } + it("Passed ST_NRings") { val polyDf = sparkSession.sql("SELECT ST_GeomFromWKT('POLYGON ((1 0, 1 1, 2 1, 2 0, 1 0))') AS geom") val expected = 1 diff --git a/spark/common/src/test/scala/org/apache/sedona/sql/functionTestScala.scala b/spark/common/src/test/scala/org/apache/sedona/sql/functionTestScala.scala index d649ee0da4..f98573cc55 100644 --- a/spark/common/src/test/scala/org/apache/sedona/sql/functionTestScala.scala +++ b/spark/common/src/test/scala/org/apache/sedona/sql/functionTestScala.scala @@ -923,6 +923,22 @@ class functionTestScala extends TestBaseScala with Matchers with GeometrySample } } + it("Should pass ST_ForcePolygonCW") { + val baseDf = sparkSession.sql("SELECT ST_GeomFromWKT('POLYGON ((20 35, 10 30, 10 10, 30 5, 45 20, 20 35),(30 20, 20 15, 20 25, 30 20))') as poly") + val actual = baseDf.selectExpr("ST_AsText(ST_ForcePolygonCW(poly))").first().getString(0) + val expected = "POLYGON ((20 35, 45 20, 30 5, 10 10, 10 30, 20 35), (30 20, 20 25, 20 15, 30 20))" + assert(expected.equals(actual)) + } + + it("Should pass ST_IsPolygonCW") { + var actual = sparkSession.sql("SELECT ST_IsPolygonCW(ST_GeomFromWKT('POLYGON ((20 35, 10 30, 10 10, 30 5, 45 20, 20 35),(30 20, 20 15, 20 25, 30 20))'))").first().getBoolean(0) + print(actual) + assert(actual == false) + + actual = sparkSession.sql("SELECT ST_IsPolygonCW(ST_GeomFromWKT('POLYGON ((20 35, 45 20, 30 5, 10 10, 10 30, 20 35), (30 20, 20 25, 20 15, 30 20))'))").first().getBoolean(0) + assert(actual == true) + } + it("Should pass ST_Boundary") { Given("Sample geometry data frame") val geometryTable = Seq(