Files
gta6/tools/extract/tests/test_street.py
T
knsoandClaude Opus 5 322693c892 feat(extract): Bordstein an jeder Straße, auch an der Gasse
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>
2026-09-18 13:43:09 +02:00

275 lines
18 KiB
Python

"""Die Mulde: Löcher in der Bodenplatte, Bordsteinlinien, Gehwegflächen, Restflächen."""
from __future__ import annotations
from shapely.geometry import LineString, Point, box
from shapely.ops import unary_union
from leonida.roads import Edge, Graph, Node
from leonida.section import SECTION, carriageway_area
from leonida.street import LOT_MIN, build_street, punch
def strasse(klass: str = "wohnstrasse") -> Graph:
return Graph(nodes=[Node((0.0, 0.0)), Node((100.0, 0.0))],
edges=[Edge(0, 1, [(0.0, 0.0), (100.0, 0.0)], SECTION[klass].corridor, klass)])
def korridor(klass: str = "wohnstrasse"):
halb = SECTION[klass].corridor / 2
return box(0, -halb, 100, halb)
def test_gehweg_fuellt_genau_den_rest_des_korridors():
street = build_street(strasse(), korridor(), bridges=[])
section = SECTION["wohnstrasse"]
# 100 m lang, zwei Streifen von 2,5 m. Die Enden sind offen, deshalb genau die Fläche.
assert round(street.sidewalk.area) == round(100 * 2 * section.walk)
assert street.sidewalk.intersection(street.carriageway).area < 0.01
assert street.sidewalk.geom_type == "MultiPolygon"
def test_gasse_bekommt_bordstein_aber_keinen_gehweg():
# Nutzerentscheidung vom 18.09.2026: Der Bordstein gehört an jede Straße, auch an die Gasse.
# Der Gehweg gerade nicht - das war und bleibt der Unterschied zwischen den Klassen.
street = build_street(strasse("gasse"), korridor("gasse"), bridges=[])
assert street.sidewalk.area < 0.01
gesamt = sum(line.length for line in street.kerb)
# 100 m Gasse, 6 m breite Fahrbahn: zwei Längsseiten, die Stirnseiten fallen unter KERB_MIN nicht,
# zählen aber kaum. Ohne die Änderung wäre die Liste leer.
assert 190 < gesamt < 215, gesamt
# Der Grenzfall für die MultiPolygon-Zusage: ein leeres Ergebnis käme ohne Normalisierung
# als leeres Polygon zurück, nicht als leeres MultiPolygon (wie carriageway_area() es seit
# Task 6 zusagt) - Task 10/12 iterieren über .geoms und würden daran brechen.
assert street.sidewalk.geom_type == "MultiPolygon"
def test_bordstein_laeuft_entlang_der_fahrbahnkante():
street = build_street(strasse(), korridor(), bridges=[])
gesamt = sum(line.length for line in street.kerb)
# Zwei Längsseiten von 100 m; die kurzen Stirnseiten sind mit 7 m kaum der Rede wert.
assert 190 < gesamt < 220
def test_bruecken_bekommen_keinen_bordstein():
bridge = box(40, -10, 60, 10)
street = build_street(strasse(), korridor(), bridges=[bridge])
for line in street.kerb:
assert not bridge.buffer(-0.5).intersects(line), "über der Brücke bleibt die Kante frei"
def test_loch_hat_genau_die_form_der_fahrbahn():
platte = box(-50, -50, 150, 50)
street = build_street(strasse(), korridor(), bridges=[])
rest = punch([platte], street.carriageway)
assert len(rest) == 1
assert not rest[0].contains(Point(50, 0)), "die Fahrbahnmitte ist ausgeschnitten"
assert rest[0].contains(Point(50, 20)), "daneben bleibt die Platte"
assert abs(unary_union(rest).area + street.carriageway.area - platte.area) < 1.0
def test_kleine_reststuecke_fallen_weg_grosse_bleiben():
# Ein Korridor mit einer 30 x 30 m großen Ausbuchtung (ein Parkplatz) und einem Splitter.
# Der Splitter braucht eine Lücke zum Korridorrand: Läge er wie die Ausbuchtung direkt an
# y = 6 an, verschmölze schon unary_union() ihn mit ihr, weil beide dieselbe durchgehende
# Kante berühren - dann bliebe für LOT_MIN unten gar nichts mehr zu filtern übrig.
weit = unary_union([korridor(), box(40, 6, 70, 36), box(10, 8, 12, 9)])
street = build_street(strasse(), weit, bridges=[])
assert all(lot.area >= LOT_MIN for lot in street.lots)
assert any(lot.area > 500 for lot in street.lots), "der Parkplatz bleibt"
# Nur die Ausbuchtung (30x30 minus den 0,5 m Gehwegsaum an ihrer Unterkante = 885 m²) bleibt.
# Die beiden Seitenstreifen des Basiskorridors (je 250 m²) sind jetzt korrekt als Gehweg
# erkannt, nicht mehr als zusätzliche Restfläche gezählt - genau der Fehler aus Fixrunde 1,
# Punkt 1 (_lots() zog vorher nur die Fahrbahn ab, nicht auch den Gehwegstreifen).
assert len(street.lots) == 1, "der 2 m² große Splitter ist herausgefallen, die Seitenstreifen sind Gehweg"
def test_gerade_strasse_ohne_ausbuchtung_hat_keine_restflaeche():
# Fixrunde 1, Punkt 1: "Korridor minus Fahrbahn" allein ist bei jeder Klasse wörtlich der
# Gehweg (SECTION garantiert carriageway + 2*walk == corridor). Ohne den Gehwegabzug in
# _lots() wären die beiden 2,5-m-Seitenstreifen (je 250 m², ab 80 m Länge über LOT_MIN)
# eine zweite, asphaltfarbene Restfläche genau dort, wo schon der Gehweg liegt.
street = build_street(strasse(), korridor(), bridges=[])
assert street.lots == []
def test_bordstein_frei_an_kurzer_gasseneinmuendung():
# Fixrunde 1, Punkt 2, mit einer nachgerechneten Korrektur der Gassenlänge (siehe Bericht):
# Bei der vom Auftrag genannten 30 m langen Gasse liefert ribbon() *kein* None (Setback 5,5 m,
# 30-5,5=24,5 >= 1,0) - ribbon() schneidet dort ganz regulär zurück, und der alte Ausschluss
# deckt die Gasse bereits vollständig ab. Der Fehler braucht eine Gasse kürzer als
# setback+1=6,5 m: Erst dann liefert trimmed_line() None, der alte Code fügt für diese Kante
# überhaupt keinen Ausschluss ein (der Walrus-Filter "is not None" schlägt fehl), und der
# Bordstein läuft quer über die Einmündung, statt an ihr abgesenkt zu sein.
graph = Graph(nodes=[Node((0.0, 0.0)), Node((50.0, 0.0)), Node((100.0, 0.0)), Node((50.0, -5.0))],
edges=[Edge(0, 1, [(0.0, 0.0), (50.0, 0.0)], SECTION["wohnstrasse"].corridor, "wohnstrasse"),
Edge(1, 2, [(50.0, 0.0), (100.0, 0.0)], SECTION["wohnstrasse"].corridor, "wohnstrasse"),
Edge(1, 3, [(50.0, 0.0), (50.0, -5.0)], SECTION["gasse"].corridor, "gasse")])
assert graph.degree(1) == 3
street = build_street(graph, box(-20, -40, 120, 20), bridges=[])
# Seit der Bordstein an jeder Straße läuft, hat auch die Gasse selbst eine Kante an ihren
# Längsseiten - quer über die Einmündung darf trotzdem keine liegen, sonst führe man beim
# Abbiegen über eine Stufe. Geprüft an der Fahrlinie: von der Kreuzung in die Gasse hinein,
# ohne das geschlossene Ende, an dem die Kante zu Recht herumläuft.
fahrlinie = LineString([(50.0, 0.0), (50.0, -4.0)])
for line in street.kerb:
assert not fahrlinie.intersects(line), "die Zufahrt bleibt frei, kein Bordstein quer davor"
# Gegenprobe zur Aussage oben: Im Gassenkorridor liegt sehr wohl Bordstein - genau das, was die
# alte Regel verboten hat. Dieser Stummel ist nur 5 m lang und steckt ganz in der Kreuzungsfläche,
# seine Kante läuft deshalb um das geschlossene Ende herum statt an geraden Längsseiten entlang.
gassenkorridor = LineString([(50.0, 0.0), (50.0, -5.0)]).buffer(SECTION["gasse"].carriageway / 2)
assert any(gassenkorridor.intersects(line) for line in street.kerb), "die Gasse selbst hat eine Kante"
def test_pflasterflaeche_neben_der_gasse_ist_restflaeche_kein_gehweg():
# Fixrunde 2: die Gasse hat laut SECTION überhaupt keinen Gehweg (walk=0.0). Eine echte,
# 3 m tiefe, 100 m lange Pflasterfläche direkt neben ihr - z.B. ein durchgehender Hofstreifen,
# den die Kartierung mit zur Straßenfläche zählt - darf nicht zu Gehweg werden, nur weil
# WALK_MAX=3.0 dort zufällig genau hinreicht. Sie muss als Restfläche erscheinen.
pflaster = box(0, 3, 100, 6)
weit = unary_union([korridor("gasse"), pflaster])
street = build_street(strasse("gasse"), weit, bridges=[])
assert street.sidewalk.area < 0.01, "die Gasse bekommt so wenig Gehweg wie sonst: keinen"
assert any(lot.area > 250 for lot in street.lots), "die Pflasterfläche bleibt als Restfläche"
def test_fahrbahn_gehweg_und_rest_ergeben_wieder_den_korridor():
# Die drei Teile dürfen sich weder überlappen noch eine Lücke lassen - unabhängig vom
# LOT_MIN-Filter in street.lots prüfen wir hier den *rohen* Rest (vor dem Filter), denn nur
# der ergänzt Fahrbahn und Gehweg exakt wieder zum Korridor.
weit = unary_union([korridor(), box(40, 6, 70, 36), box(10, 8, 12, 9)])
street = build_street(strasse(), weit, bridges=[])
roh = weit.difference(street.carriageway).difference(street.sidewalk)
assert street.carriageway.intersection(street.sidewalk).area < 1e-6
assert street.carriageway.intersection(roh).area < 1e-6
assert street.sidewalk.intersection(roh).area < 1e-6
bilanz = street.carriageway.area + street.sidewalk.area + roh.area
assert abs(bilanz - weit.area) < 1e-6
def test_aufteilung_geht_fuer_jede_klasse_auf():
# Fixrunde 3, Punkt 4: keine Klasse ausgenommen, auch nicht die Gasse (dort ist der Gehweg
# leer, aber die Bilanz muss trotzdem exakt aufgehen: Fahrbahn plus roher Rest ergibt dann
# den ganzen Korridor).
for klass in SECTION:
street = build_street(strasse(klass), korridor(klass), bridges=[])
roh = korridor(klass).difference(street.carriageway).difference(street.sidewalk)
bilanz = street.carriageway.area + street.sidewalk.area + roh.area
assert abs(bilanz - korridor(klass).area) < 1e-6, klass
def test_wohnstrasse_behaelt_ihren_gehweg_trotz_naher_paralleler_gasse():
# Fixrunde 3: Der alte Ansatz ließ den Gehweg aus der *ganzen* Fahrbahn wachsen und zog
# danach eine Reichweitenzone um jede bordsteinlose Klasse wieder ab. Diese Zone kennt ihre
# Nachbarschaft nicht - jeder Punkt näher als WALK_MAX=3 m an einer Gassen-Mittellinie fiel
# weg, auch wenn dort tatsächlich eine Wohnstraße liegt. Wohnstraße bei y=0 (Korridor bis
# y=6), Gasse parallel bei y=9,5 (Korridor ab y=6,5) - 0,5 m Lücke zwischen den Korridoren,
# die Gassen-Mittellinie liegt trotzdem nur 9,5 m entfernt, deutlich innerhalb der alten,
# 6 m weiten Reichweitenzone (carriageway/2 + WALK_MAX = 3+3).
graph = Graph(nodes=[Node((0.0, 0.0)), Node((100.0, 0.0)), Node((0.0, 9.5)), Node((100.0, 9.5))],
edges=[Edge(0, 1, [(0.0, 0.0), (100.0, 0.0)], SECTION["wohnstrasse"].corridor, "wohnstrasse"),
Edge(2, 3, [(0.0, 9.5), (100.0, 9.5)], SECTION["gasse"].corridor, "gasse")])
corridor = unary_union([box(0, -6, 100, 6), box(0, 6.5, 100, 12.5)])
street = build_street(graph, corridor, bridges=[])
assert round(street.sidewalk.area) == 500, "die volle Gehwegfläche der Wohnstraße bleibt"
def test_wohnstrassen_gehweg_bleibt_vollstaendig_an_gasseneinmuendung():
# Fixrunde 3: an der T-Einmündung reichte dieselbe Reichweitenzone der Gasse (jetzt um ihre
# volle Mittellinie, wegen Fixrunde 1) symmetrisch in beide Wohnstraßen-Gehwege an der Ecke
# hinein - unabhängig von der Gassenlänge, solange die Gasse nicht selbst in der Nähe endet
# (deshalb hier 10 m lang, nicht die 5 m aus dem Bordstein-Test oben). Zwei Punkte, die genau
# in den vorher fehlenden, je 3,79 m² großen Flächen an den gerundeten Kreuzungsecken liegen.
graph = Graph(nodes=[Node((0.0, 0.0)), Node((50.0, 0.0)), Node((100.0, 0.0)), Node((50.0, -10.0))],
edges=[Edge(0, 1, [(0.0, 0.0), (50.0, 0.0)], SECTION["wohnstrasse"].corridor, "wohnstrasse"),
Edge(1, 2, [(50.0, 0.0), (100.0, 0.0)], SECTION["wohnstrasse"].corridor, "wohnstrasse"),
Edge(1, 3, [(50.0, 0.0), (50.0, -10.0)], SECTION["gasse"].corridor, "gasse")])
wohn_korridor = box(0, -6, 100, 6)
gassen_korridor = LineString([(50.0, 0.0), (50.0, -10.0)]).buffer(SECTION["gasse"].corridor / 2,
cap_style="flat")
street = build_street(graph, unary_union([wohn_korridor, gassen_korridor]), bridges=[])
assert street.sidewalk.contains(Point(45.0, -5.0)), "Wohnstraßen-Gehweg links der Einmündung"
assert street.sidewalk.contains(Point(55.0, -5.0)), "Wohnstraßen-Gehweg rechts der Einmündung"
def test_gehweg_waechst_nicht_ueber_den_gassenstummel_an_geteilter_kreuzung():
# Fixrunde 4/5: _walk_source() nahm ursprünglich die ganze junction_area() auf, sobald
# irgendein Arm Bordstein hat - auch den Anteil, der zu einer dort einmündenden Gasse gehört.
# Ein linearer Zuschnitt (Fixrunde 4) reichte nicht, weil die FILLET-Rundung die Nische
# zwischen Gassenstummeln abseits der Gassenachse aufweitet. Seit Fixrunde 5 geht die
# Kreuzung nur noch über ihre bordsteintragenden Armstummel (junction_arms(walk_only=True),
# ungerundet) in die Quelle ein - die Nische entsteht dadurch gar nicht erst. T-Kreuzung:
# zwei durchgehende Gassenarme (West/Ost) plus ein Wohnstraßenarm (Süd). Ohne den Fix wächst
# ein Gehwegstreifen die volle Länge beider Gassenstummel entlang (53,11 m² im Nordstreifen
# y∈[3,6]), obwohl mitten auf der Gasse (Kontrollpunkt, weit von der Kreuzung) kein Gehweg
# entsteht - der Fehler sitzt eindeutig am Kreuzungsanteil.
graph = Graph(nodes=[Node((-50.0, 0.0)), Node((0.0, 0.0)), Node((50.0, 0.0)), Node((0.0, -50.0))],
edges=[Edge(0, 1, [(-50.0, 0.0), (0.0, 0.0)], SECTION["gasse"].corridor, "gasse"),
Edge(1, 2, [(0.0, 0.0), (50.0, 0.0)], SECTION["gasse"].corridor, "gasse"),
Edge(1, 3, [(0.0, 0.0), (0.0, -50.0)], SECTION["wohnstrasse"].corridor, "wohnstrasse")])
assert graph.degree(1) == 3
# Großzügiger Korridor (voller WALK_MAX-Bereich um die Fahrbahn), damit der Bug ungehindert
# zur Geltung kommt - ein knapper Korridor würde ihn wegschneiden, bevor er sichtbar wird.
corridor = carriageway_area(graph).buffer(3.0, join_style="round")
street = build_street(graph, corridor, bridges=[])
gassenstreifen_nord = box(-15, 3, 15, 6)
assert street.sidewalk.intersection(gassenstreifen_nord).area < 0.01, \
"kein Gehweg entlang der Gassenstummel, nur der Wohnstraßen-Gehweg im Süden bleibt"
def test_gehwegrest_an_kreuzung_mit_mehreren_gassen_bleibt_aus():
# Fixrunde 5, Abnahmepunkt 4 - jetzt Zielverhalten statt Zwischenstand: Kreuzung mit drei
# Gassenarmen (West/Ost/Süd) und einem Boulevard-Arm (Nord). setback = max(9,3,3,3)+2 = 11 m
# - der Boulevard zieht den Kreuzungsstummel aller vier Arme, auch der schmalen Gassen, auf
# 12 m Länge hoch. Ein linearer Zuschnitt (Fixrunde 4, _kerbless_zone entlang der
# Gassen-Mittellinie) erreichte die durch die FILLET-Rundung aufgeweitete Nische zwischen
# zwei benachbarten Gassenstummeln nicht (Rest blieb bei 27,14 m², Ausdehnung bis 11,14 m).
# Seit Fixrunde 5 geht die Kreuzung nur noch über ihren bordsteintragenden Arm (den
# Boulevard) in die Gehwegquelle ein, ungerundet - die drei Gassenarme tragen gar nichts
# mehr bei, die Nische zwischen ihnen kann also gar nicht erst entstehen.
graph = Graph(nodes=[Node((0.0, 0.0)), Node((-50.0, 0.0)), Node((50.0, 0.0)),
Node((0.0, -50.0)), Node((0.0, 50.0))],
edges=[Edge(0, 1, [(0.0, 0.0), (-50.0, 0.0)], SECTION["gasse"].corridor, "gasse"),
Edge(0, 2, [(0.0, 0.0), (50.0, 0.0)], SECTION["gasse"].corridor, "gasse"),
Edge(0, 3, [(0.0, 0.0), (0.0, -50.0)], SECTION["gasse"].corridor, "gasse"),
Edge(0, 4, [(0.0, 0.0), (0.0, 50.0)], SECTION["boulevard"].corridor, "boulevard")])
assert graph.degree(0) == 4
corridor = carriageway_area(graph).buffer(3.0, join_style="round")
street = build_street(graph, corridor, bridges=[])
nische = box(-15, -15, -3, -3)
rest = street.sidewalk.intersection(nische)
# Gemessen: 0,0 m² exakt - deutlich strenger als die von der Abnahme geforderte Obergrenze
# von rund WALK_MAX + halbe Gassenbreite (~6 m Ausdehnung); 1,0 m² lässt Spielraum für
# künftige, geometrisch bedingte Rundungsreste, ohne die Kernaussage zu verwässern.
assert rest.area < 1.0, "die Nische zwischen zwei Gassenstummeln bleibt frei von Gehweg"
def test_gehweg_schliesst_die_luecke_an_gewoehnlicher_kreuzungsecke():
"""Befund 7 der Fixwelle: An einer gewöhnlichen Kreuzung (mehrere Arme mit Gehweg, keine
Gasse beteiligt) klaffte zwischen der gerundeten Fahrbahnecke (carriageway_area() rundet mit
FILLET=6,0) und der ungerundeten Gehwegquelle eine kleine Lücke - eine reine Dilatation der
Quelle um WALK_MAX=3,0 zeichnet eine Reflexecke nicht nach. Am echten Netz 0,26-2,2 m² je
Ecke bei 690 Kreuzungen; hier reproduziert an einer T-Kreuzung aus drei Wohnstraßen-Armen,
mit demselben großzügigen Korridor wie in den Gassen-Tests oben (carriageway_area() + 3 m).
Vor der Korrektur maß jede der beiden Ecken exakt 0,256 m² Lücke (52 gerundete Doppelpunkte
davon zusammen 0,5114 m²) - innerhalb des berichteten Bereichs.
_walk_source() rundet den Kreuzungsanteil jetzt mit derselben Schließung wie carriageway_area()
(junction_area(walk_only=True)), sodass Fahrbahn- und Gehwegquelle an der Ecke wieder
zueinander passen.
"""
graph = Graph(
nodes=[Node((0.0, 0.0)), Node((-50.0, 0.0)), Node((50.0, 0.0)), Node((0.0, -50.0))],
edges=[Edge(0, 1, [(0.0, 0.0), (-50.0, 0.0)], SECTION["wohnstrasse"].corridor, "wohnstrasse"),
Edge(0, 2, [(0.0, 0.0), (50.0, 0.0)], SECTION["wohnstrasse"].corridor, "wohnstrasse"),
Edge(0, 3, [(0.0, 0.0), (0.0, -50.0)], SECTION["wohnstrasse"].corridor, "wohnstrasse")])
corridor = carriageway_area(graph).buffer(3.0, join_style="round")
street = build_street(graph, corridor, bridges=[])
# Der rohe Rest (vor dem LOT_MIN-Filter aus _lots()): An einer gewöhnlichen Ecke darf davon
# nichts übrig bleiben, sonst wird die Lücke später als zu kleines Reststück verworfen und
# bleibt ein unsichtbares Loch im Boden.
roh_rest = corridor.difference(street.carriageway).difference(street.sidewalk)
ecken = Point(0, 0).buffer(15.0)
luecke = sum(g.area for g in getattr(roh_rest, "geoms", [roh_rest])
if not g.is_empty and g.intersects(ecken) and g.area < LOT_MIN)
assert luecke < 0.05, f"{luecke:.4f} m² Lücke an den Kreuzungsecken, sollte geschlossen sein"