"""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, MultiPolygon, 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 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), "wohnstrasse": Section(corridor=12.0, carriageway=7.0, walk=2.5, lanes=2, parking=0.0), "hauptstrasse": Section(corridor=18.0, carriageway=12.0, walk=3.0, lanes=2, parking=2.5), "boulevard": Section(corridor=24.0, carriageway=18.0, walk=3.0, lanes=4, parking=2.0), } 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) # Amerikanischer Eckradius, 20 ft. Er entscheidet über den Charakter der Kreuzung: größer wirkt # großzügig und vorstädtisch, kleiner eng und städtisch. FILLET = 6.0 def junction_arms(graph: Graph, node: int, walk_only: bool = False) -> list: """Die Armstummel einer Kreuzung, je auf ihre eigene Fahrbahnbreite gepuffert. Zweimal gebraucht (Fixrunde 5 zu Task 7): junction_area() vereinigt alle und rundet die einspringenden Ecken; die Gehwegquelle in street.py nimmt nur die Arme mit Gehweg, ohne Rundung. Deshalb hier einmal, statt die Stummelbildung zu wiederholen. """ reach = setback(graph, node) arme = [] for k in graph.incident(node): edge = graph.edges[k] if len(edge.points) < 2 or (walk_only and not SECTION[edge.klass].walk): continue # Punktkanten haben keine Richtung; LineString wirft an ihnen # Eine Schleife hängt mit beiden Enden am selben Knoten und braucht deshalb zwei Stummel. # incident() liefert sie nur einmal, und edge.a == node ist bei ihr immer wahr — ohne den # zweiten Stummel bliebe an ihrem anderen Ende ein Loch in der Fahrbahn. richtungen = ([edge.points, list(reversed(edge.points))] if edge.a == edge.b else [edge.points if edge.a == node else list(reversed(edge.points))]) for points in richtungen: line = LineString(points) stumpf = substring(line, 0.0, min(reach + 1.0, line.length)) arme.append(stumpf.buffer(SECTION[edge.klass].carriageway / 2, cap_style="flat", join_style="mitre", mitre_limit=1.5)) return arme def junction_area(graph: Graph, node: int, walk_only: bool = False): """Die Fläche einer Kreuzung: die Stummel aller Arme, mit gerundeten einspringenden Ecken. Gerundet wird durch Schließen (aufblasen, dann wieder schrumpfen) - und ausdrücklich nur innerhalb einer Scheibe um den Knoten. Auf das ganze Netz angewandt verklebte dieselbe Operation zwei parallele Straßen, deren Fahrbahnen weniger als 2 * FILLET auseinanderliegen. walk_only rundet nur den Kreuzungsanteil der Arme mit Gehweg (Befund 7 der Fixwelle, für die Gehwegquelle in street.py): Eine Gasse liefert dabei gar keinen Arm in die zu schließende Fläche, kann also auch keine Nische mit einem Nachbararm bilden - anders als Fixrunde 4, die dieselbe Rundung auf ALLE Arme anwandte und damit Gassen mit rundete. """ incident = graph.incident(node) if not incident or (walk_only and not any(SECTION[graph.edges[k].klass].walk for k in incident)): return None reach = setback(graph, node) star = unary_union(junction_arms(graph, node, walk_only=walk_only)) near = Point(graph.nodes[node].point).buffer(reach + FILLET + 2.0) rounded = star.buffer(FILLET, join_style="round").buffer(-FILLET, join_style="round") return star.union(rounded.intersection(near)) def to_multipolygon(geom) -> MultiPolygon: """Nur die Flächenanteile einer Geometrie, als MultiPolygon. intersection()/difference() liefern nicht immer sauber ein (Multi)Polygon: An einer Berührstelle kann eine GeometryCollection mit zusätzlichen entarteten Linien oder Punkten herauskommen (Fixrunde 4 zu Task 7, am Gehweg reproduziert). MultiPolygon(geoms) nimmt aber nur Polygone entgegen und wirft eine TypeError, sobald ein Element davon abweicht - hier werden die Nicht-Flächen-Teile deshalb verworfen, statt dass die Rechnung daran abstürzt. """ if geom.is_empty: return MultiPolygon() return MultiPolygon([g for g in getattr(geom, "geoms", [geom]) if g.geom_type == "Polygon"]) def carriageway_area(graph: Graph): """Alle Bänder und alle Kreuzungsflächen als eine Geometrie.""" parts = [band for k in range(len(graph.edges)) if (band := ribbon(graph, k)) is not None] parts += [area for node in range(len(graph.nodes)) if graph.degree(node) >= 3 and (area := junction_area(graph, node)) is not None] # Zusage der Schnittstelle: immer MultiPolygon. unary_union() liefert bei zusammenhängender # Eingabe ein einfaches Polygon, und Task 7 iteriert über .geoms — das bricht dann an kleinen # Prüfkörpern, aber nicht am echten Netz, also genau da, wo niemand damit rechnet. return to_multipolygon(unary_union(parts))