Files
gta6/tools/extract/tests/test_street.py
T
knsoandClaude Sonnet 5 ca5710730e fix(extract): Gehweg waechst nur aus Fahrbahn mit Bordstein, nicht mehr aus allem
Die Reichweitenzone aus der letzten Runde (Gehweg aus der ganzen Fahrbahn wachsen
lassen, dann eine Zone um jede bordsteinlose Klasse wieder abziehen) fragte die
falsche Richtung: "liegt dieser Punkt nahe einer Gasse?" statt "gehoert die
naechste Fahrbahn zu einer Klasse mit Bordstein?". Die Zone kennt ihre
Nachbarschaft nicht und frass dabei echten Wohnstrassen-Gehweg mit, sobald der
zufaellig nah an einer Gasse lag - 250 von 500 m2 bei einer parallelen Gasse mit
nur 0,5 m Korridorabstand, 7,58 m2 systematisch an jeder Gasse-Wohnstrasse-
Einmuendung, unabhaengig von der Gassenlaenge.

_walk_source(graph) baut die Fahrbahnquelle jetzt nur aus Kanten und Kreuzungen
mit mindestens einem bordsteintragenden Arm (ribbon()/junction_area(), gefiltert
auf SECTION[...].kerb). _sidewalk() waechst aus dieser Quelle statt aus der
ganzen Fahrbahn und braucht keinen nachtraeglichen Abzug mehr. _kerbless_zone()
bleibt nur noch fuer den Bordstein-Ausschluss in _kerb_lines(), mit dem
urspruenglichen festen Zuschlag von 0,2 m.

Rechenzeit am echten Netz gemessen: _walk_source() kostet zusaetzlich 1,8 s
(carriageway_area() braucht 2,45 s), build_street() insgesamt rund 5,1-5,2 s -
weit unter der 30-s-Schranke, kein Anlass, die Kreuzungsflaechen nur einmal
statt zweimal zu berechnen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 06:07:32 +02:00

178 lines
11 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
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_weder_gehweg_noch_bordstein():
street = build_street(strasse("gasse"), korridor("gasse"), bridges=[])
assert street.sidewalk.area < 0.01
assert street.kerb == []
# 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=[])
gassenkorridor = LineString([(50.0, 0.0), (50.0, -5.0)]).buffer(SECTION["gasse"].carriageway / 2)
for line in street.kerb:
assert not gassenkorridor.intersects(line), "die Zufahrt bleibt frei, kein Bordstein quer davor"
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"