Files
gta6/tools/extract/leonida/section.py
T
knsoandClaude Sonnet 5 ee07a6bf2a fix(extract): carriageway_area() liefert immer MultiPolygon, Kreuzungstest schärfen
Zwei Befunde aus der Prüfung: unary_union() vereinte eine einzelne zusammenhängende
Kreuzung zu einem einfachen Polygon statt des zugesagten MultiPolygon (bricht an kleinen
Prüfkörpern, nicht am echten Netz mit seinen 213 Teilen - deshalb bisher unbemerkt).
Normalisiert jetzt auf MultiPolygon, mit Testzusicherung an der zusammenhängenden
kreuzung()-Fixture.

test_zwei_parallele_strassen_verschmelzen_nicht bewachte nichts: alle vier Knoten hatten
Grad 1, junction_area() lief nie. Fixture bekommt jetzt an beiden Straßen einen Querarm
(Grad 3), damit das Schließen tatsächlich ausgeführt wird, während die Straßen getrennt
bleiben.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 03:35:59 +02:00

125 lines
5.8 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_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]
vereint = unary_union(parts)
if vereint.is_empty:
return MultiPolygon()
# 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 vereint if vereint.geom_type == "MultiPolygon" else MultiPolygon([vereint])