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
def junction_area(graph: Graph, node: int):
"""Die Fläche einer Kreuzung: die Stummel aller Arme, mit gerundeten einspringenden Ecken.
def junction_arms(graph: Graph, node: int, kerb_only: bool = False) -> list:
"""Die Armstummel einer Kreuzung, je auf ihre eigene Fahrbahnbreite gepuffert.
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.
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
Rundung. Deshalb hier einmal, statt die Stummelbildung zu wiederholen.
"""
incident = graph.incident(node)
if not incident:
return None
reach = setback(graph, node)
arms = []
for k in incident:
arme = []
for k in graph.incident(node):
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
# 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
@@ -101,10 +98,23 @@ def junction_area(graph: Graph, node: int):
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)
stumpf = substring(line, 0.0, min(reach + 1.0, line.length))
arme.append(stumpf.buffer(SECTION[edge.klass].carriageway / 2,
cap_style="flat", join_style="mitre", mitre_limit=1.5))
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)
rounded = star.buffer(FILLET, join_style="round").buffer(-FILLET, join_style="round")
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 .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).
DEPTH = 0.15
@@ -47,26 +47,19 @@ def build_street(graph: Graph, corridor, bridges) -> Street:
def _walk_source(graph: Graph):
"""Die Fahrbahnteile, an denen ein Gehweg entstehen darf.
Alle Kanten mit Bordstein, dazu der Kreuzungsanteil jedes Knotens, an dem mindestens ein
solcher Arm hängt - aber ohne den Gassenstummel darin (Fixrunde 4): junction_area() liefert
die ganze Kreuzungsfläche als ein Stück, auch den Anteil der Gasse, die dort einmündet. Ohne
den Abzug wüchse der Gehweg die volle Länge dieses Stummels (bis zu setback+1, beim Boulevard
11 m) die Gasse hinauf, statt nur um die Ecke herum. Dieselbe schmale Fuge wie beim Bordstein
(_kerbless_zone, extra=0,2) schneidet nur den Gassenanteil heraus; die Kreuzung selbst bleibt
Quelle, ein kurzer Rest an der Einmündungsecke bleibt und soll bleiben - das ist die Ecke,
nicht die Gasse. Vorher wuchs der Gehweg aus der gesamten Fahrbahn, und die Klassen ohne
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.
Alle Kanten mit Bordstein, dazu an jeder Kreuzung nur die Armstummel mit Bordstein - roh,
ohne die Eckrundung von junction_area() (Fixrunde 5): Die volle, gerundete Kreuzungsfläche
brachte in Fixrunde 4 immer noch zu viel Gasse mit. Der Grund war nicht die Zuschnittbreite,
sondern die Rundung selbst - sie füllt die einspringende Ecke zwischen zwei benachbarten
Gassenstummeln zu einer Nische auf, die abseits der Gassenachse liegt und die ein linearer
Abzug entlang dieser Achse deshalb nie erreicht. Ohne die Rundung entsteht die Nische gar
nicht erst: Die Quelle ist exakt die Fahrbahn mit Bordstein, ungerundet, und der Gehweg
wächst von dort WALK_MAX weit - um die Ecke herum, aber nie eine Gasse hinauf.
"""
teile = [band for k, edge in enumerate(graph.edges)
if SECTION[edge.klass].kerb and (band := ribbon(graph, k)) is not None]
ohne = _kerbless_zone(graph, 0.2)
teile += [flaeche if ohne is None else flaeche.difference(ohne)
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]
teile += [arm for node in range(len(graph.nodes)) if graph.degree(node) >= 3
for arm in junction_arms(graph, node, kerb_only=True)]
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():
# Fixrunde 4: _walk_source() nahm bisher die ganze junction_area() auf, sobald irgendein Arm
# Bordstein hat - auch den Anteil, der zu einer dort einmündenden Gasse gehört. T-Kreuzung:
# zwei durchgehende Gassenarme (West/Ost) plus ein Wohnstraßenarm (Süd). Ohne Beschneidung
# wächst ein Gehwegstreifen die volle Länge beider Gassenstummel entlang (hier: 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.
# 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(kerb_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"),
@@ -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"
def test_gehwegrest_an_kreuzung_mit_mehreren_gassen_bleibt_teilproblem():
# Fixrunde 4, Abnahmepunkt 3+4: 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.
# _kerbless_zone(graph, 0.2) schneidet nur einen schmalen Streifen (Gassenbreite + 0,2 m)
# entlang der jeweiligen Gassen-Mittellinie heraus, nicht die durch die FILLET-Rundung
# aufgeweitete Nische zwischen zwei benachbarten Gassenstummeln - dort bleibt ein Rest, der
# weiter reicht als die für eine reine Eckenrundung erwartete Größenordnung.
#
# Bericht an den Koordinator (Abnahmepunkt 4): Der Rest in der Nische zwischen West- und
# 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.
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"),
@@ -224,9 +223,7 @@ def test_gehwegrest_an_kreuzung_mit_mehreren_gassen_bleibt_teilproblem():
street = build_street(graph, corridor, bridges=[])
nische = box(-15, -15, -3, -3)
rest = street.sidewalk.intersection(nische)
# 40 liegt klar zwischen der ungefixten Fassung (53,30) und der jetzigen (27,14) - die
# Schranke trennt beide Fassungen sauber, statt nur knapp unter dem alten Wert zu liegen.
assert rest.area < 40, "die Nische schrumpft gegenüber der ungefixten Fassung"
assert rest.area > 20, ("Regressionsschutz für den unten dokumentierten Zwischenstand (rund "
"27,14 m²) - stimmt diese Zahl plötzlich nicht mehr, hat sich am "
"Fix etwas geändert, das im Bericht nachgezogen werden muss")
# 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"