Skip to content

Commit b1e73f5

Browse files
committed
Merge branch 'COSIN3-UOC-feature/nodes_from_edges_intersections' into main
Merging #51 and dealing with conflicts.
2 parents f03b0f4 + 846ddc1 commit b1e73f5

3 files changed

Lines changed: 479 additions & 44 deletions

File tree

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
[tool.pytest.ini_options]
2+
pythonpath = [
3+
"src",
4+
]

src/snkit/network.py

Lines changed: 127 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@
2727
shape,
2828
mapping,
2929
)
30-
from shapely.ops import split, linemerge
30+
from shapely.ops import split, linemerge, unary_union
31+
32+
from collections import Counter
3133

3234
# optional progress bars
3335
if "SNKIT_PROGRESS" in os.environ and os.environ["SNKIT_PROGRESS"] in ("1", "TRUE"):
@@ -267,12 +269,48 @@ def split_edges_at_nodes(network, tolerance=1e-9):
267269

268270
# combine dfs
269271
edges = pandas.concat(split_edges, axis=0)
270-
# reset index and drop
271272
edges = edges.reset_index().drop("index", axis=1)
272-
# return new network with split edges
273+
273274
return Network(nodes=network.nodes, edges=edges)
274275

275276

277+
def split_edges_at_intersections(network, tolerance=1e-9):
278+
"""Split network edges where they intersect line geometries"""
279+
split_edges = []
280+
split_points = []
281+
for edge in tqdm(
282+
network.edges.itertuples(index=False), desc="split", total=len(network.edges)
283+
):
284+
# note: the symmetry of intersection is not exploited here.
285+
# (If A intersects B, then B intersects A)
286+
# since edges are not modified within the loop, this has just
287+
# potential performance consequences.
288+
289+
hits_points = edges_intersecting_points(edge.geometry, network.edges, tolerance)
290+
291+
# store the split edges and intersection points
292+
split_points.extend(hits_points)
293+
hits_points = MultiPoint(hits_points)
294+
edges = split_edge_at_points(edge, hits_points, tolerance)
295+
split_edges.append(edges)
296+
297+
# add the (potentially) split edges
298+
edges = pandas.concat(split_edges, axis=0)
299+
edges = edges.reset_index().drop("index", axis=1)
300+
301+
# combine the original nodes with the new intersection nodes
302+
# dropping the duplicates.
303+
# note: there are at least duplicates from above since intersections
304+
# are checked twice
305+
# note: intersection nodes are appended, and if any duplicates, the
306+
# original counterparts are kept.
307+
nodes = GeoDataFrame(geometry=split_points)
308+
nodes = pandas.concat([network.nodes, nodes], axis=0).drop_duplicates()
309+
nodes = nodes.reset_index().drop("index", axis=1)
310+
311+
return Network(nodes=nodes, edges=edges)
312+
313+
276314
def link_nodes_to_edges_within(network, distance, condition=None, tolerance=1e-9):
277315
"""Link nodes to all edges within some distance"""
278316
new_node_geoms = []
@@ -521,6 +559,43 @@ def edges_within(point, edges, distance):
521559
return d_within(point, edges, distance)
522560

523561

562+
def edges_intersecting_points(line, edges, tolerance=1e-9):
563+
"""Return intersection points of intersecting edges"""
564+
hits = edges_intersecting(line, edges, tolerance)
565+
566+
hits_points = []
567+
for hit in hits.geometry:
568+
# first extract the actual intersections from the hits
569+
# for being new geometrical objects, they are not in the sindex
570+
intersection = line.intersection(hit)
571+
# if the line is not simple, there is a self-crossing point
572+
# (note that it will always interact with itself)
573+
# note that __eq__ is used on purpose instead of equals()
574+
# this is stricter: for geometries constructed in the same way
575+
# it makes sense since the sindex is used here
576+
if line == hit and not line.is_simple:
577+
# there is not built-in way to find self-crossing points
578+
# duplicated points after unary_union are the intersections
579+
intersection = unary_union(line)
580+
segments_coordinates = []
581+
for seg in intersection.geoms:
582+
segments_coordinates.extend(list(seg.coords))
583+
intersection = [
584+
Point(p) for p, c in Counter(segments_coordinates).items() if c > 1
585+
]
586+
intersection = MultiPoint(intersection)
587+
588+
# then extract the intersection points
589+
hits_points = intersection_endpoints(intersection, hits_points)
590+
591+
return hits_points
592+
593+
594+
def edges_intersecting(line, edges, tolerance=1e-9):
595+
"""Find edges intersecting line"""
596+
return intersects(line, edges, tolerance)
597+
598+
524599
def nodes_intersecting(line, nodes, tolerance=1e-9):
525600
"""Find nodes intersecting line"""
526601
return intersects(line, nodes, tolerance)
@@ -569,12 +644,45 @@ def line_endpoints(line):
569644
return start, end
570645

