From f2f5db94fecc1916a0e4931c0457b8734d13f5a4 Mon Sep 17 00:00:00 2001 From: Kenso Grimm Date: Thu, 17 Sep 2026 07:02:50 +0200 Subject: [PATCH] fix(extract): Gehwegquelle nur noch aus bordsteintragenden Armstummeln, ungerundet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- tools/extract/leonida/section.py | 40 ++++++++++++++--------- tools/extract/leonida/street.py | 29 +++++++---------- tools/extract/tests/test_street.py | 51 ++++++++++++++---------------- 3 files changed, 60 insertions(+), 60 deletions(-) diff --git a/tools/extract/leonida/section.py b/tools/extract/leonida/section.py index 4fc8708..f149580 100644 --- a/tools/extract/leonida/section.py +++ b/tools/extract/leonida/section.py @@ -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)) diff --git a/tools/extract/leonida/street.py b/tools/extract/leonida/street.py index d81704b..9700d57 100644 --- a/tools/extract/leonida/street.py +++ b/tools/extract/leonida/street.py @@ -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 diff --git a/tools/extract/tests/test_street.py b/tools/extract/tests/test_street.py index 2ba73cd..248bed7 100644 --- a/tools/extract/tests/test_street.py +++ b/tools/extract/tests/test_street.py @@ -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"