Befund 7 (seit Task 7 geparkt): carriageway_area() rundet die einspringenden Ecken einer Kreuzung mit FILLET=6,0 (Schließen: aufblasen, dann wieder schrumpfen), die Gehwegquelle in _walk_source() blieb seit Fixrunde 5 dagegen ungerundet. Eine reine Dilatation der Quelle um WALK_MAX=3,0 zeichnet eine Reflexecke nicht nach - zwischen der gerundeten Fahrbahn und dem ungerundeten Gehweg blieb an jeder gewöhnlichen Kreuzung (mehrere Arme mit Bordstein, keine Gasse beteiligt) eine kleine Lücke, zu klein für LOT_MIN und damit ein unsichtbares Loch im Boden statt einer Restfläche. Gemessen 0,26-2,2 m² je Ecke bei 690 Kreuzungen. junction_area() (section.py) bekommt einen kerb_only-Schalter und rundet damit wahlweise nur den Kreuzungsanteil der bordsteintragenden Arme; _walk_source() ruft sie jetzt statt des rohen junction_arms(kerb_only=True) auf. Der Unterschied zu Fixrunde 4 (die dieselbe Rundung auf ALLE Arme anwandte und damit Gassen mit rundete): Eine Gasse liefert gar keinen Arm in die zu schließende Fläche, kann also strukturell keine Nische mit einem Nachbararm bilden - unabhängig vom Winkel. Nachgemessen an einer T-Kreuzung aus drei Wohnstraßen (derselbe großzügige Korridor wie in den bestehenden Gassen-Tests, carriageway_area()+3 m): vor der Korrektur 0,256 m² Lücke je der beiden Ecken (0,5114 m² zusammen), exakt im berichteten Bereich; danach 0,0 m². Eine zweite, an sich schon bei Fixrunde 5 bestehende Kontrolle: Gasse in einem engen Winkel (10-30°) zwischen zwei Wohnstraßen-Armen - der Gehweg bleibt für jeden geprüften Winkel bei 0,0 m² im Gassenkorridor. Alle bestehenden Gassen-Sicherungen in test_street.py (fünf Fixrunden Vorarbeit) bleiben grün.
152 lines
7.4 KiB
Python
152 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
|
|
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)
|
|
|
|
|
|
# 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, kerb_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 Bordstein, 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 (kerb_only and not SECTION[edge.klass].kerb):
|
|
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, kerb_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.
|
|
|
|
kerb_only rundet nur den Kreuzungsanteil der bordsteintragenden Arme (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 (kerb_only and not any(SECTION[graph.edges[k].klass].kerb for k in incident)):
|
|
return None
|
|
reach = setback(graph, node)
|
|
star = unary_union(junction_arms(graph, node, kerb_only=kerb_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))
|