diff --git a/tools/extract/leonida/street.py b/tools/extract/leonida/street.py index e10cf3f..a0b94fc 100644 --- a/tools/extract/leonida/street.py +++ b/tools/extract/leonida/street.py @@ -35,44 +35,59 @@ 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)) return Street( carriageway=carriage, - kerb=_kerb_lines(graph, carriage, bridges), - sidewalk=_sidewalk(carriage, corridor), - lots=_lots(carriage, corridor), + kerb=_kerb_lines(carriage, _kerbless_zone(graph, 0.2), bridges), + sidewalk=sidewalk, + lots=_lots(carriage, corridor, sidewalk), ) -def _sidewalk(carriage, corridor): +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. + + 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. + """ + zonen = [LineString(edge.points).buffer(SECTION[edge.klass].carriageway / 2 + extra, + 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): """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. Die Gasse füllt ihren Korridor ganz aus und bekommt - dadurch von selbst keinen Gehweg. + 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. 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 result.is_empty: return MultiPolygon() return result if result.geom_type == "MultiPolygon" else MultiPolygon([result]) -def _kerb_lines(graph: Graph, carriage, bridges) -> list[LineString]: +def _kerb_lines(carriage, kerbless, bridges) -> list[LineString]: """Die Fahrbahnkante, ohne die Stücke an Gassen und auf Brücken.""" - ohne = [] - for edge in graph.edges: - if SECTION[edge.klass].kerb or len(edge.points) < 2: - continue - # Die volle Mittellinie statt ribbon(): Das Band einer Gasse endet vor der Kreuzung, und - # der Bordstein bliebe genau im Einmündungstrichter stehen. Dass dabei auch die Kante der - # Hauptstraße über die Einmündungsbreite unterbrochen wird, ist richtig so — an einer - # Zufahrt ist der Bordstein abgesenkt. - ohne.append(LineString(edge.points).buffer(SECTION[edge.klass].carriageway / 2 + 0.2, - cap_style="flat", join_style="mitre", mitre_limit=1.5)) + ohne = [] if kerbless is None else [kerbless] ohne += [b.buffer(0.2) for b in bridges] rand = carriage.boundary if ohne: @@ -81,15 +96,14 @@ def _kerb_lines(graph: Graph, carriage, bridges) -> list[LineString]: return [g for g in teile if g.geom_type == "LineString" and g.length >= KERB_MIN] -def _lots(carriage, corridor) -> list[Polygon]: +def _lots(carriage, corridor, sidewalk) -> list[Polygon]: """Was weder Fahrbahn noch Gehweg ist: Parkplätze, Vorplätze, Rampen. - Der Gehwegstreifen muss mit abgezogen werden, nicht nur die Fahrbahn. Weil Fahrbahn plus - zweimal Gehweg je Klasse genau den Korridor ergibt, wäre "Korridor minus Fahrbahn" sonst - wörtlich der Gehweg — und läge auf jeder Straße ab 80 m ein zweites Mal als Restfläche - darüber. + Exakt gerechnet statt mit einem eigenen Puffer: Ein klassenunabhängiger 3-m-Abzug fräße an + einer Gasse (kein Gehweg) und an einer Wohnstraße (2,5 m) mehr weg, als dort Gehweg ist, und + ließe echte Pflasterflächen verschwinden. """ - rest = corridor.difference(carriage.buffer(WALK_MAX, join_style="round")) + rest = corridor.difference(carriage).difference(sidewalk) return [g for g in getattr(rest, "geoms", [rest]) if g.geom_type == "Polygon" and g.area >= LOT_MIN] diff --git a/tools/extract/tests/test_street.py b/tools/extract/tests/test_street.py index 88fa291..04c852b 100644 --- a/tools/extract/tests/test_street.py +++ b/tools/extract/tests/test_street.py @@ -32,9 +32,9 @@ def test_gasse_bekommt_weder_gehweg_noch_bordstein(): street = build_street(strasse("gasse"), korridor("gasse"), bridges=[]) assert street.sidewalk.area < 0.01 assert street.kerb == [] - # Der Grenzfall fuer die MultiPolygon-Zusage: ein leeres Ergebnis kaeme ohne Normalisierung - # als leeres Polygon zurueck, nicht als leeres MultiPolygon (wie carriageway_area() es seit - # Task 6 zusagt) - Task 10/12 iterieren ueber .geoms und wuerden daran brechen. + # Der Grenzfall für die MultiPolygon-Zusage: ein leeres Ergebnis käme ohne Normalisierung + # als leeres Polygon zurück, nicht als leeres MultiPolygon (wie carriageway_area() es seit + # Task 6 zusagt) - Task 10/12 iterieren über .geoms und würden daran brechen. assert street.sidewalk.geom_type == "MultiPolygon" @@ -104,3 +104,29 @@ def test_bordstein_frei_an_kurzer_gasseneinmuendung(): gassenkorridor = LineString([(50.0, 0.0), (50.0, -5.0)]).buffer(SECTION["gasse"].carriageway / 2) for line in street.kerb: assert not gassenkorridor.intersects(line), "die Zufahrt bleibt frei, kein Bordstein quer davor" + + +def test_pflasterflaeche_neben_der_gasse_ist_restflaeche_kein_gehweg(): + # Fixrunde 2: die Gasse hat laut SECTION überhaupt keinen Gehweg (walk=0.0). Eine echte, + # 3 m tiefe, 100 m lange Pflasterfläche direkt neben ihr - z.B. ein durchgehender Hofstreifen, + # den die Kartierung mit zur Straßenfläche zählt - darf nicht zu Gehweg werden, nur weil + # WALK_MAX=3.0 dort zufällig genau hinreicht. Sie muss als Restfläche erscheinen. + pflaster = box(0, 3, 100, 6) + weit = unary_union([korridor("gasse"), pflaster]) + street = build_street(strasse("gasse"), weit, bridges=[]) + assert street.sidewalk.area < 0.01, "die Gasse bekommt so wenig Gehweg wie sonst: keinen" + assert any(lot.area > 250 for lot in street.lots), "die Pflasterfläche bleibt als Restfläche" + + +def test_fahrbahn_gehweg_und_rest_ergeben_wieder_den_korridor(): + # Die drei Teile dürfen sich weder überlappen noch eine Lücke lassen - unabhängig vom + # LOT_MIN-Filter in street.lots prüfen wir hier den *rohen* Rest (vor dem Filter), denn nur + # der ergänzt Fahrbahn und Gehweg exakt wieder zum Korridor. + weit = unary_union([korridor(), box(40, 6, 70, 36), box(10, 8, 12, 9)]) + street = build_street(strasse(), weit, bridges=[]) + roh = weit.difference(street.carriageway).difference(street.sidewalk) + assert street.carriageway.intersection(street.sidewalk).area < 1e-6 + assert street.carriageway.intersection(roh).area < 1e-6 + assert street.sidewalk.intersection(roh).area < 1e-6 + bilanz = street.carriageway.area + street.sidewalk.area + roh.area + assert abs(bilanz - weit.area) < 1e-6