Skip to content

Commit 43b0cdb

Browse files
authored
[GH-3017] Implement rotate (#3018)
1 parent 1a633fc commit 43b0cdb

4 files changed

Lines changed: 174 additions & 0 deletions

File tree

python/sedona/spark/geopandas/base.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1362,6 +1362,57 @@ def segmentize(self, max_segment_length):
13621362
# def transform(self, transformation, include_z=False):
13631363
# raise NotImplementedError("This method is not implemented yet.")
13641364

1365+
def rotate(self, angle, origin="center", use_radians=False):
1366+
"""Return a ``GeoSeries`` with rotated geometries.
1367+
1368+
See http://shapely.readthedocs.io/en/latest/manual.html#shapely.affinity.rotate
1369+
for details.
1370+
1371+
Parameters
1372+
----------
1373+
angle : float
1374+
The angle of rotation can be specified in either degrees (default)
1375+
or radians by setting use_radians=True. Positive angles are
1376+
counter-clockwise and negative are clockwise rotations.
1377+
origin : string, Point, or tuple (x, y)
1378+
The point of origin can be a keyword 'center' for the bounding box
1379+
center (default), 'centroid' for the geometry's centroid, a Point
1380+
object or a coordinate tuple (x, y).
1381+
use_radians : boolean
1382+
Whether to interpret the angle of rotation as degrees or radians
1383+
1384+
Examples
1385+
--------
1386+
>>> from shapely.geometry import Point, LineString, Polygon
1387+
>>> from sedona.spark.geopandas import GeoSeries
1388+
>>> s = GeoSeries(
1389+
... [
1390+
... Point(1, 1),
1391+
... LineString([(1, -1), (1, 0)]),
1392+
... Polygon([(3, -1), (4, 0), (3, 1)]),
1393+
... ]
1394+
... )
1395+
>>> s
1396+
0 POINT (1 1)
1397+
1 LINESTRING (1 -1, 1 0)
1398+
2 POLYGON ((3 -1, 4 0, 3 1, 3 -1))
1399+
dtype: geometry
1400+
1401+
>>> s.rotate(90)
1402+
0 POINT (1 1)
1403+
1 LINESTRING (1.5 -0.5, 0.5 -0.5)
1404+
2 POLYGON ((4.5 -0.5, 3.5 0.5, 2.5 -0.5, 4.5 -0.5))
1405+
dtype: geometry
1406+
1407+
>>> s.rotate(90, origin=(0, 0))
1408+
0 POINT (-1 1)
1409+
1 LINESTRING (1 1, 0 1)
1410+
2 POLYGON ((1 3, 0 4, -1 3, 1 3))
1411+
dtype: geometry
1412+
1413+
"""
1414+
return _delegate_to_geometry_column("rotate", self, angle, origin, use_radians)
1415+
13651416
def force_2d(self):
13661417
"""Force the dimensionality of a geometry to 2D.
13671418

python/sedona/spark/geopandas/geoseries.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1160,6 +1160,36 @@ def transform(self, transformation, include_z=False):
11601160
# Implementation of the abstract method.
11611161
raise NotImplementedError("This method is not implemented yet.")
11621162

1163+
def rotate(self, angle, origin="center", use_radians=False) -> "GeoSeries":
1164+
import math
1165+
1166+
if not use_radians:
1167+
angle = angle * math.pi / 180.0
1168+
if isinstance(origin, str):
1169+
if origin == "center":
1170+
origin_x = stf.ST_X(stf.ST_Centroid(stf.ST_Envelope(self.spark.column)))
1171+
origin_y = stf.ST_Y(stf.ST_Centroid(stf.ST_Envelope(self.spark.column)))
1172+
spark_expr = stf.ST_Rotate(self.spark.column, angle, origin_x, origin_y)
1173+
elif origin == "centroid":
1174+
origin_x = stf.ST_X(stf.ST_Centroid(self.spark.column))
1175+
origin_y = stf.ST_Y(stf.ST_Centroid(self.spark.column))
1176+
spark_expr = stf.ST_Rotate(self.spark.column, angle, origin_x, origin_y)
1177+
else:
1178+
raise ValueError(
1179+
f"origin must be 'center', 'centroid', a Point, or (x, y) tuple, got {origin!r}"
1180+
)
1181+
elif isinstance(origin, (tuple, list)) and len(origin) == 2:
1182+
spark_expr = stf.ST_Rotate(
1183+
self.spark.column, angle, float(origin[0]), float(origin[1])
1184+
)
1185+
elif isinstance(origin, shapely.geometry.Point):
1186+
spark_expr = stf.ST_Rotate(self.spark.column, angle, origin.x, origin.y)
1187+
else:
1188+
raise TypeError(
1189+
"origin must be 'center', 'centroid', a Point, or a two-element tuple/list"
1190+
)
1191+
return self._query_geometry_column(spark_expr, returns_geom=True)
1192+
11631193
def force_2d(self) -> "GeoSeries":
11641194
spark_expr = stf.ST_Force_2D(self.spark.column)
11651195
return self._query_geometry_column(spark_expr, returns_geom=True)

python/tests/geopandas/test_geoseries.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1847,6 +1847,77 @@ def test_segmentize(self):
18471847
def test_transform(self):
18481848
pass
18491849

1850+
def test_rotate(self):
1851+
geoms = [
1852+
Point(1, 1),
1853+
LineString([(1, -1), (1, 0)]),
1854+
Polygon([(3, -1), (4, 0), (3, 1)]),
1855+
None,
1856+
]
1857+
s = GeoSeries(geoms)
1858+
1859+
# Test default
1860+
result = s.rotate(90)
1861+
expected = gpd.GeoSeries(
1862+
[
1863+
Point(1.0, 1.0),
1864+
LineString([(1.5, -0.5), (0.5, -0.5)]),
1865+
Polygon([(4.5, -0.5), (3.5, 0.5), (2.5, -0.5), (4.5, -0.5)]),
1866+
None,
1867+
]
1868+
)
1869+
self.check_sgpd_equals_gpd(result, expected)
1870+
1871+
# Test with explicit origin tuple (90 degrees around 0,0)
1872+
result = s.rotate(90, origin=(0, 0))
1873+
expected = gpd.GeoSeries(
1874+
[
1875+
Point(-1.0, 1.0),
1876+
LineString([(1.0, 1.0), (0.0, 1.0)]),
1877+
Polygon([(1.0, 3.0), (0.0, 4.0), (-1.0, 3.0), (1.0, 3.0)]),
1878+
None,
1879+
]
1880+
)
1881+
self.check_sgpd_equals_gpd(result, expected)
1882+
1883+
# Test use_radians
1884+
import math
1885+
1886+
result = s.rotate(math.pi / 2, origin=(0, 0), use_radians=True)
1887+
self.check_sgpd_equals_gpd(result, expected)
1888+
1889+
# Test with a Point object as the origin
1890+
result = s.rotate(90, origin=Point(0, 0))
1891+
self.check_sgpd_equals_gpd(result, expected)
1892+
1893+
# Test origin='centroid'
1894+
result = s.rotate(45, origin="centroid")
1895+
expected = gpd.GeoSeries(
1896+
[
1897+
Point(1.0, 1.0),
1898+
LineString(
1899+
[
1900+
(1.3535533905932737, -0.8535533905932737),
1901+
(0.6464466094067263, -0.14644660940672627),
1902+
]
1903+
),
1904+
Polygon(
1905+
[
1906+
(3.8047378541243653, -0.9428090415820636),
1907+
(3.8047378541243653, 0.4714045207910314),
1908+
(2.3905242917512703, 0.4714045207910314),
1909+
(3.8047378541243653, -0.9428090415820636),
1910+
]
1911+
),
1912+
None,
1913+
]
1914+
)
1915+
self.check_sgpd_equals_gpd(result, expected)
1916+
1917+
# Test invalid origin strings
1918+
with pytest.raises((ValueError, TypeError)):
1919+
s.rotate(90, origin="invalid")
1920+
18501921
def test_force_2d(self):
18511922
s = sgpd.GeoSeries(
18521923
[

python/tests/geopandas/test_match_geopandas_series.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -956,6 +956,28 @@ def test_segmentize(self):
956956
def test_transform(self):
957957
pass
958958

959+
@pytest.mark.parametrize("angle", [0, 45, 90, 180])
960+
@pytest.mark.parametrize(
961+
"origin_kwargs",
962+
[
963+
{},
964+
{"origin": (1, 2)},
965+
{"origin": [1, 2]},
966+
{"origin": "centroid"},
967+
{"origin": Point(2, 4)},
968+
],
969+
)
970+
def test_rotate(self, angle, origin_kwargs):
971+
for geom in self.geoms:
972+
# Filter empty geometries within each group
973+
non_empty = [g for g in geom if g is not None and not g.is_empty]
974+
if not non_empty:
975+
continue
976+
977+
sgpd_result = GeoSeries(non_empty).rotate(angle, **origin_kwargs)
978+
gpd_result = gpd.GeoSeries(non_empty).rotate(angle, **origin_kwargs)
979+
self.check_sgpd_equals_gpd(sgpd_result, gpd_result)
980+
959981
def test_force_2d(self):
960982
# force_2d was added from geopandas 1.0.0
961983
if parse_version(gpd.__version__) < parse_version("1.0.0"):

0 commit comments

Comments
 (0)