From d347e741260d8169ae516d2b0dea53b0563e03e8 Mon Sep 17 00:00:00 2001 From: Furqaan Khan <46216254+furqaankhan@users.noreply.github.com> Date: Mon, 25 Mar 2024 18:13:12 -0400 Subject: [PATCH] [SEDONA-483] [SEDONA-491] Implement ST_IsPolygonCCW and ST_ForcePolygonCCW (#1287) * init commit * add: ST_IsPolygonCCW and ST_ForcePolygonCCW * docs: fix type * fix: snowflake test 1 * fix: snowflake test 2 * fix: snowflake test 3 * fix: snowflake test 4 --- .../org/apache/sedona/common/Functions.java | 87 +++++++++++++++++++ .../apache/sedona/common/FunctionsTest.java | 47 ++++++++++ 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 | 14 +++ .../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 | 13 +++ .../sedona_sql/expressions/st_functions.scala | 6 ++ .../sedona/sql/dataFrameAPITestScala.scala | 19 +++- .../apache/sedona/sql/functionTestScala.scala | 15 ++++ 20 files changed, 451 insertions(+), 1 deletion(-) 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 8cf0c88134..73959a7075 100644 --- a/common/src/main/java/org/apache/sedona/common/Functions.java +++ b/common/src/main/java/org/apache/sedona/common/Functions.java @@ -26,6 +26,7 @@ import org.apache.sedona.common.utils.H3Utils; import org.apache.sedona.common.utils.S2Utils; import org.locationtech.jts.algorithm.MinimumBoundingCircle; +import org.locationtech.jts.algorithm.Orientation; import org.locationtech.jts.algorithm.hull.ConcaveHull; import org.locationtech.jts.geom.*; import org.locationtech.jts.geom.impl.CoordinateArraySequence; @@ -816,6 +817,92 @@ public static double lineLocatePoint(Geometry geom, Geometry point) return indexedLine.indexOf(point.getCoordinate()) / length; } + /** + * Forces a Polygon/MultiPolygon to use counter-clockwise orientation for the exterior ring and a clockwise for the interior ring(s). + * @param geom + * @return a counter-clockwise orientated (Multi)Polygon + */ + public static Geometry forcePolygonCCW(Geometry geom) { + if (isPolygonCCW(geom)) { + return geom; + } + + if (geom instanceof Polygon) { + return transformCCW((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) transformCCW(polygon)); + } + + return new GeometryFactory().createMultiPolygon(polygons.toArray(new Polygon[0])); + + } + // Non-polygonal geometries are returned unchanged + return geom; + } + + private static Geometry transformCCW(Polygon polygon) { + LinearRing exteriorRing = polygon.getExteriorRing(); + LinearRing exteriorRingEnforced = transformCCW(exteriorRing, true); + + List interiorRings = new ArrayList<>(); + for (int i = 0; i < polygon.getNumInteriorRing(); i++) { + interiorRings.add(transformCCW(polygon.getInteriorRingN(i), false)); + } + + return new GeometryFactory(polygon.getPrecisionModel(), polygon.getSRID()) + .createPolygon(exteriorRingEnforced, interiorRings.toArray(new LinearRing[0])); + } + + private static LinearRing transformCCW(LinearRing ring, boolean isExteriorRing) { + boolean isRingCounterClockwise = Orientation.isCCW(ring.getCoordinateSequence()); + + LinearRing enforcedRing; + if (isExteriorRing) { + enforcedRing = isRingCounterClockwise ? (LinearRing) ring.copy() : ring.reverse(); + } else { + enforcedRing = isRingCounterClockwise ? 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 counter-clockwise and the interior ring(s) are clockwise then returns true, otherwise false. + * @param geom Polygon or MultiPolygon + * @return + */ + public static boolean isPolygonCCW(Geometry geom) { + if (geom instanceof MultiPolygon) { + MultiPolygon multiPolygon = (MultiPolygon) geom; + + boolean arePolygonsCCW = checkIfPolygonCCW(((Polygon) multiPolygon.getGeometryN(0))); + for (int i = 1; i < multiPolygon.getNumGeometries(); i++) { + arePolygonsCCW = arePolygonsCCW && checkIfPolygonCCW((Polygon) multiPolygon.getGeometryN(i)); + } + return arePolygonsCCW; + } else if (geom instanceof Polygon) { + return checkIfPolygonCCW((Polygon) geom); + } + // False for remaining geometry types + return false; + } + + private static boolean checkIfPolygonCCW(Polygon geom) { + LinearRing exteriorRing = geom.getExteriorRing(); + boolean isExteriorRingCCW = Orientation.isCCW(exteriorRing.getCoordinateSequence()); + + boolean isInteriorRingCCW = !Orientation.isCCW(geom.getInteriorRingN(0).getCoordinateSequence()); + for (int i = 1; i < geom.getNumInteriorRing(); i++) { + isInteriorRingCCW = isInteriorRingCCW && !Orientation.isCCW(geom.getInteriorRingN(i).getCoordinateSequence()); + } + + return isExteriorRingCCW && isInteriorRingCCW; + } + public static Geometry difference(Geometry leftGeometry, Geometry rightGeometry) { boolean isIntersects = leftGeometry.intersects(rightGeometry); if (!isIntersects) { 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 0d1bd4cef1..7ff16e6452 100644 --- a/common/src/test/java/org/apache/sedona/common/FunctionsTest.java +++ b/common/src/test/java/org/apache/sedona/common/FunctionsTest.java @@ -689,6 +689,53 @@ public void geometricMedian() throws Exception { assertEquals(0, expected.compareTo(actual, COORDINATE_SEQUENCE_COMPARATOR)); } + @Test + 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)); + String expected = "POLYGON ((20 35, 10 30, 10 10, 30 5, 45 20, 20 35), (30 20, 20 15, 20 25, 30 20))"; + assertEquals(expected, actual); + + // both exterior ring and interior ring are counter-clockwise + 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); + actual = Functions.asWKT(Functions.forcePolygonCCW(polyCW)); + expected = "POLYGON ((20 35, 10 30, 10 10, 30 5, 45 20, 20 35), (30 20, 20 15, 20 25, 30 20))"; + assertEquals(expected, actual); + + Geometry mPoly = Constructors.geomFromWKT("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)))", 0); + actual = Functions.asWKT(Functions.forcePolygonCCW(mPoly)); + expected = "MULTIPOLYGON (((20 35, 10 30, 10 10, 30 5, 45 20, 20 35), (30 20, 20 15, 20 25, 30 20)), ((40 40, 20 45, 45 30, 40 40)))"; + assertEquals(expected, actual); + + 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); + actual = Functions.asWKT(Functions.forcePolygonCCW(mPoly)); + expected = "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)))"; + assertEquals(expected, actual); + + Geometry nonPoly = Constructors.geomFromWKT("POINT (45 20)", 0); + actual = Functions.asWKT(Functions.forcePolygonCCW(nonPoly)); + expected = Functions.asWKT(nonPoly); + assertEquals(expected, actual); + } + + @Test + 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)); + + 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); + assertFalse(Functions.isPolygonCCW(polyCW)); + + Geometry 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); + assertTrue(Functions.isPolygonCCW(mPoly)); + + Geometry point = Constructors.geomFromWKT("POINT (45 20)", 0); + assertFalse(Functions.isPolygonCCW(point)); + + Geometry lineClosed = Constructors.geomFromWKT("LINESTRING (30 20, 20 25, 20 15, 30 20)", 0); + assertFalse(Functions.isPolygonCCW(lineClosed)); + } + @Test public void geometricMedianTolerance() throws Exception { MultiPoint multiPoint = GEOMETRY_FACTORY.createMultiPointFromCoords( diff --git a/docs/api/flink/Function.md b/docs/api/flink/Function.md index 66e4247aef..7838d53c1c 100644 --- a/docs/api/flink/Function.md +++ b/docs/api/flink/Function.md @@ -1221,6 +1221,26 @@ Output: LINESTRING EMPTY ``` +## ST_ForcePolygonCCW + +Introduction: For (Multi)Polygon geometries, this function sets the exterior ring orientation to counter-clockwise and interior rings to clockwise orientation. Non-polygonal geometries are returned unchanged. + +Format: `ST_ForcePolygonCCW(geom: Geometry)` + +Since: `v1.6.0` + +SQL Example: + +```sql +SELECT ST_AsText(ST_ForcePolygonCCW(ST_GeomFromText('POLYGON ((20 35, 45 20, 30 5, 10 10, 10 30, 20 35), (30 20, 20 25, 20 15, 30 20))'))) +``` + +Output: + +``` +POLYGON ((20 35, 10 30, 10 10, 30 5, 45 20, 20 35), (30 20, 20 15, 20 25, 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, @@ -1630,6 +1650,26 @@ Output: false ``` +## ST_IsPolygonCCW + +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_IsPolygonCCW(geom: Geometry)` + +Since: `v1.6.0` + +SQL Example: + +```sql +SELECT ST_IsPolygonCCW(ST_GeomFromWKT('POLYGON ((20 35, 10 30, 10 10, 30 5, 45 20, 20 35), (30 20, 20 15, 20 25, 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 1be464633f..71f618cbf1 100644 --- a/docs/api/snowflake/vector-data/Function.md +++ b/docs/api/snowflake/vector-data/Function.md @@ -942,6 +942,24 @@ Input: `LINESTRING EMPTY` Output: `LINESTRING EMPTY` +## ST_ForcePolygonCCW + +Introduction: For (Multi)Polygon geometries, this function sets the exterior ring orientation to counter-clockwise and interior rings to clockwise orientation. Non-polygonal geometries are returned unchanged. + +Format: `ST_ForcePolygonCCW(geom: Geometry)` + +SQL Example: + +```sql +SELECT ST_AsText(ST_ForcePolygonCCW(ST_GeomFromText('POLYGON ((20 35, 45 20, 30 5, 10 10, 10 30, 20 35), (30 20, 20 25, 20 15, 30 20))'))) +``` + +Output: + +``` +POLYGON ((20 35, 10 30, 10 10, 30 5, 45 20, 20 35), (30 20, 20 15, 20 25, 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, @@ -1168,6 +1186,24 @@ SELECT ST_IsEmpty(polygondf.countyshape) FROM polygondf ``` +## ST_IsPolygonCCW + +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_IsPolygonCCW(geom: Geometry)` + +SQL Example: + +```sql +SELECT ST_IsPolygonCCW(ST_GeomFromWKT('POLYGON ((20 35, 10 30, 10 10, 30 5, 45 20, 20 35), (30 20, 20 15, 20 25, 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 9f94e591ac..d815871a85 100644 --- a/docs/api/sql/Function.md +++ b/docs/api/sql/Function.md @@ -1228,6 +1228,26 @@ Output: LINESTRING EMPTY ``` +## ST_ForcePolygonCCW + +Introduction: For (Multi)Polygon geometries, this function sets the exterior ring orientation to counter-clockwise and interior rings to clockwise orientation. Non-polygonal geometries are returned unchanged. + +Format: `ST_ForcePolygonCCW(geom: Geometry)` + +Since: `v1.6.0` + +SQL Example: + +```sql +SELECT ST_AsText(ST_ForcePolygonCCW(ST_GeomFromText('POLYGON ((20 35, 45 20, 30 5, 10 10, 10 30, 20 35), (30 20, 20 25, 20 15, 30 20))'))) +``` + +Output: + +``` +POLYGON ((20 35, 10 30, 10 10, 30 5, 45 20, 20 35), (30 20, 20 15, 20 25, 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, @@ -1638,6 +1658,26 @@ Output: false ``` +## ST_IsPolygonCCW + +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_IsPolygonCCW(geom: Geometry)` + +Since: `v1.6.0` + +SQL Example: + +```sql +SELECT ST_IsPolygonCCW(ST_GeomFromWKT('POLYGON ((20 35, 10 30, 10 10, 30 5, 45 20, 20 35), (30 20, 20 15, 20 25, 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 855bc52ed2..49d891c75d 100644 --- a/flink/src/main/java/org/apache/sedona/flink/Catalog.java +++ b/flink/src/main/java/org/apache/sedona/flink/Catalog.java @@ -140,6 +140,8 @@ public static UserDefinedFunction[] getFuncs() { new Functions.ST_NumPoints(), new Functions.ST_Force3D(), new Functions.ST_NRings(), + new Functions.ST_IsPolygonCCW(), + new Functions.ST_ForcePolygonCCW(), new Functions.ST_Translate(), new Functions.ST_VoronoiPolygons(), new Functions.ST_FrechetDistance(), 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 34bea764e8..150ac9a161 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 @@ -1007,6 +1007,22 @@ public int eval(@DataTypeHint(value = "RAW", bridgedTo = org.locationtech.jts.ge } } + public static class ST_ForcePolygonCCW 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.forcePolygonCCW(geometry); + } + } + + public static class ST_IsPolygonCCW 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.isPolygonCCW(geom); + } + } + public static class ST_Translate 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, 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 63f03160b9..d6bd22f0fd 100644 --- a/flink/src/test/java/org/apache/sedona/flink/FunctionTest.java +++ b/flink/src/test/java/org/apache/sedona/flink/FunctionTest.java @@ -1195,6 +1195,25 @@ public void testNRings() { assertEquals(expected, actual); } + @Test + public void testForcePolygonCCW() { + Table polyTable = tableEnv.sqlQuery("SELECT ST_ForcePolygonCCW(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"); + String actual = (String) first(polyTable.select(call(Functions.ST_AsText.class.getSimpleName(), $("polyCW")))).getField(0); + String expected = "POLYGON ((20 35, 10 30, 10 10, 30 5, 45 20, 20 35), (30 20, 20 15, 20 25, 30 20))"; + assertEquals(expected, actual); + } + + @Test + public void testIsPolygonCCW() { + 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_IsPolygonCCW.class.getSimpleName(), $("polyCCW")))).getField(0); + assertTrue(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_IsPolygonCCW.class.getSimpleName(), $("polyCW")))).getField(0); + assertFalse(actual); + } + @Test public void testTranslate() { Table polyTable = tableEnv.sqlQuery("SELECT ST_Translate(ST_GeomFromWKT('POLYGON ((1 0, 1 1, 2 1, 2 0, 1 0))'), 2, 5)" + "AS " + polygonColNames[0]); diff --git a/python/sedona/sql/st_functions.py b/python/sedona/sql/st_functions.py index c67c1f6f69..27e43dc985 100644 --- a/python/sedona/sql/st_functions.py +++ b/python/sedona/sql/st_functions.py @@ -111,6 +111,8 @@ "ST_SubDivideExplode", "ST_SimplifyPreserveTopology", "ST_SymDifference", + "ST_IsPolygonCCW", + "ST_ForcePolygonCCW", "ST_Transform", "ST_Union", "ST_X", @@ -1241,6 +1243,28 @@ def ST_Snap(input: ColumnOrName, reference: ColumnOrName, tolerance: Union[Colum return _call_st_function("ST_Snap", (input, reference, tolerance)) +@validate_argument_types +def ST_IsPolygonCCW(geometry: ColumnOrName) -> Column: + """Check if the Polygon or MultiPolygon use a counter-clockwise orientation for exterior ring and 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_IsPolygonCCW", geometry) + + +@validate_argument_types +def ST_ForcePolygonCCW(geometry: ColumnOrName) -> Column: + """ + Returns a geometry with counter-clockwise oriented exterior ring and clockwise oriented interior rings + :param geometry: Geometry column to change orientation + :return: counter-clockwise oriented geometry + """ + return _call_st_function("ST_ForcePolygonCCW", geometry) + + @validate_argument_types def ST_SRID(geometry: ColumnOrName) -> Column: """Get the SRID of geometry. diff --git a/python/tests/sql/test_dataframe_api.py b/python/tests/sql/test_dataframe_api.py index c6075c9050..c6a3dcf8ac 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_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)"), (stf.ST_GeometryN, ("geom", 0), "multipoint", "", "POINT (0 0)"), @@ -112,6 +113,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_IsPolygonCCW, ("geom",), "geom_with_hole", "", True), (stf.ST_IsRing, ("line",), "linestring_geom", "", False), (stf.ST_IsSimple, ("geom",), "triangle_geom", "", True), (stf.ST_IsValid, ("geom",), "triangle_geom", "", True), @@ -257,6 +259,7 @@ (stf.ST_ExteriorRing, (None,)), (stf.ST_FlipCoordinates, (None,)), (stf.ST_Force_2D, (None,)), + (stf.ST_ForcePolygonCCW, (None,)), (stf.ST_GeometryN, (None, 0)), (stf.ST_GeometryN, ("", None)), (stf.ST_GeometryN, ("", 0.0)), @@ -268,6 +271,7 @@ (stf.ST_Intersection, ("", None)), (stf.ST_IsClosed, (None,)), (stf.ST_IsEmpty, (None,)), + (stf.ST_IsPolygonCCW, (None,)), (stf.ST_IsRing, (None,)), (stf.ST_IsSimple, (None,)), (stf.ST_IsValid, (None,)), diff --git a/python/tests/sql/test_function.py b/python/tests/sql/test_function.py index 99ea4e2762..6087aab2bf 100644 --- a/python/tests/sql/test_function.py +++ b/python/tests/sql/test_function.py @@ -847,6 +847,20 @@ def test_st_is_ring(self): for actual, expected in result_and_expected: assert (actual == expected) + def test_isPolygonCCW(self): + actual = self.spark.sql("SELECT ST_IsPolygonCCW(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 == True + + actual = self.spark.sql("SELECT ST_IsPolygonCCW(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 == False + + def test_forcePolygonCCW(self): + actualDf = self.spark.sql( + "SELECT ST_ForcePolygonCCW(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 = actualDf.selectExpr("ST_AsText(polyCW)").take(1)[0][0] + expected = "POLYGON ((20 35, 10 30, 10 10, 30 5, 45 20, 20 35), (30 20, 20 15, 20 25, 30 20))" + assert expected == actual + def test_st_subdivide(self): # Given geometry_df = self.__wkt_list_to_data_frame( 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 41a465a5f7..3218a54f24 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 @@ -1012,6 +1012,24 @@ public void test_ST_LengthSpheroid() { ); } + @Test + public void test_ST_IsPolygonCCW() { + registerUDF("ST_IsPolygonCCW", byte[].class); + verifySqlSingleRes( + "SELECT sedona.ST_IsPolygonCCW(sedona.ST_GeomFromWKT('POLYGON ((20 35, 10 30, 10 10, 30 5, 45 20, 20 35),(30 20, 20 15, 20 25, 30 20))'))", + true + ); + } + + @Test + public void test_ST_ForcePolygonCCW() { + registerUDF("ST_ForcePolygonCCW", byte[].class); + verifySqlSingleRes( + "SELECT sedona.ST_AsText(sedona.ST_ForcePolygonCCW(sedona.ST_GeomFromWKT('POLYGON ((20 35, 45 20, 30 5, 10 10, 10 30, 20 35), (30 20, 20 25, 20 15, 30 20))')))", + "POLYGON ((20 35, 10 30, 10 10, 30 5, 45 20, 20 35), (30 20, 20 15, 20 25, 30 20))" + ); + } + @Test public void test_ST_GeometricMedian() { registerUDF("ST_GeometricMedian", 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 65146667d1..1933d7680e 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 @@ -973,6 +973,24 @@ public void test_ST_LengthSpheroid() { ); } + @Test + public void test_ST_ForcePolygonCCW() { + registerUDFV2("ST_ForcePolygonCCW", String.class); + verifySqlSingleRes( + "SELECT ST_AsText(sedona.ST_ForcePolygonCCW(ST_GeomFromWKT('POLYGON ((20 35, 45 20, 30 5, 10 10, 10 30, 20 35), (30 20, 20 25, 20 15, 30 20))')))", + "POLYGON((20 35,10 30,10 10,30 5,45 20,20 35),(30 20,20 15,20 25,30 20))" + ); + } + + @Test + public void test_ST_IsPolygonCCW() { + registerUDFV2("ST_IsPolygonCCW", String.class); + verifySqlSingleRes( + "SELECT sedona.ST_IsPolygonCCW(ST_GeomFromWKT('POLYGON ((20 35, 10 30, 10 10, 30 5, 45 20, 20 35),(30 20, 20 15, 20 25, 30 20))'))", + true + ); + } + @Test public void test_ST_GeometricMedian() { registerUDFV2("ST_GeometricMedian", 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 736eff3e2f..24f668586a 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_IsPolygonCCW(byte[] geom) { + return Functions.isPolygonCCW( + GeometrySerde.deserialize(geom) + ); + } + @UDFAnnotations.ParamMeta(argNames = {"geometry"}) public static boolean ST_IsRing(byte[] geometry) { return Functions.isRing( @@ -1307,6 +1314,15 @@ public static byte[] ST_Force3D(byte[] geom) { ); } + @UDFAnnotations.ParamMeta(argNames = {"geom"}) + public static byte[] ST_ForcePolygonCCW(byte[] geom) { + return GeometrySerde.serialize( + Functions.forcePolygonCCW( + GeometrySerde.deserialize(geom) + ) + ); + } + @UDFAnnotations.ParamMeta(argNames = {"geom"}) public static double ST_LengthSpheroid(byte[] geom) { return Spheroid.length( 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 74f5a69134..e43469b183 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 @@ -546,6 +546,13 @@ public static boolean ST_IsEmpty(String geometry) { ); } + @UDFAnnotations.ParamMeta(argNames = {"geom"}, argTypes = {"Geometry"}) + public static boolean ST_IsPolygonCCW(String geom) { + return Functions.isPolygonCCW( + GeometrySerde.deserGeoJson(geom) + ); + } + @UDFAnnotations.ParamMeta(argNames = {"geometry"}, argTypes = {"Geometry"}) public static boolean ST_IsRing(String geometry) { return Functions.isRing( @@ -1150,6 +1157,15 @@ public static String ST_Force3D(String geom) { ); } + @UDFAnnotations.ParamMeta(argNames = {"geom"}, argTypes = {"Geometry"}, returnTypes = "Geometry") + public static String ST_ForcePolygonCCW(String geom) { + return GeometrySerde.serGeoJson( + Functions.forcePolygonCCW( + GeometrySerde.deserGeoJson(geom) + ) + ); + } + @UDFAnnotations.ParamMeta(argNames = {"geom"}, argTypes = {"Geometry"}) public static double ST_LengthSpheroid(String geom) { return Spheroid.length( 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 36d3b46494..cc4a9bfb27 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 @@ -129,6 +129,8 @@ object Catalog { function[ST_RemovePoint](-1), function[ST_SetPoint](), function[ST_IsRing](), + function[ST_IsPolygonCCW](), + function[ST_ForcePolygonCCW](), function[ST_FlipCoordinates](), function[ST_LineSubstring](), function[ST_LineInterpolatePoint](), 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 60187e22d8..a3b985c36b 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 @@ -1108,6 +1108,19 @@ case class ST_NRings(inputExpressions: Seq[Expression]) } } +case class ST_IsPolygonCCW(inputExpressions: Seq[Expression]) extends InferredExpression(Functions.isPolygonCCW _) { + protected def withNewChildrenInternal(newChildren: IndexedSeq[Expression]) = { + copy(inputExpressions = newChildren) + } +} + +case class ST_ForcePolygonCCW(inputExpressions: Seq[Expression]) + extends InferredExpression(Functions.forcePolygonCCW _) { + protected def withNewChildrenInternal(newChildren: IndexedSeq[Expression]) = { + copy(inputExpressions = newChildren) + } +} + case class ST_Translate(inputExpressions: Seq[Expression]) extends InferredExpression(inferrableFunction4(Functions.translate)) with FoldableExpression { 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 bc7074b1ba..f320802a49 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 @@ -235,6 +235,12 @@ object st_functions extends DataFrameAPI { def ST_MinimumBoundingRadius(geometry: Column): Column = wrapExpression[ST_MinimumBoundingRadius](geometry) def ST_MinimumBoundingRadius(geometry: String): Column = wrapExpression[ST_MinimumBoundingRadius](geometry) + def ST_IsPolygonCCW(geometry: Column): Column = wrapExpression[ST_IsPolygonCCW](geometry) + def ST_IsPolygonCCW(geometry: String): Column = wrapExpression[ST_IsPolygonCCW](geometry) + + def ST_ForcePolygonCCW(geometry: Column): Column = wrapExpression[ST_ForcePolygonCCW](geometry) + def ST_ForcePolygonCCW(geometry: String): Column = wrapExpression[ST_ForcePolygonCCW](geometry) + def ST_Multi(geometry: Column): Column = wrapExpression[ST_Multi](geometry) def ST_Multi(geometry: String): Column = wrapExpression[ST_Multi](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 95370b7785..1bf0047cc0 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 @@ -24,7 +24,7 @@ import org.apache.spark.sql.sedona_sql.expressions.st_aggregates._ import org.apache.spark.sql.sedona_sql.expressions.st_constructors._ import org.apache.spark.sql.sedona_sql.expressions.st_functions._ import org.apache.spark.sql.sedona_sql.expressions.st_predicates._ -import org.junit.Assert.{assertEquals, assertTrue} +import org.junit.Assert.{assertEquals, assertFalse, assertTrue} import org.locationtech.jts.geom.{Geometry, Polygon} import org.locationtech.jts.io.WKTWriter import org.locationtech.jts.operation.buffer.BufferParameters @@ -1195,6 +1195,23 @@ class dataFrameAPITestScala extends TestBaseScala { assert(expected == actual) } + it("Passed ST_ForcePolygonCCW") { + val 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") + val actual = baseDf.select(ST_AsText(ST_ForcePolygonCCW("poly"))).take(1)(0).get(0).asInstanceOf[String] + val expected = "POLYGON ((20 35, 10 30, 10 10, 30 5, 45 20, 20 35), (30 20, 20 15, 20 25, 30 20))" + assertEquals(expected, actual) + } + + it("Passed ST_IsPolygonCCW") { + 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_IsPolygonCCW("poly")).take(1)(0).get(0).asInstanceOf[Boolean] + assertTrue(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_IsPolygonCCW("poly")).take(1)(0).get(0).asInstanceOf[Boolean] + assertFalse(actual) + } + it("Passed ST_Translate") { val polyDf = sparkSession.sql("SELECT ST_GeomFromWKT('POLYGON ((1 0 1, 1 1 1, 2 1 1, 2 0 1, 1 0 1))') AS geom") val df = polyDf.select(ST_Translate("geom", 2, 3, 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 65954aef21..d649ee0da4 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 @@ -896,6 +896,21 @@ class functionTestScala extends TestBaseScala with Matchers with GeometrySample polygons.isEmpty shouldBe true } + it("Should pass ST_ForcePolygonCCW") { + val 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") + val actual = baseDf.selectExpr("ST_AsText(ST_ForcePolygonCCW(poly))").first().getString(0) + val expected = "POLYGON ((20 35, 10 30, 10 10, 30 5, 45 20, 20 35), (30 20, 20 15, 20 25, 30 20))" + assert(expected.equals(actual)) + } + + it("Should pass ST_IsPolygonCCW") { + var actual = sparkSession.sql("SELECT ST_IsPolygonCCW(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) + assert(actual == true) + + actual = sparkSession.sql("SELECT ST_IsPolygonCCW(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 == false) + } + it("Should pass ST_Snap") { val baseDf = sparkSession.sql("SELECT ST_GeomFromWKT('POLYGON((2.6 12.5, 2.6 20.0, 12.6 20.0, 12.6 12.5, 2.6 12.5 ))') AS poly, ST_GeomFromWKT('LINESTRING (0.5 10.7, 5.4 8.4, 10.1 10.0)') AS line") var actual = baseDf.selectExpr("ST_AsText(ST_Snap(poly, line, 2.525))").first().getString(0)