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>
This commit is contained in:
knsoandClaude Opus 5 committed 2026-09-18 13:43:09 +02:00
1 parent 897eb8a1f6
commit 322693c892
5 files changed
+50 -50

No files matched your search

+1 -1
View File
@@ -119,7 +119,7 @@ def junction_marks(graph: Graph, node: int) -> list[tuple[Polygon, str]]:
blank. blank.
""" """
incident = graph.incident(node) incident = graph.incident(node)
echte = [k for k in incident if SECTION[graph.edges[k].klass].kerb] echte = [k for k in incident if SECTION[graph.edges[k].klass].walk]
if len(echte) < 2: if len(echte) < 2:
return [] return []
reach = setback(graph, node) reach = setback(graph, node)
+11 -12
View File
@@ -31,15 +31,14 @@ class Section:
walk: float walk: float
lanes: int lanes: int
parking: float parking: float
kerb: bool
SECTION = { SECTION = {
# Hinterhofgasse: eine Fahrgasse, kein Bordstein, kein Gehweg, keine Markierung. # 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), "gasse": Section(corridor=6.0, carriageway=6.0, walk=0.0, lanes=1, parking=0.0),
"wohnstrasse": Section(corridor=12.0, carriageway=7.0, walk=2.5, lanes=2, parking=0.0, kerb=True), "wohnstrasse": Section(corridor=12.0, carriageway=7.0, walk=2.5, lanes=2, parking=0.0),
"hauptstrasse": Section(corridor=18.0, carriageway=12.0, walk=3.0, lanes=2, parking=2.5, kerb=True), "hauptstrasse": Section(corridor=18.0, carriageway=12.0, walk=3.0, lanes=2, parking=2.5),
"boulevard": Section(corridor=24.0, carriageway=18.0, walk=3.0, lanes=4, parking=2.0, kerb=True), "boulevard": Section(corridor=24.0, carriageway=18.0, walk=3.0, lanes=4, parking=2.0),
} }
assert all(s.carriageway + 2 * s.walk == s.corridor == CLASS_WIDTH[n] for n, s in SECTION.items()) assert all(s.carriageway + 2 * s.walk == s.corridor == CLASS_WIDTH[n] for n, s in SECTION.items())
@@ -78,18 +77,18 @@ def ribbon(graph: Graph, k: int):
FILLET = 6.0 FILLET = 6.0
def junction_arms(graph: Graph, node: int, kerb_only: bool = False) -> list: def junction_arms(graph: Graph, node: int, walk_only: bool = False) -> list:
"""Die Armstummel einer Kreuzung, je auf ihre eigene Fahrbahnbreite gepuffert. """Die Armstummel einer Kreuzung, je auf ihre eigene Fahrbahnbreite gepuffert.
Zweimal gebraucht (Fixrunde 5 zu Task 7): junction_area() vereinigt alle und rundet die 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 einspringenden Ecken; die Gehwegquelle in street.py nimmt nur die Arme mit Gehweg, ohne
Rundung. Deshalb hier einmal, statt die Stummelbildung zu wiederholen. Rundung. Deshalb hier einmal, statt die Stummelbildung zu wiederholen.
""" """
reach = setback(graph, node) reach = setback(graph, node)
arme = [] arme = []
for k in graph.incident(node): for k in graph.incident(node):
edge = graph.edges[k] edge = graph.edges[k]
if len(edge.points) < 2 or (kerb_only and not SECTION[edge.klass].kerb): if len(edge.points) < 2 or (walk_only and not SECTION[edge.klass].walk):
continue # Punktkanten haben keine Richtung; LineString wirft an ihnen continue # Punktkanten haben keine Richtung; LineString wirft an ihnen
# Eine Schleife hängt mit beiden Enden am selben Knoten und braucht deshalb zwei Stummel. # 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 # incident() liefert sie nur einmal, und edge.a == node ist bei ihr immer wahr — ohne den
@@ -104,23 +103,23 @@ def junction_arms(graph: Graph, node: int, kerb_only: bool = False) -> list:
return arme return arme
def junction_area(graph: Graph, node: int, kerb_only: bool = False): def junction_area(graph: Graph, node: int, walk_only: bool = False):
"""Die Fläche einer Kreuzung: die Stummel aller Arme, mit gerundeten einspringenden Ecken. """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 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 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. 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, walk_only rundet nur den Kreuzungsanteil der Arme mit Gehweg (Befund 7 der Fixwelle,
für die Gehwegquelle in street.py): Eine Gasse liefert dabei gar keinen Arm in die zu 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 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. Fixrunde 4, die dieselbe Rundung auf ALLE Arme anwandte und damit Gassen mit rundete.
""" """
incident = graph.incident(node) incident = graph.incident(node)
if not incident or (kerb_only and not any(SECTION[graph.edges[k].klass].kerb for k in incident)): if not incident or (walk_only and not any(SECTION[graph.edges[k].klass].walk for k in incident)):
return None return None
reach = setback(graph, node) reach = setback(graph, node)
star = unary_union(junction_arms(graph, node, kerb_only=kerb_only)) star = unary_union(junction_arms(graph, node, walk_only=walk_only))
near = Point(graph.nodes[node].point).buffer(reach + FILLET + 2.0) near = Point(graph.nodes[node].point).buffer(reach + FILLET + 2.0)
rounded = star.buffer(FILLET, join_style="round").buffer(-FILLET, join_style="round") rounded = star.buffer(FILLET, join_style="round").buffer(-FILLET, join_style="round")
return star.union(rounded.intersection(near)) return star.union(rounded.intersection(near))
+16 -29
View File
@@ -38,7 +38,7 @@ def build_street(graph: Graph, corridor, bridges) -> Street:
sidewalk = _sidewalk(_walk_source(graph), carriage, corridor) sidewalk = _sidewalk(_walk_source(graph), carriage, corridor)
return Street( return Street(
carriageway=carriage, carriageway=carriage,
kerb=_kerb_lines(carriage, _kerbless_zone(graph, 0.2), bridges), kerb=_kerb_lines(carriage, bridges),
sidewalk=sidewalk, sidewalk=sidewalk,
lots=_lots(carriage, corridor, sidewalk), lots=_lots(carriage, corridor, sidewalk),
) )
@@ -53,44 +53,24 @@ def _walk_source(graph: Graph):
in Fixrunde 4 zu viel Gasse mit, weil sie die einspringende Ecke zwischen zwei benachbarten in Fixrunde 4 zu viel Gasse mit, weil sie die einspringende Ecke zwischen zwei benachbarten
Gassenstummeln zu einer Nische auffüllte. Der eigentliche Fehler war aber, Gassenarme Gassenstummeln zu einer Nische auffüllte. Der eigentliche Fehler war aber, Gassenarme
überhaupt in die zu rundende Fläche aufzunehmen - nicht die Rundung selbst. Ohne sie blieb überhaupt in die zu rundende Fläche aufzunehmen - nicht die Rundung selbst. Ohne sie blieb
stattdessen an jeder gewöhnlichen Kreuzung (mehrere Arme mit Bordstein, keine Gasse stattdessen an jeder gewöhnlichen Kreuzung (mehrere Arme mit Gehweg, keine Gasse
beteiligt) eine Lücke zwischen der ungerundeten Gehwegquelle und der gerundeten Fahrbahn: beteiligt) eine Lücke zwischen der ungerundeten Gehwegquelle und der gerundeten Fahrbahn:
0,26-2,2 m² je Ecke bei 690 Kreuzungen, weil `carriage` (carriageway_area(), gerundet mit 0,26-2,2 m² je Ecke bei 690 Kreuzungen, weil `carriage` (carriageway_area(), gerundet mit
FILLET=6) und `walk_source` (ungerundet) an der Kreuzungsecke verschieden geformt waren und FILLET=6) und `walk_source` (ungerundet) an der Kreuzungsecke verschieden geformt waren und
eine reine Dilatation um WALK_MAX=3 eine Reflexecke nicht nachzeichnet. eine reine Dilatation um WALK_MAX=3 eine Reflexecke nicht nachzeichnet.
junction_area(kerb_only=True) rundet jetzt nur den Kreuzungsanteil der bordsteintragenden junction_area(walk_only=True) rundet jetzt nur den Kreuzungsanteil der bordsteintragenden
Arme: Eine Gasse liefert dabei keinen Arm in die zu schließende Fläche und kann deshalb auch Arme: Eine Gasse liefert dabei keinen Arm in die zu schließende Fläche und kann deshalb auch
keine Nische mit einem Nachbararm bilden, gewöhnliche Kreuzungsecken bekommen aber dieselbe keine Nische mit einem Nachbararm bilden, gewöhnliche Kreuzungsecken bekommen aber dieselbe
Rundung wie die Fahrbahn und schließen die Lücke. Rundung wie die Fahrbahn und schließen die Lücke.
""" """
teile = [band for k, edge in enumerate(graph.edges) teile = [band for k, edge in enumerate(graph.edges)
if SECTION[edge.klass].kerb and (band := ribbon(graph, k)) is not None] if SECTION[edge.klass].walk and (band := ribbon(graph, k)) is not None]
teile += [area for node in range(len(graph.nodes)) if graph.degree(node) >= 3 teile += [area for node in range(len(graph.nodes)) if graph.degree(node) >= 3
and (area := junction_area(graph, node, kerb_only=True)) is not None and not area.is_empty] and (area := junction_area(graph, node, walk_only=True)) is not None and not area.is_empty]
return unary_union(teile) if teile else None return unary_union(teile) if teile else None
def _kerbless_zone(graph: Graph, extra: float):
"""Der Korridor aller Klassen ohne Bordstein, aus den vollen Mittellinien, um `extra` über die
halbe Fahrbahnbreite hinaus gepuffert.
Nur noch einmal gebraucht: Der Bordstein hört hier auf (_kerb_lines()) - eine Gasse hat laut
SECTION weder Bordstein noch Gehweg, und die Fuge dafür ist 0,2 m über die eigene
Fahrbahnbreite hinaus.
Bis Fixrunde 4 schnitt dieselbe Zone auch den Kreuzungsanteil der Gehwegquelle zu; seit
Fixrunde 5 nimmt _walk_source() stattdessen von vornherein nur die Arme mit Bordstein
(junction_area(kerb_only=True), Befund 7 der Fixwelle) und braucht deshalb keinen
nachträglichen Abzug mehr.
"""
zonen = [LineString(edge.points).buffer(SECTION[edge.klass].carriageway / 2 + extra,
cap_style="flat", join_style="mitre", mitre_limit=1.5)
for edge in graph.edges
if not SECTION[edge.klass].kerb and len(edge.points) >= 2]
return unary_union(zonen) if zonen else None
def _sidewalk(walk_source, carriage, corridor): def _sidewalk(walk_source, carriage, corridor):
"""Der Streifen zwischen Fahrbahnkante und Korridorrand. """Der Streifen zwischen Fahrbahnkante und Korridorrand.
@@ -114,10 +94,17 @@ def _sidewalk(walk_source, carriage, corridor):
return to_multipolygon(result) return to_multipolygon(result)
def _kerb_lines(carriage, kerbless, bridges) -> list[LineString]: def _kerb_lines(carriage, bridges) -> list[LineString]:
"""Die Fahrbahnkante, ohne die Stücke an Gassen und auf Brücken.""" """Die ganze Fahrbahnkante, nur ohne die Stücke auf Brücken.
ohne = [] if kerbless is None else [kerbless]
ohne += [b.buffer(0.2) for b in bridges] Bis zur Nutzerentscheidung vom 18.09.2026 blieben auch die Gassen frei: Der Entwurf sah für sie
weder Bordstein noch Gehweg noch Markierung vor (Hinterhofgassen in Miami Beach). Der Bordstein
gehört jetzt an jede Straße - wo Asphalt an Land grenzt, endet er mit einer Kante. Gehweg und
Markierung bleiben der Gasse weiterhin fern, das ändert die Entscheidung nicht.
Brücken bleiben ausgenommen: Dort steckt die Fahrbahn im Deck, eine Kante stünde in der Luft.
"""
ohne = [b.buffer(0.2) for b in bridges]
rand = carriage.boundary rand = carriage.boundary
if ohne: if ohne:
rand = rand.difference(unary_union(ohne)) rand = rand.difference(unary_union(ohne))
+1 -1
View File
@@ -26,7 +26,7 @@ def test_spuren_und_parkspuren_fuellen_die_fahrbahn():
def test_nur_die_gasse_hat_keinen_bordstein(): def test_nur_die_gasse_hat_keinen_bordstein():
assert [name for name, s in SECTION.items() if not s.kerb] == ["gasse"] assert [name for name, s in SECTION.items() if not s.walk] == ["gasse"]
def test_band_ist_so_breit_wie_die_fahrbahn(): def test_band_ist_so_breit_wie_die_fahrbahn():
+21 -7
View File
@@ -28,10 +28,15 @@ def test_gehweg_fuellt_genau_den_rest_des_korridors():
assert street.sidewalk.geom_type == "MultiPolygon" assert street.sidewalk.geom_type == "MultiPolygon"
def test_gasse_bekommt_weder_gehweg_noch_bordstein(): 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=[]) street = build_street(strasse("gasse"), korridor("gasse"), bridges=[])
assert street.sidewalk.area < 0.01 assert street.sidewalk.area < 0.01
assert street.kerb == [] 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 # 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 # 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. # Task 6 zusagt) - Task 10/12 iterieren über .geoms und würden daran brechen.
@@ -101,9 +106,18 @@ def test_bordstein_frei_an_kurzer_gasseneinmuendung():
Edge(1, 3, [(50.0, 0.0), (50.0, -5.0)], SECTION["gasse"].corridor, "gasse")]) Edge(1, 3, [(50.0, 0.0), (50.0, -5.0)], SECTION["gasse"].corridor, "gasse")])
assert graph.degree(1) == 3 assert graph.degree(1) == 3
street = build_street(graph, box(-20, -40, 120, 20), bridges=[]) 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) # 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: for line in street.kerb:
assert not gassenkorridor.intersects(line), "die Zufahrt bleibt frei, kein Bordstein quer davor" 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(): def test_pflasterflaeche_neben_der_gasse_ist_restflaeche_kein_gehweg():
@@ -182,7 +196,7 @@ def test_gehweg_waechst_nicht_ueber_den_gassenstummel_an_geteilter_kreuzung():
# irgendein Arm Bordstein hat - auch den Anteil, der zu einer dort einmündenden Gasse gehört. # 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 # Ein linearer Zuschnitt (Fixrunde 4) reichte nicht, weil die FILLET-Rundung die Nische
# zwischen Gassenstummeln abseits der Gassenachse aufweitet. Seit Fixrunde 5 geht die # zwischen Gassenstummeln abseits der Gassenachse aufweitet. Seit Fixrunde 5 geht die
# Kreuzung nur noch über ihre bordsteintragenden Armstummel (junction_arms(kerb_only=True), # 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: # 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 # 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 # ein Gehwegstreifen die volle Länge beider Gassenstummel entlang (53,11 m² im Nordstreifen
@@ -230,7 +244,7 @@ def test_gehwegrest_an_kreuzung_mit_mehreren_gassen_bleibt_aus():
def test_gehweg_schliesst_die_luecke_an_gewoehnlicher_kreuzungsecke(): def test_gehweg_schliesst_die_luecke_an_gewoehnlicher_kreuzungsecke():
"""Befund 7 der Fixwelle: An einer gewöhnlichen Kreuzung (mehrere Arme mit Bordstein, keine """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 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 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 Quelle um WALK_MAX=3,0 zeichnet eine Reflexecke nicht nach. Am echten Netz 0,26-2,2 m² je
@@ -240,7 +254,7 @@ def test_gehweg_schliesst_die_luecke_an_gewoehnlicher_kreuzungsecke():
davon zusammen 0,5114 m²) - innerhalb des berichteten Bereichs. davon zusammen 0,5114 m²) - innerhalb des berichteten Bereichs.
_walk_source() rundet den Kreuzungsanteil jetzt mit derselben Schließung wie carriageway_area() _walk_source() rundet den Kreuzungsanteil jetzt mit derselben Schließung wie carriageway_area()
(junction_area(kerb_only=True)), sodass Fahrbahn- und Gehwegquelle an der Ecke wieder (junction_area(walk_only=True)), sodass Fahrbahn- und Gehwegquelle an der Ecke wieder
zueinander passen. zueinander passen.
""" """
graph = Graph( graph = Graph(