From 51cab4ad51616f8fdb050631be5c710b93685ec3 Mon Sep 17 00:00:00 2001 From: enprava Date: Sun, 8 Sep 2024 20:32:13 +0200 Subject: [PATCH] Fixed #35705 -- Added Rotate GIS database function to rotate geometries. --- .../gis/db/backends/base/operations.py | 1 + .../gis/db/backends/mysql/operations.py | 1 + .../gis/db/backends/oracle/operations.py | 1 + .../gis/db/backends/spatialite/operations.py | 2 +- django/contrib/gis/db/models/functions.py | 14 ++++++++ docs/ref/contrib/gis/db-api.txt | 1 + docs/ref/contrib/gis/functions.txt | 22 +++++++++--- docs/releases/6.0.txt | 4 +++ tests/gis_tests/geoapp/test_functions.py | 35 +++++++++++++++++++ 9 files changed, 76 insertions(+), 5 deletions(-) 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 ea9c6fd86f..8af78d8703 100644 --- a/django/contrib/gis/db/backends/mysql/operations.py +++ b/django/contrib/gis/db/backends/mysql/operations.py @@ -98,6 +98,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..f26e9ae3e0 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,19 @@ class Reverse(GeoFunc): arity = 1 +class Rotate(GeomOutputGeoFunc): + def __init__(self, expression, angle, origin=None, **extra): + expressions = [ + expression, + self._handle_param(angle, "angle", NUMERIC_TYPES), + ] + if origin is not None: + if not isinstance(origin, Point): + raise TypeError("origin argument must be a Point") + expressions.append(Value(origin.wkt, output_field=GeometryField())) + 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/db-api.txt b/docs/ref/contrib/gis/db-api.txt index f2b036dac4..ee365b9320 100644 --- a/docs/ref/contrib/gis/db-api.txt +++ b/docs/ref/contrib/gis/db-api.txt @@ -420,6 +420,7 @@ Function PostGIS Oracle MariaDB MySQL :class:`Perimeter` X X X :class:`PointOnSurface` X X X X :class:`Reverse` X X X +:class:`Rotate` X :class:`Scale` X X :class:`SnapToGrid` X X :class:`SymDifference` X X X X X diff --git a/docs/ref/contrib/gis/functions.txt b/docs/ref/contrib/gis/functions.txt index 03f5268d55..4e1ae167b8 100644 --- a/docs/ref/contrib/gis/functions.txt +++ b/docs/ref/contrib/gis/functions.txt @@ -28,10 +28,11 @@ Measurement Relationships Operations Edi :class:`Area` :class:`Azimuth` :class:`Difference` :class:`ForcePolygonCW` :class:`AsGeoJSON` :class:`IsEmpty` :class:`Distance` :class:`BoundingCircle` :class:`Intersection` :class:`MakeValid` :class:`AsGML` :class:`IsValid` :class:`GeometryDistance` :class:`Centroid` :class:`SymDifference` :class:`Reverse` :class:`AsKML` :class:`MemSize` -:class:`Length` :class:`ClosestPoint` :class:`Union` :class:`Scale` :class:`AsSVG` :class:`NumGeometries` -:class:`Perimeter` :class:`Envelope` :class:`SnapToGrid` :class:`FromWKB` :class:`AsWKB` :class:`NumPoints` - :class:`LineLocatePoint` :class:`Transform` :class:`FromWKT` :class:`AsWKT` - :class:`PointOnSurface` :class:`Translate` :class:`GeoHash` +:class:`Length` :class:`ClosestPoint` :class:`Union` :class:`Rotate` :class:`AsSVG` :class:`NumGeometries` +:class:`Perimeter` :class:`Envelope` :class:`Scale` :class:`FromWKB` :class:`AsWKB` :class:`NumPoints` + :class:`LineLocatePoint` :class:`SnapToGrid` :class:`FromWKT` :class:`AsWKT` + :class:`PointOnSurface` :class:`Transform` :class:`GeoHash` + :class:`Translate` ========================= ======================== ====================== ======================= ================== ================== ====================== ``Area`` @@ -556,6 +557,19 @@ SpatiaLite Accepts a single geographic field or expression and returns a geometry with reversed coordinates. +``Rotate`` +========== + +.. versionadded:: 6.0 + +.. class:: Rotate(expression, angle, origin=None, **extra) + +*Availability*: `PostGIS `__ + +Rotates a geometry by a specified ``angle`` around the origin. Optionally, the +rotation can be performed around a point, defined by the ``origin`` +parameter. + ``Scale`` ========= diff --git a/docs/releases/6.0.txt b/docs/releases/6.0.txt index aeb38d7874..d8e35bb557 100644 --- a/docs/releases/6.0.txt +++ b/docs/releases/6.0.txt @@ -68,6 +68,10 @@ Minor features * The new :attr:`.GEOSGeometry.hasm` property checks whether the geometry has the M dimension. +* 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..3e881b41dc 100644 --- a/tests/gis_tests/geoapp/test_functions.py +++ b/tests/gis_tests/geoapp/test_functions.py @@ -612,6 +612,41 @@ 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 + tests = [ + {"angle": angle}, + {"angle": angle, "origin": Point(0, 0)}, + {"angle": angle, "origin": Point(1, 1)}, + ] + for params in tests: + with self.subTest(params=params): + qs = Country.objects.annotate( + rotated=functions.Rotate("mpoly", **params) + ) + for country in qs: + for p1, p2 in zip(country.mpoly, country.rotated): + for r1, r2 in zip(p1, p2): + for c1, c2 in zip(r1.coords, r2.coords): + origin = params.get("origin") + if origin is None: + origin = Point(0, 0) + self.assertAlmostEqual(-c1[0] + 2 * origin.x, c2[0], 5) + self.assertAlmostEqual(-c1[1] + 2 * origin.y, c2[1], 5) + + @skipUnlessDBFeature("has_Rotate_function") + def test_rotate_invalid_params(self): + angle = math.pi + bad_params_tests = [ + {"angle": angle, "origin": 0}, + {"angle": angle, "origin": [0, 0]}, + ] + msg = "origin argument must be a Point" + for params in bad_params_tests: + with self.subTest(params=params), self.assertRaisesMessage(TypeError, msg): + functions.Rotate("mpoly", **params) + @skipUnlessDBFeature("has_Scale_function") def test_scale(self): xfac, yfac = 2, 3