diff --git a/django/contrib/gis/geos/coordseq.py b/django/contrib/gis/geos/coordseq.py index dec3495d25c9..febeeebfa3bc 100644 --- a/django/contrib/gis/geos/coordseq.py +++ b/django/contrib/gis/geos/coordseq.py @@ -9,7 +9,7 @@ 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 @@ -20,6 +20,8 @@ class GEOSCoordSeq(GEOSBase): def __init__(self, ptr, z=False): "Initialize from a GEOS pointer." + # TODO when dropping support for GEOS 3.13 the z argument can be + # deprecated in favor of using the GEOS function GEOSCoordSeq_hasZ. if not isinstance(ptr, CS_PTR): raise TypeError("Coordinate sequence should initialize with a CS_PTR.") self._ptr = ptr @@ -58,6 +60,12 @@ def __setitem__(self, index, value): if self.dims == 3 and self._z: n_args = 3 point_setter = self._set_point_3d + elif self.dims == 3 and self.hasm: + n_args = 3 + point_setter = self._set_point_3d_m + elif self.dims == 4 and self._z and self.hasm: + n_args = 4 + point_setter = self._set_point_4d else: n_args = 2 point_setter = self._set_point_2d @@ -74,7 +82,7 @@ def _checkindex(self, index): def _checkdim(self, dim): "Check the given dimension." - if dim < 0 or dim > 2: + if dim < 0 or dim > 3: raise GEOSException(f'Invalid ordinate dimension: "{dim:d}"') def _get_x(self, index): @@ -86,6 +94,9 @@ def _get_y(self, index): def _get_z(self, index): return capi.cs_getz(self.ptr, index, byref(c_double())) + def _get_m(self, index): + return capi.cs_getm(self.ptr, index, byref(c_double())) + def _set_x(self, index, value): capi.cs_setx(self.ptr, index, value) @@ -95,9 +106,18 @@ def _set_y(self, index, value): def _set_z(self, index, value): capi.cs_setz(self.ptr, index, value) + def _set_m(self, index, value): + capi.cs_setm(self.ptr, index, value) + @property def _point_getter(self): - return self._get_point_3d if self.dims == 3 and self._z else self._get_point_2d + if self.dims == 3 and self._z: + return self._get_point_3d + elif self.dims == 3 and self.hasm: + return self._get_point_3d_m + elif self.dims == 4 and self._z and self.hasm: + return self._get_point_4d + return self._get_point_2d def _get_point_2d(self, index): return (self._get_x(index), self._get_y(index)) @@ -105,6 +125,17 @@ def _get_point_2d(self, index): def _get_point_3d(self, index): return (self._get_x(index), self._get_y(index), self._get_z(index)) + def _get_point_3d_m(self, index): + return (self._get_x(index), self._get_y(index), self._get_m(index)) + + def _get_point_4d(self, index): + return ( + self._get_x(index), + self._get_y(index), + self._get_z(index), + self._get_m(index), + ) + def _set_point_2d(self, index, value): x, y = value self._set_x(index, x) @@ -116,6 +147,19 @@ def _set_point_3d(self, index, value): self._set_y(index, y) self._set_z(index, z) + def _set_point_3d_m(self, index, value): + x, y, m = value + self._set_x(index, x) + self._set_y(index, y) + self._set_m(index, m) + + def _set_point_4d(self, index, value): + x, y, z, m = value + self._set_x(index, x) + self._set_y(index, y) + self._set_z(index, z) + self._set_m(index, m) + # #### Ordinate getting and setting routines #### def getOrdinate(self, dimension, index): "Return the value for the given dimension and index." @@ -153,6 +197,14 @@ def setZ(self, index, value): "Set Z with the value at the given index." self.setOrdinate(2, index, value) + def getM(self, index): + "Get M with the value at the given index." + return self.getOrdinate(3, index) + + def setM(self, index, value): + "Set M with the value at the given index." + self.setOrdinate(3, index, value) + # ### Dimensions ### @property def size(self): @@ -172,6 +224,18 @@ def hasz(self): """ return self._z + @property + def hasm(self): + """ + Return whether this coordinate sequence has M dimension. + """ + if geos_version_tuple() >= (3, 14): + return capi.cs_hasm(self._ptr) + else: + raise NotImplementedError( + "GEOSCoordSeq with an M dimension requires GEOS 3.14+." + ) + # ### Other Methods ### def clone(self): "Clone this coordinate sequence." @@ -180,16 +244,13 @@ def clone(self): @property def kml(self): "Return the KML representation for the coordinates." - # Getting the substitution string depending on whether the coordinates - # have a Z dimension. if self.hasz: - substr = "%s,%s,%s " + coords = [f"{coord[0]},{coord[1]},{coord[2]}" for coord in self] else: - substr = "%s,%s,0 " - return ( - "%s" - % "".join(substr % self[i] for i in range(len(self))).strip() - ) + coords = [f"{coord[0]},{coord[1]},0" for coord in self] + + coordinate_string = " ".join(coords) + return f"{coordinate_string}" @property def tuple(self): diff --git a/django/contrib/gis/geos/prototypes/__init__.py b/django/contrib/gis/geos/prototypes/__init__.py index 6b0da37ee676..cac0b3fdf46b 100644 --- a/django/contrib/gis/geos/prototypes/__init__.py +++ b/django/contrib/gis/geos/prototypes/__init__.py @@ -8,12 +8,15 @@ create_cs, cs_clone, cs_getdims, + cs_getm, cs_getordinate, cs_getsize, cs_getx, cs_gety, cs_getz, + cs_hasm, cs_is_ccw, + cs_setm, cs_setordinate, cs_setx, cs_sety, diff --git a/django/contrib/gis/geos/prototypes/coordseq.py b/django/contrib/gis/geos/prototypes/coordseq.py index cfc242c00dd1..eadaf8dfcf1e 100644 --- a/django/contrib/gis/geos/prototypes/coordseq.py +++ b/django/contrib/gis/geos/prototypes/coordseq.py @@ -1,7 +1,15 @@ 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 GEOSException, last_arg_byref +from django.contrib.gis.geos.libgeos import ( + CS_PTR, + GEOM_PTR, + GEOSFuncFactory, +) +from django.contrib.gis.geos.prototypes.errcheck import ( + GEOSException, + check_predicate, + last_arg_byref, +) # ## Error-checking routines specific to coordinate sequences. ## @@ -67,6 +75,12 @@ def errcheck(result, func, cargs): return result +class CsUnaryPredicate(GEOSFuncFactory): + argtypes = [CS_PTR] + restype = c_byte + errcheck = staticmethod(check_predicate) + + # ## Coordinate Sequence ctypes prototypes ## # Coordinate Sequence constructors & cloning. @@ -78,20 +92,25 @@ def errcheck(result, func, cargs): cs_getordinate = CsOperation("GEOSCoordSeq_getOrdinate", ordinate=True, get=True) cs_setordinate = CsOperation("GEOSCoordSeq_setOrdinate", ordinate=True) -# For getting, x, y, z +# For getting, x, y, z, m cs_getx = CsOperation("GEOSCoordSeq_getX", get=True) cs_gety = CsOperation("GEOSCoordSeq_getY", get=True) cs_getz = CsOperation("GEOSCoordSeq_getZ", get=True) +cs_getm = CsOperation("GEOSCoordSeq_getM", get=True) -# For setting, x, y, z +# For setting, x, y, z, m cs_setx = CsOperation("GEOSCoordSeq_setX") cs_sety = CsOperation("GEOSCoordSeq_setY") cs_setz = CsOperation("GEOSCoordSeq_setZ") +cs_setm = CsOperation("GEOSCoordSeq_setM") # These routines return size & dimensions. cs_getsize = CsInt("GEOSCoordSeq_getSize") cs_getdims = CsInt("GEOSCoordSeq_getDimensions") +# Unary Predicates +cs_hasm = CsUnaryPredicate("GEOSCoordSeq_hasM") + cs_is_ccw = GEOSFuncFactory( "GEOSCoordSeq_isCCW", restype=c_int, argtypes=[CS_PTR, POINTER(c_byte)] ) diff --git a/docs/topics/email.txt b/docs/topics/email.txt index bad4a79d6164..6a32a0728e04 100644 --- a/docs/topics/email.txt +++ b/docs/topics/email.txt @@ -25,7 +25,6 @@ plain text message:: "Here is the message.", "from@example.com", ["to@example.com"], - fail_silently=False, ) When additional email sending functionality is needed, use diff --git a/tests/gis_tests/geos_tests/test_coordseq.py b/tests/gis_tests/geos_tests/test_coordseq.py index b6f5136dd19c..37c421f5c3b8 100644 --- a/tests/gis_tests/geos_tests/test_coordseq.py +++ b/tests/gis_tests/geos_tests/test_coordseq.py @@ -1,4 +1,11 @@ -from django.contrib.gis.geos import LineString +import math +from unittest import skipIf +from unittest.mock import patch + +from django.contrib.gis.geos import GEOSGeometry, LineString +from django.contrib.gis.geos import prototypes as capi +from django.contrib.gis.geos.coordseq import GEOSCoordSeq +from django.contrib.gis.geos.libgeos import geos_version_tuple from django.test import SimpleTestCase @@ -13,3 +20,130 @@ def test_getitem(self): with self.subTest(i): with self.assertRaisesMessage(IndexError, msg): coord_seq[i] + + @skipIf(geos_version_tuple() < (3, 14), "GEOS M support requires 3.14+") + def test_has_m(self): + geom = GEOSGeometry("POINT ZM (1 2 3 4)") + coord_seq = GEOSCoordSeq(capi.get_cs(geom.ptr), z=True) + self.assertIs(coord_seq.hasm, True) + + geom = GEOSGeometry("POINT Z (1 2 3)") + coord_seq = GEOSCoordSeq(capi.get_cs(geom.ptr), z=True) + self.assertIs(coord_seq.hasm, False) + + geom = GEOSGeometry("POINT M (1 2 3)") + coord_seq = GEOSCoordSeq(capi.get_cs(geom.ptr), z=False) + self.assertIs(coord_seq.hasm, True) + + @skipIf(geos_version_tuple() < (3, 14), "GEOS M support requires 3.14+") + def test_get_set_m(self): + geom = GEOSGeometry("POINT ZM (1 2 3 4)") + coord_seq = GEOSCoordSeq(capi.get_cs(geom.ptr), z=True) + self.assertEqual(coord_seq.tuple, (1, 2, 3, 4)) + self.assertEqual(coord_seq.getM(0), 4) + coord_seq.setM(0, 10) + self.assertEqual(coord_seq.tuple, (1, 2, 3, 10)) + self.assertEqual(coord_seq.getM(0), 10) + + geom = GEOSGeometry("POINT M (1 2 4)") + coord_seq = GEOSCoordSeq(capi.get_cs(geom.ptr), z=False) + self.assertEqual(coord_seq.tuple, (1, 2, 4)) + self.assertEqual(coord_seq.getM(0), 4) + coord_seq.setM(0, 10) + self.assertEqual(coord_seq.tuple, (1, 2, 10)) + self.assertEqual(coord_seq.getM(0), 10) + self.assertIs(math.isnan(coord_seq.getZ(0)), True) + + @skipIf(geos_version_tuple() < (3, 14), "GEOS M support requires 3.14+") + def test_setitem(self): + geom = GEOSGeometry("POINT ZM (1 2 3 4)") + coord_seq = GEOSCoordSeq(capi.get_cs(geom.ptr), z=True) + coord_seq[0] = (10, 20, 30, 40) + self.assertEqual(coord_seq.tuple, (10, 20, 30, 40)) + + geom = GEOSGeometry("POINT M (1 2 4)") + coord_seq = GEOSCoordSeq(capi.get_cs(geom.ptr), z=False) + coord_seq[0] = (10, 20, 40) + self.assertEqual(coord_seq.tuple, (10, 20, 40)) + self.assertEqual(coord_seq.getM(0), 40) + self.assertIs(math.isnan(coord_seq.getZ(0)), True) + + @skipIf(geos_version_tuple() < (3, 14), "GEOS M support requires 3.14+") + def test_kml_m_dimension(self): + geom = GEOSGeometry("POINT ZM (1 2 3 4)") + coord_seq = GEOSCoordSeq(capi.get_cs(geom.ptr), z=True) + self.assertEqual(coord_seq.kml, "1.0,2.0,3.0") + geom = GEOSGeometry("POINT M (1 2 4)") + coord_seq = GEOSCoordSeq(capi.get_cs(geom.ptr), z=False) + self.assertEqual(coord_seq.kml, "1.0,2.0,0") + + @skipIf(geos_version_tuple() < (3, 14), "GEOS M support requires 3.14+") + def test_clone_m_dimension(self): + geom = GEOSGeometry("POINT ZM (1 2 3 4)") + coord_seq = GEOSCoordSeq(capi.get_cs(geom.ptr), z=True) + clone = coord_seq.clone() + self.assertEqual(clone.tuple, (1, 2, 3, 4)) + self.assertIs(clone.hasz, True) + self.assertIs(clone.hasm, True) + + geom = GEOSGeometry("POINT M (1 2 4)") + coord_seq = GEOSCoordSeq(capi.get_cs(geom.ptr), z=False) + clone = coord_seq.clone() + self.assertEqual(clone.tuple, (1, 2, 4)) + self.assertIs(clone.hasz, False) + self.assertIs(clone.hasm, True) + + @skipIf(geos_version_tuple() < (3, 14), "GEOS M support requires 3.14+") + def test_dims(self): + geom = GEOSGeometry("POINT ZM (1 2 3 4)") + coord_seq = GEOSCoordSeq(capi.get_cs(geom.ptr), z=True) + self.assertEqual(coord_seq.dims, 4) + + geom = GEOSGeometry("POINT M (1 2 4)") + coord_seq = GEOSCoordSeq(capi.get_cs(geom.ptr), z=False) + self.assertEqual(coord_seq.dims, 3) + + geom = GEOSGeometry("POINT Z (1 2 3)") + coord_seq = GEOSCoordSeq(capi.get_cs(geom.ptr), z=True) + self.assertEqual(coord_seq.dims, 3) + + geom = GEOSGeometry("POINT (1 2)") + coord_seq = GEOSCoordSeq(capi.get_cs(geom.ptr), z=False) + self.assertEqual(coord_seq.dims, 2) + + def test_size(self): + geom = GEOSGeometry("POINT (1 2)") + coord_seq = GEOSCoordSeq(capi.get_cs(geom.ptr), z=False) + self.assertEqual(coord_seq.size, 1) + + geom = GEOSGeometry("POINT M (1 2 4)") + coord_seq = GEOSCoordSeq(capi.get_cs(geom.ptr), z=False) + self.assertEqual(coord_seq.size, 1) + + @skipIf(geos_version_tuple() < (3, 14), "GEOS M support requires 3.14+") + def test_iscounterclockwise(self): + geom = GEOSGeometry("LINEARRING ZM (0 0 3 0, 1 0 0 2, 0 1 1 3, 0 0 3 4)") + coord_seq = GEOSCoordSeq(capi.get_cs(geom.ptr), z=True) + self.assertEqual( + coord_seq.tuple, + ( + (0.0, 0.0, 3.0, 0.0), + (1.0, 0.0, 0.0, 2.0), + (0.0, 1.0, 1.0, 3.0), + (0.0, 0.0, 3.0, 4.0), + ), + ) + self.assertIs(coord_seq.is_counterclockwise, True) + + def test_m_support_error(self): + geom = GEOSGeometry("POINT M (1 2 4)") + coord_seq = GEOSCoordSeq(capi.get_cs(geom.ptr), z=True) + msg = "GEOSCoordSeq with an M dimension requires GEOS 3.14+." + + # mock geos_version_tuple to be 3.13.13 + with patch( + "django.contrib.gis.geos.coordseq.geos_version_tuple", + return_value=(3, 13, 13), + ): + with self.assertRaisesMessage(NotImplementedError, msg): + coord_seq.hasm