diff --git a/django/contrib/gis/db/backend/postgis/__init__.py b/django/contrib/gis/db/backend/postgis/__init__.py index 7833376d1e1..323fef3f953 100644 --- a/django/contrib/gis/db/backend/postgis/__init__.py +++ b/django/contrib/gis/db/backend/postgis/__init__.py @@ -18,18 +18,21 @@ SpatialBackend = BaseSpatialBackend(name='postgis', postgis=True, distance_spheroid=DISTANCE_SPHEROID, envelope=ENVELOPE, extent=EXTENT, + extent3d=EXTENT3D, gis_terms=POSTGIS_TERMS, geojson=ASGEOJSON, gml=ASGML, intersection=INTERSECTION, kml=ASKML, length=LENGTH, + length3d=LENGTH3D, length_spheroid=LENGTH_SPHEROID, make_line=MAKE_LINE, mem_size=MEM_SIZE, num_geom=NUM_GEOM, num_points=NUM_POINTS, perimeter=PERIMETER, + perimeter3d=PERIMETER3D, point_on_surface=POINT_ON_SURFACE, scale=SCALE, select=GEOM_SELECT, diff --git a/django/contrib/gis/db/backend/postgis/adaptor.py b/django/contrib/gis/db/backend/postgis/adaptor.py index 467e52e4750..d8d4dfd4eaa 100644 --- a/django/contrib/gis/db/backend/postgis/adaptor.py +++ b/django/contrib/gis/db/backend/postgis/adaptor.py @@ -2,7 +2,7 @@ This object provides quoting for GEOS geometries into PostgreSQL/PostGIS. """ -from django.contrib.gis.db.backend.postgis.query import GEOM_FROM_WKB +from django.contrib.gis.db.backend.postgis.query import GEOM_FROM_EWKB from psycopg2 import Binary from psycopg2.extensions import ISQLQuote @@ -11,7 +11,7 @@ class PostGISAdaptor(object): "Initializes on the geometry." # Getting the WKB (in string form, to allow easy pickling of # the adaptor) and the SRID from the geometry. - self.wkb = str(geom.wkb) + self.ewkb = str(geom.ewkb) self.srid = geom.srid def __conform__(self, proto): @@ -30,7 +30,7 @@ class PostGISAdaptor(object): def getquoted(self): "Returns a properly quoted string for use in PostgreSQL/PostGIS." # Want to use WKB, so wrap with psycopg2 Binary() to quote properly. - return "%s(E%s, %s)" % (GEOM_FROM_WKB, Binary(self.wkb), self.srid or -1) + return "%s(E%s)" % (GEOM_FROM_EWKB, Binary(self.ewkb)) def prepare_database_save(self, unused): return self diff --git a/django/contrib/gis/db/backend/postgis/query.py b/django/contrib/gis/db/backend/postgis/query.py index 74916760577..3279f24b185 100644 --- a/django/contrib/gis/db/backend/postgis/query.py +++ b/django/contrib/gis/db/backend/postgis/query.py @@ -63,17 +63,21 @@ if MAJOR_VERSION >= 1: DISTANCE_SPHERE = get_func('distance_sphere') DISTANCE_SPHEROID = get_func('distance_spheroid') ENVELOPE = get_func('Envelope') - EXTENT = get_func('extent') + EXTENT = get_func('Extent') + EXTENT3D = get_func('Extent3D') GEOM_FROM_TEXT = get_func('GeomFromText') + GEOM_FROM_EWKB = get_func('GeomFromEWKB') GEOM_FROM_WKB = get_func('GeomFromWKB') INTERSECTION = get_func('Intersection') LENGTH = get_func('Length') + LENGTH3D = get_func('Length3D') LENGTH_SPHEROID = get_func('length_spheroid') MAKE_LINE = get_func('MakeLine') MEM_SIZE = get_func('mem_size') NUM_GEOM = get_func('NumGeometries') NUM_POINTS = get_func('npoints') PERIMETER = get_func('Perimeter') + PERIMETER3D = get_func('Perimeter3D') POINT_ON_SURFACE = get_func('PointOnSurface') SCALE = get_func('Scale') SNAP_TO_GRID = get_func('SnapToGrid') diff --git a/django/contrib/gis/db/models/aggregates.py b/django/contrib/gis/db/models/aggregates.py index 7c8ab694c4f..fc359393b3a 100644 --- a/django/contrib/gis/db/models/aggregates.py +++ b/django/contrib/gis/db/models/aggregates.py @@ -24,6 +24,9 @@ class Collect(GeoAggregate): class Extent(GeoAggregate): name = 'Extent' +class Extent3D(GeoAggregate): + name = 'Extent3D' + class MakeLine(GeoAggregate): name = 'MakeLine' diff --git a/django/contrib/gis/db/models/manager.py b/django/contrib/gis/db/models/manager.py index eac66f4a832..d3d7f6be97d 100644 --- a/django/contrib/gis/db/models/manager.py +++ b/django/contrib/gis/db/models/manager.py @@ -34,6 +34,9 @@ class GeoManager(Manager): def extent(self, *args, **kwargs): return self.get_query_set().extent(*args, **kwargs) + def extent3d(self, *args, **kwargs): + return self.get_query_set().extent3d(*args, **kwargs) + def geojson(self, *args, **kwargs): return self.get_query_set().geojson(*args, **kwargs) diff --git a/django/contrib/gis/db/models/query.py b/django/contrib/gis/db/models/query.py index ad2cd8ceda5..d4bc206d9b7 100644 --- a/django/contrib/gis/db/models/query.py +++ b/django/contrib/gis/db/models/query.py @@ -110,6 +110,14 @@ class GeoQuerySet(QuerySet): """ return self._spatial_aggregate(aggregates.Extent, **kwargs) + def extent3d(self, **kwargs): + """ + Returns the aggregate extent, in 3D, of the features in the + GeoQuerySet. It is returned as a 6-tuple, comprising: + (xmin, ymin, zmin, xmax, ymax, zmax). + """ + return self._spatial_aggregate(aggregates.Extent3D, **kwargs) + def geojson(self, precision=8, crs=False, bbox=False, **kwargs): """ Returns a GeoJSON representation of the geomtry field in a `geojson` @@ -524,12 +532,14 @@ class GeoQuerySet(QuerySet): else: dist_att = Distance.unit_attname(geo_field.units_name) - # Shortcut booleans for what distance function we're using. + # Shortcut booleans for what distance function we're using and + # whether the geometry field is 3D. distance = func == 'distance' length = func == 'length' perimeter = func == 'perimeter' if not (distance or length or perimeter): raise ValueError('Unknown distance function: %s' % func) + geom_3d = geo_field.dim == 3 # The field's get_db_prep_lookup() is used to get any # extra distance parameters. Here we set up the @@ -604,7 +614,7 @@ class GeoQuerySet(QuerySet): # some error checking is required. if not isinstance(geo_field, PointField): raise ValueError('Spherical distance calculation only supported on PointFields.') - if not str(SpatialBackend.Geometry(buffer(params[0].wkb)).geom_type) == 'Point': + if not str(SpatialBackend.Geometry(buffer(params[0].ewkb)).geom_type) == 'Point': raise ValueError('Spherical distance calculation only supported with Point Geometry parameters') # The `function` procedure argument needs to be set differently for # geodetic distance calculations. @@ -617,9 +627,16 @@ class GeoQuerySet(QuerySet): elif length or perimeter: procedure_fmt = '%(geo_col)s' if geodetic and length: - # There's no `length_sphere` + # There's no `length_sphere`, and `length_spheroid` also + # works on 3D geometries. procedure_fmt += ',%(spheroid)s' procedure_args.update({'function' : SpatialBackend.length_spheroid, 'spheroid' : where[1]}) + elif geom_3d and SpatialBackend.postgis: + # Use 3D variants of perimeter and length routines on PostGIS. + if perimeter: + procedure_args.update({'function' : SpatialBackend.perimeter3d}) + elif length: + procedure_args.update({'function' : SpatialBackend.length3d}) # Setting up the settings for `_spatial_attribute`. s = {'select_field' : DistanceField(dist_att), diff --git a/django/contrib/gis/db/models/sql/aggregates.py b/django/contrib/gis/db/models/sql/aggregates.py index b534288891a..7e91869ca39 100644 --- a/django/contrib/gis/db/models/sql/aggregates.py +++ b/django/contrib/gis/db/models/sql/aggregates.py @@ -11,6 +11,9 @@ geo_template = '%(function)s(%(field)s)' def convert_extent(box): raise NotImplementedError('Aggregate extent not implemented for this spatial backend.') +def convert_extent3d(box): + raise NotImplementedError('Aggregate 3D extent not implemented for this spatial backend.') + def convert_geom(wkt, geo_field): raise NotImplementedError('Aggregate method not implemented for this spatial backend.') @@ -23,6 +26,14 @@ if SpatialBackend.postgis: xmax, ymax = map(float, ur.split()) return (xmin, ymin, xmax, ymax) + def convert_extent3d(box3d): + # Box text will be something like "BOX3D(-90.0 30.0 1, -85.0 40.0 2)"; + # parsing out and returning as a 4-tuple. + ll, ur = box3d[6:-1].split(',') + xmin, ymin, zmin = map(float, ll.split()) + xmax, ymax, zmax = map(float, ur.split()) + return (xmin, ymin, zmin, xmax, ymax, zmax) + def convert_geom(hex, geo_field): if hex: return SpatialBackend.Geometry(hex) else: return None @@ -94,7 +105,7 @@ class Collect(GeoAggregate): sql_function = SpatialBackend.collect class Extent(GeoAggregate): - is_extent = True + is_extent = '2D' sql_function = SpatialBackend.extent if SpatialBackend.oracle: @@ -102,6 +113,10 @@ if SpatialBackend.oracle: Extent.conversion_class = GeomField Extent.sql_template = '%(function)s(%(field)s)' +class Extent3D(GeoAggregate): + is_extent = '3D' + sql_function = SpatialBackend.extent3d + class MakeLine(GeoAggregate): conversion_class = GeomField sql_function = SpatialBackend.make_line diff --git a/django/contrib/gis/db/models/sql/query.py b/django/contrib/gis/db/models/sql/query.py index 094fc5815f3..1691637c1e0 100644 --- a/django/contrib/gis/db/models/sql/query.py +++ b/django/contrib/gis/db/models/sql/query.py @@ -262,7 +262,10 @@ class GeoQuery(sql.Query): """ if isinstance(aggregate, self.aggregates_module.GeoAggregate): if aggregate.is_extent: - return self.aggregates_module.convert_extent(value) + if aggregate.is_extent == '3D': + return self.aggregates_module.convert_extent3d(value) + else: + return self.aggregates_module.convert_extent(value) else: return self.aggregates_module.convert_geom(value, aggregate.source) else: diff --git a/django/contrib/gis/geos/geometry.py b/django/contrib/gis/geos/geometry.py index 9f2a119c47e..68c116657c8 100644 --- a/django/contrib/gis/geos/geometry.py +++ b/django/contrib/gis/geos/geometry.py @@ -373,9 +373,10 @@ class GEOSGeometry(GEOSBase, ListMixin): @property def hex(self): """ - Returns the HEX of the Geometry -- please note that the SRID is not - included in this representation, because it is not a part of the - OGC specification (use the `hexewkb` property instead). + Returns the WKB of this Geometry in hexadecimal form. Please note + that the SRID and Z values are not included in this representation + because it is not a part of the OGC specification (use the `hexewkb` + property instead). """ # A possible faster, all-python, implementation: # str(self.wkb).encode('hex') @@ -384,9 +385,9 @@ class GEOSGeometry(GEOSBase, ListMixin): @property def hexewkb(self): """ - Returns the HEXEWKB of this Geometry. This is an extension of the WKB - specification that includes SRID and Z values taht are a part of this - geometry. + Returns the EWKB of this Geometry in hexadecimal form. This is an + extension of the WKB specification that includes SRID and Z values + that are a part of this geometry. """ if self.hasz: if not GEOS_PREPARE: diff --git a/django/contrib/gis/tests/__init__.py b/django/contrib/gis/tests/__init__.py index 75b8fc9d0d5..532dcffebab 100644 --- a/django/contrib/gis/tests/__init__.py +++ b/django/contrib/gis/tests/__init__.py @@ -9,9 +9,10 @@ def geo_suite(): some backends). """ from django.conf import settings + from django.contrib.gis.geos import GEOS_PREPARE from django.contrib.gis.gdal import HAS_GDAL from django.contrib.gis.utils import HAS_GEOIP - from django.contrib.gis.tests.utils import mysql + from django.contrib.gis.tests.utils import postgis, mysql # The test suite. s = unittest.TestSuite() @@ -32,6 +33,10 @@ def geo_suite(): if not mysql: test_apps.append('distapp') + # Only PostGIS using GEOS 3.1+ can support 3D so far. + if postgis and GEOS_PREPARE: + test_apps.append('geo3d') + if HAS_GDAL: # These tests require GDAL. test_suite_names.extend(['test_spatialrefsys', 'test_geoforms']) diff --git a/django/contrib/gis/tests/geo3d/__init__.py b/django/contrib/gis/tests/geo3d/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/django/contrib/gis/tests/geo3d/models.py b/django/contrib/gis/tests/geo3d/models.py new file mode 100644 index 00000000000..3c4f77ee055 --- /dev/null +++ b/django/contrib/gis/tests/geo3d/models.py @@ -0,0 +1,69 @@ +from django.contrib.gis.db import models + +class City3D(models.Model): + name = models.CharField(max_length=30) + point = models.PointField(dim=3) + objects = models.GeoManager() + + def __unicode__(self): + return self.name + +class Interstate2D(models.Model): + name = models.CharField(max_length=30) + line = models.LineStringField(srid=4269) + objects = models.GeoManager() + + def __unicode__(self): + return self.name + +class Interstate3D(models.Model): + name = models.CharField(max_length=30) + line = models.LineStringField(dim=3, srid=4269) + objects = models.GeoManager() + + def __unicode__(self): + return self.name + +class InterstateProj2D(models.Model): + name = models.CharField(max_length=30) + line = models.LineStringField(srid=32140) + objects = models.GeoManager() + + def __unicode__(self): + return self.name + +class InterstateProj3D(models.Model): + name = models.CharField(max_length=30) + line = models.LineStringField(dim=3, srid=32140) + objects = models.GeoManager() + + def __unicode__(self): + return self.name + +class Polygon2D(models.Model): + name = models.CharField(max_length=30) + poly = models.PolygonField(srid=32140) + objects = models.GeoManager() + + def __unicode__(self): + return self.name + +class Polygon3D(models.Model): + name = models.CharField(max_length=30) + poly = models.PolygonField(dim=3, srid=32140) + objects = models.GeoManager() + + def __unicode__(self): + return self.name + +class Point2D(models.Model): + point = models.PointField() + objects = models.GeoManager() + +class Point3D(models.Model): + point = models.PointField(dim=3) + objects = models.GeoManager() + +class MultiPoint3D(models.Model): + mpoint = models.MultiPointField(dim=3) + objects = models.GeoManager() diff --git a/django/contrib/gis/tests/geo3d/tests.py b/django/contrib/gis/tests/geo3d/tests.py new file mode 100644 index 00000000000..034a979a4c4 --- /dev/null +++ b/django/contrib/gis/tests/geo3d/tests.py @@ -0,0 +1,234 @@ +import os, re, unittest +from django.contrib.gis.db.models import Union, Extent3D +from django.contrib.gis.geos import GEOSGeometry, Point, Polygon +from django.contrib.gis.utils import LayerMapping, LayerMapError + +from models import City3D, Interstate2D, Interstate3D, \ + InterstateProj2D, InterstateProj3D, \ + Point2D, Point3D, MultiPoint3D, Polygon2D, Polygon3D + +data_path = os.path.realpath(os.path.join(os.path.dirname(__file__), '..', 'data')) +city_file = os.path.join(data_path, 'cities', 'cities.shp') +vrt_file = os.path.join(data_path, 'test_vrt', 'test_vrt.vrt') + +# The coordinates of each city, with Z values corresponding to their +# altitude in meters. +city_data = ( + ('Houston', (-95.363151, 29.763374, 18)), + ('Dallas', (-96.801611, 32.782057, 147)), + ('Oklahoma City', (-97.521157, 34.464642, 380)), + ('Wellington', (174.783117, -41.315268, 14)), + ('Pueblo', (-104.609252, 38.255001, 1433)), + ('Lawrence', (-95.235060, 38.971823, 251)), + ('Chicago', (-87.650175, 41.850385, 181)), + ('Victoria', (-123.305196, 48.462611, 15)), +) + +# Reference mapping of city name to its altitude (Z value). +city_dict = dict((name, coords) for name, coords in city_data) + +# 3D freeway data derived from the National Elevation Dataset: +# http://seamless.usgs.gov/products/9arc.php +interstate_data = ( + ('I-45', + 'LINESTRING(-95.3708481 29.7765870 11.339,-95.3694580 29.7787980 4.536,-95.3690305 29.7797359 9.762,-95.3691886 29.7812450 12.448,-95.3696447 29.7850144 10.457,-95.3702511 29.7868518 9.418,-95.3706724 29.7881286 14.858,-95.3711632 29.7896157 15.386,-95.3714525 29.7936267 13.168,-95.3717848 29.7955007 15.104,-95.3717719 29.7969804 16.516,-95.3717305 29.7982117 13.923,-95.3717254 29.8000778 14.385,-95.3719875 29.8013539 15.160,-95.3720575 29.8026785 15.544,-95.3721321 29.8040912 14.975,-95.3722074 29.8050998 15.688,-95.3722779 29.8060430 16.099,-95.3733818 29.8076750 15.197,-95.3741563 29.8103686 17.268,-95.3749458 29.8129927 19.857,-95.3763564 29.8144557 15.435)', + ( 11.339, 4.536, 9.762, 12.448, 10.457, 9.418, 14.858, + 15.386, 13.168, 15.104, 16.516, 13.923, 14.385, 15.16 , + 15.544, 14.975, 15.688, 16.099, 15.197, 17.268, 19.857, + 15.435), + ), + ) + +# Bounding box polygon for inner-loop of Houston (in projected coordinate +# system 32140), with elevation values from the National Elevation Dataset +# (see above). +bbox_wkt = 'POLYGON((941527.97 4225693.20,962596.48 4226349.75,963152.57 4209023.95,942051.75 4208366.38,941527.97 4225693.20))' +bbox_z = (21.71, 13.21, 9.12, 16.40, 21.71) +def gen_bbox(): + bbox_2d = GEOSGeometry(bbox_wkt, srid=32140) + bbox_3d = Polygon(tuple((x, y, z) for (x, y), z in zip(bbox_2d[0].coords, bbox_z)), srid=32140) + return bbox_2d, bbox_3d + +class Geo3DTest(unittest.TestCase): + """ + Only a subset of the PostGIS routines are 3D-enabled, and this TestCase + tries to test the features that can handle 3D and that are also + available within GeoDjango. For more information, see the PostGIS docs + on the routines that support 3D: + + http://postgis.refractions.net/documentation/manual-1.4/ch08.html#PostGIS_3D_Functions + """ + + def test01_3d(self): + "Test the creation of 3D models." + # 3D models for the rest of the tests will be populated in here. + # For each 3D data set create model (and 2D version if necessary), + # retrieve, and assert geometry is in 3D and contains the expected + # 3D values. + for name, pnt_data in city_data: + x, y, z = pnt_data + pnt = Point(x, y, z, srid=4326) + City3D.objects.create(name=name, point=pnt) + city = City3D.objects.get(name=name) + self.failUnless(city.point.hasz) + self.assertEqual(z, city.point.z) + + # Interstate (2D / 3D and Geographic/Projected variants) + for name, line, exp_z in interstate_data: + line_3d = GEOSGeometry(line, srid=4269) + # Using `hex` attribute because it omits 3D. + line_2d = GEOSGeometry(line_3d.hex, srid=4269) + + # Creating a geographic and projected version of the + # interstate in both 2D and 3D. + Interstate3D.objects.create(name=name, line=line_3d) + InterstateProj3D.objects.create(name=name, line=line_3d) + Interstate2D.objects.create(name=name, line=line_2d) + InterstateProj2D.objects.create(name=name, line=line_2d) + + # Retrieving and making sure it's 3D and has expected + # Z values -- shouldn't change because of coordinate system. + interstate = Interstate3D.objects.get(name=name) + interstate_proj = InterstateProj3D.objects.get(name=name) + for i in [interstate, interstate_proj]: + self.failUnless(i.line.hasz) + self.assertEqual(exp_z, tuple(i.line.z)) + + # Creating 3D Polygon. + bbox2d, bbox3d = gen_bbox() + Polygon2D.objects.create(name='2D BBox', poly=bbox2d) + Polygon3D.objects.create(name='3D BBox', poly=bbox3d) + p3d = Polygon3D.objects.get(name='3D BBox') + self.failUnless(p3d.poly.hasz) + self.assertEqual(bbox3d, p3d.poly) + + def test01a_3d_layermapping(self): + "Testing LayerMapping on 3D models." + from models import Point2D, Point3D + + point_mapping = {'point' : 'POINT'} + mpoint_mapping = {'mpoint' : 'MULTIPOINT'} + + # The VRT is 3D, but should still be able to map sans the Z. + lm = LayerMapping(Point2D, vrt_file, point_mapping, transform=False) + lm.save() + self.assertEqual(3, Point2D.objects.count()) + + # The city shapefile is 2D, and won't be able to fill the coordinates + # in the 3D model -- thus, a LayerMapError is raised. + self.assertRaises(LayerMapError, LayerMapping, + Point3D, city_file, point_mapping, transform=False) + + # 3D model should take 3D data just fine. + lm = LayerMapping(Point3D, vrt_file, point_mapping, transform=False) + lm.save() + self.assertEqual(3, Point3D.objects.count()) + + # Making sure LayerMapping.make_multi works right, by converting + # a Point25D into a MultiPoint25D. + lm = LayerMapping(MultiPoint3D, vrt_file, mpoint_mapping, transform=False) + lm.save() + self.assertEqual(3, MultiPoint3D.objects.count()) + + def test02a_kml(self): + "Test GeoQuerySet.kml() with Z values." + h = City3D.objects.kml(precision=6).get(name='Houston') + # KML should be 3D. + # `SELECT ST_AsKML(point, 6) FROM geo3d_city3d WHERE name = 'Houston';` + ref_kml_regex = re.compile(r'^-95.363\d+,29.763\d+,18$') + self.failUnless(ref_kml_regex.match(h.kml)) + + def test02b_geojson(self): + "Test GeoQuerySet.geojson() with Z values." + h = City3D.objects.geojson(precision=6).get(name='Houston') + # GeoJSON should be 3D + # `SELECT ST_AsGeoJSON(point, 6) FROM geo3d_city3d WHERE name='Houston';` + ref_json_regex = re.compile(r'^{"type":"Point","coordinates":\[-95.363151,29.763374,18(\.0+)?\]}$') + self.failUnless(ref_json_regex.match(h.geojson)) + + def test03a_union(self): + "Testing the Union aggregate of 3D models." + # PostGIS query that returned the reference EWKT for this test: + # `SELECT ST_AsText(ST_Union(point)) FROM geo3d_city3d;` + ref_ewkt = 'SRID=4326;MULTIPOINT(-123.305196 48.462611 15,-104.609252 38.255001 1433,-97.521157 34.464642 380,-96.801611 32.782057 147,-95.363151 29.763374 18,-95.23506 38.971823 251,-87.650175 41.850385 181,174.783117 -41.315268 14)' + ref_union = GEOSGeometry(ref_ewkt) + union = City3D.objects.aggregate(Union('point'))['point__union'] + self.failUnless(union.hasz) + self.assertEqual(ref_union, union) + + def test03b_extent(self): + "Testing the Extent3D aggregate for 3D models." + # `SELECT ST_Extent3D(point) FROM geo3d_city3d;` + ref_extent3d = (-123.305196, -41.315268, 14,174.783117, 48.462611, 1433) + extent1 = City3D.objects.aggregate(Extent3D('point'))['point__extent3d'] + extent2 = City3D.objects.extent3d() + + def check_extent3d(extent3d, tol=6): + for ref_val, ext_val in zip(ref_extent3d, extent3d): + self.assertAlmostEqual(ref_val, ext_val, tol) + + for e3d in [extent1, extent2]: + check_extent3d(e3d) + + def test04_perimeter(self): + "Testing GeoQuerySet.perimeter() on 3D fields." + # Reference query for values below: + # `SELECT ST_Perimeter3D(poly), ST_Perimeter2D(poly) FROM geo3d_polygon3d;` + ref_perim_3d = 76859.2620451 + ref_perim_2d = 76859.2577803 + tol = 6 + self.assertAlmostEqual(ref_perim_2d, + Polygon2D.objects.perimeter().get(name='2D BBox').perimeter.m, + tol) + self.assertAlmostEqual(ref_perim_3d, + Polygon3D.objects.perimeter().get(name='3D BBox').perimeter.m, + tol) + + def test05_length(self): + "Testing GeoQuerySet.length() on 3D fields." + # ST_Length_Spheroid Z-aware, and thus does not need to use + # a separate function internally. + # `SELECT ST_Length_Spheroid(line, 'SPHEROID["GRS 1980",6378137,298.257222101]') + # FROM geo3d_interstate[2d|3d];` + tol = 3 + ref_length_2d = 4368.1721949481 + ref_length_3d = 4368.62547052088 + self.assertAlmostEqual(ref_length_2d, + Interstate2D.objects.length().get(name='I-45').length.m, + tol) + self.assertAlmostEqual(ref_length_3d, + Interstate3D.objects.length().get(name='I-45').length.m, + tol) + + # Making sure `ST_Length3D` is used on for a projected + # and 3D model rather than `ST_Length`. + # `SELECT ST_Length(line) FROM geo3d_interstateproj2d;` + ref_length_2d = 4367.71564892392 + # `SELECT ST_Length3D(line) FROM geo3d_interstateproj3d;` + ref_length_3d = 4368.16897234101 + self.assertAlmostEqual(ref_length_2d, + InterstateProj2D.objects.length().get(name='I-45').length.m, + tol) + self.assertAlmostEqual(ref_length_3d, + InterstateProj3D.objects.length().get(name='I-45').length.m, + tol) + + def test06_scale(self): + "Testing GeoQuerySet.scale() on Z values." + # Mapping of City name to reference Z values. + zscales = (-3, 4, 23) + for zscale in zscales: + for city in City3D.objects.scale(1.0, 1.0, zscale): + self.assertEqual(city_dict[city.name][2] * zscale, city.scale.z) + + def test07_translate(self): + "Testing GeoQuerySet.translate() on Z values." + ztranslations = (5.23, 23, -17) + for ztrans in ztranslations: + for city in City3D.objects.translate(0, 0, ztrans): + self.assertEqual(city_dict[city.name][2] + ztrans, city.translate.z) + +def suite(): + s = unittest.TestSuite() + s.addTest(unittest.makeSuite(Geo3DTest)) + return s diff --git a/django/contrib/gis/tests/geo3d/views.py b/django/contrib/gis/tests/geo3d/views.py new file mode 100644 index 00000000000..60f00ef0ef3 --- /dev/null +++ b/django/contrib/gis/tests/geo3d/views.py @@ -0,0 +1 @@ +# Create your views here. diff --git a/django/contrib/gis/utils/__init__.py b/django/contrib/gis/utils/__init__.py index 2c9f2f3ce65..f336bcadbf5 100644 --- a/django/contrib/gis/utils/__init__.py +++ b/django/contrib/gis/utils/__init__.py @@ -10,7 +10,7 @@ if HAS_GDAL: try: # LayerMapping requires DJANGO_SETTINGS_MODULE to be set, # so this needs to be in try/except. - from django.contrib.gis.utils.layermapping import LayerMapping + from django.contrib.gis.utils.layermapping import LayerMapping, LayerMapError except: pass diff --git a/django/contrib/gis/utils/layermapping.py b/django/contrib/gis/utils/layermapping.py index 57c957811de..e2c66740eb4 100644 --- a/django/contrib/gis/utils/layermapping.py +++ b/django/contrib/gis/utils/layermapping.py @@ -133,6 +133,9 @@ class LayerMapping(object): MULTI_TYPES = {1 : OGRGeomType('MultiPoint'), 2 : OGRGeomType('MultiLineString'), 3 : OGRGeomType('MultiPolygon'), + OGRGeomType('Point25D').num : OGRGeomType('MultiPoint25D'), + OGRGeomType('LineString25D').num : OGRGeomType('MultiLineString25D'), + OGRGeomType('Polygon25D').num : OGRGeomType('MultiPolygon25D'), } # Acceptable Django field types and corresponding acceptable OGR @@ -282,19 +285,28 @@ class LayerMapping(object): if self.geom_field: raise LayerMapError('LayerMapping does not support more than one GeometryField per model.') + # Getting the coordinate dimension of the geometry field. + coord_dim = model_field.dim + try: - gtype = OGRGeomType(ogr_name) + if coord_dim == 3: + gtype = OGRGeomType(ogr_name + '25D') + else: + gtype = OGRGeomType(ogr_name) except OGRException: raise LayerMapError('Invalid mapping for GeometryField "%s".' % field_name) # Making sure that the OGR Layer's Geometry is compatible. ltype = self.layer.geom_type - if not (gtype == ltype or self.make_multi(ltype, model_field)): - raise LayerMapError('Invalid mapping geometry; model has %s, feature has %s.' % (fld_name, gtype)) + if not (ltype.name.startswith(gtype.name) or self.make_multi(ltype, model_field)): + raise LayerMapError('Invalid mapping geometry; model has %s%s, layer is %s.' % + (fld_name, (coord_dim == 3 and '(dim=3)') or '', ltype)) # Setting the `geom_field` attribute w/the name of the model field - # that is a Geometry. + # that is a Geometry. Also setting the coordinate dimension + # attribute. self.geom_field = field_name + self.coord_dim = coord_dim fields_val = model_field elif isinstance(model_field, models.ForeignKey): if isinstance(ogr_name, dict): @@ -482,6 +494,10 @@ class LayerMapping(object): if necessary (for example if the model field is MultiPolygonField while the mapped shapefile only contains Polygons). """ + # Downgrade a 3D geom to a 2D one, if necessary. + if self.coord_dim != geom.coord_dim: + geom.coord_dim = self.coord_dim + if self.make_multi(geom.geom_type, model_field): # Constructing a multi-geometry type to contain the single geometry multi_type = self.MULTI_TYPES[geom.geom_type.num]