incident() liefert eine Schleife (a == b) nur einmal, und edge.a == node ist bei ihr immer wahr - die zweite Abgangsrichtung bekam deshalb nie einen eigenen Arm im Kreuzungsfächer. Das war kein Schönheitsfehler, sondern ein Loch in der Fahrbahn genau dort, wo das zurückgeschnittene Schleifenband auf die Kreuzungsfläche trifft: 179 von 196 Schleifen sitzen an einem Knoten mit Grad >= 3 und waren betroffen. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
119 lines
5.4 KiB
Python
119 lines
5.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, 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_area(graph: Graph, node: int):
|
|
"""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.
|
|
"""
|
|
incident = graph.incident(node)
|
|
if not incident:
|
|
return None
|
|
reach = setback(graph, node)
|
|
arms = []
|
|
for k in incident:
|
|
edge = graph.edges[k]
|
|
if len(edge.points) < 2:
|
|
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)
|
|
stub = substring(line, 0.0, min(reach + 1.0, line.length))
|
|
arms.append(stub.buffer(SECTION[edge.klass].carriageway / 2,
|
|
cap_style="flat", join_style="mitre", mitre_limit=1.5))
|
|
star = unary_union(arms)
|
|
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 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]
|
|
return unary_union(parts)
|