Browse Source

Fixed #35705 -- Added Rotate GIS database function to rotate geometries.

enprava 6 months ago
parent
commit
51cab4ad51

+ 1 - 0
django/contrib/gis/db/backends/base/operations.py

@@ -62,6 +62,7 @@ class BaseSpatialOperations:
         "Perimeter",
         "Perimeter",
         "PointOnSurface",
         "PointOnSurface",
         "Reverse",
         "Reverse",
+        "Rotate",
         "Scale",
         "Scale",
         "SnapToGrid",
         "SnapToGrid",
         "SymDifference",
         "SymDifference",

+ 1 - 0
django/contrib/gis/db/backends/mysql/operations.py

@@ -98,6 +98,7 @@ class MySQLOperations(BaseSpatialOperations, DatabaseOperations):
             "Perimeter",
             "Perimeter",
             "PointOnSurface",
             "PointOnSurface",
             "Reverse",
             "Reverse",
+            "Rotate",
             "Scale",
             "Scale",
             "SnapToGrid",
             "SnapToGrid",
             "Transform",
             "Transform",

+ 1 - 0
django/contrib/gis/db/backends/oracle/operations.py

@@ -129,6 +129,7 @@ class OracleOperations(BaseSpatialOperations, DatabaseOperations):
         "LineLocatePoint",
         "LineLocatePoint",
         "MakeValid",
         "MakeValid",
         "MemSize",
         "MemSize",
+        "Rotate",
         "Scale",
         "Scale",
         "SnapToGrid",
         "SnapToGrid",
         "Translate",
         "Translate",

+ 1 - 1
django/contrib/gis/db/backends/spatialite/operations.py

@@ -82,7 +82,7 @@ class SpatiaLiteOperations(BaseSpatialOperations, DatabaseOperations):
 
 
     @cached_property
     @cached_property
     def unsupported_functions(self):
     def unsupported_functions(self):
-        unsupported = {"GeometryDistance", "IsEmpty", "MemSize"}
+        unsupported = {"GeometryDistance", "IsEmpty", "MemSize", "Rotate"}
         if not self.geom_lib_version():
         if not self.geom_lib_version():
             unsupported |= {"Azimuth", "GeoHash", "MakeValid"}
             unsupported |= {"Azimuth", "GeoHash", "MakeValid"}
         if self.spatial_version < (5, 1):
         if self.spatial_version < (5, 1):

+ 14 - 0
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.fields import BaseSpatialField, GeometryField
 from django.contrib.gis.db.models.sql import AreaField, DistanceField
 from django.contrib.gis.db.models.sql import AreaField, DistanceField
 from django.contrib.gis.geos import GEOSGeometry
 from django.contrib.gis.geos import GEOSGeometry
+from django.contrib.gis.geos.point import Point
 from django.core.exceptions import FieldError
 from django.core.exceptions import FieldError
 from django.db import NotSupportedError
 from django.db import NotSupportedError
 from django.db.models import (
 from django.db.models import (
@@ -529,6 +530,19 @@ class Reverse(GeoFunc):
     arity = 1
     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):
 class Scale(SQLiteDecimalToFloatMixin, GeomOutputGeoFunc):
     def __init__(self, expression, x, y, z=0.0, **extra):
     def __init__(self, expression, x, y, z=0.0, **extra):
         expressions = [
         expressions = [

+ 1 - 0
docs/ref/contrib/gis/db-api.txt

@@ -420,6 +420,7 @@ Function                              PostGIS  Oracle         MariaDB      MySQL
 :class:`Perimeter`                    X        X                                       X
 :class:`Perimeter`                    X        X                                       X
 :class:`PointOnSurface`               X        X              X                        X
 :class:`PointOnSurface`               X        X              X                        X
 :class:`Reverse`                      X        X                                       X
 :class:`Reverse`                      X        X                                       X
+:class:`Rotate`                       X
 :class:`Scale`                        X                                                X
 :class:`Scale`                        X                                                X
 :class:`SnapToGrid`                   X                                                X
 :class:`SnapToGrid`                   X                                                X
 :class:`SymDifference`                X        X              X            X           X
 :class:`SymDifference`                X        X              X            X           X

+ 18 - 4
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:`Area`              :class:`Azimuth`          :class:`Difference`     :class:`ForcePolygonCW`                      :class:`AsGeoJSON`  :class:`IsEmpty`
 :class:`Distance`          :class:`BoundingCircle`   :class:`Intersection`   :class:`MakeValid`                           :class:`AsGML`      :class:`IsValid`
 :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:`GeometryDistance`  :class:`Centroid`         :class:`SymDifference`  :class:`Reverse`                             :class:`AsKML`      :class:`MemSize`
-:class:`Length`            :class:`ClosestPoint`     :class:`Union`          :class:`Scale`                               :class:`AsSVG`      :class:`NumGeometries`
+:class:`Length`            :class:`ClosestPoint`     :class:`Union`          :class:`Rotate`                              :class:`AsSVG`      :class:`NumGeometries`
-:class:`Perimeter`         :class:`Envelope`                                 :class:`SnapToGrid`      :class:`FromWKB`    :class:`AsWKB`      :class:`NumPoints`
+:class:`Perimeter`         :class:`Envelope`                                 :class:`Scale`           :class:`FromWKB`    :class:`AsWKB`      :class:`NumPoints`
-                           :class:`LineLocatePoint`                          :class:`Transform`       :class:`FromWKT`    :class:`AsWKT`
+                           :class:`LineLocatePoint`                          :class:`SnapToGrid`      :class:`FromWKT`    :class:`AsWKT`
-                           :class:`PointOnSurface`                           :class:`Translate`                           :class:`GeoHash`
+                           :class:`PointOnSurface`                           :class:`Transform`                           :class:`GeoHash`
+                                                                             :class:`Translate`
 =========================  ========================  ======================  =======================  ==================  ==================  ======================
 =========================  ========================  ======================  =======================  ==================  ==================  ======================
 
 
 ``Area``
 ``Area``
@@ -556,6 +557,19 @@ SpatiaLite
 Accepts a single geographic field or expression and returns a geometry with
 Accepts a single geographic field or expression and returns a geometry with
 reversed coordinates.
 reversed coordinates.
 
 
+``Rotate``
+==========
+
+.. versionadded:: 6.0
+
+.. class:: Rotate(expression, angle, origin=None, **extra)
+
+*Availability*: `PostGIS <https://postgis.net/docs/ST_Rotate.html>`__
+
+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``
 ``Scale``
 =========
 =========
 
 

+ 4 - 0
docs/releases/6.0.txt

@@ -68,6 +68,10 @@ Minor features
 * The new :attr:`.GEOSGeometry.hasm` property checks whether the geometry has
 * The new :attr:`.GEOSGeometry.hasm` property checks whether the geometry has
   the M dimension.
   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`
 :mod:`django.contrib.messages`
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
 

+ 35 - 0
tests/gis_tests/geoapp/test_functions.py

@@ -612,6 +612,41 @@ class GISFunctionsTests(FuncTestMixin, TestCase):
         coords.reverse()
         coords.reverse()
         self.assertEqual(tuple(coords), track.reverse_geom.coords)
         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")
     @skipUnlessDBFeature("has_Scale_function")
     def test_scale(self):
     def test_scale(self):
         xfac, yfac = 2, 3
         xfac, yfac = 2, 3