Browse Source

Fixed #29770 -- Added LinearRing.is_counterclockwise property.

Sergey Fedoseev 5 years ago
parent
commit
6bbf9a20e2

+ 4 - 15
django/contrib/gis/db/backends/oracle/adapter.py

@@ -25,11 +25,13 @@ class OracleSpatialAdapter(WKTAdapter):
 
     def _fix_polygon(self, poly):
         """Fix single polygon orientation as described in __init__()."""
-        if self._isClockwise(poly.exterior_ring):
+        if poly.empty:
+            return poly
+        if not poly.exterior_ring.is_counterclockwise:
             poly.exterior_ring = list(reversed(poly.exterior_ring))
 
         for i in range(1, len(poly)):
-            if not self._isClockwise(poly[i]):
+            if poly[i].is_counterclockwise:
                 poly[i] = list(reversed(poly[i]))
 
         return poly
@@ -42,16 +44,3 @@ class OracleSpatialAdapter(WKTAdapter):
         for i, geom in enumerate(coll):
             if isinstance(geom, Polygon):
                 coll[i] = self._fix_polygon(geom)
-
-    def _isClockwise(self, coords):
-        """
-        A modified shoelace algorithm to determine polygon orientation.
-        See https://en.wikipedia.org/wiki/Shoelace_formula.
-        """
-        n = len(coords)
-        area = 0.0
-        for i in range(n):
-            j = (i + 1) % n
-            area += coords[i][0] * coords[j][1]
-            area -= coords[j][0] * coords[i][1]
-        return area < 0.0

+ 22 - 2
django/contrib/gis/geos/coordseq.py

