diff --git a/tools/extract/leonida/section.py b/tools/extract/leonida/section.py index 7ca4aea..011b231 100644 --- a/tools/extract/leonida/section.py +++ b/tools/extract/leonida/section.py @@ -71,3 +71,41 @@ def ribbon(graph: Graph, k: int): return None return line.buffer(SECTION[graph.edges[k].klass].carriageway / 2, cap_style="flat", join_style="mitre", mitre_limit=1.5) + + +# Amerikanischer Eckradius, 20 ft. Er entscheidet über den Charakter der Kreuzung: größer wirkt +# großzügig und vorstädtisch, kleiner eng und städtisch. +FILLET = 6.0 + + +def junction_area(graph: Graph, node: int): + """Die Fläche einer Kreuzung: die Stummel aller Arme, mit gerundeten einspringenden Ecken. + + Gerundet wird durch Schließen (aufblasen, dann wieder schrumpfen) - und ausdrücklich nur + innerhalb einer Scheibe um den Knoten. Auf das ganze Netz angewandt verklebte dieselbe + Operation zwei parallele Straßen, deren Fahrbahnen weniger als 2 * FILLET auseinanderliegen. + """ + incident = graph.incident(node) + if not incident: + return None + reach = setback(graph, node) + arms = [] + for k in incident: + edge = graph.edges[k] + points = edge.points if edge.a == node else list(reversed(edge.points)) + line = LineString(points) + stub = substring(line, 0.0, min(reach + 1.0, line.length)) + arms.append(stub.buffer(SECTION[edge.klass].carriageway / 2, + cap_style="flat", join_style="mitre", mitre_limit=1.5)) + star = unary_union(arms) + near = Point(graph.nodes[node].point).buffer(reach + FILLET + 2.0) + rounded = star.buffer(FILLET, join_style="round").buffer(-FILLET, join_style="round") + return star.union(rounded.intersection(near)) + + +def carriageway_area(graph: Graph): + """Alle Bänder und alle Kreuzungsflächen als eine Geometrie.""" + parts = [band for k in range(len(graph.edges)) if (band := ribbon(graph, k)) is not None] + parts += [area for node in range(len(graph.nodes)) if graph.degree(node) >= 3 + and (area := junction_area(graph, node)) is not None] + return unary_union(parts) diff --git a/tools/extract/tests/test_junction.py b/tools/extract/tests/test_junction.py new file mode 100644 index 0000000..725bb73 --- /dev/null +++ b/tools/extract/tests/test_junction.py @@ -0,0 +1,44 @@ +"""Kreuzungsflächen und die Fahrbahn als Ganzes.""" +from __future__ import annotations + +from shapely.geometry import Point + +from leonida.roads import Edge, Graph, Node +from leonida.section import FILLET, carriageway_area, junction_area + + +def kreuzung(klass: str = "hauptstrasse", arm: float = 60.0) -> Graph: + return Graph( + nodes=[Node((0.0, 0.0)), Node((arm, 0.0)), Node((-arm, 0.0)), Node((0.0, arm)), Node((0.0, -arm))], + edges=[Edge(0, i, [(0.0, 0.0), p], 18.0, klass) + for i, p in ((1, (arm, 0.0)), (2, (-arm, 0.0)), (3, (0.0, arm)), (4, (0.0, -arm)))]) + + +def test_kreuzung_deckt_den_knoten_ab(): + area = junction_area(kreuzung(), 0) + assert area.contains(Point(0, 0)) + + +def test_einspringende_ecke_ist_gerundet(): + area = junction_area(kreuzung(), 0) + # Ohne Rundung endete die Fläche an der Ecke (6, 6) scharf - halbe Fahrbahn in beide + # Richtungen. Mit 6 m Radius ist die einspringende Ecke ausgefüllt. + assert area.contains(Point(6.5, 6.5)) + assert FILLET == 6.0 + + +def test_zwei_parallele_strassen_verschmelzen_nicht(): + # Zwei Wohnstraßen mit 10 m Abstand der Mittellinien: Fahrbahn je 7 m, dazwischen 3 m Lücke. + # Ein global angewandtes Schließen mit 6 m würde sie zu einer Fläche verkleben. + graph = Graph(nodes=[Node((0.0, 0.0)), Node((100.0, 0.0)), Node((0.0, 10.0)), Node((100.0, 10.0))], + edges=[Edge(0, 1, [(0.0, 0.0), (100.0, 0.0)], 12.0, "wohnstrasse"), + Edge(2, 3, [(0.0, 10.0), (100.0, 10.0)], 12.0, "wohnstrasse")]) + area = carriageway_area(graph) + assert len(area.geoms) == 2, "getrennte Straßen bleiben getrennt" + + +def test_fahrbahn_enthaelt_baender_und_kreuzung(): + area = carriageway_area(kreuzung()) + assert area.contains(Point(0, 0)), "die Kreuzung selbst" + assert area.contains(Point(40, 0)), "ein Band" + assert not area.contains(Point(40, 40)), "die Ecke zwischen zwei Armen bleibt frei"