From 2a78b1863aba9bd984ea93e1c55e49b512e115e6 Mon Sep 17 00:00:00 2001 From: Jia Yu Date: Sat, 28 Mar 2026 23:55:34 -0700 Subject: [PATCH 1/5] [GH-2799] Add ST_OffsetCurve function --- .../org/apache/sedona/common/Functions.java | 15 ++++++ .../apache/sedona/common/FunctionsTest.java | 46 ++++++++++++++++ docs/api/flink/Geometry-Functions.md | 1 + .../Geometry-Processing/ST_OffsetCurve.md | 52 ++++++++++++++++++ .../vector-data/Geometry-Functions.md | 1 + .../Geometry-Processing/ST_OffsetCurve.md | 50 +++++++++++++++++ docs/api/sql/Geometry-Functions.md | 1 + .../sql/Geometry-Processing/ST_OffsetCurve.md | 54 +++++++++++++++++++ docs/image/ST_OffsetCurve/ST_OffsetCurve.svg | 34 ++++++++++++ .../java/org/apache/sedona/flink/Catalog.java | 1 + .../sedona/flink/expressions/Functions.java | 33 ++++++++++++ .../org/apache/sedona/flink/FunctionTest.java | 18 +++++++ python/sedona/spark/sql/st_functions.py | 26 +++++++++ python/tests/sql/test_dataframe_api.py | 15 ++++++ python/tests/sql/test_function.py | 22 ++++++++ .../org/apache/sedona/sql/UDF/Catalog.scala | 1 + .../sedona_sql/expressions/Functions.scala | 10 ++++ .../sedona_sql/expressions/st_functions.scala | 9 ++++ .../apache/sedona/sql/PreserveSRIDSuite.scala | 1 + .../sedona/sql/dataFrameAPITestScala.scala | 16 ++++++ .../apache/sedona/sql/functionTestScala.scala | 20 +++++++ 21 files changed, 426 insertions(+) create mode 100644 docs/api/flink/Geometry-Processing/ST_OffsetCurve.md create mode 100644 docs/api/snowflake/vector-data/Geometry-Processing/ST_OffsetCurve.md create mode 100644 docs/api/sql/Geometry-Processing/ST_OffsetCurve.md create mode 100644 docs/image/ST_OffsetCurve/ST_OffsetCurve.svg 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 fdec8913c37..78bd8740517 100644 --- a/common/src/main/java/org/apache/sedona/common/Functions.java +++ b/common/src/main/java/org/apache/sedona/common/Functions.java @@ -54,6 +54,7 @@ import org.locationtech.jts.linearref.LengthIndexedLine; import org.locationtech.jts.operation.buffer.BufferOp; import org.locationtech.jts.operation.buffer.BufferParameters; +import org.locationtech.jts.operation.buffer.OffsetCurve; import org.locationtech.jts.operation.distance.DistanceOp; import org.locationtech.jts.operation.distance3d.Distance3DOp; import org.locationtech.jts.operation.linemerge.LineMerger; @@ -424,6 +425,20 @@ else if (singleParam[0].equalsIgnoreCase(listBufferParameters[5])) { return bufferParameters; } + public static Geometry offsetCurve(Geometry geometry, double distance) { + return offsetCurve(geometry, distance, BufferParameters.DEFAULT_QUADRANT_SEGMENTS); + } + + public static Geometry offsetCurve(Geometry geometry, double distance, int quadrantSegments) { + if (geometry.isEmpty()) { + return null; + } + BufferParameters bufferParameters = new BufferParameters(); + bufferParameters.setQuadrantSegments(quadrantSegments); + OffsetCurve oc = new OffsetCurve(geometry, distance, bufferParameters); + return oc.getCurve(); + } + public static int bestSRID(Geometry geometry) throws IllegalArgumentException { // Shift longitudes if geometry crosses dateline if (crossesDateLine(geometry)) { 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 e48f62c5461..9264e7ea5ec 100644 --- a/common/src/test/java/org/apache/sedona/common/FunctionsTest.java +++ b/common/src/test/java/org/apache/sedona/common/FunctionsTest.java @@ -4250,6 +4250,52 @@ public void shortestLineSameGeometry() { assertEquals(0.0, result.getLength(), 1e-6); } + @Test + public void offsetCurvePositiveDistance() throws ParseException { + // Offset to the left of a horizontal line + Geometry line = Constructors.geomFromWKT("LINESTRING (0 0, 10 0)", 0); + Geometry result = Functions.offsetCurve(line, 5.0); + String actual = Functions.asWKT(result); + assertEquals("LINESTRING (0 5, 10 5)", actual); + } + + @Test + public void offsetCurveNegativeDistance() throws ParseException { + // Offset to the right of a horizontal line + Geometry line = Constructors.geomFromWKT("LINESTRING (0 0, 10 0)", 0); + Geometry result = Functions.offsetCurve(line, -5.0); + String actual = Functions.asWKT(result); + assertEquals("LINESTRING (0 -5, 10 -5)", actual); + } + + @Test + public void offsetCurveZeroDistance() throws ParseException { + // Zero distance returns a copy of the input + Geometry line = Constructors.geomFromWKT("LINESTRING (0 0, 10 0)", 0); + Geometry result = Functions.offsetCurve(line, 0.0); + String actual = Functions.asWKT(result); + assertEquals("LINESTRING (0 0, 10 0)", actual); + } + + @Test + public void offsetCurveEmptyGeometry() throws ParseException { + // Empty geometry returns null + Geometry empty = Constructors.geomFromWKT("LINESTRING EMPTY", 0); + Geometry result = Functions.offsetCurve(empty, 5.0); + assertNull(result); + } + + @Test + public void offsetCurveWithQuadrantSegments() throws ParseException { + // Test with custom quadrant segments + Geometry line = Constructors.geomFromWKT("LINESTRING (0 0, 10 0)", 0); + Geometry result = Functions.offsetCurve(line, 5.0, 4); + assertNotNull(result); + // The result should still be a simple offset for a straight line + String actual = Functions.asWKT(result); + assertEquals("LINESTRING (0 5, 10 5)", actual); + } + @Test public void testZmFlag() throws ParseException { int _2D = 0, _3DM = 1, _3DZ = 2, _4D = 3; diff --git a/docs/api/flink/Geometry-Functions.md b/docs/api/flink/Geometry-Functions.md index d0c3f42d5c9..489ebf11f7b 100644 --- a/docs/api/flink/Geometry-Functions.md +++ b/docs/api/flink/Geometry-Functions.md @@ -221,6 +221,7 @@ These functions compute geometric constructions, or alter geometry size or shape | [ST_LabelPoint](Geometry-Processing/ST_LabelPoint.md) | Geometry | `ST_LabelPoint` computes and returns a label point for a given polygon or geometry collection. The label point is chosen to be sufficiently far from boundaries of the geometry. For a regular Polygo... | v1.7.1 | | [ST_MinimumBoundingCircle](Geometry-Processing/ST_MinimumBoundingCircle.md) | Geometry | Returns the smallest circle polygon that contains a geometry. The optional quadrantSegments parameter determines how many segments to use per quadrant and the default number of segments is 48. | v1.5.0 | | [ST_MinimumBoundingRadius](Geometry-Processing/ST_MinimumBoundingRadius.md) | Struct | Returns a struct containing the center point and radius of the smallest circle that contains a geometry. | v1.5.0 | +| [ST_OffsetCurve](Geometry-Processing/ST_OffsetCurve.md) | Geometry | Returns a line at a given offset distance from a linear geometry. Positive distance offsets to the left, negative to the right. | v1.9.0 | | [ST_OrientedEnvelope](Geometry-Processing/ST_OrientedEnvelope.md) | Geometry | Returns the minimum-area rotated rectangle enclosing a geometry. The rectangle may be rotated relative to the coordinate axes. Degenerate inputs may result in a Point or LineString being returned. | v1.8.1 | | [ST_PointOnSurface](Geometry-Processing/ST_PointOnSurface.md) | Geometry | Returns a POINT guaranteed to lie on the surface. | v1.2.1 | | [ST_Polygonize](Geometry-Processing/ST_Polygonize.md) | Geometry | Generates a GeometryCollection composed of polygons that are formed from the linework of an input GeometryCollection. When the input does not contain any linework that forms a polygon, the function... | v1.6.0 | diff --git a/docs/api/flink/Geometry-Processing/ST_OffsetCurve.md b/docs/api/flink/Geometry-Processing/ST_OffsetCurve.md new file mode 100644 index 00000000000..e00a0a728b3 --- /dev/null +++ b/docs/api/flink/Geometry-Processing/ST_OffsetCurve.md @@ -0,0 +1,52 @@ + + +# ST_OffsetCurve + +Introduction: Returns a line at a given offset distance from a linear geometry. If the distance is positive, the offset is on the left side of the input line; if it is negative, it is on the right side. Returns null for empty geometries. + +The optional third parameter `quadrantSegments` controls the number of line segments used to approximate a quarter circle at round joins. The default value is 8. + +Format: `ST_OffsetCurve(geometry: Geometry, distance: Double, quadrantSegments: Integer)` + +Format: `ST_OffsetCurve(geometry: Geometry, distance: Double)` + +Return type: `Geometry` + +Since: `v1.9.0` + +SQL Example + +```sql +SELECT ST_AsText(ST_OffsetCurve(ST_GeomFromWKT('LINESTRING(0 0, 10 0)'), 5.0)) +``` + +Output: `LINESTRING (0 5, 10 5)` + +```sql +SELECT ST_AsText(ST_OffsetCurve(ST_GeomFromWKT('LINESTRING(0 0, 10 0)'), -5.0)) +``` + +Output: `LINESTRING (0 -5, 10 -5)` + +```sql +SELECT ST_AsText(ST_OffsetCurve(ST_GeomFromWKT('LINESTRING(0 0, 10 0)'), 5.0, 4)) +``` + +Output: `LINESTRING (0 5, 10 5)` diff --git a/docs/api/snowflake/vector-data/Geometry-Functions.md b/docs/api/snowflake/vector-data/Geometry-Functions.md index e22c5d0a308..01ecdda07dc 100644 --- a/docs/api/snowflake/vector-data/Geometry-Functions.md +++ b/docs/api/snowflake/vector-data/Geometry-Functions.md @@ -214,6 +214,7 @@ These functions compute geometric constructions, or alter geometry size or shape | [ST_MaximumInscribedCircle](Geometry-Processing/ST_MaximumInscribedCircle.md) | Finds the largest circle that is contained within a (multi)polygon, or which does not overlap any lines and points. Returns a row with fields: | | [ST_MinimumBoundingCircle](Geometry-Processing/ST_MinimumBoundingCircle.md) | Returns the smallest circle polygon that contains a geometry. | | [ST_MinimumBoundingRadius](Geometry-Processing/ST_MinimumBoundingRadius.md) | Returns two columns containing the center point and radius of the smallest circle that contains a geometry. | +| [ST_OffsetCurve](Geometry-Processing/ST_OffsetCurve.md) | Returns a line at a given offset distance from a linear geometry. Positive distance offsets to the left, negative to the right. | | [ST_OrientedEnvelope](Geometry-Processing/ST_OrientedEnvelope.md) | Returns the minimum-area rotated rectangle enclosing a geometry. The rectangle may be rotated relative to the coordinate axes. Degenerate inputs may result in a Point or LineString being returned. | | [ST_PointOnSurface](Geometry-Processing/ST_PointOnSurface.md) | Returns a POINT guaranteed to lie on the surface. | | [ST_Polygonize](Geometry-Processing/ST_Polygonize.md) | Generates a GeometryCollection composed of polygons that are formed from the linework of an input GeometryCollection. When the input does not contain any linework that forms a polygon, the function... | diff --git a/docs/api/snowflake/vector-data/Geometry-Processing/ST_OffsetCurve.md b/docs/api/snowflake/vector-data/Geometry-Processing/ST_OffsetCurve.md new file mode 100644 index 00000000000..c5e09beb183 --- /dev/null +++ b/docs/api/snowflake/vector-data/Geometry-Processing/ST_OffsetCurve.md @@ -0,0 +1,50 @@ + + +# ST_OffsetCurve + +Introduction: Returns a line at a given offset distance from a linear geometry. If the distance is positive, the offset is on the left side of the input line; if it is negative, it is on the right side. Returns null for empty geometries. + +The optional third parameter `quadrantSegments` controls the number of line segments used to approximate a quarter circle at round joins. The default value is 8. + +Format: `ST_OffsetCurve(geometry: Geometry, distance: Double, quadrantSegments: Integer)` + +Format: `ST_OffsetCurve(geometry: Geometry, distance: Double)` + +Return type: `Geometry` + +SQL Example + +```sql +SELECT ST_AsText(ST_OffsetCurve(ST_GeomFromWKT('LINESTRING(0 0, 10 0)'), 5.0)) +``` + +Output: `LINESTRING (0 5, 10 5)` + +```sql +SELECT ST_AsText(ST_OffsetCurve(ST_GeomFromWKT('LINESTRING(0 0, 10 0)'), -5.0)) +``` + +Output: `LINESTRING (0 -5, 10 -5)` + +```sql +SELECT ST_AsText(ST_OffsetCurve(ST_GeomFromWKT('LINESTRING(0 0, 10 0)'), 5.0, 4)) +``` + +Output: `LINESTRING (0 5, 10 5)` diff --git a/docs/api/sql/Geometry-Functions.md b/docs/api/sql/Geometry-Functions.md index 14ce382530b..d6d6db75757 100644 --- a/docs/api/sql/Geometry-Functions.md +++ b/docs/api/sql/Geometry-Functions.md @@ -223,6 +223,7 @@ These functions compute geometric constructions, or alter geometry size or shape | [ST_MaximumInscribedCircle](Geometry-Processing/ST_MaximumInscribedCircle.md) | Struct | Finds the largest circle that is contained within a (multi)polygon, or which does not overlap any lines and points. Returns a row with fields: | v1.6.1 | | [ST_MinimumBoundingCircle](Geometry-Processing/ST_MinimumBoundingCircle.md) | Geometry | Returns the smallest circle polygon that contains a geometry. The optional quadrantSegments parameter determines how many segments to use per quadrant and the default number of segments has been ch... | v1.0.1 | | [ST_MinimumBoundingRadius](Geometry-Processing/ST_MinimumBoundingRadius.md) | Struct | Returns a struct containing the center point and radius of the smallest circle that contains a geometry. | v1.0.1 | +| [ST_OffsetCurve](Geometry-Processing/ST_OffsetCurve.md) | Geometry | Returns a line at a given offset distance from a linear geometry. Positive distance offsets to the left, negative to the right. | v1.9.0 | | [ST_OrientedEnvelope](Geometry-Processing/ST_OrientedEnvelope.md) | Geometry | Returns the minimum-area rotated rectangle enclosing a geometry. The rectangle may be rotated relative to the coordinate axes. Degenerate inputs may result in a Point or LineString being returned. | v1.8.1 | | [ST_PointOnSurface](Geometry-Processing/ST_PointOnSurface.md) | Geometry | Returns a POINT guaranteed to lie on the surface. | v1.2.1 | | [ST_Polygonize](Geometry-Processing/ST_Polygonize.md) | Geometry | Generates a GeometryCollection composed of polygons that are formed from the linework of an input GeometryCollection. When the input does not contain any linework that forms a polygon, the function... | v1.6.0 | diff --git a/docs/api/sql/Geometry-Processing/ST_OffsetCurve.md b/docs/api/sql/Geometry-Processing/ST_OffsetCurve.md new file mode 100644 index 00000000000..1802d4a079d --- /dev/null +++ b/docs/api/sql/Geometry-Processing/ST_OffsetCurve.md @@ -0,0 +1,54 @@ + + +# ST_OffsetCurve + +![ST_OffsetCurve](../../../image/ST_OffsetCurve/ST_OffsetCurve.svg "ST_OffsetCurve") + +Introduction: Returns a line at a given offset distance from a linear geometry. If the distance is positive, the offset is on the left side of the input line; if it is negative, it is on the right side. Returns null for empty geometries. + +The optional third parameter `quadrantSegments` controls the number of line segments used to approximate a quarter circle at round joins. The default value is 8. + +Format: `ST_OffsetCurve(geometry: Geometry, distance: Double, quadrantSegments: Integer)` + +Format: `ST_OffsetCurve(geometry: Geometry, distance: Double)` + +Return type: `Geometry` + +Since: `v1.9.0` + +SQL Example + +```sql +SELECT ST_AsText(ST_OffsetCurve(ST_GeomFromWKT('LINESTRING(0 0, 10 0)'), 5.0)) +``` + +Output: `LINESTRING (0 5, 10 5)` + +```sql +SELECT ST_AsText(ST_OffsetCurve(ST_GeomFromWKT('LINESTRING(0 0, 10 0)'), -5.0)) +``` + +Output: `LINESTRING (0 -5, 10 -5)` + +```sql +SELECT ST_AsText(ST_OffsetCurve(ST_GeomFromWKT('LINESTRING(0 0, 10 0)'), 5.0, 4)) +``` + +Output: `LINESTRING (0 5, 10 5)` diff --git a/docs/image/ST_OffsetCurve/ST_OffsetCurve.svg b/docs/image/ST_OffsetCurve/ST_OffsetCurve.svg new file mode 100644 index 00000000000..2515275e63a --- /dev/null +++ b/docs/image/ST_OffsetCurve/ST_OffsetCurve.svg @@ -0,0 +1,34 @@ + + + + + ST_OffsetCurve + + + Input + + + +d (left) + + + −d (right) + + + + + d + + + d + + Returns a line offset by distance d from the input + + + Input line + + Positive offset + + Negative offset + 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 a10d9f15777..3ef00ce247e 100644 --- a/flink/src/main/java/org/apache/sedona/flink/Catalog.java +++ b/flink/src/main/java/org/apache/sedona/flink/Catalog.java @@ -70,6 +70,7 @@ public static UserDefinedFunction[] getFuncs() { new Functions.ST_BestSRID(), new Functions.ST_ClosestPoint(), new Functions.ST_ShortestLine(), + new Functions.ST_OffsetCurve(), new Functions.ST_Centroid(), new Functions.ST_Collect(), new Functions.ST_CollectionExtract(), 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 cf5e5267c78..71029be3a44 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 @@ -309,6 +309,39 @@ public Geometry eval( } } + public static class ST_OffsetCurve extends ScalarFunction { + @DataTypeHint( + value = "RAW", + rawSerializer = GeometryTypeSerializer.class, + bridgedTo = Geometry.class) + public Geometry eval( + @DataTypeHint( + value = "RAW", + rawSerializer = GeometryTypeSerializer.class, + bridgedTo = Geometry.class) + Object o, + @DataTypeHint("Double") Double distance) { + Geometry geom = (Geometry) o; + return org.apache.sedona.common.Functions.offsetCurve(geom, distance); + } + + @DataTypeHint( + value = "RAW", + rawSerializer = GeometryTypeSerializer.class, + bridgedTo = Geometry.class) + public Geometry eval( + @DataTypeHint( + value = "RAW", + rawSerializer = GeometryTypeSerializer.class, + bridgedTo = Geometry.class) + Object o, + @DataTypeHint("Double") Double distance, + @DataTypeHint("Integer") Integer quadrantSegments) { + Geometry geom = (Geometry) o; + return org.apache.sedona.common.Functions.offsetCurve(geom, distance, quadrantSegments); + } + } + public static class ST_Centroid extends ScalarFunction { @DataTypeHint( value = "RAW", 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 f0d7ffa7f14..f5969d59c63 100644 --- a/flink/src/test/java/org/apache/sedona/flink/FunctionTest.java +++ b/flink/src/test/java/org/apache/sedona/flink/FunctionTest.java @@ -251,6 +251,24 @@ public void testShortestLine() { assertEquals("LINESTRING (0 0, 3 4)", result.toString()); } + @Test + public void testOffsetCurve() { + Table table = + tableEnv.sqlQuery("SELECT ST_GeomFromWKT('LINESTRING(0 0, 10 0)') AS geom"); + table = table.select(call(Functions.ST_OffsetCurve.class.getSimpleName(), $("geom"), 5.0)); + Geometry result = (Geometry) first(table).getField(0); + assertEquals("LINESTRING (0 5, 10 5)", result.toString()); + } + + @Test + public void testOffsetCurveWithQuadrantSegments() { + Table table = + tableEnv.sqlQuery("SELECT ST_GeomFromWKT('LINESTRING(0 0, 10 0)') AS geom"); + table = table.select(call(Functions.ST_OffsetCurve.class.getSimpleName(), $("geom"), 5.0, 4)); + Geometry result = (Geometry) first(table).getField(0); + assertEquals("LINESTRING (0 5, 10 5)", result.toString()); + } + @Test public void testCentroid() { Table polygonTable = diff --git a/python/sedona/spark/sql/st_functions.py b/python/sedona/spark/sql/st_functions.py index f32d57e7adc..186aaadd311 100644 --- a/python/sedona/spark/sql/st_functions.py +++ b/python/sedona/spark/sql/st_functions.py @@ -1788,6 +1788,32 @@ def ST_OrientedEnvelope(geometry: ColumnOrName) -> Column: return _call_st_function("ST_OrientedEnvelope", geometry) +@validate_argument_types +def ST_OffsetCurve( + geometry: ColumnOrName, + distance: ColumnOrNameOrNumber, + quadrant_segments: Optional[Union[ColumnOrName, int]] = None, +) -> Column: + """Return a line at a given offset distance from a linear geometry. + + Positive distance offsets to the left, negative to the right. + + :param geometry: Linear geometry column. + :type geometry: ColumnOrName + :param distance: Offset distance. + :type distance: ColumnOrNameOrNumber + :param quadrant_segments: Number of segments to approximate a quarter circle (default 8). + :type quadrant_segments: Optional[Union[ColumnOrName, int]] + :return: Offset curve as a geometry column. + :rtype: Column + """ + if quadrant_segments is None: + args = (geometry, distance) + else: + args = (geometry, distance, quadrant_segments) + return _call_st_function("ST_OffsetCurve", args) + + @validate_argument_types def ST_PointN(geometry: ColumnOrName, n: Union[ColumnOrName, int]) -> Column: """Get the n-th point (starts at 1) for a geometry. diff --git a/python/tests/sql/test_dataframe_api.py b/python/tests/sql/test_dataframe_api.py index 920550a1cfe..9156b1d48f6 100644 --- a/python/tests/sql/test_dataframe_api.py +++ b/python/tests/sql/test_dataframe_api.py @@ -907,6 +907,20 @@ "", "POLYGON ((0 0, 4.5 4.5, 5 4, 0.5 -0.5, 0 0))", ), + ( + stf.ST_OffsetCurve, + ("line", 1.0), + "linestring_geom", + "ST_AsText(geom)", + "LINESTRING (0 1, 5 1)", + ), + ( + stf.ST_OffsetCurve, + ("line", 1.0, 4), + "linestring_geom", + "ST_AsText(geom)", + "LINESTRING (0 1, 5 1)", + ), (stf.ST_PointN, ("line", 2), "linestring_geom", "", "POINT (1 0)"), (stf.ST_PointOnSurface, ("line",), "linestring_geom", "", "POINT (2 0)"), ( @@ -1444,6 +1458,7 @@ (stf.ST_MinimumBoundingCircle, (None,)), (stf.ST_MinimumBoundingRadius, (None,)), (stf.ST_OrientedEnvelope, (None,)), + (stf.ST_OffsetCurve, (None, 1.0)), (stf.ST_Multi, (None,)), (stf.ST_Normalize, (None,)), (stf.ST_NPoints, (None,)), diff --git a/python/tests/sql/test_function.py b/python/tests/sql/test_function.py index 48a24d0ce7a..ff3e1497c4d 100644 --- a/python/tests/sql/test_function.py +++ b/python/tests/sql/test_function.py @@ -1983,6 +1983,28 @@ def test_st_shortest_line_empty(self): actual = actual_df.take(1)[0][0] assert actual is None + def test_st_offset_curve(self): + # Positive distance offsets to the left + actual_df = self.spark.sql( + "SELECT ST_AsText(ST_OffsetCurve(ST_GeomFromWKT('LINESTRING(0 0, 10 0)'), 5.0))" + ) + actual = actual_df.take(1)[0][0] + assert actual == "LINESTRING (0 5, 10 5)" + + # Negative distance offsets to the right + actual_df = self.spark.sql( + "SELECT ST_AsText(ST_OffsetCurve(ST_GeomFromWKT('LINESTRING(0 0, 10 0)'), -5.0))" + ) + actual = actual_df.take(1)[0][0] + assert actual == "LINESTRING (0 -5, 10 -5)" + + # With quadrantSegments parameter + actual_df = self.spark.sql( + "SELECT ST_AsText(ST_OffsetCurve(ST_GeomFromWKT('LINESTRING(0 0, 10 0)'), 5.0, 4))" + ) + actual = actual_df.take(1)[0][0] + assert actual == "LINESTRING (0 5, 10 5)" + def test_st_collect_on_array_type(self): # given geometry_df = self.spark.createDataFrame( 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 c931694db40..2629d8b178b 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 @@ -137,6 +137,7 @@ object Catalog extends AbstractCatalog with Logging { function[ST_Snap](), function[ST_ClosestPoint](), function[ST_ShortestLine](), + function[ST_OffsetCurve](), function[ST_Boundary](), function[ST_HasZ](), function[ST_HasM](), 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 0925dfb1043..f15efb0e70e 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 @@ -1012,6 +1012,16 @@ private[apache] case class ST_ShortestLine(inputExpressions: Seq[Expression]) } } +private[apache] case class ST_OffsetCurve(inputExpressions: Seq[Expression]) + extends InferredExpression( + inferrableFunction2(Functions.offsetCurve), + inferrableFunction3(Functions.offsetCurve)) { + + protected def withNewChildrenInternal(newChildren: IndexedSeq[Expression]) = { + copy(inputExpressions = newChildren) + } +} + private[apache] case class ST_IsPolygonCW(inputExpressions: Seq[Expression]) extends InferredExpression(Functions.isPolygonCW _) { 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 07f2e78d3bf..f712bf30ffd 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 @@ -566,6 +566,15 @@ object st_functions { def ST_OrientedEnvelope(geometry: String): Column = wrapExpression[ST_OrientedEnvelope](geometry) + def ST_OffsetCurve(geometry: Column, distance: Column): Column = + wrapExpression[ST_OffsetCurve](geometry, distance) + def ST_OffsetCurve(geometry: String, distance: Double): Column = + wrapExpression[ST_OffsetCurve](geometry, distance) + def ST_OffsetCurve(geometry: Column, distance: Column, quadrantSegments: Column): Column = + wrapExpression[ST_OffsetCurve](geometry, distance, quadrantSegments) + def ST_OffsetCurve(geometry: String, distance: Double, quadrantSegments: Int): Column = + wrapExpression[ST_OffsetCurve](geometry, distance, quadrantSegments) + def ST_IsPolygonCCW(geometry: Column): Column = wrapExpression[ST_IsPolygonCCW](geometry) def ST_IsPolygonCCW(geometry: String): Column = wrapExpression[ST_IsPolygonCCW](geometry) diff --git a/spark/common/src/test/scala/org/apache/sedona/sql/PreserveSRIDSuite.scala b/spark/common/src/test/scala/org/apache/sedona/sql/PreserveSRIDSuite.scala index b9e5b7b29e5..f66794dd53f 100644 --- a/spark/common/src/test/scala/org/apache/sedona/sql/PreserveSRIDSuite.scala +++ b/spark/common/src/test/scala/org/apache/sedona/sql/PreserveSRIDSuite.scala @@ -75,6 +75,7 @@ class PreserveSRIDSuite extends TestBaseScala with TableDrivenPropertyChecks { ("ST_SetPoint(geom3, 1, ST_Point(0.5, 0.5))", 1000), ("ST_ClosestPoint(geom1, geom2)", 1000), ("ST_ShortestLine(geom1, geom2)", 1000), + ("ST_OffsetCurve(geom3, 1.0)", 1000), ("ST_FlipCoordinates(geom1)", 1000), ("ST_SubDivide(geom4, 5)", 1000), ("ST_Segmentize(geom4, 0.1)", 1000), 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 f10f36e38af..1ebccfc16c1 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 @@ -575,6 +575,22 @@ class dataFrameAPITestScala extends TestBaseScala { assertEquals(expected, actual) } + it("Passed ST_OffsetCurve") { + val lineDf = sparkSession.sql( + "SELECT ST_GeomFromWKT('LINESTRING(0 0, 10 0)') AS geom") + val df = lineDf.select(ST_OffsetCurve("geom", 5.0)) + val actual = df.take(1)(0).get(0).asInstanceOf[Geometry].toText() + assertEquals("LINESTRING (0 5, 10 5)", actual) + } + + it("Passed ST_OffsetCurve with quadrantSegments") { + val lineDf = sparkSession.sql( + "SELECT ST_GeomFromWKT('LINESTRING(0 0, 10 0)') AS geom") + val df = lineDf.select(ST_OffsetCurve("geom", 5.0, 4)) + val actual = df.take(1)(0).get(0).asInstanceOf[Geometry].toText() + assertEquals("LINESTRING (0 5, 10 5)", actual) + } + it("Passed ST_BestSRID") { val pointDf = sparkSession.sql("SELECT ST_Point(-177, -60) AS geom") val df = pointDf.select(ST_BestSRID("geom").as("geom")) 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 8d311bae90f..0903a5b818e 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 @@ -3047,6 +3047,26 @@ class functionTestScala assert(result == null) } + it("should pass ST_OffsetCurve") { + // Positive distance offsets to the left + var df = sparkSession.sql( + "SELECT ST_AsText(ST_OffsetCurve(ST_GeomFromWKT('LINESTRING(0 0, 10 0)'), 5.0))") + var actual = df.take(1)(0).get(0).asInstanceOf[String] + assertEquals("LINESTRING (0 5, 10 5)", actual) + + // Negative distance offsets to the right + df = sparkSession.sql( + "SELECT ST_AsText(ST_OffsetCurve(ST_GeomFromWKT('LINESTRING(0 0, 10 0)'), -5.0))") + actual = df.take(1)(0).get(0).asInstanceOf[String] + assertEquals("LINESTRING (0 -5, 10 -5)", actual) + + // With quadrantSegments parameter + df = sparkSession.sql( + "SELECT ST_AsText(ST_OffsetCurve(ST_GeomFromWKT('LINESTRING(0 0, 10 0)'), 5.0, 4))") + actual = df.take(1)(0).get(0).asInstanceOf[String] + assertEquals("LINESTRING (0 5, 10 5)", actual) + } + it("Should pass ST_AreaSpheroid") { val geomTestCases = Map( ("'POINT (-0.56 51.3168)'") -> "0.0", From 866bd7ca31f450dba44accbf0f6c795e228247b6 Mon Sep 17 00:00:00 2001 From: Jia Yu Date: Tue, 31 Mar 2026 17:54:41 -0700 Subject: [PATCH 2/5] Fix Spotless formatting violations in ST_OffsetCurve tests --- .../src/test/java/org/apache/sedona/flink/FunctionTest.java | 6 ++---- .../scala/org/apache/sedona/sql/dataFrameAPITestScala.scala | 6 ++---- 2 files changed, 4 insertions(+), 8 deletions(-) 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 f5969d59c63..b95f45c09e3 100644 --- a/flink/src/test/java/org/apache/sedona/flink/FunctionTest.java +++ b/flink/src/test/java/org/apache/sedona/flink/FunctionTest.java @@ -253,8 +253,7 @@ public void testShortestLine() { @Test public void testOffsetCurve() { - Table table = - tableEnv.sqlQuery("SELECT ST_GeomFromWKT('LINESTRING(0 0, 10 0)') AS geom"); + Table table = tableEnv.sqlQuery("SELECT ST_GeomFromWKT('LINESTRING(0 0, 10 0)') AS geom"); table = table.select(call(Functions.ST_OffsetCurve.class.getSimpleName(), $("geom"), 5.0)); Geometry result = (Geometry) first(table).getField(0); assertEquals("LINESTRING (0 5, 10 5)", result.toString()); @@ -262,8 +261,7 @@ public void testOffsetCurve() { @Test public void testOffsetCurveWithQuadrantSegments() { - Table table = - tableEnv.sqlQuery("SELECT ST_GeomFromWKT('LINESTRING(0 0, 10 0)') AS geom"); + Table table = tableEnv.sqlQuery("SELECT ST_GeomFromWKT('LINESTRING(0 0, 10 0)') AS geom"); table = table.select(call(Functions.ST_OffsetCurve.class.getSimpleName(), $("geom"), 5.0, 4)); Geometry result = (Geometry) first(table).getField(0); assertEquals("LINESTRING (0 5, 10 5)", result.toString()); 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 1ebccfc16c1..e2e4964d4d7 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 @@ -576,16 +576,14 @@ class dataFrameAPITestScala extends TestBaseScala { } it("Passed ST_OffsetCurve") { - val lineDf = sparkSession.sql( - "SELECT ST_GeomFromWKT('LINESTRING(0 0, 10 0)') AS geom") + val lineDf = sparkSession.sql("SELECT ST_GeomFromWKT('LINESTRING(0 0, 10 0)') AS geom") val df = lineDf.select(ST_OffsetCurve("geom", 5.0)) val actual = df.take(1)(0).get(0).asInstanceOf[Geometry].toText() assertEquals("LINESTRING (0 5, 10 5)", actual) } it("Passed ST_OffsetCurve with quadrantSegments") { - val lineDf = sparkSession.sql( - "SELECT ST_GeomFromWKT('LINESTRING(0 0, 10 0)') AS geom") + val lineDf = sparkSession.sql("SELECT ST_GeomFromWKT('LINESTRING(0 0, 10 0)') AS geom") val df = lineDf.select(ST_OffsetCurve("geom", 5.0, 4)) val actual = df.take(1)(0).get(0).asInstanceOf[Geometry].toText() assertEquals("LINESTRING (0 5, 10 5)", actual) From fa69154507e3834dc5cc6abb6f6da6c6db4841e9 Mon Sep 17 00:00:00 2001 From: Jia Yu Date: Tue, 31 Mar 2026 18:19:22 -0700 Subject: [PATCH 3/5] Add ST_OffsetCurve to Snowflake UDFs and tests --- .../snowflake/snowsql/TestFunctions.java | 12 ++++++++++++ .../snowflake/snowsql/TestFunctionsV2.java | 12 ++++++++++++ .../apache/sedona/snowflake/snowsql/UDFs.java | 12 ++++++++++++ .../sedona/snowflake/snowsql/UDFsV2.java | 18 ++++++++++++++++++ 4 files changed, 54 insertions(+) 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 2c226f70ba0..689c65476fd 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 @@ -768,6 +768,18 @@ public void test_ST_NPoints() { "select sedona.ST_NPoints(sedona.ST_GeomFromText('LINESTRING(1 2, 3 4, 5 6)'))", 3); } + @Test + public void test_ST_OffsetCurve() { + registerUDF("ST_OffsetCurve", byte[].class, double.class); + verifySqlSingleRes( + "select sedona.ST_AsText(sedona.ST_OffsetCurve(sedona.ST_GeomFromText('LINESTRING(0 0, 10 0)'), 5.0))", + "LINESTRING (0 5, 10 5)"); + registerUDF("ST_OffsetCurve", byte[].class, double.class, int.class); + verifySqlSingleRes( + "select sedona.ST_AsText(sedona.ST_OffsetCurve(sedona.ST_GeomFromText('LINESTRING(0 0, 10 0)'), 5.0, 4))", + "LINESTRING (0 5, 10 5)"); + } + @Test public void test_ST_NumGeometries() { registerUDF("ST_NumGeometries", 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 67bc09b187d..14039081a71 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 @@ -716,6 +716,18 @@ public void test_ST_NPoints() { "select sedona.ST_NPoints(ST_GeometryFromWKT('LINESTRING(1 2, 3 4, 5 6)'))", 3); } + @Test + public void test_ST_OffsetCurve() { + registerUDFV2("ST_OffsetCurve", String.class, double.class); + verifySqlSingleRes( + "select sedona.ST_AsText(sedona.ST_OffsetCurve(ST_GeometryFromWKT('LINESTRING(0 0, 10 0)'), 5.0))", + "LINESTRING (0 5, 10 5)"); + registerUDFV2("ST_OffsetCurve", String.class, double.class, int.class); + verifySqlSingleRes( + "select sedona.ST_AsText(sedona.ST_OffsetCurve(ST_GeometryFromWKT('LINESTRING(0 0, 10 0)'), 5.0, 4))", + "LINESTRING (0 5, 10 5)"); + } + @Test public void test_ST_NumGeometries() { registerUDFV2("ST_NumGeometries", 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 dafdc8fc974..f78ad7d3f31 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 @@ -798,6 +798,18 @@ public static byte[] ST_Normalize(byte[] geometry) { return GeometrySerde.serialize(Functions.normalize(GeometrySerde.deserialize(geometry))); } + @UDFAnnotations.ParamMeta(argNames = {"geometry", "distance"}) + public static byte[] ST_OffsetCurve(byte[] geometry, double distance) { + return GeometrySerde.serialize( + Functions.offsetCurve(GeometrySerde.deserialize(geometry), distance)); + } + + @UDFAnnotations.ParamMeta(argNames = {"geometry", "distance", "quadrantSegments"}) + public static byte[] ST_OffsetCurve(byte[] geometry, double distance, int quadrantSegments) { + return GeometrySerde.serialize( + Functions.offsetCurve(GeometrySerde.deserialize(geometry), distance, quadrantSegments)); + } + @UDFAnnotations.ParamMeta(argNames = {"geometry"}) public static int ST_NumGeometries(byte[] geometry) { return Functions.numGeometries(GeometrySerde.deserialize(geometry)); 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 80fbb34d07f..18cf8a281f8 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 @@ -961,6 +961,24 @@ public static String ST_Normalize(String geometry) { return GeometrySerde.serGeoJson(Functions.normalize(GeometrySerde.deserGeoJson(geometry))); } + @UDFAnnotations.ParamMeta( + argNames = {"geometry", "distance"}, + argTypes = {"Geometry", "double"}, + returnTypes = "Geometry") + public static String ST_OffsetCurve(String geometry, double distance) { + return GeometrySerde.serGeoJson( + Functions.offsetCurve(GeometrySerde.deserGeoJson(geometry), distance)); + } + + @UDFAnnotations.ParamMeta( + argNames = {"geometry", "distance", "quadrantSegments"}, + argTypes = {"Geometry", "double", "int"}, + returnTypes = "Geometry") + public static String ST_OffsetCurve(String geometry, double distance, int quadrantSegments) { + return GeometrySerde.serGeoJson( + Functions.offsetCurve(GeometrySerde.deserGeoJson(geometry), distance, quadrantSegments)); + } + @UDFAnnotations.ParamMeta( argNames = {"geometry"}, argTypes = {"Geometry"}) From 5c5d8dd743fffe4e5e740f8bc1aec27188b02835 Mon Sep 17 00:00:00 2001 From: Jia Yu Date: Tue, 31 Mar 2026 21:19:33 -0700 Subject: [PATCH 4/5] Add per-example SVG visuals to ST_OffsetCurve docs --- .../Geometry-Processing/ST_OffsetCurve.md | 26 +++++++--- .../Geometry-Processing/ST_OffsetCurve.md | 26 +++++++--- .../sql/Geometry-Processing/ST_OffsetCurve.md | 24 +++++++--- docs/image/ST_OffsetCurve/ST_OffsetCurve.svg | 47 ++++++++++--------- .../ST_OffsetCurve_negative.svg | 47 +++++++++++++++++++ .../ST_OffsetCurve_positive.svg | 39 +++++++++++++++ .../ST_OffsetCurve_quadrant.svg | 42 +++++++++++++++++ 7 files changed, 208 insertions(+), 43 deletions(-) create mode 100644 docs/image/ST_OffsetCurve/ST_OffsetCurve_negative.svg create mode 100644 docs/image/ST_OffsetCurve/ST_OffsetCurve_positive.svg create mode 100644 docs/image/ST_OffsetCurve/ST_OffsetCurve_quadrant.svg diff --git a/docs/api/flink/Geometry-Processing/ST_OffsetCurve.md b/docs/api/flink/Geometry-Processing/ST_OffsetCurve.md index e00a0a728b3..300ec32410f 100644 --- a/docs/api/flink/Geometry-Processing/ST_OffsetCurve.md +++ b/docs/api/flink/Geometry-Processing/ST_OffsetCurve.md @@ -19,6 +19,8 @@ # ST_OffsetCurve +![ST_OffsetCurve](../../../image/ST_OffsetCurve/ST_OffsetCurve.svg "ST_OffsetCurve") + Introduction: Returns a line at a given offset distance from a linear geometry. If the distance is positive, the offset is on the left side of the input line; if it is negative, it is on the right side. Returns null for empty geometries. The optional third parameter `quadrantSegments` controls the number of line segments used to approximate a quarter circle at round joins. The default value is 8. @@ -31,22 +33,32 @@ Return type: `Geometry` Since: `v1.9.0` -SQL Example +SQL Example: + +![ST_OffsetCurve Positive](../../../image/ST_OffsetCurve/ST_OffsetCurve_positive.svg "ST_OffsetCurve Positive Offset") ```sql -SELECT ST_AsText(ST_OffsetCurve(ST_GeomFromWKT('LINESTRING(0 0, 10 0)'), 5.0)) +SELECT ST_AsText(ST_OffsetCurve(ST_GeomFromWKT('LINESTRING(0 0, 10 0, 10 10)'), 5.0)) ``` -Output: `LINESTRING (0 5, 10 5)` +Output: `LINESTRING (0 5, 5 5, 5 10)` + +SQL Example: + +![ST_OffsetCurve Negative](../../../image/ST_OffsetCurve/ST_OffsetCurve_negative.svg "ST_OffsetCurve Negative Offset") ```sql -SELECT ST_AsText(ST_OffsetCurve(ST_GeomFromWKT('LINESTRING(0 0, 10 0)'), -5.0)) +SELECT ST_NPoints(ST_OffsetCurve(ST_GeomFromWKT('LINESTRING(0 0, 10 0, 10 10)'), -3.0)) ``` -Output: `LINESTRING (0 -5, 10 -5)` +Output: `11` + +SQL Example: + +![ST_OffsetCurve QuadrantSegments](../../../image/ST_OffsetCurve/ST_OffsetCurve_quadrant.svg "ST_OffsetCurve with quadrantSegments") ```sql -SELECT ST_AsText(ST_OffsetCurve(ST_GeomFromWKT('LINESTRING(0 0, 10 0)'), 5.0, 4)) +SELECT ST_NPoints(ST_OffsetCurve(ST_GeomFromWKT('LINESTRING(0 0, 10 0, 10 10)'), -3.0, 16)) ``` -Output: `LINESTRING (0 5, 10 5)` +Output: `19` diff --git a/docs/api/snowflake/vector-data/Geometry-Processing/ST_OffsetCurve.md b/docs/api/snowflake/vector-data/Geometry-Processing/ST_OffsetCurve.md index c5e09beb183..5853b10a43f 100644 --- a/docs/api/snowflake/vector-data/Geometry-Processing/ST_OffsetCurve.md +++ b/docs/api/snowflake/vector-data/Geometry-Processing/ST_OffsetCurve.md @@ -19,6 +19,8 @@ # ST_OffsetCurve +![ST_OffsetCurve](../../../../image/ST_OffsetCurve/ST_OffsetCurve.svg "ST_OffsetCurve") + Introduction: Returns a line at a given offset distance from a linear geometry. If the distance is positive, the offset is on the left side of the input line; if it is negative, it is on the right side. Returns null for empty geometries. The optional third parameter `quadrantSegments` controls the number of line segments used to approximate a quarter circle at round joins. The default value is 8. @@ -29,22 +31,32 @@ Format: `ST_OffsetCurve(geometry: Geometry, distance: Double)` Return type: `Geometry` -SQL Example +SQL Example: + +![ST_OffsetCurve Positive](../../../../image/ST_OffsetCurve/ST_OffsetCurve_positive.svg "ST_OffsetCurve Positive Offset") ```sql -SELECT ST_AsText(ST_OffsetCurve(ST_GeomFromWKT('LINESTRING(0 0, 10 0)'), 5.0)) +SELECT ST_AsText(ST_OffsetCurve(ST_GeomFromWKT('LINESTRING(0 0, 10 0, 10 10)'), 5.0)) ``` -Output: `LINESTRING (0 5, 10 5)` +Output: `LINESTRING (0 5, 5 5, 5 10)` + +SQL Example: + +![ST_OffsetCurve Negative](../../../../image/ST_OffsetCurve/ST_OffsetCurve_negative.svg "ST_OffsetCurve Negative Offset") ```sql -SELECT ST_AsText(ST_OffsetCurve(ST_GeomFromWKT('LINESTRING(0 0, 10 0)'), -5.0)) +SELECT ST_NPoints(ST_OffsetCurve(ST_GeomFromWKT('LINESTRING(0 0, 10 0, 10 10)'), -3.0)) ``` -Output: `LINESTRING (0 -5, 10 -5)` +Output: `11` + +SQL Example: + +![ST_OffsetCurve QuadrantSegments](../../../../image/ST_OffsetCurve/ST_OffsetCurve_quadrant.svg "ST_OffsetCurve with quadrantSegments") ```sql -SELECT ST_AsText(ST_OffsetCurve(ST_GeomFromWKT('LINESTRING(0 0, 10 0)'), 5.0, 4)) +SELECT ST_NPoints(ST_OffsetCurve(ST_GeomFromWKT('LINESTRING(0 0, 10 0, 10 10)'), -3.0, 16)) ``` -Output: `LINESTRING (0 5, 10 5)` +Output: `19` diff --git a/docs/api/sql/Geometry-Processing/ST_OffsetCurve.md b/docs/api/sql/Geometry-Processing/ST_OffsetCurve.md index 1802d4a079d..300ec32410f 100644 --- a/docs/api/sql/Geometry-Processing/ST_OffsetCurve.md +++ b/docs/api/sql/Geometry-Processing/ST_OffsetCurve.md @@ -33,22 +33,32 @@ Return type: `Geometry` Since: `v1.9.0` -SQL Example +SQL Example: + +![ST_OffsetCurve Positive](../../../image/ST_OffsetCurve/ST_OffsetCurve_positive.svg "ST_OffsetCurve Positive Offset") ```sql -SELECT ST_AsText(ST_OffsetCurve(ST_GeomFromWKT('LINESTRING(0 0, 10 0)'), 5.0)) +SELECT ST_AsText(ST_OffsetCurve(ST_GeomFromWKT('LINESTRING(0 0, 10 0, 10 10)'), 5.0)) ``` -Output: `LINESTRING (0 5, 10 5)` +Output: `LINESTRING (0 5, 5 5, 5 10)` + +SQL Example: + +![ST_OffsetCurve Negative](../../../image/ST_OffsetCurve/ST_OffsetCurve_negative.svg "ST_OffsetCurve Negative Offset") ```sql -SELECT ST_AsText(ST_OffsetCurve(ST_GeomFromWKT('LINESTRING(0 0, 10 0)'), -5.0)) +SELECT ST_NPoints(ST_OffsetCurve(ST_GeomFromWKT('LINESTRING(0 0, 10 0, 10 10)'), -3.0)) ``` -Output: `LINESTRING (0 -5, 10 -5)` +Output: `11` + +SQL Example: + +![ST_OffsetCurve QuadrantSegments](../../../image/ST_OffsetCurve/ST_OffsetCurve_quadrant.svg "ST_OffsetCurve with quadrantSegments") ```sql -SELECT ST_AsText(ST_OffsetCurve(ST_GeomFromWKT('LINESTRING(0 0, 10 0)'), 5.0, 4)) +SELECT ST_NPoints(ST_OffsetCurve(ST_GeomFromWKT('LINESTRING(0 0, 10 0, 10 10)'), -3.0, 16)) ``` -Output: `LINESTRING (0 5, 10 5)` +Output: `19` diff --git a/docs/image/ST_OffsetCurve/ST_OffsetCurve.svg b/docs/image/ST_OffsetCurve/ST_OffsetCurve.svg index 2515275e63a..b375b020713 100644 --- a/docs/image/ST_OffsetCurve/ST_OffsetCurve.svg +++ b/docs/image/ST_OffsetCurve/ST_OffsetCurve.svg @@ -5,30 +5,33 @@ ST_OffsetCurve - - - Input - - - +d (left) - - - −d (right) + + + Input + + + +d (left) + + + −d (right) - - - - d - - - d + + + + d + + + d + + arc Returns a line offset by distance d from the input - - Input line - - Positive offset - - Negative offset + + Input line + + Positive offset + + Negative offset diff --git a/docs/image/ST_OffsetCurve/ST_OffsetCurve_negative.svg b/docs/image/ST_OffsetCurve/ST_OffsetCurve_negative.svg new file mode 100644 index 00000000000..4093d1b79f0 --- /dev/null +++ b/docs/image/ST_OffsetCurve/ST_OffsetCurve_negative.svg @@ -0,0 +1,47 @@ + + + + + ST_OffsetCurve — Negative Offset on L-shape + + + + + + + + + + + + + + + (0, 0) + (10, 0) + (10, 10) + + + + + + (0, -3) + (13, 10) + + + + + d + + arc + + Negative offset creates a smooth arc at the outer corner + + + Input line + + Offset curve (distance = -3.0) + diff --git a/docs/image/ST_OffsetCurve/ST_OffsetCurve_positive.svg b/docs/image/ST_OffsetCurve/ST_OffsetCurve_positive.svg new file mode 100644 index 00000000000..393cb2a4efd --- /dev/null +++ b/docs/image/ST_OffsetCurve/ST_OffsetCurve_positive.svg @@ -0,0 +1,39 @@ + + + + + ST_OffsetCurve — Positive Offset on L-shape + + + + + + + + (0, 0) + (10, 0) + (10, 10) + + + + + + + (0, 5) + (5, 5) + (5, 10) + + + + + d = 5 + + LINESTRING (0 5, 5 5, 5 10) + + + Input line + + Offset curve (distance = 5.0) + diff --git a/docs/image/ST_OffsetCurve/ST_OffsetCurve_quadrant.svg b/docs/image/ST_OffsetCurve/ST_OffsetCurve_quadrant.svg new file mode 100644 index 00000000000..e8703f4caf5 --- /dev/null +++ b/docs/image/ST_OffsetCurve/ST_OffsetCurve_quadrant.svg @@ -0,0 +1,42 @@ + + + + + ST_OffsetCurve — quadrantSegments Effect + + + + + + + + (0, 0) + (10, 0) + (10, 10) + + + + + + + corner arc + + (0, -3) + (13, 10) + + default (8): 11 points + quadrantSegments=16: 19 points + + Higher quadrantSegments produces smoother arcs at outer corners + + + Input + + Default (qs=8) + + Smoother (qs=16) + From 1c527ac229ab0c05526c30b066a25866a7ba432c Mon Sep 17 00:00:00 2001 From: Jia Yu Date: Tue, 31 Mar 2026 21:55:22 -0700 Subject: [PATCH 5/5] Use L-shape geometry in quadrantSegments tests to exercise arc behavior --- .../apache/sedona/common/FunctionsTest.java | 14 +-- .../Geometry-Processing/ST_OffsetCurve.md | 2 - .../Geometry-Processing/ST_OffsetCurve.md | 2 - .../sql/Geometry-Processing/ST_OffsetCurve.md | 2 - docs/image/ST_OffsetCurve/ST_OffsetCurve.svg | 37 ------ .../ST_OffsetCurve_negative.svg | 68 ++++++----- .../ST_OffsetCurve_positive.svg | 49 ++++---- .../ST_OffsetCurve_quadrant.svg | 107 ++++++++++++------ .../org/apache/sedona/flink/FunctionTest.java | 19 +++- python/tests/sql/test_function.py | 14 ++- .../snowflake/snowsql/TestFunctions.java | 5 +- .../snowflake/snowsql/TestFunctionsV2.java | 5 +- .../sedona/sql/dataFrameAPITestScala.scala | 11 +- .../apache/sedona/sql/functionTestScala.scala | 13 ++- 14 files changed, 191 insertions(+), 157 deletions(-) delete mode 100644 docs/image/ST_OffsetCurve/ST_OffsetCurve.svg 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 9264e7ea5ec..92b59489054 100644 --- a/common/src/test/java/org/apache/sedona/common/FunctionsTest.java +++ b/common/src/test/java/org/apache/sedona/common/FunctionsTest.java @@ -4287,13 +4287,13 @@ public void offsetCurveEmptyGeometry() throws ParseException { @Test public void offsetCurveWithQuadrantSegments() throws ParseException { - // Test with custom quadrant segments - Geometry line = Constructors.geomFromWKT("LINESTRING (0 0, 10 0)", 0); - Geometry result = Functions.offsetCurve(line, 5.0, 4); - assertNotNull(result); - // The result should still be a simple offset for a straight line - String actual = Functions.asWKT(result); - assertEquals("LINESTRING (0 5, 10 5)", actual); + // Test with custom quadrant segments on a line with a corner + Geometry line = Constructors.geomFromWKT("LINESTRING (0 0, 10 0, 10 10)", 0); + Geometry defaultResult = Functions.offsetCurve(line, -3.0); + Geometry customResult = Functions.offsetCurve(line, -3.0, 16); + assertNotNull(customResult); + // Higher quadrantSegments produces more points on the arc at outer corners + assertTrue(customResult.getNumPoints() > defaultResult.getNumPoints()); } @Test diff --git a/docs/api/flink/Geometry-Processing/ST_OffsetCurve.md b/docs/api/flink/Geometry-Processing/ST_OffsetCurve.md index 300ec32410f..0e70866f00a 100644 --- a/docs/api/flink/Geometry-Processing/ST_OffsetCurve.md +++ b/docs/api/flink/Geometry-Processing/ST_OffsetCurve.md @@ -19,8 +19,6 @@ # ST_OffsetCurve -![ST_OffsetCurve](../../../image/ST_OffsetCurve/ST_OffsetCurve.svg "ST_OffsetCurve") - Introduction: Returns a line at a given offset distance from a linear geometry. If the distance is positive, the offset is on the left side of the input line; if it is negative, it is on the right side. Returns null for empty geometries. The optional third parameter `quadrantSegments` controls the number of line segments used to approximate a quarter circle at round joins. The default value is 8. diff --git a/docs/api/snowflake/vector-data/Geometry-Processing/ST_OffsetCurve.md b/docs/api/snowflake/vector-data/Geometry-Processing/ST_OffsetCurve.md index 5853b10a43f..37ce097cb4f 100644 --- a/docs/api/snowflake/vector-data/Geometry-Processing/ST_OffsetCurve.md +++ b/docs/api/snowflake/vector-data/Geometry-Processing/ST_OffsetCurve.md @@ -19,8 +19,6 @@ # ST_OffsetCurve -![ST_OffsetCurve](../../../../image/ST_OffsetCurve/ST_OffsetCurve.svg "ST_OffsetCurve") - Introduction: Returns a line at a given offset distance from a linear geometry. If the distance is positive, the offset is on the left side of the input line; if it is negative, it is on the right side. Returns null for empty geometries. The optional third parameter `quadrantSegments` controls the number of line segments used to approximate a quarter circle at round joins. The default value is 8. diff --git a/docs/api/sql/Geometry-Processing/ST_OffsetCurve.md b/docs/api/sql/Geometry-Processing/ST_OffsetCurve.md index 300ec32410f..0e70866f00a 100644 --- a/docs/api/sql/Geometry-Processing/ST_OffsetCurve.md +++ b/docs/api/sql/Geometry-Processing/ST_OffsetCurve.md @@ -19,8 +19,6 @@ # ST_OffsetCurve -![ST_OffsetCurve](../../../image/ST_OffsetCurve/ST_OffsetCurve.svg "ST_OffsetCurve") - Introduction: Returns a line at a given offset distance from a linear geometry. If the distance is positive, the offset is on the left side of the input line; if it is negative, it is on the right side. Returns null for empty geometries. The optional third parameter `quadrantSegments` controls the number of line segments used to approximate a quarter circle at round joins. The default value is 8. diff --git a/docs/image/ST_OffsetCurve/ST_OffsetCurve.svg b/docs/image/ST_OffsetCurve/ST_OffsetCurve.svg deleted file mode 100644 index b375b020713..00000000000 --- a/docs/image/ST_OffsetCurve/ST_OffsetCurve.svg +++ /dev/null @@ -1,37 +0,0 @@ - - - - - ST_OffsetCurve - - - Input - - - +d (left) - - - −d (right) - - - - - d - - - d - - arc - - Returns a line offset by distance d from the input - - - Input line - - Positive offset - - Negative offset - diff --git a/docs/image/ST_OffsetCurve/ST_OffsetCurve_negative.svg b/docs/image/ST_OffsetCurve/ST_OffsetCurve_negative.svg index 4093d1b79f0..134472c798b 100644 --- a/docs/image/ST_OffsetCurve/ST_OffsetCurve_negative.svg +++ b/docs/image/ST_OffsetCurve/ST_OffsetCurve_negative.svg @@ -5,40 +5,48 @@ ST_OffsetCurve — Negative Offset on L-shape - - - - - - - - - + + + - - - - + + + + (0, 0) - (10, 0) - (10, 10) - - - - - - (0, -3) - (13, 10) - - + (10, 0) + (10, 10) + + + + + + + + + + + + + + + + (0, -3) + (13, 10) + + + - - d - - arc + + d = 3 + + + 11 points + (9 on arc) + - Negative offset creates a smooth arc at the outer corner + Negative offset creates a segmented arc at the outer corner Input line diff --git a/docs/image/ST_OffsetCurve/ST_OffsetCurve_positive.svg b/docs/image/ST_OffsetCurve/ST_OffsetCurve_positive.svg index 393cb2a4efd..a4aa7587542 100644 --- a/docs/image/ST_OffsetCurve/ST_OffsetCurve_positive.svg +++ b/docs/image/ST_OffsetCurve/ST_OffsetCurve_positive.svg @@ -5,30 +5,35 @@ ST_OffsetCurve — Positive Offset on L-shape - - + + + + + - - - - - (0, 0) - (10, 0) - (10, 10) - - - - - - - (0, 5) - (5, 5) - (5, 10) + + + + + (0, 0) + (10, 0) + (10, 10) + + + + + + + (0, 5) + (5, 5) + (5, 10) + - - - - d = 5 + + + + d = 5 + LINESTRING (0 5, 5 5, 5 10) diff --git a/docs/image/ST_OffsetCurve/ST_OffsetCurve_quadrant.svg b/docs/image/ST_OffsetCurve/ST_OffsetCurve_quadrant.svg index e8703f4caf5..11c8c7b4781 100644 --- a/docs/image/ST_OffsetCurve/ST_OffsetCurve_quadrant.svg +++ b/docs/image/ST_OffsetCurve/ST_OffsetCurve_quadrant.svg @@ -4,39 +4,80 @@ text { font-family: 'Segoe UI', Arial, Helvetica, sans-serif; } - ST_OffsetCurve — quadrantSegments Effect - - - - - - - - (0, 0) - (10, 0) - (10, 10) - - - - - - - corner arc - - (0, -3) - (13, 10) - - default (8): 11 points - quadrantSegments=16: 19 points + ST_OffsetCurve — quadrantSegments Effect + + + + + + + + default (qs=8): 11 points + + + + + + + + + + + + + + + + + + + + + + + + + + + + qs=16: 19 points + + + + + + + + + + + + + + + + + + + + + + + + + + + + - Higher quadrantSegments produces smoother arcs at outer corners + Higher quadrantSegments adds more line segments to approximate the arc - - Input - - Default (qs=8) - - Smoother (qs=16) + + Input + + Offset (qs=8) + + Offset (qs=16) 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 b95f45c09e3..2171ecc78ea 100644 --- a/flink/src/test/java/org/apache/sedona/flink/FunctionTest.java +++ b/flink/src/test/java/org/apache/sedona/flink/FunctionTest.java @@ -261,10 +261,21 @@ public void testOffsetCurve() { @Test public void testOffsetCurveWithQuadrantSegments() { - Table table = tableEnv.sqlQuery("SELECT ST_GeomFromWKT('LINESTRING(0 0, 10 0)') AS geom"); - table = table.select(call(Functions.ST_OffsetCurve.class.getSimpleName(), $("geom"), 5.0, 4)); - Geometry result = (Geometry) first(table).getField(0); - assertEquals("LINESTRING (0 5, 10 5)", result.toString()); + Table table = + tableEnv.sqlQuery("SELECT ST_GeomFromWKT('LINESTRING(0 0, 10 0, 10 10)') AS geom"); + Table defaultTable = + table.select( + call( + "ST_NPoints", + call(Functions.ST_OffsetCurve.class.getSimpleName(), $("geom"), -3.0))); + Table customTable = + table.select( + call( + "ST_NPoints", + call(Functions.ST_OffsetCurve.class.getSimpleName(), $("geom"), -3.0, 16))); + int defaultPts = (int) first(defaultTable).getField(0); + int customPts = (int) first(customTable).getField(0); + assertTrue(customPts > defaultPts); } @Test diff --git a/python/tests/sql/test_function.py b/python/tests/sql/test_function.py index ff3e1497c4d..ad7cabe7395 100644 --- a/python/tests/sql/test_function.py +++ b/python/tests/sql/test_function.py @@ -1998,12 +1998,16 @@ def test_st_offset_curve(self): actual = actual_df.take(1)[0][0] assert actual == "LINESTRING (0 -5, 10 -5)" - # With quadrantSegments parameter - actual_df = self.spark.sql( - "SELECT ST_AsText(ST_OffsetCurve(ST_GeomFromWKT('LINESTRING(0 0, 10 0)'), 5.0, 4))" + # With quadrantSegments parameter on a line with a corner + default_df = self.spark.sql( + "SELECT ST_NPoints(ST_OffsetCurve(ST_GeomFromWKT('LINESTRING(0 0, 10 0, 10 10)'), -3.0))" ) - actual = actual_df.take(1)[0][0] - assert actual == "LINESTRING (0 5, 10 5)" + default_pts = default_df.take(1)[0][0] + custom_df = self.spark.sql( + "SELECT ST_NPoints(ST_OffsetCurve(ST_GeomFromWKT('LINESTRING(0 0, 10 0, 10 10)'), -3.0, 16))" + ) + custom_pts = custom_df.take(1)[0][0] + assert custom_pts > default_pts def test_st_collect_on_array_type(self): # given 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 689c65476fd..0e66423c10c 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 @@ -775,9 +775,10 @@ public void test_ST_OffsetCurve() { "select sedona.ST_AsText(sedona.ST_OffsetCurve(sedona.ST_GeomFromText('LINESTRING(0 0, 10 0)'), 5.0))", "LINESTRING (0 5, 10 5)"); registerUDF("ST_OffsetCurve", byte[].class, double.class, int.class); + registerUDF("ST_NPoints", byte[].class); verifySqlSingleRes( - "select sedona.ST_AsText(sedona.ST_OffsetCurve(sedona.ST_GeomFromText('LINESTRING(0 0, 10 0)'), 5.0, 4))", - "LINESTRING (0 5, 10 5)"); + "select sedona.ST_NPoints(sedona.ST_OffsetCurve(sedona.ST_GeomFromText('LINESTRING(0 0, 10 0, 10 10)'), -3.0, 16))", + 19); } @Test 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 14039081a71..df864987fe9 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 @@ -723,9 +723,10 @@ public void test_ST_OffsetCurve() { "select sedona.ST_AsText(sedona.ST_OffsetCurve(ST_GeometryFromWKT('LINESTRING(0 0, 10 0)'), 5.0))", "LINESTRING (0 5, 10 5)"); registerUDFV2("ST_OffsetCurve", String.class, double.class, int.class); + registerUDFV2("ST_NPoints", String.class); verifySqlSingleRes( - "select sedona.ST_AsText(sedona.ST_OffsetCurve(ST_GeometryFromWKT('LINESTRING(0 0, 10 0)'), 5.0, 4))", - "LINESTRING (0 5, 10 5)"); + "select sedona.ST_NPoints(sedona.ST_OffsetCurve(ST_GeometryFromWKT('LINESTRING(0 0, 10 0, 10 10)'), -3.0, 16))", + 19); } @Test 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 e2e4964d4d7..c14608bc31c 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 @@ -583,10 +583,13 @@ class dataFrameAPITestScala extends TestBaseScala { } it("Passed ST_OffsetCurve with quadrantSegments") { - val lineDf = sparkSession.sql("SELECT ST_GeomFromWKT('LINESTRING(0 0, 10 0)') AS geom") - val df = lineDf.select(ST_OffsetCurve("geom", 5.0, 4)) - val actual = df.take(1)(0).get(0).asInstanceOf[Geometry].toText() - assertEquals("LINESTRING (0 5, 10 5)", actual) + val lineDf = + sparkSession.sql("SELECT ST_GeomFromWKT('LINESTRING(0 0, 10 0, 10 10)') AS geom") + val defaultDf = lineDf.select(ST_NPoints(ST_OffsetCurve("geom", -3.0))) + val customDf = lineDf.select(ST_NPoints(ST_OffsetCurve("geom", -3.0, 16))) + val defaultPts = defaultDf.take(1)(0).getInt(0) + val customPts = customDf.take(1)(0).getInt(0) + assertTrue(customPts > defaultPts) } it("Passed ST_BestSRID") { 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 0903a5b818e..9629cd406ca 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 @@ -3060,11 +3060,14 @@ class functionTestScala actual = df.take(1)(0).get(0).asInstanceOf[String] assertEquals("LINESTRING (0 -5, 10 -5)", actual) - // With quadrantSegments parameter - df = sparkSession.sql( - "SELECT ST_AsText(ST_OffsetCurve(ST_GeomFromWKT('LINESTRING(0 0, 10 0)'), 5.0, 4))") - actual = df.take(1)(0).get(0).asInstanceOf[String] - assertEquals("LINESTRING (0 5, 10 5)", actual) + // With quadrantSegments parameter on a line with a corner + val defaultDf = sparkSession.sql( + "SELECT ST_NPoints(ST_OffsetCurve(ST_GeomFromWKT('LINESTRING(0 0, 10 0, 10 10)'), -3.0))") + val defaultPts = defaultDf.take(1)(0).get(0).asInstanceOf[Int] + val customDf = sparkSession.sql( + "SELECT ST_NPoints(ST_OffsetCurve(ST_GeomFromWKT('LINESTRING(0 0, 10 0, 10 10)'), -3.0, 16))") + val customPts = customDf.take(1)(0).get(0).asInstanceOf[Int] + assertTrue(customPts > defaultPts) } it("Should pass ST_AreaSpheroid") {