diff --git a/tools/extract/leonida/street.py b/tools/extract/leonida/street.py index f35967f..e10cf3f 100644 --- a/tools/extract/leonida/street.py +++ b/tools/extract/leonida/street.py @@ -10,11 +10,11 @@ from __future__ import annotations from dataclasses import dataclass, field -from shapely.geometry import LineString, Polygon +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, ribbon +from .section import SECTION, WALK_MAX, carriageway_area # Die Tiefe steht hier als dokumentierter Wert; gebaut wird sie im Browser (geometry.js). DEPTH = 0.15 @@ -39,7 +39,7 @@ def build_street(graph: Graph, corridor, bridges) -> Street: carriageway=carriage, kerb=_kerb_lines(graph, carriage, bridges), sidewalk=_sidewalk(carriage, corridor), - lots=_lots(corridor, carriage), + lots=_lots(carriage, corridor), ) @@ -50,14 +50,29 @@ def _sidewalk(carriage, corridor): 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. + + 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. """ - return carriage.buffer(WALK_MAX, join_style="round").intersection(corridor).difference(carriage) + result = carriage.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]) def _kerb_lines(graph: Graph, carriage, bridges) -> list[LineString]: """Die Fahrbahnkante, ohne die Stücke an Gassen und auf Brücken.""" - ohne = [band.buffer(0.2) for k, edge in enumerate(graph.edges) - if not SECTION[edge.klass].kerb and (band := ribbon(graph, k)) is not None] + 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 += [b.buffer(0.2) for b in bridges] rand = carriage.boundary if ohne: @@ -66,8 +81,15 @@ 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(corridor, carriage) -> list[Polygon]: - rest = corridor.difference(carriage) +def _lots(carriage, corridor) -> 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. + """ + rest = corridor.difference(carriage.buffer(WALK_MAX, join_style="round")) 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 756e02d..88fa291 100644 --- a/tools/extract/tests/test_street.py +++ b/tools/extract/tests/test_street.py @@ -1,7 +1,7 @@ """Die Mulde: Löcher in der Bodenplatte, Bordsteinlinien, Gehwegflächen, Restflächen.""" from __future__ import annotations -from shapely.geometry import Point, box +from shapely.geometry import LineString, Point, box from shapely.ops import unary_union from leonida.roads import Edge, Graph, Node @@ -25,12 +25,17 @@ def test_gehweg_fuellt_genau_den_rest_des_korridors(): # 100 m lang, zwei Streifen von 2,5 m. Die Enden sind offen, deshalb genau die Fläche. assert round(street.sidewalk.area) == round(100 * 2 * section.walk) assert street.sidewalk.intersection(street.carriageway).area < 0.01 + assert street.sidewalk.geom_type == "MultiPolygon" 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. + assert street.sidewalk.geom_type == "MultiPolygon" def test_bordstein_laeuft_entlang_der_fahrbahnkante(): @@ -66,4 +71,36 @@ def test_kleine_reststuecke_fallen_weg_grosse_bleiben(): street = build_street(strasse(), weit, bridges=[]) assert all(lot.area >= LOT_MIN for lot in street.lots) assert any(lot.area > 500 for lot in street.lots), "der Parkplatz bleibt" - assert len(street.lots) == 2, "der 2 m² große Splitter ist tatsächlich herausgefallen" + # Nur die Ausbuchtung (30x30 minus den 0,5 m Gehwegsaum an ihrer Unterkante = 885 m²) bleibt. + # Die beiden Seitenstreifen des Basiskorridors (je 250 m²) sind jetzt korrekt als Gehweg + # erkannt, nicht mehr als zusätzliche Restfläche gezählt - genau der Fehler aus Fixrunde 1, + # Punkt 1 (_lots() zog vorher nur die Fahrbahn ab, nicht auch den Gehwegstreifen). + assert len(street.lots) == 1, "der 2 m² große Splitter ist herausgefallen, die Seitenstreifen sind Gehweg" + + +def test_gerade_strasse_ohne_ausbuchtung_hat_keine_restflaeche(): + # Fixrunde 1, Punkt 1: "Korridor minus Fahrbahn" allein ist bei jeder Klasse wörtlich der + # Gehweg (SECTION garantiert carriageway + 2*walk == corridor). Ohne den Gehwegabzug in + # _lots() wären die beiden 2,5-m-Seitenstreifen (je 250 m², ab 80 m Länge über LOT_MIN) + # eine zweite, asphaltfarbene Restfläche genau dort, wo schon der Gehweg liegt. + street = build_street(strasse(), korridor(), bridges=[]) + assert street.lots == [] + + +def test_bordstein_frei_an_kurzer_gasseneinmuendung(): + # Fixrunde 1, Punkt 2, mit einer nachgerechneten Korrektur der Gassenlänge (siehe Bericht): + # Bei der vom Auftrag genannten 30 m langen Gasse liefert ribbon() *kein* None (Setback 5,5 m, + # 30-5,5=24,5 >= 1,0) - ribbon() schneidet dort ganz regulär zurück, und der alte Ausschluss + # deckt die Gasse bereits vollständig ab. Der Fehler braucht eine Gasse kürzer als + # setback+1=6,5 m: Erst dann liefert trimmed_line() None, der alte Code fügt für diese Kante + # überhaupt keinen Ausschluss ein (der Walrus-Filter "is not None" schlägt fehl), und der + # Bordstein läuft quer über die Einmündung, statt an ihr abgesenkt zu sein. + graph = Graph(nodes=[Node((0.0, 0.0)), Node((50.0, 0.0)), Node((100.0, 0.0)), Node((50.0, -5.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, -5.0)], SECTION["gasse"].corridor, "gasse")]) + assert graph.degree(1) == 3 + street = build_street(graph, box(-20, -40, 120, 20), bridges=[]) + 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"