Der Bordstein ist keine Ausstattung wie ein Gehweg, sondern die Kante, an der der Asphalt endet — und die hat auch eine Hinterhofgasse. _kerb_lines() schneidet darum nur noch die Brücken aus dem Fahrbahnrand: Dort steckt die Fahrbahn im Deck, eine Kante stünde in der Luft. Gehweg und Markierung bleiben der Gasse weiterhin fern. Das Feld kerb im Querschnitt entfällt. Es steuerte zuletzt nur noch Gehweg und Markierung und war aus walk > 0 ableitbar; kerb_only heißt deshalb jetzt walk_only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
151 lines
7.4 KiB
Python
151 lines
7.4 KiB
Python
"""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))
|