fix(extract): Gehwegquelle nur noch aus bordsteintragenden Armstummeln, ungerundet

Zuschneiden (Fixrunde 4) war der falsche Hebel: Egal wie breit die abgezogene
Zone, die Nische zwischen zwei Gassenstummeln entsteht erst durch die
FILLET-Rundung in junction_area() und liegt abseits der Gassenachse - ein
linearer Abzug entlang dieser Achse kann sie grundsaetzlich nicht erreichen.

Die Stummelbildung aus junction_area() in einen eigenen Helfer gezogen,
junction_arms(graph, node, kerb_only=False), mit einem Filter fuer Kanten ohne
Bordstein. junction_area() nutzt ihn ungefiltert und rundet wie bisher -
verhaelt sich dadurch identisch (913.566,7 m² in 213 Teilen am echten Netz,
bit-genau nachgemessen). _walk_source() in street.py nimmt jetzt nur noch die
rohen, ungerundeten Armstummel mit Bordstein (kerb_only=True) als Quelle,
ohne den kerbless_zone-Abzug aus Fixrunde 4 - die Nische entsteht dadurch gar
nicht erst, weil die Kreuzung ohne Rundung nur noch ueber ihre eigenen,
bordsteintragenden Arme in die Quelle eingeht.

Gemessen: an der Kreuzung mit drei Gassen und einem Boulevard verschwindet
der Rest in der Nische vollstaendig (0,0 statt der 27,14 m² aus Fixrunde 4)
- weiter als die fuer diese Runde erwartete Obergrenze von rund 6 m
Ausdehnung. Kriterium 4 damit erfuellt, keine weitere Variante noetig.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Sonnet 5 committed 2026-09-17 07:02:50 +02:00
1 parent 685ac666e6
commit f2f5db94fe
3 files changed
+60 -60

No files matched your search