@@ -3,12 +3,12 @@
  by GEOSGeometry to house the actual coordinates of the Point,
  LineString, and LinearRing geometries.
 """
-from ctypes import byref, c_double, c_uint
+from ctypes import byref, c_byte, c_double, c_uint
 
 from django.contrib.gis.geos import prototypes as capi
 from django.contrib.gis.geos.base import GEOSBase
 from django.contrib.gis.geos.error import GEOSException
-from django.contrib.gis.geos.libgeos import CS_PTR
+from django.contrib.gis.geos.libgeos import CS_PTR, geos_version_tuple
 from django.contrib.gis.shortcuts import numpy
 
 
@@ -194,3 +194,23 @@ class GEOSCoordSeq(GEOSBase):
         if n == 1:
             return get_point(0)
         return tuple(get_point(i) for i in range(n))
+
+    @property
+    def is_counterclockwise(self):
+        """Return whether this coordinate sequence is counterclockwise."""
+        if geos_version_tuple() < (3, 7):
+            # A modified shoelace algorithm to determine polygon orientation.
+            # See https://en.wikipedia.org/wiki/Shoelace_formula.
+            area = 0.0
+            n = len(self)
+            for i in range(n):
+                j = (i + 1) % n
+                area += self[i][0] * self[j][1]
+                area -= self[j][0] * self[i][1]
+            return area > 0.0
+        ret = c_byte()
+        if not capi.cs_is_ccw(self.ptr, byref(ret)):
+            raise GEOSException(
+                'Error encountered in GEOS C function "%s".' % capi.cs_is_ccw.func_name
+            )
+        return ret.value == 1

+ 8 - 0
django/contrib/gis/geos/linestring.py

@@ -176,3 +176,11 @@ class LineString(LinearGeometryMixin, GEOSGeometry):
 class LinearRing(LineString):
     _minlength = 4
     _init_func = capi.create_linearring
+
+    @property
+    def is_counterclockwise(self):
+        if self.empty:
+            raise ValueError(
+                'Orientation of an empty LinearRing cannot be determined.'
+            )
+        return self._cs.is_counterclockwise

+ 2 - 1
django/contrib/gis/geos/prototypes/__init__.py

@@ -6,7 +6,8 @@
 
 from django.contrib.gis.geos.prototypes.coordseq import (  # NOQA
     create_cs, cs_clone, cs_getdims, cs_getordinate, cs_getsize, cs_getx,
-    cs_gety, cs_getz, cs_setordinate, cs_setx, cs_sety, cs_setz, get_cs,
+    cs_gety, cs_getz, cs_is_ccw, cs_setordinate, cs_setx, cs_sety, cs_setz,
+    get_cs,
 )
 from django.contrib.gis.geos.prototypes.geom import (  # NOQA
     create_collection, create_empty_polygon, create_linearring,

+ 3 - 1
django/contrib/gis/geos/prototypes/coordseq.py

@@ -1,4 +1,4 @@
-from ctypes import POINTER, c_double, c_int, c_uint
+from ctypes import POINTER, c_byte, c_double, c_int, c_uint
 
 from django.contrib.gis.geos.libgeos import CS_PTR, GEOM_PTR, GEOSFuncFactory
 from django.contrib.gis.geos.prototypes.errcheck import (
@@ -89,3 +89,5 @@ cs_setz = CsOperation('GEOSCoordSeq_setZ')
 # These routines return size & dimensions.
 cs_getsize = CsInt('GEOSCoordSeq_getSize')
 cs_getdims = CsInt('GEOSCoordSeq_getDimensions')
+
+cs_is_ccw = GEOSFuncFactory('GEOSCoordSeq_isCCW', restype=c_int, argtypes=[CS_PTR, POINTER(c_byte)])

+ 6 - 0
docs/ref/contrib/gis/geos.txt

@@ -730,6 +730,12 @@ Other Properties & Methods
     Notice that ``(0, 0)`` is the first and last coordinate -- if they were not
     equal, an error would be raised.
 
+    .. attribute:: is_counterclockwise
+
+        .. versionadded:: 3.1
+
+        Returns whether this ``LinearRing`` is counterclockwise.
+
 ``Polygon``
 -----------
 

+ 2 - 0
docs/releases/3.1.txt

@@ -61,6 +61,8 @@ Minor features
 
 * :lookup:`relate` lookup is now supported on MariaDB.
 
+* Added the :attr:`.LinearRing.is_counterclockwise` property.
+
 :mod:`django.contrib.messages`
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 

+ 27 - 1
tests/gis_tests/geos_tests/test_geos.py

@@ -5,7 +5,7 @@ import pickle
 import random
 from binascii import a2b_hex
 from io import BytesIO
-from unittest import mock
+from unittest import mock, skipIf
 
 from django.contrib.gis import gdal
 from django.contrib.gis.geos import (
@@ -360,6 +360,32 @@ class GEOSTest(SimpleTestCase, TestDataMixin):
         line.reverse()
         self.assertEqual(line.ewkt, 'SRID=4326;LINESTRING (151.2607 -33.887, 144.963 -37.8143)')
 
+    def _test_is_counterclockwise(self):
+        lr = LinearRing((0, 0), (1, 0), (0, 1), (0, 0))
+        self.assertIs(lr.is_counterclockwise, True)
+        lr.reverse()
+        self.assertIs(lr.is_counterclockwise, False)
+        msg = 'Orientation of an empty LinearRing cannot be determined.'
+        with self.assertRaisesMessage(ValueError, msg):
+            LinearRing().is_counterclockwise
+
+    @skipIf(geos_version_tuple() < (3, 7), 'GEOS >= 3.7.0 is required')
+    def test_is_counterclockwise(self):
+        self._test_is_counterclockwise()
+
+    @skipIf(geos_version_tuple() < (3, 7), 'GEOS >= 3.7.0 is required')
+    def test_is_counterclockwise_geos_error(self):
+        with mock.patch('django.contrib.gis.geos.prototypes.cs_is_ccw') as mocked:
+            mocked.return_value = 0
+            mocked.func_name = 'GEOSCoordSeq_isCCW'
+            msg = 'Error encountered in GEOS C function "GEOSCoordSeq_isCCW".'
+            with self.assertRaisesMessage(GEOSException, msg):
+                LinearRing((0, 0), (1, 0), (0, 1), (0, 0)).is_counterclockwise
+
+    @mock.patch('django.contrib.gis.geos.libgeos.geos_version', lambda: b'3.6.9')
+    def test_is_counterclockwise_fallback(self):
+        self._test_is_counterclockwise()
+
     def test_multilinestring(self):
         "Testing MultiLineString objects."
         prev = fromstr('POINT(0 0)')