Repository: geopandas/geopandas

Issue:
BUG: GeoDataFrame.to_parquet writes geometry in EWKB instead of ISO WKB required by spec
- [x] I have checked that this issue has not already been reported.

- [x] I have confirmed this bug exists on the latest version of geopandas.

- [x] (optional) I have confirmed this bug exists on the main branch of geopandas.

---


#### Code Sample, a copy-pastable example

```python
import geopandas
geopandas.GeoDataFrame(geometry=geopandas.GeoSeries.from_wkt(['POINT Z (1 2 3)'])) \
    .to_parquet('pointz.parquet', schema_version='0.4.0')
import pyarrow.parquet as pq
print(pq.read_table('pointz.parquet').to_pydict())
{'geometry': [b'\x01\x01\x00\x00\x80\x00\x00\x00\x00\x00\x00\xf0?\x00\x00\x00\x00\x00\x00\x00@\x00\x00\x00\x00\x00\x00\x08@']}

```

#### Problem description

The [0.4.0 GeoParquet specification](https://github.com/opengeospatial/geoparquet/blob/v0.4.0/format-specs/geoparquet.md#encoding) explicitly requires encoding extended geometry types in ISO WKB:
> using codes for 3D geometry types in the [1001,1007] range

The geometry was instead encoded using EWKB 0x80000001 code, making the resulting file invalid GeoParquet.
As I understand, GEOS has `GEOSWKBWriter_setFlavor` since 3.10 that allows to write ISO WKB correctly. I understand that there are currently several different code paths implementing WKB writing, and implementing such support for them all could be challenging. But even raising an error early on non-supported geometry types would be in my opinion preferable to silently writing invalid data files.

#### Expected Output
`{'geometry': [b'\x01\xe9\x03\x00\x00\x00\x00\x00\x00\x00\x00\xf0?\x00\x00\x00\x00\x00\x00\x00@\x00\x00\x00\x00\x00\x00\x08@']}`
Alternatively, an error explaining that serializing POINT Z geometry type in GeoParquet is not currently supported.

#### Output of ``geopandas.show_versions()``

<details>

SYSTEM INFO
-----------
python     : 3.9.9 (main, Nov 19 2021, 00:00:00)  [GCC 10.3.1 20210422 (Red Hat 10.3.1-1)]
executable : /home/nofitserov/.cache/pypoetry/virtualenvs/test-d9F45URs-py3.9/bin/python
machine    : Linux-5.14.18-100.fc33.x86_64-x86_64-with-glibc2.32

GEOS, GDAL, PROJ INFO
---------------------
GEOS       : 3.8.1
GEOS lib   : /usr/lib64/libgeos_c.so
GDAL       : 3.4.3
GDAL data dir: /home/nofitserov/.cache/pypoetry/virtualenvs/test-d9F45URs-py3.9/lib64/python3.9/site-packages/fiona/gdal_data
PROJ       : 9.1.0
PROJ data dir: /home/nofitserov/.cache/pypoetry/virtualenvs/test-d9F45URs-py3.9/lib64/python3.9/site-packages/pyproj/proj_dir/share/proj

PYTHON DEPENDENCIES
-------------------
geopandas  : 0.12.1
numpy      : 1.23.5
pandas     : 1.5.1
pyproj     : 3.4.0
shapely    : 1.8.5.post1
fiona      : 1.8.22
geoalchemy2: None
geopy      : None
matplotlib : 3.6.2
mapclassify: 2.4.3
pygeos     : None
pyogrio    : v0.4.2
psycopg2   : None
pyarrow    : 10.0.0
rtree      : None

</details>



Discussion:
Thanks. Support for this has been added in https://github.com/shapely/shapely/pull/1628 and just needs to be used here now.
Yes, I intentionally included that last-minute in shapely 2.0 so we could already start using it here. I was planning to do a PR for this (after https://github.com/geopandas/geopandas/pull/2663), but feel free to beat me to it.

Resolving patch:
diff --git a/geopandas/geodataframe.py b/geopandas/geodataframe.py
--- a/geopandas/geodataframe.py
+++ b/geopandas/geodataframe.py
@@ -951,6 +951,7 @@ def to_wkb(self, hex=False, **kwargs):
             The default is to return a binary bytes object.
         kwargs
             Additional keyword args will be passed to
+            :func:`shapely.to_wkb` if shapely >= 2 is installed or
             :func:`pygeos.to_wkb` if pygeos is installed.
 
         Returns
diff --git a/geopandas/geoseries.py b/geopandas/geoseries.py
--- a/geopandas/geoseries.py
+++ b/geopandas/geoseries.py
@@ -1209,6 +1209,7 @@ def to_wkb(self, hex=False, **kwargs):
             The default is to return a binary bytes object.
         kwargs
             Additional keyword args will be passed to
+            :func:`shapely.to_wkb` if shapely >= 2 is installed or
             :func:`pygeos.to_wkb` if pygeos is installed.
 
         Returns
diff --git a/geopandas/io/arrow.py b/geopandas/io/arrow.py
--- a/geopandas/io/arrow.py
+++ b/geopandas/io/arrow.py
@@ -5,6 +5,7 @@
 import numpy as np
 from pandas import DataFrame, Series
 
+import geopandas._compat as compat
 from geopandas._compat import import_optional_dependency
 from geopandas.array import from_wkb
 from geopandas import GeoDataFrame
@@ -246,7 +247,23 @@ def _geopandas_to_arrow(df, index=None, schema_version=None):
     # create geo metadata before altering incoming data frame
     geo_metadata = _create_metadata(df, schema_version=schema_version)
 
-    df = df.to_wkb()
+    kwargs = {}
+    if compat.USE_SHAPELY_20:
+        kwargs = dict(flavor="iso")
+    else:
+        for col in df.columns[df.dtypes == "geometry"]:
+            series = df[col]
+            if series.has_z.any():
+                warnings.warn(
+                    "The GeoDataFrame contains 3D geometries, and when using "
+                    "shapely < 2.0, such geometries will be written not exactly "
+                    "following to the GeoParquet spec (not using ISO WKB). For "
+                    "most use cases this should not be a problem (GeoPandas can "
+                    "read such files fine).",
+                    stacklevel=2,
+                )
+                break
+    df = df.to_wkb(**kwargs)
 
     table = Table.from_pandas(df, preserve_index=index)
 


Test patch:
diff --git a/geopandas/io/tests/test_arrow.py b/geopandas/io/tests/test_arrow.py
--- a/geopandas/io/tests/test_arrow.py
+++ b/geopandas/io/tests/test_arrow.py
@@ -15,6 +15,7 @@
 
 
 import geopandas
+import geopandas._compat as compat
 from geopandas import GeoDataFrame, read_file, read_parquet, read_feather
 from geopandas.array import to_wkb
 from geopandas.datasets import get_path
@@ -692,6 +693,28 @@ def test_write_read_default_crs(tmpdir, format):
     assert df.crs.equals(pyproj.CRS("OGC:CRS84"))
 
 
+def test_write_iso_wkb(tmpdir):
+    gdf = geopandas.GeoDataFrame(
+        geometry=geopandas.GeoSeries.from_wkt(["POINT Z (1 2 3)"])
+    )
+    if compat.USE_SHAPELY_20:
+        gdf.to_parquet(tmpdir / "test.parquet")
+    else:
+        with pytest.warns(UserWarning, match="The GeoDataFrame contains 3D geometries"):
+            gdf.to_parquet(tmpdir / "test.parquet")
+
+    from pyarrow.parquet import read_table
+
+    table = read_table(tmpdir / "test.parquet")
+    wkb = table["geometry"][0].as_py().hex()
+
+    if compat.USE_SHAPELY_20:
+        # correct ISO flavor
+        assert wkb == "01e9030000000000000000f03f00000000000000400000000000000840"
+    else:
+        assert wkb == "0101000080000000000000f03f00000000000000400000000000000840"
+
+
 @pytest.mark.parametrize(
     "format,schema_version",
     product(["feather", "parquet"], [None] + SUPPORTED_VERSIONS),
@@ -699,7 +722,6 @@ def test_write_read_default_crs(tmpdir, format):
 def test_write_spec_version(tmpdir, format, schema_version):
     if format == "feather":
         from pyarrow.feather import read_table
-
     else:
         from pyarrow.parquet import read_table