This is an automated email from the ASF dual-hosted git repository.

jiayu pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/sedona.git


The following commit(s) were added to refs/heads/master by this push:
     new 3d9913b9e9 [GH-2799] Add ST_OffsetCurve function (#2811)
3d9913b9e9 is described below

commit 3d9913b9e9c724b9609a796f7341641ab0a890c7
Author: Jia Yu <[email protected]>
AuthorDate: Wed Apr 1 09:03:44 2026 -0700

    [GH-2799] Add ST_OffsetCurve function (#2811)
---
 .../java/org/apache/sedona/common/Functions.java   | 15 ++++
 .../org/apache/sedona/common/FunctionsTest.java    | 46 ++++++++++++
 docs/api/flink/Geometry-Functions.md               |  1 +
 .../flink/Geometry-Processing/ST_OffsetCurve.md    | 62 ++++++++++++++++
 .../snowflake/vector-data/Geometry-Functions.md    |  1 +
 .../Geometry-Processing/ST_OffsetCurve.md          | 60 ++++++++++++++++
 docs/api/sql/Geometry-Functions.md                 |  1 +
 docs/api/sql/Geometry-Processing/ST_OffsetCurve.md | 62 ++++++++++++++++
 .../ST_OffsetCurve/ST_OffsetCurve_negative.svg     | 55 ++++++++++++++
 .../ST_OffsetCurve/ST_OffsetCurve_positive.svg     | 44 ++++++++++++
 .../ST_OffsetCurve/ST_OffsetCurve_quadrant.svg     | 83 ++++++++++++++++++++++
 .../main/java/org/apache/sedona/flink/Catalog.java |  1 +
 .../apache/sedona/flink/expressions/Functions.java | 33 +++++++++
 .../java/org/apache/sedona/flink/FunctionTest.java | 27 +++++++
 python/sedona/spark/sql/st_functions.py            | 26 +++++++
 python/tests/sql/test_dataframe_api.py             | 15 ++++
 python/tests/sql/test_function.py                  | 26 +++++++
 .../sedona/snowflake/snowsql/TestFunctions.java    | 13 ++++
 .../sedona/snowflake/snowsql/TestFunctionsV2.java  | 13 ++++
 .../org/apache/sedona/snowflake/snowsql/UDFs.java  | 12 ++++
 .../apache/sedona/snowflake/snowsql/UDFsV2.java    | 18 +++++
 .../scala/org/apache/sedona/sql/UDF/Catalog.scala  |  1 +
 .../sql/sedona_sql/expressions/Functions.scala     | 10 +++
 .../sql/sedona_sql/expressions/st_functions.scala  |  9 +++
 .../org/apache/sedona/sql/PreserveSRIDSuite.scala  |  1 +
 .../apache/sedona/sql/dataFrameAPITestScala.scala  | 17 +++++
 .../org/apache/sedona/sql/functionTestScala.scala  | 23 ++++++
 27 files changed, 675 insertions(+)

diff --git a/common/src/main/java/org/apache/sedona/common/Functions.java 
b/common/src/main/java/org/apache/sedona/common/Functions.java
index fdec8913c3..78bd874051 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.io.kml.KMLWriter;
 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 @@ public class Functions {
     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 e48f62c546..92b5948905 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 class FunctionsTest extends TestBase {
     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 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
   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 d0c3f42d5c..489ebf11f7 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 0000000000..0e70866f00
--- /dev/null
+++ b/docs/api/flink/Geometry-Processing/ST_OffsetCurve.md
@@ -0,0 +1,62 @@
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements.  See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership.  The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License.  You may obtain a copy of the License at
+
+   http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied.  See the License for the
+ specific language governing permissions and limitations
+ under the License.
+ -->
+
+# 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:
+
+![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, 10 
10)'), 5.0))
+```
+
+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_NPoints(ST_OffsetCurve(ST_GeomFromWKT('LINESTRING(0 0, 10 0, 10 
10)'), -3.0))
+```
+
+Output: `11`
+
+SQL Example:
+
+![ST_OffsetCurve 
QuadrantSegments](../../../image/ST_OffsetCurve/ST_OffsetCurve_quadrant.svg 
"ST_OffsetCurve with quadrantSegments")
+
+```sql
+SELECT ST_NPoints(ST_OffsetCurve(ST_GeomFromWKT('LINESTRING(0 0, 10 0, 10 
10)'), -3.0, 16))
+```
+
+Output: `19`
diff --git a/docs/api/snowflake/vector-data/Geometry-Functions.md 
b/docs/api/snowflake/vector-data/Geometry-Functions.md
index e22c5d0a30..01ecdda07d 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 0000000000..37ce097cb4
--- /dev/null
+++ b/docs/api/snowflake/vector-data/Geometry-Processing/ST_OffsetCurve.md
@@ -0,0 +1,60 @@
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements.  See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership.  The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License.  You may obtain a copy of the License at
+
+   http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied.  See the License for the
+ specific language governing permissions and limitations
+ under the License.
+ -->
+
+# 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:
+
+![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, 10 
10)'), 5.0))
+```
+
+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_NPoints(ST_OffsetCurve(ST_GeomFromWKT('LINESTRING(0 0, 10 0, 10 
10)'), -3.0))
+```
+
+Output: `11`
+
+SQL Example:
+
+![ST_OffsetCurve 
QuadrantSegments](../../../../image/ST_OffsetCurve/ST_OffsetCurve_quadrant.svg 
"ST_OffsetCurve with quadrantSegments")
+
+```sql
+SELECT ST_NPoints(ST_OffsetCurve(ST_GeomFromWKT('LINESTRING(0 0, 10 0, 10 
10)'), -3.0, 16))
+```
+
+Output: `19`
diff --git a/docs/api/sql/Geometry-Functions.md 
b/docs/api/sql/Geometry-Functions.md
index 14ce382530..d6d6db7575 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 0000000000..0e70866f00
--- /dev/null
+++ b/docs/api/sql/Geometry-Processing/ST_OffsetCurve.md
@@ -0,0 +1,62 @@
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements.  See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership.  The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License.  You may obtain a copy of the License at
+
+   http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied.  See the License for the
+ specific language governing permissions and limitations
+ under the License.
+ -->
+
+# 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:
+
+![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, 10 
10)'), 5.0))
+```
+
+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_NPoints(ST_OffsetCurve(ST_GeomFromWKT('LINESTRING(0 0, 10 0, 10 
10)'), -3.0))
+```
+
+Output: `11`
+
+SQL Example:
+
+![ST_OffsetCurve 
QuadrantSegments](../../../image/ST_OffsetCurve/ST_OffsetCurve_quadrant.svg 
"ST_OffsetCurve with quadrantSegments")
+
+```sql
+SELECT ST_NPoints(ST_OffsetCurve(ST_GeomFromWKT('LINESTRING(0 0, 10 0, 10 
10)'), -3.0, 16))
+```
+
+Output: `19`
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 0000000000..134472c798
--- /dev/null
+++ b/docs/image/ST_OffsetCurve/ST_OffsetCurve_negative.svg
@@ -0,0 +1,55 @@
+<svg xmlns="http://www.w3.org/2000/svg"; viewBox="0 0 500 300" width="500" 
height="300" style="background:#ffffff">
+  <defs>
+    <style>
+      text { font-family: 'Segoe UI', Arial, Helvetica, sans-serif; }
+    </style>
+  </defs>
+  <text x="250" y="22" text-anchor="middle" font-size="16" font-weight="bold" 
fill="#333333">ST_OffsetCurve — Negative Offset on L-shape</text>
+  <!-- Mapping: x range [0,13] → [60,450], y range [-3,10] → [245,35] -->
+  <!-- cx = x*30+60, cy = 245 - (y+3)*16.15 -->
+
+  <!-- Input: LINESTRING(0 0, 10 0, 10 10) -->
+  <polyline points="60,196 360,196 360,35" fill="none" stroke="#4a90d9" 
stroke-width="3" />
+  <circle cx="60" cy="196" r="4" fill="#4a90d9" />
+  <circle cx="360" cy="196" r="4" fill="#4a90d9" />
+  <circle cx="360" cy="35" r="4" fill="#4a90d9" />
+  <text x="40" y="192" font-size="11" fill="#4a90d9">(0, 0)</text>
+  <text x="324" y="192" font-size="11" fill="#4a90d9">(10, 0)</text>
+  <text x="364" y="33" font-size="11" fill="#4a90d9">(10, 10)</text>
+
+  <!-- Result: actual JTS output with 9 arc segments (default qs=8) -->
+  <polyline points="60,245 360,245 377,244 394,240 410,235 424,228 435,219 
443,209 448,199 450,196 450,35"
+    fill="none" stroke="#e67e22" stroke-width="3" />
+  <!-- Vertex dots on arc segments -->
+  <circle cx="60" cy="245" r="4" fill="#e67e22" />
+  <circle cx="360" cy="245" r="3" fill="#e67e22" />
+  <circle cx="377" cy="244" r="3" fill="#e67e22" />
+  <circle cx="394" cy="240" r="3" fill="#e67e22" />
+  <circle cx="410" cy="235" r="3" fill="#e67e22" />
+  <circle cx="424" cy="228" r="3" fill="#e67e22" />
+  <circle cx="435" cy="219" r="3" fill="#e67e22" />
+  <circle cx="443" cy="209" r="3" fill="#e67e22" />
+  <circle cx="448" cy="199" r="3" fill="#e67e22" />
+  <circle cx="450" cy="196" r="3" fill="#e67e22" />
+  <circle cx="450" cy="35" r="4" fill="#e67e22" />
+  <text x="40" y="253" font-size="11" fill="#e67e22">(0, -3)</text>
+  <text x="453" y="41" font-size="11" fill="#e67e22">(13, 10)</text>
+
+  <!-- Distance marker -->
+  <line x1="200" y1="196" x2="200" y2="245" stroke="#999" stroke-width="1" 
stroke-dasharray="3,2" />
+  <line x1="196" y1="196" x2="204" y2="196" stroke="#999" stroke-width="1" />
+  <line x1="196" y1="245" x2="204" y2="245" stroke="#999" stroke-width="1" />
+  <text x="207" y="224" font-size="12" font-weight="bold" fill="#999">d = 
3</text>
+
+  <!-- Point count annotation -->
+  <text x="460" y="224" font-size="11" fill="#e67e22">11 points</text>
+  <text x="460" y="237" font-size="11" fill="#e67e22">(9 on arc)</text>
+
+  <!-- Caption -->
+  <text x="250" y="290" text-anchor="middle" font-size="13" fill="#333333" 
font-style="italic">Negative offset creates a segmented arc at the outer 
corner</text>
+  <!-- Legend -->
+  <rect x="73" y="265" width="12" height="12" rx="2" fill="#4a90d9" 
stroke="#4a90d9" stroke-width="1" />
+  <text x="89" y="276" font-size="13" fill="#333333">Input line</text>
+  <rect x="253" y="265" width="12" height="12" rx="2" fill="#e67e22" 
stroke="#e67e22" stroke-width="1" />
+  <text x="269" y="276" font-size="13" fill="#333333">Offset curve (distance = 
-3.0)</text>
+</svg>
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 0000000000..a4aa758754
--- /dev/null
+++ b/docs/image/ST_OffsetCurve/ST_OffsetCurve_positive.svg
@@ -0,0 +1,44 @@
+<svg xmlns="http://www.w3.org/2000/svg"; viewBox="0 0 500 300" width="500" 
height="300" style="background:#ffffff">
+  <defs>
+    <style>
+      text { font-family: 'Segoe UI', Arial, Helvetica, sans-serif; }
+    </style>
+  </defs>
+  <text x="250" y="22" text-anchor="middle" font-size="16" font-weight="bold" 
fill="#333333">ST_OffsetCurve — Positive Offset on L-shape</text>
+  <!-- Mapping: x range [0,13] → [60,450], y range [-1,10] → [230,35] -->
+  <!-- cx = x*30+60, cy = 230 - y*17.7 -->
+  <!-- (0,0)→(60,230) (10,0)→(360,230) (10,10)→(360,53) -->
+  <!-- (0,5)→(60,141) (5,5)→(210,141) (5,10)→(210,53) -->
+
+  <!-- Input: LINESTRING(0 0, 10 0, 10 10) -->
+  <polyline points="60,230 360,230 360,53" fill="none" stroke="#4a90d9" 
stroke-width="3" />
+  <circle cx="60" cy="230" r="4" fill="#4a90d9" />
+  <circle cx="360" cy="230" r="4" fill="#4a90d9" />
+  <circle cx="360" cy="53" r="4" fill="#4a90d9" />
+  <text x="36" y="235" font-size="11" fill="#4a90d9">(0, 0)</text>
+  <text x="363" y="245" font-size="11" fill="#4a90d9">(10, 0)</text>
+  <text x="363" y="50" font-size="11" fill="#4a90d9">(10, 10)</text>
+
+  <!-- Result: LINESTRING(0 5, 5 5, 5 10) — inner corner is clipped, no arc -->
+  <polyline points="60,141 210,141 210,53" fill="none" stroke="#2ecc71" 
stroke-width="3" />
+  <circle cx="60" cy="141" r="4" fill="#2ecc71" />
+  <circle cx="210" cy="141" r="4" fill="#2ecc71" />
+  <circle cx="210" cy="53" r="4" fill="#2ecc71" />
+  <text x="36" y="136" font-size="11" fill="#2ecc71">(0, 5)</text>
+  <text x="213" y="155" font-size="11" fill="#2ecc71">(5, 5)</text>
+  <text x="178" y="50" font-size="11" fill="#2ecc71">(5, 10)</text>
+
+  <!-- Distance marker -->
+  <line x1="150" y1="141" x2="150" y2="230" stroke="#999" stroke-width="1" 
stroke-dasharray="3,2" />
+  <line x1="146" y1="141" x2="154" y2="141" stroke="#999" stroke-width="1" />
+  <line x1="146" y1="230" x2="154" y2="230" stroke="#999" stroke-width="1" />
+  <text x="156" y="190" font-size="12" font-weight="bold" fill="#999">d = 
5</text>
+
+  <!-- Caption -->
+  <text x="250" y="290" text-anchor="middle" font-size="13" fill="#333333" 
font-style="italic">LINESTRING (0 5, 5 5, 5 10)</text>
+  <!-- Legend -->
+  <rect x="73" y="265" width="12" height="12" rx="2" fill="#4a90d9" 
stroke="#4a90d9" stroke-width="1" />
+  <text x="89" y="276" font-size="13" fill="#333333">Input line</text>
+  <rect x="233" y="265" width="12" height="12" rx="2" fill="#2ecc71" 
stroke="#2ecc71" stroke-width="1" />
+  <text x="249" y="276" font-size="13" fill="#333333">Offset curve (distance = 
5.0)</text>
+</svg>
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 0000000000..11c8c7b478
--- /dev/null
+++ b/docs/image/ST_OffsetCurve/ST_OffsetCurve_quadrant.svg
@@ -0,0 +1,83 @@
+<svg xmlns="http://www.w3.org/2000/svg"; viewBox="0 0 500 300" width="500" 
height="300" style="background:#ffffff">
+  <defs>
+    <style>
+      text { font-family: 'Segoe UI', Arial, Helvetica, sans-serif; }
+    </style>
+  </defs>
+  <text x="250" y="18" text-anchor="middle" font-size="15" font-weight="bold" 
fill="#333333">ST_OffsetCurve — quadrantSegments Effect</text>
+
+  <!-- LEFT CHART: default qs=8 -->
+  <!-- Mapping: x[0,13]→[20,230], y[-3,10]→[230,40] -->
+  <!-- cx = x*16.2+20, cy = 230 - (y+3)*14.6 -->
+  <!-- (0,0)→(20,186) (10,0)→(182,186) (10,10)→(182,40) -->
+  <!-- (0,-3)→(20,230) (13,0)→(231,186) (13,10)→(231,40) -->
+
+  <text x="125" y="36" text-anchor="middle" font-size="13" font-weight="bold" 
fill="#e67e22">default (qs=8): 11 points</text>
+
+  <!-- Input -->
+  <polyline points="20,186 182,186 182,40" fill="none" stroke="#4a90d9" 
stroke-width="2" />
+
+  <!-- Default arc: 9 arc points from JTS -->
+  <!-- (10,-3)→(182,230) (10.59,-2.94)→(192,229) (11.15,-2.77)→(201,226) 
(11.67,-2.49)→(209,222) -->
+  <!-- (12.12,-2.12)→(216,217) (12.49,-1.67)→(222,211) (12.77,-1.15)→(227,203) 
(12.94,-0.59)→(230,195) (13,0)→(231,186) -->
+  <polyline points="20,230 182,230 192,229 201,226 209,222 216,217 222,211 
227,203 230,195 231,186 231,40"
+    fill="none" stroke="#e67e22" stroke-width="2" />
+  <circle cx="182" cy="230" r="2.5" fill="#e67e22" />
+  <circle cx="192" cy="229" r="2.5" fill="#e67e22" />
+  <circle cx="201" cy="226" r="2.5" fill="#e67e22" />
+  <circle cx="209" cy="222" r="2.5" fill="#e67e22" />
+  <circle cx="216" cy="217" r="2.5" fill="#e67e22" />
+  <circle cx="222" cy="211" r="2.5" fill="#e67e22" />
+  <circle cx="227" cy="203" r="2.5" fill="#e67e22" />
+  <circle cx="230" cy="195" r="2.5" fill="#e67e22" />
+  <circle cx="231" cy="186" r="2.5" fill="#e67e22" />
+
+  <!-- Divider -->
+  <line x1="250" y1="30" x2="250" y2="250" stroke="#ddd" stroke-width="1" />
+
+  <!-- RIGHT CHART: qs=16 -->
+  <!-- Mapping: x[0,13]→[270,480], y[-3,10]→[230,40] -->
+  <!-- cx = x*16.2+270, cy = 230 - (y+3)*14.6 -->
+  <!-- (0,0)→(270,186) (10,0)→(432,186) (10,10)→(432,40) -->
+  <!-- (13,0)→(481,186) (13,10)→(481,40) -->
+
+  <text x="375" y="36" text-anchor="middle" font-size="13" font-weight="bold" 
fill="#9b59b6">qs=16: 19 points</text>
+
+  <!-- Input -->
+  <polyline points="270,186 432,186 432,40" fill="none" stroke="#4a90d9" 
stroke-width="2" />
+
+  <!-- qs=16 arc: 17 arc points from JTS -->
+  <!-- (10,-3)→(432,230) (10.29,-2.99)→(437,230) (10.59,-2.94)→(442,229) 
(10.87,-2.87)→(448,228) -->
+  <!-- (11.15,-2.77)→(451,226) (11.41,-2.65)→(455,223) (11.67,-2.49)→(459,222) 
(11.90,-2.32)→(463,219) -->
+  <!-- (12.12,-2.12)→(466,217) (12.32,-1.90)→(470,214) (12.49,-1.67)→(472,211) 
(12.65,-1.41)→(475,207) -->
+  <!-- (12.77,-1.15)→(477,203) (12.87,-0.87)→(478,199) (12.94,-0.59)→(480,195) 
(12.99,-0.29)→(481,190) (13,0)→(481,186) -->
+  <polyline points="270,230 432,230 437,230 442,229 448,228 451,226 455,223 
459,222 463,219 466,217 470,214 472,211 475,207 477,203 478,199 480,195 481,190 
481,186 481,40"
+    fill="none" stroke="#9b59b6" stroke-width="2" />
+  <circle cx="432" cy="230" r="2" fill="#9b59b6" />
+  <circle cx="437" cy="230" r="2" fill="#9b59b6" />
+  <circle cx="442" cy="229" r="2" fill="#9b59b6" />
+  <circle cx="448" cy="228" r="2" fill="#9b59b6" />
+  <circle cx="451" cy="226" r="2" fill="#9b59b6" />
+  <circle cx="455" cy="223" r="2" fill="#9b59b6" />
+  <circle cx="459" cy="222" r="2" fill="#9b59b6" />
+  <circle cx="463" cy="219" r="2" fill="#9b59b6" />
+  <circle cx="466" cy="217" r="2" fill="#9b59b6" />
+  <circle cx="470" cy="214" r="2" fill="#9b59b6" />
+  <circle cx="472" cy="211" r="2" fill="#9b59b6" />
+  <circle cx="475" cy="207" r="2" fill="#9b59b6" />
+  <circle cx="477" cy="203" r="2" fill="#9b59b6" />
+  <circle cx="478" cy="199" r="2" fill="#9b59b6" />
+  <circle cx="480" cy="195" r="2" fill="#9b59b6" />
+  <circle cx="481" cy="190" r="2" fill="#9b59b6" />
+  <circle cx="481" cy="186" r="2" fill="#9b59b6" />
+
+  <!-- Caption -->
+  <text x="250" y="280" text-anchor="middle" font-size="13" fill="#333333" 
font-style="italic">Higher quadrantSegments adds more line segments to 
approximate the arc</text>
+  <!-- Legend -->
+  <rect x="73" y="258" width="12" height="12" rx="2" fill="#4a90d9" 
stroke="#4a90d9" stroke-width="1" />
+  <text x="89" y="269" font-size="12" fill="#333333">Input</text>
+  <rect x="163" y="258" width="12" height="12" rx="2" fill="#e67e22" 
stroke="#e67e22" stroke-width="1" />
+  <text x="179" y="269" font-size="12" fill="#333333">Offset (qs=8)</text>
+  <rect x="293" y="258" width="12" height="12" rx="2" fill="#9b59b6" 
stroke="#9b59b6" stroke-width="1" />
+  <text x="309" y="269" font-size="12" fill="#333333">Offset (qs=16)</text>
+</svg>
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 a10d9f1577..3ef00ce247 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 class Catalog {
       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 cf5e5267c7..71029be3a4 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 class Functions {
     }
   }
 
+  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 f0d7ffa7f1..2171ecc78e 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,33 @@ public class FunctionTest extends TestBase {
     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, 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
   public void testCentroid() {
     Table polygonTable =
diff --git a/python/sedona/spark/sql/st_functions.py 
b/python/sedona/spark/sql/st_functions.py
index f32d57e7ad..186aaadd31 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 920550a1cf..9156b1d48f 100644
--- a/python/tests/sql/test_dataframe_api.py
+++ b/python/tests/sql/test_dataframe_api.py
@@ -907,6 +907,20 @@ test_configurations = [
         "",
         "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 @@ wrong_type_configurations = [
     (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 48a24d0ce7..ad7cabe739 100644
--- a/python/tests/sql/test_function.py
+++ b/python/tests/sql/test_function.py
@@ -1983,6 +1983,32 @@ class TestPredicateJoin(TestBase):
         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 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))"
+        )
+        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
         geometry_df = self.spark.createDataFrame(
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 2c226f70ba..0e66423c10 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,19 @@ public class TestFunctions extends TestBase {
         "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);
+    registerUDF("ST_NPoints", byte[].class);
+    verifySqlSingleRes(
+        "select 
sedona.ST_NPoints(sedona.ST_OffsetCurve(sedona.ST_GeomFromText('LINESTRING(0 0, 
10 0, 10 10)'), -3.0, 16))",
+        19);
+  }
+
   @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 67bc09b187..df864987fe 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,19 @@ public class TestFunctionsV2 extends TestBase {
         "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);
+    registerUDFV2("ST_NPoints", String.class);
+    verifySqlSingleRes(
+        "select 
sedona.ST_NPoints(sedona.ST_OffsetCurve(ST_GeometryFromWKT('LINESTRING(0 0, 10 
0, 10 10)'), -3.0, 16))",
+        19);
+  }
+
   @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 dafdc8fc97..f78ad7d3f3 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 class UDFs {
     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 80fbb34d07..18cf8a281f 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 class UDFsV2 {
     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"})
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 c931694db4..2629d8b178 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 0925dfb104..f15efb0e70 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 07f2e78d3b..f712bf30ff 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 b9e5b7b29e..f66794dd53 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 f10f36e38a..c14608bc31 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,23 @@ 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, 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") {
       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 8d311bae90..9629cd406c 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,29 @@ 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 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") {
     val geomTestCases = Map(
       ("'POINT (-0.56 51.3168)'") -> "0.0",

Reply via email to