571646

647+
def intersection_endpoints(geom, output=None):
648+
"""Return the points from an intersection geometry
649+
650+
It extracts the starting and ending points of intersection
651+
geometries recursively and appends them to `output`.
652+
This doesn't handle polygons or collections of polygons.
653+
"""
654+
if output is None:
655+
output = []
656+
657+
geom_type = geom.geom_type
658+
if geom.is_empty:
659+
pass
660+
elif geom_type == "Point":
661+
output.append(geom)
662+
elif geom_type == "LineString":
663+
start = Point(geom.coords[0])
664+
end = Point(geom.coords[-1])
665+
output.append(start)
666+
output.append(end)
667+
# recursively for collections of geometries
668+
# note that there is no shared inheritance relationship
669+
elif (
670+
geom_type == "MultiPoint"
671+
or geom_type == "MultiLineString"
672+
or geom_type == "GeometryCollection"
673+
):
674+
for geom_ in geom.geoms:
675+
output = intersection_endpoints(geom_, output)
676+
677+
return output
678+
679+
572680
def split_edge_at_points(edge, points, tolerance=1e-9):
573681
"""Split edge at point/multipoint"""
574682
try:
575683
segments = split_line(edge.geometry, points, tolerance)
576684
except ValueError:
577-
# if splitting fails, e.g. becuase points is empty GeometryCollection
685+
# if splitting fails, e.g. because points is empty GeometryCollection
578686
segments = [edge.geometry]
579687
edges = GeoDataFrame([edge] * len(segments))
580688
edges.geometry = segments
@@ -584,6 +692,13 @@ def split_edge_at_points(edge, points, tolerance=1e-9):
584692
def split_line(line, points, tolerance=1e-9):
585693
"""Split line at point or multipoint, within some tolerance"""
586694
to_split = snap_line(line, points, tolerance)
695+
# when the splitter is a self-intersection point, shapely splits in
696+
# two parts only in a semi-arbitrary way, see the related question:
697+
# https://gis.stackexchange.com/questions/435879/python-shapely-split-a-complex-line-at-self-intersections?noredirect=1#comment711214_435879
698+
# checking only that the line is complex might not be enough
699+
# but the difference operation is useless in the worst case
700+
if not to_split.is_simple:
701+
to_split = to_split.difference(points)
587702
return list(split(to_split, points).geoms)
588703

589704

@@ -675,14 +790,20 @@ def to_networkx(network, directed=False, weight_col=None):
675790
G.add_nodes_from(network.nodes.id.to_list())
676791

677792
# add nodal positions from geom
678-
for node_id, x, y in zip(network.nodes.id, network.nodes.geometry.x, network.nodes.geometry.y):
793+
for node_id, x, y in zip(
794+
network.nodes.id, network.nodes.geometry.x, network.nodes.geometry.y
795+
):
679796
G.nodes[node_id]["pos"] = (x, y)
680797

681798
# get edges from network data
682799
if weight_col is None:
683800
# default to geometry length
684801
edges_as_list = list(
685-
zip(network.edges.from_id, network.edges.to_id, network.edges.geometry.length)
802+
zip(
803+
network.edges.from_id,
804+
network.edges.to_id,
805+
network.edges.geometry.length,
806+
)
686807
)
687808
else:
688809
edges_as_list = list(

0 commit comments

Comments
 (0)