+25 -15
View File
@@ -78,21 +78,18 @@ def ribbon(graph: Graph, k: int):
FILLET = 6.0 FILLET = 6.0
def junction_area(graph: Graph, node: int): def junction_arms(graph: Graph, node: int, kerb_only: bool = False) -> list:
"""Die Fläche einer Kreuzung: die Stummel aller Arme, mit gerundeten einspringenden Ecken. """Die Armstummel einer Kreuzung, je auf ihre eigene Fahrbahnbreite gepuffert.
Gerundet wird durch Schließen (aufblasen, dann wieder schrumpfen) - und ausdrücklich nur Zweimal gebraucht (Fixrunde 5 zu Task 7): junction_area() vereinigt alle und rundet die
innerhalb einer Scheibe um den Knoten. Auf das ganze Netz angewandt verklebte dieselbe einspringenden Ecken; die Gehwegquelle in street.py nimmt nur die Arme mit Bordstein, ohne
Operation zwei parallele Straßen, deren Fahrbahnen weniger als 2 * FILLET auseinanderliegen. Rundung. Deshalb hier einmal, statt die Stummelbildung zu wiederholen.
""" """
incident = graph.incident(node)
if not incident:
return None
reach = setback(graph, node) reach = setback(graph, node)
arms = [] arme = []
for k in incident: for k in graph.incident(node):
edge = graph.edges[k] edge = graph.edges[k]
if len(edge.points) < 2: if len(edge.points) < 2 or (kerb_only and not SECTION[edge.klass].kerb):
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
@@ -101,10 +98,23 @@ def junction_area(graph: Graph, node: int):
else [edge.points if edge.a == node else list(reversed(edge.points))]) else [edge.points if edge.a == node else list(reversed(edge.points))])
for points in richtungen: for points in richtungen:
line = LineString(points) line = LineString(points)
stub = substring(line, 0.0, min(reach + 1.0, line.length)) stumpf = substring(line, 0.0, min(reach + 1.0, line.length))
arms.append(stub.buffer(SECTION[edge.klass].carriageway / 2, arme.append(stumpf.buffer(SECTION[edge.klass].carriageway / 2,
cap_style="flat", join_style="mitre", mitre_limit=1.5)) cap_style="flat", join_style="mitre", mitre_limit=1.5))
star = unary_union(arms) return arme
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.
"""
if not graph.incident(node):
return None
reach = setback(graph, node)
star = unary_union(junction_arms(graph, node))
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))
+11 -18
View File
@@ -14,7 +14,7 @@ from shapely.geometry import LineString, MultiPolygon, Polygon
from shapely.ops import unary_union from shapely.ops import unary_union
from .roads import Graph from .roads import Graph
from .section import SECTION, WALK_MAX, carriageway_area, junction_area, ribbon, to_multipolygon from .section import SECTION, WALK_MAX, carriageway_area, junction_arms, ribbon, to_multipolygon
# Die Tiefe steht hier als dokumentierter Wert; gebaut wird sie im Browser (geometry.js). # Die Tiefe steht hier als dokumentierter Wert; gebaut wird sie im Browser (geometry.js).
DEPTH = 0.15 DEPTH = 0.15
@@ -47,26 +47,19 @@ def build_street(graph: Graph, corridor, bridges) -> Street:
def _walk_source(graph: Graph): def _walk_source(graph: Graph):
"""Die Fahrbahnteile, an denen ein Gehweg entstehen darf. """Die Fahrbahnteile, an denen ein Gehweg entstehen darf.
Alle Kanten mit Bordstein, dazu der Kreuzungsanteil jedes Knotens, an dem mindestens ein Alle Kanten mit Bordstein, dazu an jeder Kreuzung nur die Armstummel mit Bordstein - roh,
solcher Arm hängt - aber ohne den Gassenstummel darin (Fixrunde 4): junction_area() liefert ohne die Eckrundung von junction_area() (Fixrunde 5): Die volle, gerundete Kreuzungsfläche
die ganze Kreuzungsfläche als ein Stück, auch den Anteil der Gasse, die dort einmündet. Ohne brachte in Fixrunde 4 immer noch zu viel Gasse mit. Der Grund war nicht die Zuschnittbreite,
den Abzug wüchse der Gehweg die volle Länge dieses Stummels (bis zu setback+1, beim Boulevard sondern die Rundung selbst - sie füllt die einspringende Ecke zwischen zwei benachbarten
11 m) die Gasse hinauf, statt nur um die Ecke herum. Dieselbe schmale Fuge wie beim Bordstein Gassenstummeln zu einer Nische auf, die abseits der Gassenachse liegt und die ein linearer
(_kerbless_zone, extra=0,2) schneidet nur den Gassenanteil heraus; die Kreuzung selbst bleibt Abzug entlang dieser Achse deshalb nie erreicht. Ohne die Rundung entsteht die Nische gar
Quelle, ein kurzer Rest an der Einmündungsecke bleibt und soll bleiben - das ist die Ecke, nicht erst: Die Quelle ist exakt die Fahrbahn mit Bordstein, ungerundet, und der Gehweg
nicht die Gasse. Vorher wuchs der Gehweg aus der gesamten Fahrbahn, und die Klassen ohne wächst von dort WALK_MAX weit - um die Ecke herum, aber nie eine Gasse hinauf.
Bordstein wurden hinterher über eine Reichweitenzone wieder abgezogen — die kennt aber ihre
Nachbarschaft nicht und nahm dabei auch den Gehweg einer Wohnstraße mit, die zufällig nah an
einer Gasse lag. Wer von der richtigen Seite her wächst, braucht diesen zweiten Abzug nicht.
""" """
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].kerb and (band := ribbon(graph, k)) is not None]
ohne = _kerbless_zone(graph, 0.2) teile += [arm for node in range(len(graph.nodes)) if graph.degree(node) >= 3
teile += [flaeche if ohne is None else flaeche.difference(ohne) for arm in junction_arms(graph, node, kerb_only=True)]
for node in range(len(graph.nodes))
if graph.degree(node) >= 3
and any(SECTION[graph.edges[k].klass].kerb for k in graph.incident(node))
and (flaeche := junction_area(graph, node)) is not None]
return unary_union(teile) if teile else None return unary_union(teile) if teile else None
+24 -27
View File
@@ -178,12 +178,16 @@ def test_wohnstrassen_gehweg_bleibt_vollstaendig_an_gasseneinmuendung():
def test_gehweg_waechst_nicht_ueber_den_gassenstummel_an_geteilter_kreuzung(): def test_gehweg_waechst_nicht_ueber_den_gassenstummel_an_geteilter_kreuzung():
# Fixrunde 4: _walk_source() nahm bisher die ganze junction_area() auf, sobald irgendein Arm # Fixrunde 4/5: _walk_source() nahm ursprünglich die ganze junction_area() auf, sobald
# Bordstein hat - auch den Anteil, der zu einer dort einmündenden Gasse gehört. T-Kreuzung: # irgendein Arm Bordstein hat - auch den Anteil, der zu einer dort einmündenden Gasse gehört.
# zwei durchgehende Gassenarme (West/Ost) plus ein Wohnstraßenarm (Süd). Ohne Beschneidung # Ein linearer Zuschnitt (Fixrunde 4) reichte nicht, weil die FILLET-Rundung die Nische
# wächst ein Gehwegstreifen die volle Länge beider Gassenstummel entlang (hier: 53,11 m² im # zwischen Gassenstummeln abseits der Gassenachse aufweitet. Seit Fixrunde 5 geht die
# Nordstreifen y∈[3,6]), obwohl mitten auf der Gasse (Kontrollpunkt, weit von der Kreuzung) # Kreuzung nur noch über ihre bordsteintragenden Armstummel (junction_arms(kerb_only=True),
# kein Gehweg entsteht - der Fehler sitzt eindeutig am Kreuzungsanteil. # 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))], 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"), 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, 2, [(0.0, 0.0), (50.0, 0.0)], SECTION["gasse"].corridor, "gasse"),
@@ -198,21 +202,16 @@ def test_gehweg_waechst_nicht_ueber_den_gassenstummel_an_geteilter_kreuzung():
"kein Gehweg entlang der Gassenstummel, nur der Wohnstraßen-Gehweg im Süden bleibt" "kein Gehweg entlang der Gassenstummel, nur der Wohnstraßen-Gehweg im Süden bleibt"
def test_gehwegrest_an_kreuzung_mit_mehreren_gassen_bleibt_teilproblem(): def test_gehwegrest_an_kreuzung_mit_mehreren_gassen_bleibt_aus():
# Fixrunde 4, Abnahmepunkt 3+4: Kreuzung mit drei Gassenarmen (West/Ost/Süd) und einem # Fixrunde 5, Abnahmepunkt 4 - jetzt Zielverhalten statt Zwischenstand: Kreuzung mit drei
# Boulevard-Arm (Nord). setback = max(9, 3, 3, 3) + 2 = 11 m - der Boulevard zieht den # Gassenarmen (West/Ost/Süd) und einem Boulevard-Arm (Nord). setback = max(9,3,3,3)+2 = 11 m
# Kreuzungsstummel aller vier Arme, auch der schmalen Gassen, auf 12 m Länge hoch. # - der Boulevard zieht den Kreuzungsstummel aller vier Arme, auch der schmalen Gassen, auf
# _kerbless_zone(graph, 0.2) schneidet nur einen schmalen Streifen (Gassenbreite + 0,2 m) # 12 m Länge hoch. Ein linearer Zuschnitt (Fixrunde 4, _kerbless_zone entlang der
# entlang der jeweiligen Gassen-Mittellinie heraus, nicht die durch die FILLET-Rundung # Gassen-Mittellinie) erreichte die durch die FILLET-Rundung aufgeweitete Nische zwischen
# aufgeweitete Nische zwischen zwei benachbarten Gassenstummeln - dort bleibt ein Rest, der # zwei benachbarten Gassenstummeln nicht (Rest blieb bei 27,14 m², Ausdehnung bis 11,14 m).
# weiter reicht als die für eine reine Eckenrundung erwartete Größenordnung. # 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
# Bericht an den Koordinator (Abnahmepunkt 4): Der Rest in der Nische zwischen West- und # mehr bei, die Nische zwischen ihnen kann also gar nicht erst entstehen.
# Süd-Gassenstummel schrumpft zwar von 53,3 auf 27,14 m², aber seine maximale Ausdehnung von
# der Kreuzungsmitte reicht bis 11,14 m - weit über die als Grenze genannten 3-4 m. Der
# Ansatz aus Fixrunde 4 behebt den Kreuzungsstummel-Fehler also nur teilweise; dieser Test
# hält den tatsächlich erreichten (nicht den gewünschten) Zwischenstand fest, bis eine
# weitere Entscheidung vorliegt - siehe Fixbericht.
graph = Graph(nodes=[Node((0.0, 0.0)), Node((-50.0, 0.0)), Node((50.0, 0.0)), 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))], 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"), edges=[Edge(0, 1, [(0.0, 0.0), (-50.0, 0.0)], SECTION["gasse"].corridor, "gasse"),
@@ -224,9 +223,7 @@ def test_gehwegrest_an_kreuzung_mit_mehreren_gassen_bleibt_teilproblem():
street = build_street(graph, corridor, bridges=[]) street = build_street(graph, corridor, bridges=[])
nische = box(-15, -15, -3, -3) nische = box(-15, -15, -3, -3)
rest = street.sidewalk.intersection(nische) rest = street.sidewalk.intersection(nische)
# 40 liegt klar zwischen der ungefixten Fassung (53,30) und der jetzigen (27,14) - die # Gemessen: 0,0 m² exakt - deutlich strenger als die von der Abnahme geforderte Obergrenze
# Schranke trennt beide Fassungen sauber, statt nur knapp unter dem alten Wert zu liegen. # von rund WALK_MAX + halbe Gassenbreite (~6 m Ausdehnung); 1,0 m² lässt Spielraum für
assert rest.area < 40, "die Nische schrumpft gegenüber der ungefixten Fassung" # künftige, geometrisch bedingte Rundungsreste, ohne die Kernaussage zu verwässern.
assert rest.area > 20, ("Regressionsschutz für den unten dokumentierten Zwischenstand (rund " assert rest.area < 1.0, "die Nische zwischen zwei Gassenstummeln bleibt frei von Gehweg"
"27,14 m²) - stimmt diese Zahl plötzlich nicht mehr, hat sich am "
"Fix etwas geändert, das im Bericht nachgezogen werden muss")