浏览代码

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

enprava 6 月之前
父节点
当前提交
51cab4ad51

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

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

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

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

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

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

+ 1 - 1
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):

+ 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.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 = [

+ 1 - 0
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

+ 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:`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 <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``
 =========
 

+ 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 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`
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 

+ 35 - 0
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