diff --git a/tools/extract/leonida/section.py b/tools/extract/leonida/section.py new file mode 100644 index 0000000..7ca4aea --- /dev/null +++ b/tools/extract/leonida/section.py @@ -0,0 +1,73 @@ +"""Querschnitt je Straßenklasse und das Fahrbahnband, das daraus entsteht. + +Die Maße stehen im Entwurf (docs/superpowers/specs/2026-09-16-strassen-design.md) und stammen +aus dem amerikanischen Regelwerk: Fahrspur 3,5 m, Längsparkspur 2,0-2,5 m, Gehweg 2,5-3,0 m. +Gemessen wird auf der Karte der ganze graue Korridor - Fahrbahn und Gehweg sind dort dasselbe +Grau. Die Aufteilung nimmt dem Korridor deshalb nichts weg und gibt ihm nichts dazu: Fahrbahn +plus zweimal Gehweg ist genau die gemessene Breite. Der Stadtgrundriss bleibt damit unberührt. +""" +from __future__ import annotations + +from dataclasses import dataclass + +from shapely.geometry import LineString, Point +from shapely.ops import substring, unary_union + +from .roads import CLASS_WIDTH, Graph + +LANE = 3.5 +# Wie weit das Band vor dem Kreuzungsmittelpunkt endet: halbe Fahrbahn der breitesten +# anliegenden Kante plus etwas Luft, damit die Kreuzungsfläche die Lücke sicher füllt. +SETBACK_EXTRA = 2.0 +# Breitester Gehweg. Weil die Gehwegfläche am Korridor beschnitten wird, ergibt sich die +# schmalere Variante von selbst: bei 12 m Korridor und 7 m Fahrbahn bleiben genau 2,5 m. +WALK_MAX = 3.0 + + +@dataclass(frozen=True) +class Section: + corridor: float + carriageway: float + walk: float + lanes: int + parking: float + kerb: bool + + +SECTION = { + # Hinterhofgasse: eine Fahrgasse, kein Bordstein, kein Gehweg, keine Markierung. + "gasse": Section(corridor=6.0, carriageway=6.0, walk=0.0, lanes=1, parking=0.0, kerb=False), + "wohnstrasse": Section(corridor=12.0, carriageway=7.0, walk=2.5, lanes=2, parking=0.0, kerb=True), + "hauptstrasse": Section(corridor=18.0, carriageway=12.0, walk=3.0, lanes=2, parking=2.5, kerb=True), + "boulevard": Section(corridor=24.0, carriageway=18.0, walk=3.0, lanes=4, parking=2.0, kerb=True), +} +assert all(s.carriageway + 2 * s.walk == s.corridor == CLASS_WIDTH[n] for n, s in SECTION.items()) + + +def setback(graph: Graph, node: int) -> float: + return max(SECTION[graph.edges[k].klass].carriageway / 2 for k in graph.incident(node)) + SETBACK_EXTRA + + +def trimmed_line(graph: Graph, k: int): + """Die Mittellinie zwischen den Kreuzungsrändern. Freie Enden bleiben, wo sie sind.""" + edge = graph.edges[k] + line = LineString(edge.points) + start = setback(graph, edge.a) if graph.degree(edge.a) >= 3 else 0.0 + end = line.length - (setback(graph, edge.b) if graph.degree(edge.b) >= 3 else 0.0) + if end - start < 1.0: + return None # die Kante liegt ganz in den Kreuzungen; die füllen sie mit + return substring(line, start, end) + + +def ribbon(graph: Graph, k: int): + """Das Fahrbahnband einer Kante: Mittellinie auf Fahrbahnbreite gepuffert. + + join_style mitre mit knappem Limit: Eine Straße mit einem Knick soll eine gerade Außenkante + behalten statt einer abgerundeten, aber bei einem sehr spitzen Knick würde eine unbegrenzte + Gehrung einen meterlangen Dorn erzeugen. + """ + line = trimmed_line(graph, k) + if line is None: + return None + return line.buffer(SECTION[graph.edges[k].klass].carriageway / 2, + cap_style="flat", join_style="mitre", mitre_limit=1.5) diff --git a/tools/extract/tests/test_section.py b/tools/extract/tests/test_section.py new file mode 100644 index 0000000..e8c8773 --- /dev/null +++ b/tools/extract/tests/test_section.py @@ -0,0 +1,68 @@ +"""Querschnitte und Fahrbahnbänder.""" +from __future__ import annotations + +from leonida.roads import CLASS_WIDTH, Edge, Graph, Node +from leonida.section import LANE, SECTION, ribbon, setback, trimmed_line + + +def strecke(klass: str, length: float = 100.0) -> Graph: + return Graph(nodes=[Node((0.0, 0.0)), Node((length, 0.0))], + edges=[Edge(0, 1, [(0.0, 0.0), (length, 0.0)], CLASS_WIDTH[klass], klass)]) + + +def test_fahrbahn_und_zwei_gehwege_ergeben_den_korridor(): + for name, section in SECTION.items(): + assert section.carriageway + 2 * section.walk == section.corridor, name + assert section.corridor == CLASS_WIDTH[name], name + + +def test_spuren_und_parkspuren_fuellen_die_fahrbahn(): + for name, section in SECTION.items(): + if name == "gasse": + continue # eine Fahrgasse ohne Spureinteilung, breiter als eine Regelspur + assert section.lanes * LANE + 2 * section.parking == section.carriageway, name + + +def test_nur_die_gasse_hat_keinen_bordstein(): + assert [name for name, s in SECTION.items() if not s.kerb] == ["gasse"] + + +def test_band_ist_so_breit_wie_die_fahrbahn(): + band = ribbon(strecke("hauptstrasse"), 0) + x0, y0, x1, y1 = band.bounds + assert round(y1 - y0, 6) == SECTION["hauptstrasse"].carriageway + assert round(x1 - x0, 6) == 100.0, "freie Enden werden nicht zurückgeschnitten" + + +def test_an_einer_kreuzung_wird_zurueckgeschnitten(): + graph = Graph( + nodes=[Node((0.0, 0.0)), Node((100.0, 0.0)), Node((100.0, 60.0)), Node((160.0, 0.0))], + edges=[Edge(0, 1, [(0.0, 0.0), (100.0, 0.0)], 12.0, "wohnstrasse"), + Edge(1, 2, [(100.0, 0.0), (100.0, 60.0)], 12.0, "wohnstrasse"), + Edge(1, 3, [(100.0, 0.0), (160.0, 0.0)], 12.0, "wohnstrasse")]) + assert graph.degree(1) == 3 + r = setback(graph, 1) + assert r == SECTION["wohnstrasse"].carriageway / 2 + 2.0 + line = trimmed_line(graph, 0) + assert round(line.length, 6) == round(100.0 - r, 6) + assert line.coords[0] == (0.0, 0.0), "das freie Ende bleibt, nur das Kreuzungsende weicht" + + +def test_kante_ganz_innerhalb_der_kreuzung_liefert_kein_band(): + # Zwei Kreuzungen (Grad 3) nur 3 m auseinander - genau der Fall, den CONTRACT_MAX in roads.py + # sonst zusammenzieht, hier aber synthetisch stehen gelassen, um trimmed_line() an diesem + # Rand zu prüfen. Der Auftrag hatte hier nur ein Kreuz aus vier Knoten mit je einem Arm an + # den Knoten 0 und 1 - das ergibt Grad 2, keine Kreuzung, und die Kante würde nicht + # zurückgeschnitten. Erst der zweite Arm an jedem der beiden Knoten macht sie zu echten + # Kreuzungen (Grad 3), deren Rückschnitt die 3 m kurze Verbindungskante ganz auffrisst. + graph = Graph(nodes=[Node((0.0, 0.0)), Node((3.0, 0.0)), + Node((0.0, 50.0)), Node((0.0, -50.0)), + Node((3.0, 50.0)), Node((3.0, -50.0))], + edges=[Edge(0, 1, [(0.0, 0.0), (3.0, 0.0)], 18.0, "hauptstrasse"), + Edge(0, 2, [(0.0, 0.0), (0.0, 50.0)], 18.0, "hauptstrasse"), + Edge(0, 3, [(0.0, 0.0), (0.0, -50.0)], 18.0, "hauptstrasse"), + Edge(1, 4, [(3.0, 0.0), (3.0, 50.0)], 18.0, "hauptstrasse"), + Edge(1, 5, [(3.0, 0.0), (3.0, -50.0)], 18.0, "hauptstrasse")]) + assert graph.degree(0) == 3 and graph.degree(1) == 3 + assert trimmed_line(graph, 0) is None + assert ribbon(graph, 0) is None