diff --git a/tools/extract/leonida/street.py b/tools/extract/leonida/street.py new file mode 100644 index 0000000..f35967f --- /dev/null +++ b/tools/extract/leonida/street.py @@ -0,0 +1,82 @@ +"""Die Straße als Mulde: Loch in der Bodenplatte, Fahrbahn darunter, Bordstein dazwischen. + +Warum die Fahrbahn nach unten und nicht der Gehweg nach oben (Entwurf, Abschnitt "Die Mulde"): +Läge der Gehweg 15 cm hoch, hätte er überall dort eine Kante ins Nichts, wo kein Haus +anschließt - an jedem Park, jeder Baulücke, jedem Strandzugang. So bleibt die ganze Welt auf +y = 0, und der Bordstein ist genau das, was er in Wirklichkeit ist: die Wand zwischen Gehweg +und Fahrbahn. +""" +from __future__ import annotations + +from dataclasses import dataclass, field + +from shapely.geometry import LineString, Polygon +from shapely.ops import unary_union + +from .roads import Graph +from .section import SECTION, WALK_MAX, carriageway_area, ribbon + +# Die Tiefe steht hier als dokumentierter Wert; gebaut wird sie im Browser (geometry.js). +DEPTH = 0.15 +# Kürzere Bordsteinstücke sind Rechenreste an Kreuzungsecken, keine Kante, die jemand sieht. +KERB_MIN = 2.0 +# Ab hier ist ein Reststück eine Fläche (Parkplatz, Vorplatz, Rampe) und keine Zacke. +LOT_MIN = 200.0 + + +@dataclass +class Street: + carriageway: object + kerb: list = field(default_factory=list) + sidewalk: object = None + lots: list = field(default_factory=list) + + +def build_street(graph: Graph, corridor, bridges) -> Street: + """Fahrbahn, Bordsteinlinien, Gehweg und Restflächen aus Graph und gemessenem Korridor.""" + carriage = carriageway_area(graph) + return Street( + carriageway=carriage, + kerb=_kerb_lines(graph, carriage, bridges), + sidewalk=_sidewalk(carriage, corridor), + lots=_lots(corridor, carriage), + ) + + +def _sidewalk(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. Die Gasse füllt ihren Korridor ganz aus und bekommt + dadurch von selbst keinen Gehweg. + """ + return carriage.buffer(WALK_MAX, join_style="round").intersection(corridor).difference(carriage) + + +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 += [b.buffer(0.2) for b in bridges] + rand = carriage.boundary + if ohne: + rand = rand.difference(unary_union(ohne)) + teile = getattr(rand, "geoms", [rand]) + 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) + return [g for g in getattr(rest, "geoms", [rest]) + if g.geom_type == "Polygon" and g.area >= LOT_MIN] + + +def punch(polygons, carriage) -> list[Polygon]: + """Die Fahrbahn aus Bodenplatte oder Belag herausschneiden.""" + out = [] + for poly in polygons: + rest = poly.difference(carriage) + out += [g for g in getattr(rest, "geoms", [rest]) + if g.geom_type == "Polygon" and g.area > 0.5] + return out diff --git a/tools/extract/tests/test_street.py b/tools/extract/tests/test_street.py new file mode 100644 index 0000000..756e02d --- /dev/null +++ b/tools/extract/tests/test_street.py @@ -0,0 +1,69 @@ +"""Die Mulde: Löcher in der Bodenplatte, Bordsteinlinien, Gehwegflächen, Restflächen.""" +from __future__ import annotations + +from shapely.geometry import Point, box +from shapely.ops import unary_union + +from leonida.roads import Edge, Graph, Node +from leonida.section import SECTION +from leonida.street import LOT_MIN, build_street, punch + + +def strasse(klass: str = "wohnstrasse") -> Graph: + return Graph(nodes=[Node((0.0, 0.0)), Node((100.0, 0.0))], + edges=[Edge(0, 1, [(0.0, 0.0), (100.0, 0.0)], SECTION[klass].corridor, klass)]) + + +def korridor(klass: str = "wohnstrasse"): + halb = SECTION[klass].corridor / 2 + return box(0, -halb, 100, halb) + + +def test_gehweg_fuellt_genau_den_rest_des_korridors(): + street = build_street(strasse(), korridor(), bridges=[]) + section = SECTION["wohnstrasse"] + # 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 + + +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 == [] + + +def test_bordstein_laeuft_entlang_der_fahrbahnkante(): + street = build_street(strasse(), korridor(), bridges=[]) + gesamt = sum(line.length for line in street.kerb) + # Zwei Längsseiten von 100 m; die kurzen Stirnseiten sind mit 7 m kaum der Rede wert. + assert 190 < gesamt < 220 + + +def test_bruecken_bekommen_keinen_bordstein(): + bridge = box(40, -10, 60, 10) + street = build_street(strasse(), korridor(), bridges=[bridge]) + for line in street.kerb: + assert not bridge.buffer(-0.5).intersects(line), "über der Brücke bleibt die Kante frei" + + +def test_loch_hat_genau_die_form_der_fahrbahn(): + platte = box(-50, -50, 150, 50) + street = build_street(strasse(), korridor(), bridges=[]) + rest = punch([platte], street.carriageway) + assert len(rest) == 1 + assert not rest[0].contains(Point(50, 0)), "die Fahrbahnmitte ist ausgeschnitten" + assert rest[0].contains(Point(50, 20)), "daneben bleibt die Platte" + assert abs(unary_union(rest).area + street.carriageway.area - platte.area) < 1.0 + + +def test_kleine_reststuecke_fallen_weg_grosse_bleiben(): + # Ein Korridor mit einer 30 x 30 m großen Ausbuchtung (ein Parkplatz) und einem Splitter. + # Der Splitter braucht eine Lücke zum Korridorrand: Läge er wie die Ausbuchtung direkt an + # y = 6 an, verschmölze schon unary_union() ihn mit ihr, weil beide dieselbe durchgehende + # Kante berühren - dann bliebe für LOT_MIN unten gar nichts mehr zu filtern übrig. + weit = unary_union([korridor(), box(40, 6, 70, 36), box(10, 8, 12, 9)]) + 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"