diff --git a/tools/extract/leonida/section.py b/tools/extract/leonida/section.py index f149580..4ec6d98 100644 --- a/tools/extract/leonida/section.py +++ b/tools/extract/leonida/section.py @@ -104,17 +104,23 @@ def junction_arms(graph: Graph, node: int, kerb_only: bool = False) -> list: return arme -def junction_area(graph: Graph, node: int): +def junction_area(graph: Graph, node: int, kerb_only: bool = False): """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. + + kerb_only rundet nur den Kreuzungsanteil der bordsteintragenden Arme (Befund 7 der Fixwelle, + für die Gehwegquelle in street.py): Eine Gasse liefert dabei gar keinen Arm in die zu + schließende Fläche, kann also auch keine Nische mit einem Nachbararm bilden - anders als + Fixrunde 4, die dieselbe Rundung auf ALLE Arme anwandte und damit Gassen mit rundete. """ - if not graph.incident(node): + incident = graph.incident(node) + if not incident or (kerb_only and not any(SECTION[graph.edges[k].klass].kerb for k in incident)): return None reach = setback(graph, node) - star = unary_union(junction_arms(graph, node)) + star = unary_union(junction_arms(graph, node, kerb_only=kerb_only)) 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)) diff --git a/tools/extract/leonida/street.py b/tools/extract/leonida/street.py index 5fd3ca7..7f41c79 100644 --- a/tools/extract/leonida/street.py +++ b/tools/extract/leonida/street.py @@ -14,7 +14,7 @@ from shapely.geometry import LineString, MultiPolygon, Polygon from shapely.ops import unary_union from .roads import Graph -from .section import SECTION, WALK_MAX, carriageway_area, junction_arms, ribbon, to_multipolygon +from .section import SECTION, WALK_MAX, carriageway_area, junction_area, ribbon, to_multipolygon # Die Tiefe steht hier als dokumentierter Wert; gebaut wird sie im Browser (geometry.js). DEPTH = 0.15 @@ -47,19 +47,27 @@ def build_street(graph: Graph, corridor, bridges) -> Street: def _walk_source(graph: Graph): """Die Fahrbahnteile, an denen ein Gehweg entstehen darf. - Alle Kanten mit Bordstein, dazu an jeder Kreuzung nur die Armstummel mit Bordstein - roh, - ohne die Eckrundung von junction_area() (Fixrunde 5): Die volle, gerundete Kreuzungsfläche - brachte in Fixrunde 4 immer noch zu viel Gasse mit. Der Grund war nicht die Zuschnittbreite, - sondern die Rundung selbst - sie füllt die einspringende Ecke zwischen zwei benachbarten - Gassenstummeln zu einer Nische auf, die abseits der Gassenachse liegt und die ein linearer - Abzug entlang dieser Achse deshalb nie erreicht. Ohne die Rundung entsteht die Nische gar - nicht erst: Die Quelle ist exakt die Fahrbahn mit Bordstein, ungerundet, und der Gehweg - wächst von dort WALK_MAX weit - um die Ecke herum, aber nie eine Gasse hinauf. + Alle Kanten mit Bordstein, dazu an jeder Kreuzung der Kreuzungsanteil nur der Armstummel mit + Bordstein, mit derselben Eckrundung wie junction_area() (Befund 7 der Fixwelle). Fixrunde 5 + hatte die Rundung noch ganz weggelassen: Die volle, auf ALLE Arme angewandte Rundung brachte + in Fixrunde 4 zu viel Gasse mit, weil sie die einspringende Ecke zwischen zwei benachbarten + Gassenstummeln zu einer Nische auffüllte. Der eigentliche Fehler war aber, Gassenarme + überhaupt in die zu rundende Fläche aufzunehmen - nicht die Rundung selbst. Ohne sie blieb + stattdessen an jeder gewöhnlichen Kreuzung (mehrere Arme mit Bordstein, keine Gasse + beteiligt) eine Lücke zwischen der ungerundeten Gehwegquelle und der gerundeten Fahrbahn: + 0,26-2,2 m² je Ecke bei 690 Kreuzungen, weil `carriage` (carriageway_area(), gerundet mit + FILLET=6) und `walk_source` (ungerundet) an der Kreuzungsecke verschieden geformt waren und + eine reine Dilatation um WALK_MAX=3 eine Reflexecke nicht nachzeichnet. + + junction_area(kerb_only=True) rundet jetzt nur den Kreuzungsanteil der bordsteintragenden + Arme: Eine Gasse liefert dabei keinen Arm in die zu schließende Fläche und kann deshalb auch + keine Nische mit einem Nachbararm bilden, gewöhnliche Kreuzungsecken bekommen aber dieselbe + Rundung wie die Fahrbahn und schließen die Lücke. """ teile = [band for k, edge in enumerate(graph.edges) if SECTION[edge.klass].kerb and (band := ribbon(graph, k)) is not None] - teile += [arm for node in range(len(graph.nodes)) if graph.degree(node) >= 3 - for arm in junction_arms(graph, node, kerb_only=True)] + teile += [area for node in range(len(graph.nodes)) if graph.degree(node) >= 3 + and (area := junction_area(graph, node, kerb_only=True)) is not None and not area.is_empty] return unary_union(teile) if teile else None @@ -73,7 +81,8 @@ def _kerbless_zone(graph: Graph, extra: float): Bis Fixrunde 4 schnitt dieselbe Zone auch den Kreuzungsanteil der Gehwegquelle zu; seit Fixrunde 5 nimmt _walk_source() stattdessen von vornherein nur die Arme mit Bordstein - (junction_arms(kerb_only=True)) und braucht deshalb keinen nachträglichen Abzug mehr. + (junction_area(kerb_only=True), Befund 7 der Fixwelle) und braucht deshalb keinen + nachträglichen Abzug mehr. """ zonen = [LineString(edge.points).buffer(SECTION[edge.klass].carriageway / 2 + extra, cap_style="flat", join_style="mitre", mitre_limit=1.5) diff --git a/tools/extract/tests/test_street.py b/tools/extract/tests/test_street.py index 248bed7..24f8370 100644 --- a/tools/extract/tests/test_street.py +++ b/tools/extract/tests/test_street.py @@ -1,6 +1,8 @@ """Die Mulde: Löcher in der Bodenplatte, Bordsteinlinien, Gehwegflächen, Restflächen.""" from __future__ import annotations +import math + from shapely.geometry import LineString, Point, box from shapely.ops import unary_union @@ -227,3 +229,60 @@ def test_gehwegrest_an_kreuzung_mit_mehreren_gassen_bleibt_aus(): # von rund WALK_MAX + halbe Gassenbreite (~6 m Ausdehnung); 1,0 m² lässt Spielraum für # künftige, geometrisch bedingte Rundungsreste, ohne die Kernaussage zu verwässern. assert rest.area < 1.0, "die Nische zwischen zwei Gassenstummeln bleibt frei von Gehweg" + + +def test_gehweg_schliesst_die_luecke_an_gewoehnlicher_kreuzungsecke(): + """Befund 7 der Fixwelle: An einer gewöhnlichen Kreuzung (mehrere Arme mit Bordstein, keine + Gasse beteiligt) klaffte zwischen der gerundeten Fahrbahnecke (carriageway_area() rundet mit + FILLET=6,0) und der ungerundeten Gehwegquelle eine kleine Lücke - eine reine Dilatation der + Quelle um WALK_MAX=3,0 zeichnet eine Reflexecke nicht nach. Am echten Netz 0,26-2,2 m² je + Ecke bei 690 Kreuzungen; hier reproduziert an einer T-Kreuzung aus drei Wohnstraßen-Armen, + mit demselben großzügigen Korridor wie in den Gassen-Tests oben (carriageway_area() + 3 m). + Vor der Korrektur maß jede der beiden Ecken exakt 0,256 m² Lücke (52 gerundete Doppelpunkte + davon zusammen 0,5114 m²) - innerhalb des berichteten Bereichs. + + _walk_source() rundet den Kreuzungsanteil jetzt mit derselben Schließung wie carriageway_area() + (junction_area(kerb_only=True)), sodass Fahrbahn- und Gehwegquelle an der Ecke wieder + zueinander passen. + """ + graph = Graph( + nodes=[Node((0.0, 0.0)), Node((-50.0, 0.0)), Node((50.0, 0.0)), Node((0.0, -50.0))], + edges=[Edge(0, 1, [(0.0, 0.0), (-50.0, 0.0)], SECTION["wohnstrasse"].corridor, "wohnstrasse"), + Edge(0, 2, [(0.0, 0.0), (50.0, 0.0)], SECTION["wohnstrasse"].corridor, "wohnstrasse"), + Edge(0, 3, [(0.0, 0.0), (0.0, -50.0)], SECTION["wohnstrasse"].corridor, "wohnstrasse")]) + corridor = carriageway_area(graph).buffer(3.0, join_style="round") + street = build_street(graph, corridor, bridges=[]) + # Der rohe Rest (vor dem LOT_MIN-Filter aus _lots()): An einer gewöhnlichen Ecke darf davon + # nichts übrig bleiben, sonst wird die Lücke später als zu kleines Reststück verworfen und + # bleibt ein unsichtbares Loch im Boden. + roh_rest = corridor.difference(street.carriageway).difference(street.sidewalk) + ecken = Point(0, 0).buffer(15.0) + luecke = sum(g.area for g in getattr(roh_rest, "geoms", [roh_rest]) + if not g.is_empty and g.intersects(ecken) and g.area < LOT_MIN) + assert luecke < 0.05, f"{luecke:.4f} m² Lücke an den Kreuzungsecken, sollte geschlossen sein" + + +def test_gehweg_waechst_auch_bei_engem_winkel_nicht_in_die_gasse(): + """Gegenprobe zu obigem Befund-7-Fix, am schärfsten denkbaren Fall: Die Rundung des + Kreuzungsanteils darf nicht wieder (wie in Fixrunde 4) über eine benachbarte Gasse + hinwegreichen, auch wenn die Gasse in einem enges Winkel zwischen zwei Armen mit Bordstein + liegt - der Fall, in dem eine Schließung am ehesten eine Nische neben der Gasse mitfüllen + könnte. Zwei Wohnstraßen-Arme bei 0° und 40°, eine Gasse dazwischen, bei 10° bis 30° in 5°- + Schritten durchprobiert: In jedem Fall bleibt der Gehweg außerhalb des Gassenkorridors, weil + die Gasse selbst nie einen Arm in die zu schließende Fläche liefert (kerb_only=True). + """ + for grad in (10.0, 15.0, 20.0, 25.0, 30.0): + rad = math.radians(grad) + a = (60.0, 0.0) + b = (60.0 * math.cos(math.radians(40.0)), 60.0 * math.sin(math.radians(40.0))) + g = (60.0 * math.cos(rad), 60.0 * math.sin(rad)) + graph = Graph( + nodes=[Node((0.0, 0.0)), Node(a), Node(b), Node(g)], + edges=[Edge(0, 1, [(0.0, 0.0), a], SECTION["wohnstrasse"].corridor, "wohnstrasse"), + Edge(0, 2, [(0.0, 0.0), b], SECTION["wohnstrasse"].corridor, "wohnstrasse"), + Edge(0, 3, [(0.0, 0.0), g], SECTION["gasse"].corridor, "gasse")]) + corridor = carriageway_area(graph).buffer(3.0, join_style="round") + street = build_street(graph, corridor, bridges=[]) + gassen_korridor = LineString([(0.0, 0.0), g]).buffer(SECTION["gasse"].corridor / 2, cap_style="flat") + leck = street.sidewalk.intersection(gassen_korridor).area + assert leck < 0.01, f"{leck:.4f} m² Gehweg im Gassenkorridor bei {grad}° Gassenwinkel"