Fixed #11433 -- 3D geometry fields are now supported with PostGIS; EWKB is now used by `PostGISAdaptor`.

git-svn-id: http://code.djangoproject.com/svn/django/trunk@11742 bcc190cf-cafb-0310-a4f2-bffc1f526a37
This commit is contained in:
Justin Bronn 2009-11-16 18:49:00 +00:00
parent 6c61ca3d74
commit 32d0730b89
16 changed files with 395 additions and 21 deletions

View File

@ -18,18 +18,21 @@ SpatialBackend = BaseSpatialBackend(name='postgis', postgis=True,
distance_spheroid=DISTANCE_SPHEROID, distance_spheroid=DISTANCE_SPHEROID,
envelope=ENVELOPE, envelope=ENVELOPE,
extent=EXTENT, extent=EXTENT,
extent3d=EXTENT3D,
gis_terms=POSTGIS_TERMS, gis_terms=POSTGIS_TERMS,
geojson=ASGEOJSON, geojson=ASGEOJSON,
gml=ASGML, gml=ASGML,
intersection=INTERSECTION, intersection=INTERSECTION,
kml=ASKML, kml=ASKML,
length=LENGTH, length=LENGTH,
length3d=LENGTH3D,
length_spheroid=LENGTH_SPHEROID, length_spheroid=LENGTH_SPHEROID,
make_line=MAKE_LINE, make_line=MAKE_LINE,
mem_size=MEM_SIZE, mem_size=MEM_SIZE,
num_geom=NUM_GEOM, num_geom=NUM_GEOM,
num_points=NUM_POINTS, num_points=NUM_POINTS,
perimeter=PERIMETER, perimeter=PERIMETER,
perimeter3d=PERIMETER3D,
point_on_surface=POINT_ON_SURFACE, point_on_surface=POINT_ON_SURFACE,
scale=SCALE, scale=SCALE,
select=GEOM_SELECT, select=GEOM_SELECT,

View File

@ -2,7 +2,7 @@
This object provides quoting for GEOS geometries into PostgreSQL/PostGIS. 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 import Binary
from psycopg2.extensions import ISQLQuote from psycopg2.extensions import ISQLQuote
@ -11,7 +11,7 @@ class PostGISAdaptor(object):
"Initializes on the geometry." "Initializes on the geometry."
# Getting the WKB (in string form, to allow easy pickling of # Getting the WKB (in string form, to allow easy pickling of
# the adaptor) and the SRID from the geometry. # the adaptor) and the SRID from the geometry.
self.wkb = str(geom.wkb) self.ewkb = str(geom.ewkb)
self.srid = geom.srid self.srid = geom.srid
def __conform__(self, proto): def __conform__(self, proto):
@ -30,7 +30,7 @@ class PostGISAdaptor(object):
def getquoted(self): def getquoted(self):
"Returns a properly quoted string for use in PostgreSQL/PostGIS." "Returns a properly quoted string for use in PostgreSQL/PostGIS."
# Want to use WKB, so wrap with psycopg2 Binary() to quote properly. # 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): def prepare_database_save(self, unused):
return self return self

View File

@ -63,17 +63,21 @@ if MAJOR_VERSION >= 1:
DISTANCE_SPHERE = get_func('distance_sphere') DISTANCE_SPHERE = get_func('distance_sphere')
DISTANCE_SPHEROID = get_func('distance_spheroid') DISTANCE_SPHEROID = get_func('distance_spheroid')
ENVELOPE = get_func('Envelope') ENVELOPE = get_func('Envelope')
EXTENT = get_func('extent') EXTENT = get_func('Extent')
EXTENT3D = get_func('Extent3D')
GEOM_FROM_TEXT = get_func('GeomFromText') GEOM_FROM_TEXT = get_func('GeomFromText')
GEOM_FROM_EWKB = get_func('GeomFromEWKB')
GEOM_FROM_WKB = get_func('GeomFromWKB') GEOM_FROM_WKB = get_func('GeomFromWKB')
INTERSECTION = get_func('Intersection') INTERSECTION = get_func('Intersection')
LENGTH = get_func('Length') LENGTH = get_func('Length')
LENGTH3D = get_func('Length3D')
LENGTH_SPHEROID = get_func('length_spheroid') LENGTH_SPHEROID = get_func('length_spheroid')
MAKE_LINE = get_func('MakeLine') MAKE_LINE = get_func('MakeLine')
MEM_SIZE = get_func('mem_size') MEM_SIZE = get_func('mem_size')
NUM_GEOM = get_func('NumGeometries') NUM_GEOM = get_func('NumGeometries')
NUM_POINTS = get_func('npoints') NUM_POINTS = get_func('npoints')
PERIMETER = get_func('Perimeter') PERIMETER = get_func('Perimeter')
PERIMETER3D = get_func('Perimeter3D')
POINT_ON_SURFACE = get_func('PointOnSurface') POINT_ON_SURFACE = get_func('PointOnSurface')
SCALE = get_func('Scale') SCALE = get_func('Scale')
SNAP_TO_GRID = get_func('SnapToGrid') SNAP_TO_GRID = get_func('SnapToGrid')

View File

@ -24,6 +24,9 @@ class Collect(GeoAggregate):
class Extent(GeoAggregate): class Extent(GeoAggregate):
name = 'Extent' name = 'Extent'
class Extent3D(GeoAggregate):
name = 'Extent3D'
class MakeLine(GeoAggregate): class MakeLine(GeoAggregate):
name = 'MakeLine' name = 'MakeLine'

View File

@ -34,6 +34,9 @@ class GeoManager(Manager):
def extent(self, *args, **kwargs): def extent(self, *args, **kwargs):
return self.get_query_set().extent(*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): def geojson(self, *args, **kwargs):
return self.get_query_set().geojson(*args, **kwargs) return self.get_query_set().geojson(*args, **kwargs)

View File

@ -110,6 +110,14 @@ class GeoQuerySet(QuerySet):
""" """
return self._spatial_aggregate(aggregates.Extent, **kwargs) 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): def geojson(self, precision=8, crs=False, bbox=False, **kwargs):
""" """
Returns a GeoJSON representation of the geomtry field in a `geojson` Returns a GeoJSON representation of the geomtry field in a `geojson`
@ -524,12 +532,14 @@ class GeoQuerySet(QuerySet):
else: else:
dist_att = Distance.unit_attname(geo_field.units_name) 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' distance = func == 'distance'
length = func == 'length' length = func == 'length'
perimeter = func == 'perimeter' perimeter = func == 'perimeter'
if not (distance or length or perimeter): if not (distance or length or perimeter):
raise ValueError('Unknown distance function: %s' % func) 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 # The field's get_db_prep_lookup() is used to get any
# extra distance parameters. Here we set up the # extra distance parameters. Here we set up the
@ -604,7 +614,7 @@ class GeoQuerySet(QuerySet):
# some error checking is required. # some error checking is required.
if not isinstance(geo_field, PointField): if not isinstance(geo_field, PointField):
raise ValueError('Spherical distance calculation only supported on PointFields.') 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') raise ValueError('Spherical distance calculation only supported with Point Geometry parameters')
# The `function` procedure argument needs to be set differently for # The `function` procedure argument needs to be set differently for
# geodetic distance calculations. # geodetic distance calculations.
@ -617,9 +627,16 @@ class GeoQuerySet(QuerySet):
elif length or perimeter: elif length or perimeter:
procedure_fmt = '%(geo_col)s' procedure_fmt = '%(geo_col)s'
if geodetic and length: 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_fmt += ',%(spheroid)s'
procedure_args.update({'function' : SpatialBackend.length_spheroid, 'spheroid' : where[1]}) 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`. # Setting up the settings for `_spatial_attribute`.
s = {'select_field' : DistanceField(dist_att), s = {'select_field' : DistanceField(dist_att),

View File

@ -11,6 +11,9 @@ geo_template = '%(function)s(%(field)s)'
def convert_extent(box): def convert_extent(box):
raise NotImplementedError('Aggregate extent not implemented for this spatial backend.') 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): def convert_geom(wkt, geo_field):
raise NotImplementedError('Aggregate method not implemented for this spatial backend.') raise NotImplementedError('Aggregate method not implemented for this spatial backend.')
@ -23,6 +26,14 @@ if SpatialBackend.postgis:
xmax, ymax = map(float, ur.split()) xmax, ymax = map(float, ur.split())
return (xmin, ymin, xmax, ymax) 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): def convert_geom(hex, geo_field):
if hex: return SpatialBackend.Geometry(hex) if hex: return SpatialBackend.Geometry(hex)
else: return None else: return None
@ -94,7 +105,7 @@ class Collect(GeoAggregate):
sql_function = SpatialBackend.collect sql_function = SpatialBackend.collect
class Extent(GeoAggregate): class Extent(GeoAggregate):
is_extent = True is_extent = '2D'
sql_function = SpatialBackend.extent sql_function = SpatialBackend.extent
if SpatialBackend.oracle: if SpatialBackend.oracle:
@ -102,6 +113,10 @@ if SpatialBackend.oracle:
Extent.conversion_class = GeomField Extent.conversion_class = GeomField
Extent.sql_template = '%(function)s(%(field)s)' Extent.sql_template = '%(function)s(%(field)s)'
class Extent3D(GeoAggregate):
is_extent = '3D'
sql_function = SpatialBackend.extent3d
class MakeLine(GeoAggregate): class MakeLine(GeoAggregate):
conversion_class = GeomField conversion_class = GeomField
sql_function = SpatialBackend.make_line sql_function = SpatialBackend.make_line

View File

@ -262,7 +262,10 @@ class GeoQuery(sql.Query):
""" """
if isinstance(aggregate, self.aggregates_module.GeoAggregate): if isinstance(aggregate, self.aggregates_module.GeoAggregate):
if aggregate.is_extent: 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: else:
return self.aggregates_module.convert_geom(value, aggregate.source) return self.aggregates_module.convert_geom(value, aggregate.source)
else: else:

View File

@ -373,9 +373,10 @@ class GEOSGeometry(GEOSBase, ListMixin):
@property @property
def hex(self): def hex(self):
""" """
Returns the HEX of the Geometry -- please note that the SRID is not Returns the WKB of this Geometry in hexadecimal form. Please note
included in this representation, because it is not a part of the that the SRID and Z values are not included in this representation
OGC specification (use the `hexewkb` property instead). because it is not a part of the OGC specification (use the `hexewkb`
property instead).
""" """
# A possible faster, all-python, implementation: # A possible faster, all-python, implementation:
# str(self.wkb).encode('hex') # str(self.wkb).encode('hex')
@ -384,9 +385,9 @@ class GEOSGeometry(GEOSBase, ListMixin):
@property @property
def hexewkb(self): def hexewkb(self):
""" """
Returns the HEXEWKB of this Geometry. This is an extension of the WKB Returns the EWKB of this Geometry in hexadecimal form. This is an
specification that includes SRID and Z values taht are a part of this extension of the WKB specification that includes SRID and Z values
geometry. that are a part of this geometry.
""" """
if self.hasz: if self.hasz:
if not GEOS_PREPARE: if not GEOS_PREPARE:

View File

@ -9,9 +9,10 @@ def geo_suite():
some backends). some backends).
""" """
from django.conf import settings 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.gdal import HAS_GDAL
from django.contrib.gis.utils import HAS_GEOIP 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. # The test suite.
s = unittest.TestSuite() s = unittest.TestSuite()
@ -32,6 +33,10 @@ def geo_suite():
if not mysql: if not mysql:
test_apps.append('distapp') 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: if HAS_GDAL:
# These tests require GDAL. # These tests require GDAL.
test_suite_names.extend(['test_spatialrefsys', 'test_geoforms']) test_suite_names.extend(['test_spatialrefsys', 'test_geoforms'])

View File

@ -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()

View File

@ -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'^<Point><coordinates>-95.363\d+,29.763\d+,18</coordinates></Point>$')
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

View File

@ -0,0 +1 @@
# Create your views here.

View File

@ -10,7 +10,7 @@ if HAS_GDAL:
try: try:
# LayerMapping requires DJANGO_SETTINGS_MODULE to be set, # LayerMapping requires DJANGO_SETTINGS_MODULE to be set,
# so this needs to be in try/except. # 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: except:
pass pass

View File

@ -133,6 +133,9 @@ class LayerMapping(object):
MULTI_TYPES = {1 : OGRGeomType('MultiPoint'), MULTI_TYPES = {1 : OGRGeomType('MultiPoint'),
2 : OGRGeomType('MultiLineString'), 2 : OGRGeomType('MultiLineString'),
3 : OGRGeomType('MultiPolygon'), 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 # Acceptable Django field types and corresponding acceptable OGR
@ -282,19 +285,28 @@ class LayerMapping(object):
if self.geom_field: if self.geom_field:
raise LayerMapError('LayerMapping does not support more than one GeometryField per model.') 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: try:
gtype = OGRGeomType(ogr_name) if coord_dim == 3:
gtype = OGRGeomType(ogr_name + '25D')
else:
gtype = OGRGeomType(ogr_name)
except OGRException: except OGRException:
raise LayerMapError('Invalid mapping for GeometryField "%s".' % field_name) raise LayerMapError('Invalid mapping for GeometryField "%s".' % field_name)
# Making sure that the OGR Layer's Geometry is compatible. # Making sure that the OGR Layer's Geometry is compatible.
ltype = self.layer.geom_type ltype = self.layer.geom_type
if not (gtype == ltype or self.make_multi(ltype, model_field)): if not (ltype.name.startswith(gtype.name) or self.make_multi(ltype, model_field)):
raise LayerMapError('Invalid mapping geometry; model has %s, feature has %s.' % (fld_name, gtype)) 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 # 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.geom_field = field_name
self.coord_dim = coord_dim
fields_val = model_field fields_val = model_field
elif isinstance(model_field, models.ForeignKey): elif isinstance(model_field, models.ForeignKey):
if isinstance(ogr_name, dict): if isinstance(ogr_name, dict):
@ -482,6 +494,10 @@ class LayerMapping(object):
if necessary (for example if the model field is MultiPolygonField while if necessary (for example if the model field is MultiPolygonField while
the mapped shapefile only contains Polygons). 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): if self.make_multi(geom.geom_type, model_field):
# Constructing a multi-geometry type to contain the single geometry # Constructing a multi-geometry type to contain the single geometry
multi_type = self.MULTI_TYPES[geom.geom_type.num] multi_type = self.MULTI_TYPES[geom.geom_type.num]