diff --git a/tools/extract/leonida/street.py b/tools/extract/leonida/street.py index a0b94fc..6e09923 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 +from .section import SECTION, WALK_MAX, carriageway_area, junction_area, ribbon # Die Tiefe steht hier als dokumentierter Wert; gebaut wird sie im Browser (geometry.js). DEPTH = 0.15 @@ -35,51 +35,66 @@ class Street: def build_street(graph: Graph, corridor, bridges) -> Street: """Fahrbahn, Bordsteinlinien, Gehweg und Restflächen aus Graph und gemessenem Korridor.""" carriage = carriageway_area(graph) - sidewalk = _sidewalk(carriage, corridor, _kerbless_zone(graph, WALK_MAX)) + sidewalk = _sidewalk(_walk_source(graph), carriage, corridor) return Street( carriageway=carriage, - kerb=_kerb_lines(carriage, _kerbless_zone(graph, 0.2), bridges), + kerb=_kerb_lines(carriage, _kerbless_zone(graph), bridges), sidewalk=sidewalk, lots=_lots(carriage, corridor, sidewalk), ) -def _kerbless_zone(graph: Graph, extra: float): - """Der Korridor aller Klassen ohne Bordstein, aus den vollen Mittellinien, um `extra` über die - halbe Fahrbahnbreite hinaus gepuffert. +def _walk_source(graph: Graph): + """Die Fahrbahnteile, an denen ein Gehweg entstehen darf. - Zweimal gebraucht, mit unterschiedlicher Reichweite: Der Bordstein hört direkt an der - Fahrbahnkante auf (extra=0,2, nur eine Fuge für einen sauberen Schnitt), aber der Gehwegpuffer - in _sidewalk() reicht bis WALK_MAX über dieselbe Kante hinaus - mit der schmalen Fuge bliebe - dort ein bis zu 2,8 m breiter Streifen übrig, den eine Klasse ohne Gehweg trotzdem als Gehweg - bekäme, sobald neben ihr mehr gemessene Fläche liegt als ihre eigene Breite (siehe Bericht). - Die volle Mittellinie statt ribbon(), damit auch der Anteil der Kreuzungsfläche abgedeckt ist, - an dem die bordsteinlose Klasse einmündet. + Alle Kanten mit Bordstein, dazu jede Kreuzung, an der mindestens ein solcher Arm hängt. + Vorher wuchs der Gehweg aus der gesamten Fahrbahn, und die Klassen ohne Bordstein wurden + hinterher über eine Reichweitenzone wieder abgezogen — die kennt aber ihre Nachbarschaft + nicht und nahm dabei auch den Gehweg einer Wohnstraße mit, die zufällig nah an einer Gasse + lag. Wer von der richtigen Seite her wächst, braucht den Abzug gar nicht. """ - zonen = [LineString(edge.points).buffer(SECTION[edge.klass].carriageway / 2 + extra, + teile = [band for k, edge in enumerate(graph.edges) + if SECTION[edge.klass].kerb and (band := ribbon(graph, k)) is not None] + teile += [flaeche for node in range(len(graph.nodes)) + if graph.degree(node) >= 3 + and any(SECTION[graph.edges[k].klass].kerb for k in graph.incident(node)) + and (flaeche := junction_area(graph, node)) is not None] + return unary_union(teile) if teile else None + + +def _kerbless_zone(graph: Graph): + """Der Korridor aller Klassen ohne Bordstein, aus den vollen Mittellinien, um 0,2 m über die + halbe Fahrbahnbreite hinaus gepuffert - eine Fuge für einen sauberen Schnitt. + + Nur noch für _kerb_lines() gebraucht: Der Bordstein hört hier auf. Der Gehweg braucht diesen + Abzug seit Fixrunde 3 nicht mehr - er wächst aus _walk_source() und damit von vornherein nur + dort, wo eine Klasse mit Bordstein liegt, statt aus der ganzen Fahrbahn mit nachträglichem + Abzug einer Zone, die ihre Nachbarschaft nicht kennt. + """ + zonen = [LineString(edge.points).buffer(SECTION[edge.klass].carriageway / 2 + 0.2, cap_style="flat", join_style="mitre", mitre_limit=1.5) for edge in graph.edges if not SECTION[edge.klass].kerb and len(edge.points) >= 2] return unary_union(zonen) if zonen else None -def _sidewalk(carriage, corridor, kerbless): +def _sidewalk(walk_source, carriage, corridor): """Der Streifen zwischen Fahrbahnkante und Korridorrand. - Ein einziger Puffer für alle Klassen reicht: Weil am Korridor beschnitten wird, bleibt bei - 12 m Korridor und 7 m Fahrbahn genau der 2,5-m-Gehweg der Wohnstraße übrig, bei 18 m und - 12 m genau die 3,0 m der Hauptstraße. `kerbless` schneidet zusätzlich heraus, wo eine Klasse - laut SECTION gar keinen Gehweg vorsieht (die Gasse) - der Korridor allein reicht dafür nicht: - er füllt zwar meist genau die Gassenbreite, aber sobald daneben mehr gemessene Fläche liegt, - würde der WALK_MAX-Puffer sie sonst fälschlich zu Gehweg machen. + Wächst aus walk_source (nur die Fahrbahnteile mit Bordstein) statt aus der ganzen Fahrbahn: + Weil am Korridor beschnitten wird, bleibt bei 12 m Korridor und 7 m Fahrbahn genau der + 2,5-m-Gehweg der Wohnstraße übrig, bei 18 m und 12 m genau die 3,0 m der Hauptstraße - und + weil der Puffer erst gar nicht aus einer Gasse heraus wächst, kann er auch keinen fremden + Gehweg verschlucken, der zufällig nah an einer liegt. `.difference(carriage)` schneidet die + tatsächliche Fahrbahn heraus (auch die einer benachbarten Gasse), nicht nur die Quelle. Normalisiert wie carriageway_area() auf MultiPolygon (Task 6): Ein einseitiger Gehweg oder ein leeres Ergebnis kämen sonst als einfaches Polygon bzw. als leere Geometrie zurück, und Task 10/12 verlassen sich auf die MultiPolygon-Zusage. """ - result = carriage.buffer(WALK_MAX, join_style="round").intersection(corridor).difference(carriage) - if kerbless is not None: - result = result.difference(kerbless) + if walk_source is None or walk_source.is_empty: + return MultiPolygon() + result = walk_source.buffer(WALK_MAX, join_style="round").intersection(corridor).difference(carriage) if result.is_empty: return MultiPolygon() return result if result.geom_type == "MultiPolygon" else MultiPolygon([result]) diff --git a/tools/extract/tests/test_street.py b/tools/extract/tests/test_street.py index 04c852b..f658533 100644 --- a/tools/extract/tests/test_street.py +++ b/tools/extract/tests/test_street.py @@ -130,3 +130,48 @@ def test_fahrbahn_gehweg_und_rest_ergeben_wieder_den_korridor(): assert street.sidewalk.intersection(roh).area < 1e-6 bilanz = street.carriageway.area + street.sidewalk.area + roh.area assert abs(bilanz - weit.area) < 1e-6 + + +def test_aufteilung_geht_fuer_jede_klasse_auf(): + # Fixrunde 3, Punkt 4: keine Klasse ausgenommen, auch nicht die Gasse (dort ist der Gehweg + # leer, aber die Bilanz muss trotzdem exakt aufgehen: Fahrbahn plus roher Rest ergibt dann + # den ganzen Korridor). + for klass in SECTION: + street = build_street(strasse(klass), korridor(klass), bridges=[]) + roh = korridor(klass).difference(street.carriageway).difference(street.sidewalk) + bilanz = street.carriageway.area + street.sidewalk.area + roh.area + assert abs(bilanz - korridor(klass).area) < 1e-6, klass + + +def test_wohnstrasse_behaelt_ihren_gehweg_trotz_naher_paralleler_gasse(): + # Fixrunde 3: Der alte Ansatz ließ den Gehweg aus der *ganzen* Fahrbahn wachsen und zog + # danach eine Reichweitenzone um jede bordsteinlose Klasse wieder ab. Diese Zone kennt ihre + # Nachbarschaft nicht - jeder Punkt näher als WALK_MAX=3 m an einer Gassen-Mittellinie fiel + # weg, auch wenn dort tatsächlich eine Wohnstraße liegt. Wohnstraße bei y=0 (Korridor bis + # y=6), Gasse parallel bei y=9,5 (Korridor ab y=6,5) - 0,5 m Lücke zwischen den Korridoren, + # die Gassen-Mittellinie liegt trotzdem nur 9,5 m entfernt, deutlich innerhalb der alten, + # 6 m weiten Reichweitenzone (carriageway/2 + WALK_MAX = 3+3). + graph = Graph(nodes=[Node((0.0, 0.0)), Node((100.0, 0.0)), Node((0.0, 9.5)), Node((100.0, 9.5))], + edges=[Edge(0, 1, [(0.0, 0.0), (100.0, 0.0)], SECTION["wohnstrasse"].corridor, "wohnstrasse"), + Edge(2, 3, [(0.0, 9.5), (100.0, 9.5)], SECTION["gasse"].corridor, "gasse")]) + corridor = unary_union([box(0, -6, 100, 6), box(0, 6.5, 100, 12.5)]) + street = build_street(graph, corridor, bridges=[]) + assert round(street.sidewalk.area) == 500, "die volle Gehwegfläche der Wohnstraße bleibt" + + +def test_wohnstrassen_gehweg_bleibt_vollstaendig_an_gasseneinmuendung(): + # Fixrunde 3: an der T-Einmündung reichte dieselbe Reichweitenzone der Gasse (jetzt um ihre + # volle Mittellinie, wegen Fixrunde 1) symmetrisch in beide Wohnstraßen-Gehwege an der Ecke + # hinein - unabhängig von der Gassenlänge, solange die Gasse nicht selbst in der Nähe endet + # (deshalb hier 10 m lang, nicht die 5 m aus dem Bordstein-Test oben). Zwei Punkte, die genau + # in den vorher fehlenden, je 3,79 m² großen Flächen an den gerundeten Kreuzungsecken liegen. + graph = Graph(nodes=[Node((0.0, 0.0)), Node((50.0, 0.0)), Node((100.0, 0.0)), Node((50.0, -10.0))], + edges=[Edge(0, 1, [(0.0, 0.0), (50.0, 0.0)], SECTION["wohnstrasse"].corridor, "wohnstrasse"), + Edge(1, 2, [(50.0, 0.0), (100.0, 0.0)], SECTION["wohnstrasse"].corridor, "wohnstrasse"), + Edge(1, 3, [(50.0, 0.0), (50.0, -10.0)], SECTION["gasse"].corridor, "gasse")]) + wohn_korridor = box(0, -6, 100, 6) + gassen_korridor = LineString([(50.0, 0.0), (50.0, -10.0)]).buffer(SECTION["gasse"].corridor / 2, + cap_style="flat") + street = build_street(graph, unary_union([wohn_korridor, gassen_korridor]), bridges=[]) + assert street.sidewalk.contains(Point(45.0, -5.0)), "Wohnstraßen-Gehweg links der Einmündung" + assert street.sidewalk.contains(Point(55.0, -5.0)), "Wohnstraßen-Gehweg rechts der Einmündung"