diff --git a/django/contrib/gis/db/backends/base/operations.py b/django/contrib/gis/db/backends/base/operations.py index fafdf60743..e97e694d1d 100644 --- a/django/contrib/gis/db/backends/base/operations.py +++ b/django/contrib/gis/db/backends/base/operations.py @@ -62,6 +62,7 @@ class BaseSpatialOperations: "Perimeter", "PointOnSurface", "Reverse", + "Rotate", "Scale", "SnapToGrid", "SymDifference", diff --git a/django/contrib/gis/db/backends/mysql/operations.py b/django/contrib/gis/db/backends/mysql/operations.py index 5c02d5e5a7..309aaf224b 100644 --- a/django/contrib/gis/db/backends/mysql/operations.py +++ b/django/contrib/gis/db/backends/mysql/operations.py @@ -103,6 +103,7 @@ class MySQLOperations(BaseSpatialOperations, DatabaseOperations): "Perimeter", "PointOnSurface", "Reverse", + "Rotate", "Scale", "SnapToGrid", "Transform", diff --git a/django/contrib/gis/db/backends/oracle/operations.py b/django/contrib/gis/db/backends/oracle/operations.py index eb86dc39de..adc0a0a860 100644 --- a/django/contrib/gis/db/backends/oracle/operations.py +++ b/django/contrib/gis/db/backends/oracle/operations.py @@ -129,6 +129,7 @@ class OracleOperations(BaseSpatialOperations, DatabaseOperations): "LineLocatePoint", "MakeValid", "MemSize", + "Rotate", "Scale", "SnapToGrid", "Translate", diff --git a/django/contrib/gis/db/backends/spatialite/operations.py b/django/contrib/gis/db/backends/spatialite/operations.py index 3d10a53641..c7a86d5aef 100644 --- a/django/contrib/gis/db/backends/spatialite/operations.py +++ b/django/contrib/gis/db/backends/spatialite/operations.py @@ -82,7 +82,7 @@ class SpatiaLiteOperations(BaseSpatialOperations, DatabaseOperations): @cached_property def unsupported_functions(self): - unsupported = {"GeometryDistance", "IsEmpty", "MemSize"} + unsupported = {"GeometryDistance", "IsEmpty", "MemSize", "Rotate"} if not self.geom_lib_version(): unsupported |= {"Azimuth", "GeoHash", "MakeValid"} if self.spatial_version < (5, 1): diff --git a/django/contrib/gis/db/models/functions.py b/django/contrib/gis/db/models/functions.py index 10272b7d68..91f5f729bf 100644 --- a/django/contrib/gis/db/models/functions.py +++ b/django/contrib/gis/db/models/functions.py @@ -3,6 +3,7 @@ from decimal import Decimal from django.contrib.gis.db.models.fields import BaseSpatialField, GeometryField from django.contrib.gis.db.models.sql import AreaField, DistanceField from django.contrib.gis.geos import GEOSGeometry +from django.contrib.gis.geos.point import Point from django.core.exceptions import FieldError from django.db import NotSupportedError from django.db.models import ( @@ -529,6 +530,25 @@ class Reverse(GeoFunc): arity = 1 +class Rotate(GeomOutputGeoFunc): + def __init__(self, expression, angle, x=0.0, y=0.0, origin=None, **extra): + expressions = [ + expression, + self._handle_param(angle, "angle", NUMERIC_TYPES), + ] + + if origin is not None: + if not isinstance(origin, Point): + raise TypeError("Param origin must be of type Point") + expressions.append(Value(origin.wkt, output_field=GeometryField())) + else: + expressions = expressions + [ + self._handle_param(x, "x", NUMERIC_TYPES), + self._handle_param(y, "y", NUMERIC_TYPES), + ] + super().__init__(*expressions, **extra) + + class Scale(SQLiteDecimalToFloatMixin, GeomOutputGeoFunc): def __init__(self, expression, x, y, z=0.0, **extra): expressions = [ diff --git a/docs/ref/contrib/gis/functions.txt b/docs/ref/contrib/gis/functions.txt index ff62c17580..28ca1410ac 100644 --- a/docs/ref/contrib/gis/functions.txt +++ b/docs/ref/contrib/gis/functions.txt @@ -576,6 +576,32 @@ SpatiaLite Accepts a single geographic field or expression and returns a geometry with reversed coordinates. +``Rotate`` +=========== + +.. class:: Rotate(expression, angle, x=0.0, y=0.0, origin=None, **extra) + + .. versionadded:: 5.2 + + *Availability*: `PostGIS `__ + + Rotates a geometry by a specified ``angle`` around the origin. Optionally, + the rotation can be performed around a specified point, defined by either + the ``x`` and ``y`` parameters or the ``origin`` parameter. + + :param expression: A geographic field or expression to rotate. + :param angle: The angle in radians by which to rotate the geometry. + Positive values result in counterclockwise rotation, + negative values result in clockwise rotation. + :param x: The X-coordinate of the point around which the geometry will be rotated. + :param y: The Y-coordinate of the point around which the geometry will be rotated. + :param origin: A :class:`~django.contrib.gis.geos.Point` around which + to rotate. Takes precedence over ``x`` and ``y``. + :param extra: Additional arguments passed to the underlying function. + + :returns: A geometry with coordinates rotated by the specified angle. + :rtype: :class:`~django.contrib.gis.geos.GEOSGeometry` + ``Scale`` ========= diff --git a/docs/releases/5.2.txt b/docs/releases/5.2.txt index aaf47ff8e8..0a33ef60b1 100644 --- a/docs/releases/5.2.txt +++ b/docs/releases/5.2.txt @@ -132,6 +132,10 @@ Minor features :class:`~django.contrib.gis.db.models.functions.IsValid` database functions are now supported on MariaDB 11.7+. +* The new :class:`~django.contrib.gis.db.models.functions.Rotate` database + function rotates a geometry by a specified angle around the origin or a + specified point. + :mod:`django.contrib.messages` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/tests/gis_tests/geoapp/test_functions.py b/tests/gis_tests/geoapp/test_functions.py index 80b08f8d39..b9eaec00da 100644 --- a/tests/gis_tests/geoapp/test_functions.py +++ b/tests/gis_tests/geoapp/test_functions.py @@ -612,6 +612,37 @@ class GISFunctionsTests(FuncTestMixin, TestCase): coords.reverse() self.assertEqual(tuple(coords), track.reverse_geom.coords) + @skipUnlessDBFeature("has_Rotate_function") + def test_rotate(self): + angle = math.pi + qs_origin = Country.objects.annotate( + rotated=functions.Rotate("mpoly", angle=angle) + ) + qs_xy_params = Country.objects.annotate( + rotated=functions.Rotate("mpoly", angle=angle, x=1, y=1) + ) + qs_origin_param = Country.objects.annotate( + rotated=functions.Rotate("mpoly", angle=angle, origin=Point(0, 0)) + ) + for c_origin, c_xy_params, c_origin_point_param in zip( + qs_origin, qs_xy_params, qs_origin_param + ): + for p1, p2 in zip(c_origin.mpoly, c_origin.rotated): + for r1, r2 in zip(p1, p2): + for c1, c2 in zip(r1.coords, r2.coords): + self.assertAlmostEqual(-c1[0], c2[0], 5) + self.assertAlmostEqual(-c1[1], c2[1], 5) + for p1, p2 in zip(c_xy_params.mpoly, c_xy_params.rotated): + for r1, r2 in zip(p1, p2): + for c1, c2 in zip(r1.coords, r2.coords): + self.assertAlmostEqual(-c1[0] + 2, c2[0], 5) + self.assertAlmostEqual(-c1[1] + 2, c2[1], 5) + for p1, p2 in zip(c_origin_point_param.mpoly, c_origin_point_param.rotated): + for r1, r2 in zip(p1, p2): + for c1, c2 in zip(r1.coords, r2.coords): + self.assertAlmostEqual(-c1[0], c2[0], 5) + self.assertAlmostEqual(-c1[1], c2[1], 5) + @skipUnlessDBFeature("has_Scale_function") def test_scale(self): xfac, yfac = 2, 3