Repository: geopandas/geopandas

Issue:
gpd.to_file(): Z dimension not saved with ESRI driver
# Issue

The Z dimension of geometries is not preserved when using `gpd.to_file()` using the ESRI driver.

# Example

```python
import geopandas as gpd
from shapely.geometry import Point

point = Point(0, 0, 500)
gdf = gpd.GeoDataFrame(geometry=[point])
print(gdf)
```
```
          geometry
0  POINT Z (0 0 500)
```
```python
gdf.to_file('./foo')
# Try to read file back
gdf2 = gpd.read_file('./foo')
print(gdf2)
```
```
   FID     geometry
0    0  POINT (0 0)
```

# Expected Behavior

I'd expect the Z dimension to be included.


Discussion:
@mattayes Thanks for the clear bug report! It looks like `geopandas` isn't correctly inferring a "3D Point" geometry schema. Are you interested in making a PR on this? If not, I can take a shot in the next week or two.
@jdmcbr I'm interested, though I'm not quite sure what needs to change right now. Do you have a good idea? I'm happy to start investigating.
To further investigate, could you open the written file with another program? Just to know whether the bug is in the writing of the file, or in the reading it afterwards? (or if you have an existing 3D shapefile, to try to read that)
@mattayes @jorisvandenbossche I'm sorry, I should have provided a little more detail in my previous message. In confirming this was a `geopandas` issue and not a `fiona` issue, I saw that this was occurring during a write. For a 3D point file written with `fiona`, `geopandas` correctly read it back in.
I suppose the error is in inferring the schema (https://github.com/geopandas/geopandas/blob/master/geopandas/io/file.py#L111), in that it needs special casing for 3D data.

If you manually specify the schema, it seems to work:

```
In [167]: point = Point(0, 0, 500)
     ...: gdf = gpd.GeoDataFrame({'a': [1]}, geometry=[point])

In [168]: gdf
   a           geometry
0  1  POINT Z (0 0 500)

In [170]: gpd.io.file.infer_schema(gdf)
Out[170]: {'geometry': 'Point', 'properties': OrderedDict([('a', 'int')])}

In [175]: schema = {'geometry': '3D Point', 'properties': {'a': 'int'}}

In [176]: gdf.to_file("__test3D.shp", schema=schema)

In [177]: gpd.read_file("__test3D.shp")
   a           geometry
0  1  POINT Z (0 0 500)
```
Although I don't fully understand how it works, as here (https://github.com/Toblerity/Fiona/issues/465) is stated that '3D Point' should now be an alias for 'Point'

Resolving patch:
diff --git a/geopandas/base.py b/geopandas/base.py
--- a/geopandas/base.py
+++ b/geopandas/base.py
@@ -147,6 +147,13 @@ def is_ring(self):
         return Series([geom.exterior.is_ring for geom in self.geometry],
                       index=self.index)
 
+    @property
+    def has_z(self):
+        """Returns a ``Series`` of ``dtype('bool')`` with value ``True`` for
+        features that have a z-component."""
+        # operates on the exterior, so can't use _series_unary_op()
+        return _series_unary_op(self, 'has_z', null_value=False)
+
     #
     # Unary operations that return a GeoSeries
     #
diff --git a/geopandas/io/file.py b/geopandas/io/file.py
--- a/geopandas/io/file.py
+++ b/geopandas/io/file.py
@@ -146,9 +146,14 @@ def _common_geom_type(df):
     # Point, LineString, or Polygon
     geom_types = df.geometry.geom_type.unique()
 
-    from os.path import commonprefix   # To find longest common prefix
-    geom_type = commonprefix([g[::-1] for g in geom_types if g])[::-1]  # Reverse
+    from os.path import commonprefix
+    # use reversed geom types and commonprefix to find the common suffix,
+    # then reverse the result to get back to a geom type
+    geom_type = commonprefix([g[::-1] for g in geom_types if g])[::-1]
     if not geom_type:
-        geom_type = None
+        return None
+
+    if df.geometry.has_z.any():
+        geom_type = "3D " + geom_type
 
     return geom_type


Test patch:
diff --git a/geopandas/tests/test_geodataframe.py b/geopandas/tests/test_geodataframe.py
--- a/geopandas/tests/test_geodataframe.py
+++ b/geopandas/tests/test_geodataframe.py
@@ -320,6 +320,28 @@ def test_to_file_bool(self):
         with pytest.raises(ValueError):
             df_with_bool.to_file(tempfilename)
 
+    def test_to_file_with_point_z(self):
+        """Test that 3D geometries are retained in writes (GH #612)."""
+
+        tempfilename = os.path.join(self.tempdir, 'test_3Dpoint.shp')
+        point3d = Point(0, 0, 500)
+        point2d = Point(1, 1)
+        df = GeoDataFrame({'a': [1, 2]}, geometry=[point3d, point2d], crs={})
+        df.to_file(tempfilename)
+        df_read = GeoDataFrame.from_file(tempfilename)
+        assert_geoseries_equal(df.geometry, df_read.geometry)
+
+    def test_to_file_with_poly_z(self):
+        """Test that 3D geometries are retained in writes (GH #612)."""
+
+        tempfilename = os.path.join(self.tempdir, 'test_3Dpoly.shp')
+        poly3d = Polygon([[0, 0, 5], [0, 1, 5], [1, 1, 5], [1, 0, 5]])
+        poly2d = Polygon([[0, 0], [0, 1], [1, 1], [1, 0]])
+        df = GeoDataFrame({'a': [1, 2]}, geometry=[poly3d, poly2d], crs={})
+        df.to_file(tempfilename)
+        df_read = GeoDataFrame.from_file(tempfilename)
+        assert_geoseries_equal(df.geometry, df_read.geometry)
+
     def test_to_file_types(self):
         """ Test various integer type columns (GH#93) """
         tempfilename = os.path.join(self.tempdir, 'int.shp')
diff --git a/geopandas/tests/test_geom_methods.py b/geopandas/tests/test_geom_methods.py
--- a/geopandas/tests/test_geom_methods.py
+++ b/geopandas/tests/test_geom_methods.py
@@ -32,6 +32,7 @@ def setup_method(self):
         self.nested_squares = Polygon(self.sq.boundary,
                                       [self.inner_sq.boundary])
         self.p0 = Point(5, 5)
+        self.p3d = Point(5, 5, 5)
         self.g0 = GeoSeries([self.t1, self.t2, self.sq, self.inner_sq,
                              self.nested_squares, self.p0])
         self.g1 = GeoSeries([self.t1, self.sq])
@@ -40,6 +41,7 @@ def setup_method(self):
         self.g3.crs = {'init': 'epsg:4326', 'no_defs': True}
         self.g4 = GeoSeries([self.t2, self.t1])
         self.g4.crs = {'init': 'epsg:4326', 'no_defs': True}
+        self.g_3d = GeoSeries([self.p0, self.p3d])
         self.na = GeoSeries([self.t1, self.t2, Polygon()])
         self.na_none = GeoSeries([self.t1, None])
         self.a1 = self.g1.copy()
@@ -334,6 +336,10 @@ def test_is_simple(self):
         expected = Series(np.array([True] * len(self.g1)), self.g1.index)
         self._test_unary_real('is_simple', expected, self.g1)
 
+    def test_has_z(self):
+        expected = Series([False, True], self.g_3d.index)
+        self._test_unary_real('has_z', expected, self.g_3d)
+
     def test_xy_points(self):
         expected_x = [-73.9847, -74.0446]
         expected_y = [40.7484, 40.6893]