Repository: pysal/momepy

Issue:
remove_false_nodes throws an unexpected error
### Describe the problem

When trying to remove false nodes from a [street network obtained from Ordnance Survey](https://www.ordnancesurvey.co.uk/products/os-open-roads) I get the following error:

```
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[56], line 1
----> 1 streets_BR = mm.remove_false_nodes(streets_BR)
      2 len(streets_BR)

File [~\AppData\Local\miniconda3\envs\ox\Lib\site-packages\momepy\preprocessing.py:260](http://localhost:8889/lab/tree/OneDrive%20-%20University%20of%20Strathclyde/FB/Birmingham/_scripts/~/AppData/Local/miniconda3/envs/ox/Lib/site-packages/momepy/preprocessing.py#line=259), in remove_false_nodes(gdf)
    256 node_coords = shapely.get_coordinates(target_nodes)
    257 coords = np.array(loop_geom.coords)
    258 new_start = (
    259     node_coords[0]
--> 260     if (node_coords[0] != coords[0]).all()
    261     else node_coords[1]
    262 )
    263 new_start_idx = np.where(coords == new_start)[0][0]
    264 rolled_coords = np.roll(coords[:-1], -new_start_idx, axis=0)

ValueError: operands could not be broadcast together with shapes (2,) (3,)
```

Any solution?

### Steps to reproduce

```
import geopandas as gpd
import momepy as mm
import pandas as pd

sk = gpd.read_file('data/OS Open Roads/SK_RoadLink.shp')
so = gpd.read_file('data/OS Open Roads/SO_RoadLink.shp')
sp = gpd.read_file('data/OS Open Roads/SP_RoadLink.shp')

streets = pd.concat([sk, so, sp], ignore_index=True)

streets = streets.reset_index(drop=True).drop_duplicates('identifier')

bound_BR = gpd.read_file('data/boundary.shp')

streets_BR = streets[streets.within(bound_BR)]

streets_BR = mm.remove_false_nodes(streets_BR)
```

### Versions of your packages

momepy = 0.8.0
geopandas = 1.0.1
libpysal = 4.12.1
networkx = 3.3

### Your operating system

Windows 10

### Additional context

_No response_

Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/pysal/momepy/pull/661?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pysal) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 98.0%. Comparing base [(`4037c70`)](https://app.codecov.io/gh/pysal/momepy/commit/4037c703ae35c0258939c4a2f326dbf08538bc20?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pysal) to head [(`49a2807`)](https://app.codecov.io/gh/pysal/momepy/commit/49a28070a23e5c30e63f0f3a44417b9276318039?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pysal).
> Report is 90 commits behind head on main.

<details><summary>Additional details and impacted files</summary>


[![Impacted file tree graph](https://app.codecov.io/gh/pysal/momepy/pull/661/graphs/tree.svg?width=650&height=150&src=pr&token=VNn0WR5JWT&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pysal)](https://app.codecov.io/gh/pysal/momepy/pull/661?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pysal)

```diff
@@           Coverage Diff           @@
##            main    #661     +/-   ##
=======================================
+ Coverage   97.4%   98.0%   +0.7%     
=======================================
  Files         26      40     +14     
  Lines       4328    7167   +2839     
=======================================
+ Hits        4214    7025   +2811     
- Misses       114     142     +28     
```

| [Files with missing lines](https://app.codecov.io/gh/pysal/momepy/pull/661?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pysal) | Coverage Δ | |
|---|---|---|
| [momepy/preprocessing.py](https://app.codecov.io/gh/pysal/momepy/pull/661?src=pr&el=tree&filepath=momepy%2Fpreprocessing.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pysal#diff-bW9tZXB5L3ByZXByb2Nlc3NpbmcucHk=) | `92.3% <100.0%> (-1.0%)` | :arrow_down: |
| [momepy/tests/test\_preprocess.py](https://app.codecov.io/gh/pysal/momepy/pull/661?src=pr&el=tree&filepath=momepy%2Ftests%2Ftest_preprocess.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pysal#diff-bW9tZXB5L3Rlc3RzL3Rlc3RfcHJlcHJvY2Vzcy5weQ==) | `100.0% <100.0%> (ø)` | |

</details>
martinfleis: pre-commit.ci autofix

Patch:
diff --git a/momepy/preprocessing.py b/momepy/preprocessing.py
index 196331a..839cdca 100644
--- a/momepy/preprocessing.py
+++ b/momepy/preprocessing.py
@@ -166,8 +166,8 @@ def preprocess(
 def remove_false_nodes(gdf):
     """
     Clean topology of existing LineString geometry by removal of nodes of degree 2.
-
-    Returns the original gdf if there's no node of degree 2.
+    Returns the original gdf if there's no node of degree 2. Some geometries may
+    be forced to 2D where a Z coordinate is present.
 
     Parameters
     ----------
@@ -178,7 +178,7 @@ def remove_false_nodes(gdf):
     -------
     gdf : GeoDataFrame, GeoSeries
 
-    See also
+    See Also
     --------
     momepy.extend_lines
     momepy.close_gaps
@@ -247,14 +247,16 @@ def remove_false_nodes(gdf):
             ),
             lines=False,
         )
+
         loops = combined[combined.is_ring]
+
         node_ix, loop_ix = loops.sindex.query(nodes.geometry, predicate="intersects")
         for ix in np.unique(loop_ix):
             loop_geom = loops.geometry.iloc[ix]
             target_nodes = nodes.geometry.iloc[node_ix[loop_ix == ix]]
             if len(target_nodes) == 2:
                 node_coords = shapely.get_coordinates(target_nodes)
-                coords = np.array(loop_geom.coords)
+                coords = shapely.get_coordinates(loop_geom)
                 new_start = (
                     node_coords[0]
                     if (node_coords[0] != coords[0]).all()


Test patch:
diff --git a/momepy/tests/test_preprocess.py b/momepy/tests/test_preprocess.py
index 98f8ee4..0180337 100644
--- a/momepy/tests/test_preprocess.py
+++ b/momepy/tests/test_preprocess.py
@@ -2,7 +2,7 @@ import geopandas as gpd
 import numpy as np
 import pytest
 from geopandas.testing import assert_geodataframe_equal
-from shapely import affinity
+from shapely import affinity, force_3d
 from shapely.geometry import LineString, MultiPoint, Point, Polygon
 from shapely.ops import polygonize
 
@@ -67,6 +67,16 @@ class TestPreprocessing:
         df = self.df_streets.drop([4, 7, 17, 22])
         assert_geodataframe_equal(df, mm.remove_false_nodes(df))
 
+        # check 3d coords in loop
+        line_1 = LineString((Point(1, 1), Point(2, 2)))
+        line_2 = LineString((Point(2, 2), Point(3, 3)))
+        line_3 = force_3d(LineString((Point(2, 2), Point(1, 2))))
+        line_4 = LineString((Point(1, 2), Point(1, 3)))
+        line_5 = LineString((Point(1, 3), Point(2, 2)))
+        with_3d = np.array([line_1, line_2, line_3, line_4, line_5])
+        no_3d = mm.remove_false_nodes(with_3d)
+        assert len(no_3d) == 3
+
     def test_CheckTessellationInput(self):
         df = self.df_buildings
         df.loc[144, "geometry"] = Polygon([(0, 0), (0, 1), (1, 0)])