From 685ac666e698b1855b85c52c96117cc1023b577e Mon Sep 17 00:00:00 2001 From: Kenso Grimm Date: Thu, 17 Sep 2026 06:52:30 +0200 Subject: [PATCH] fix(extract): Kreuzungsstummel schmaeler wachsen lassen, GeometryCollection-Absturz beheben _walk_source() nahm bisher die ganze junction_area() eines Knotens auf, sobald irgendein anliegender Arm Bordstein hat - auch den Anteil, der zu einer dort einmuendenden Gasse gehoert. Der Gehweg wuchs dadurch die volle Laenge des Kreuzungsstummels die Gasse hinauf (bis zu setback+1, beim Boulevard 11 m) statt nur um die Ecke. _kerbless_zone() bekommt den extra-Parameter zurueck (_kerb_lines() und _walk_source() rufen sie beide mit 0.2 auf) und beschneidet jetzt auch den Kreuzungsanteil der Gehwegquelle. Gemessen: an einer T-Kreuzung (2 Gassen + 1 Wohnstrasse) verschwindet der fehlerhafte Gassenstummel-Gehweg vollstaendig (53,11 -> 0,0 m2). An einer 4-Wege-Kreuzung mit 3 Gassen + 1 Boulevard reicht der Fix nicht aus: der Rest in der Nische zwischen zwei Gassenstummeln schrumpft nur auf 27,14 statt der erwarteten rund 2 m2, und seine maximale Ausdehnung von der Kreuzungsmitte (11,14 m) liegt weit ueber der als Grenze genannten 3-4 m - dokumentiert als bekannter, nicht ausreichend geloester Punkt, siehe Fixbericht. Unabhaengig davon: MultiPolygon([result]) wirft TypeError, wenn result eine GeometryCollection ist (intersection()/difference() liefern das gelegentlich an Beruehrstellen, mit einer entarteten Nulllinie neben echten Polygonen). Neuer Helfer to_multipolygon() in section.py filtert auf echte Polygone, statt daran abzustuerzen; carriageway_area() und _sidewalk() nutzen ihn beide. Co-Authored-By: Claude Sonnet 5 --- tools/extract/leonida/section.py | 19 ++++++++-- tools/extract/leonida/street.py | 53 ++++++++++++++++----------- tools/extract/tests/test_section.py | 26 ++++++++++++- tools/extract/tests/test_street.py | 57 ++++++++++++++++++++++++++++- 4 files changed, 127 insertions(+), 28 deletions(-) diff --git a/tools/extract/leonida/section.py b/tools/extract/leonida/section.py index cfe4a68..4fc8708 100644 --- a/tools/extract/leonida/section.py +++ b/tools/extract/leonida/section.py @@ -110,15 +110,26 @@ def junction_area(graph: Graph, node: int): return star.union(rounded.intersection(near)) +def to_multipolygon(geom) -> MultiPolygon: + """Nur die Flächenanteile einer Geometrie, als MultiPolygon. + + intersection()/difference() liefern nicht immer sauber ein (Multi)Polygon: An einer + Berührstelle kann eine GeometryCollection mit zusätzlichen entarteten Linien oder Punkten + herauskommen (Fixrunde 4 zu Task 7, am Gehweg reproduziert). MultiPolygon(geoms) nimmt aber + nur Polygone entgegen und wirft eine TypeError, sobald ein Element davon abweicht - hier + werden die Nicht-Flächen-Teile deshalb verworfen, statt dass die Rechnung daran abstürzt. + """ + if geom.is_empty: + return MultiPolygon() + return MultiPolygon([g for g in getattr(geom, "geoms", [geom]) if g.geom_type == "Polygon"]) + + def carriageway_area(graph: Graph): """Alle Bänder und alle Kreuzungsflächen als eine Geometrie.""" parts = [band for k in range(len(graph.edges)) if (band := ribbon(graph, k)) is not None] parts += [area for node in range(len(graph.nodes)) if graph.degree(node) >= 3 and (area := junction_area(graph, node)) is not None] - vereint = unary_union(parts) - if vereint.is_empty: - return MultiPolygon() # Zusage der Schnittstelle: immer MultiPolygon. unary_union() liefert bei zusammenhängender # Eingabe ein einfaches Polygon, und Task 7 iteriert über .geoms — das bricht dann an kleinen # Prüfkörpern, aber nicht am echten Netz, also genau da, wo niemand damit rechnet. - return vereint if vereint.geom_type == "MultiPolygon" else MultiPolygon([vereint]) + return to_multipolygon(unary_union(parts)) diff --git a/tools/extract/leonida/street.py b/tools/extract/leonida/street.py index 6e09923..d81704b 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 +from .section import SECTION, WALK_MAX, carriageway_area, junction_area, ribbon, to_multipolygon # Die Tiefe steht hier als dokumentierter Wert; gebaut wird sie im Browser (geometry.js). DEPTH = 0.15 @@ -38,7 +38,7 @@ def build_street(graph: Graph, corridor, bridges) -> Street: sidewalk = _sidewalk(_walk_source(graph), carriage, corridor) return Street( carriageway=carriage, - kerb=_kerb_lines(carriage, _kerbless_zone(graph), bridges), + kerb=_kerb_lines(carriage, _kerbless_zone(graph, 0.2), bridges), sidewalk=sidewalk, lots=_lots(carriage, corridor, sidewalk), ) @@ -47,31 +47,39 @@ 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 jede Kreuzung, an der mindestens ein solcher Arm hängt. - 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 den Abzug gar nicht. + 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. """ teile = [band for k, edge in enumerate(graph.edges) if SECTION[edge.klass].kerb and (band := ribbon(graph, k)) is not None] - teile += [flaeche for node in range(len(graph.nodes)) + 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] return unary_union(teile) if teile else None -def _kerbless_zone(graph: Graph): - """Der Korridor aller Klassen ohne Bordstein, aus den vollen Mittellinien, um 0,2 m über die - halbe Fahrbahnbreite hinaus gepuffert - eine Fuge für einen sauberen Schnitt. +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 für _kerb_lines() gebraucht: Der Bordstein hört hier auf. Der Gehweg braucht diesen - Abzug seit Fixrunde 3 nicht mehr - er wächst aus _walk_source() und damit von vornherein nur - dort, wo eine Klasse mit Bordstein liegt, statt aus der ganzen Fahrbahn mit nachträglichem - Abzug einer Zone, die ihre Nachbarschaft nicht kennt. + Zweimal gebraucht, mit derselben Reichweite (Fixrunde 4): Der Bordstein hört hier auf + (_kerb_lines()), und der Kreuzungsanteil der Gehwegquelle hört hier ebenfalls auf + (_walk_source()) - eine Gasse hat laut SECTION weder Bordstein noch Gehweg, in beiden Fällen + ist es dieselbe Fuge von 0,2 m über die eigene Fahrbahnbreite hinaus. """ - zonen = [LineString(edge.points).buffer(SECTION[edge.klass].carriageway / 2 + 0.2, + 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] @@ -88,16 +96,17 @@ def _sidewalk(walk_source, carriage, corridor): Gehweg verschlucken, der zufällig nah an einer liegt. `.difference(carriage)` schneidet die tatsächliche Fahrbahn heraus (auch die einer benachbarten Gasse), nicht nur die Quelle. - Normalisiert wie carriageway_area() auf MultiPolygon (Task 6): Ein einseitiger Gehweg oder - ein leeres Ergebnis kämen sonst als einfaches Polygon bzw. als leere Geometrie zurück, und - Task 10/12 verlassen sich auf die MultiPolygon-Zusage. + Normalisiert wie carriageway_area() auf MultiPolygon (Task 6, Fixrunde 4): Ein einseitiger + Gehweg oder ein leeres Ergebnis kämen sonst als einfaches Polygon bzw. als leere Geometrie + zurück; an einer Berührstelle kann intersection()/difference() sogar eine GeometryCollection + mit zusätzlichen entarteten Linien liefern, an der MultiPolygon([result]) mit TypeError + abstürzt (der Mechanismus ist in test_section.py mit einer erzwungenen GeometryCollection + reproduziert). to_multipolygon() fängt alle drei Fälle einheitlich ab. """ if walk_source is None or walk_source.is_empty: return MultiPolygon() result = walk_source.buffer(WALK_MAX, join_style="round").intersection(corridor).difference(carriage) - if result.is_empty: - return MultiPolygon() - return result if result.geom_type == "MultiPolygon" else MultiPolygon([result]) + return to_multipolygon(result) def _kerb_lines(carriage, kerbless, bridges) -> list[LineString]: diff --git a/tools/extract/tests/test_section.py b/tools/extract/tests/test_section.py index cf4662b..388f0d9 100644 --- a/tools/extract/tests/test_section.py +++ b/tools/extract/tests/test_section.py @@ -1,8 +1,10 @@ """Querschnitte und Fahrbahnbänder.""" from __future__ import annotations +from shapely.geometry import GeometryCollection, LineString, box + from leonida.roads import CLASS_WIDTH, Edge, Graph, Node -from leonida.section import LANE, SECTION, ribbon, setback, trimmed_line +from leonida.section import LANE, SECTION, ribbon, setback, to_multipolygon, trimmed_line def strecke(klass: str, length: float = 100.0) -> Graph: @@ -66,3 +68,25 @@ def test_kante_ganz_innerhalb_der_kreuzung_liefert_kein_band(): assert graph.degree(0) == 3 and graph.degree(1) == 3 assert trimmed_line(graph, 0) is None assert ribbon(graph, 0) is None + + +def test_geometriesammlung_wird_zu_reinem_multipolygon(): + # Fixrunde 4 zu Task 7: intersection()/difference() liefern nicht immer sauber ein + # (Multi)Polygon zurück - an einer Berührstelle kann eine GeometryCollection mit einer + # zusätzlichen entarteten Linie oder einem Punkt herauskommen (am Gehweg gemeldet). + # MultiPolygon(geoms) nimmt aber nur Polygone entgegen und wirft eine TypeError, sobald ein + # Bestandteil davon abweicht - carriageway_area() und _sidewalk() (street.py) stürzten daran + # ab. Hier direkt erzwungen (zwei Polygone plus eine entartete Nulllinie), weil sich die + # Geometriesammlung über echte Fahrbahnrechnungen trotz mehrerer Versuche nicht zuverlässig + # nachbauen ließ - GEOS bereinigt Berührstellen bei Polygon-Operationen erstaunlich robust. + entartet = GeometryCollection([box(0, 0, 10, 10), box(20, 0, 30, 10), LineString([(15, 5), (15, 5)])]) + assert entartet.geom_type == "GeometryCollection" + ergebnis = to_multipolygon(entartet) + assert ergebnis.geom_type == "MultiPolygon" + assert len(ergebnis.geoms) == 2, "nur die zwei echten Flächen, die Nulllinie fällt weg" + assert round(ergebnis.area) == 200 + + +def test_leere_geometrie_wird_zu_leerem_multipolygon(): + assert to_multipolygon(GeometryCollection()).geom_type == "MultiPolygon" + assert to_multipolygon(GeometryCollection()).is_empty diff --git a/tools/extract/tests/test_street.py b/tools/extract/tests/test_street.py index f658533..2ba73cd 100644 --- a/tools/extract/tests/test_street.py +++ b/tools/extract/tests/test_street.py @@ -5,7 +5,7 @@ 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.section import SECTION, carriageway_area from leonida.street import LOT_MIN, build_street, punch @@ -175,3 +175,58 @@ def test_wohnstrassen_gehweg_bleibt_vollstaendig_an_gasseneinmuendung(): 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" + + +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. + 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"), + Edge(1, 3, [(0.0, 0.0), (0.0, -50.0)], SECTION["wohnstrasse"].corridor, "wohnstrasse")]) + assert graph.degree(1) == 3 + # Großzügiger Korridor (voller WALK_MAX-Bereich um die Fahrbahn), damit der Bug ungehindert + # zur Geltung kommt - ein knapper Korridor würde ihn wegschneiden, bevor er sichtbar wird. + corridor = carriageway_area(graph).buffer(3.0, join_style="round") + street = build_street(graph, corridor, bridges=[]) + gassenstreifen_nord = box(-15, 3, 15, 6) + assert street.sidewalk.intersection(gassenstreifen_nord).area < 0.01, \ + "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. + 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"), + Edge(0, 2, [(0.0, 0.0), (50.0, 0.0)], SECTION["gasse"].corridor, "gasse"), + Edge(0, 3, [(0.0, 0.0), (0.0, -50.0)], SECTION["gasse"].corridor, "gasse"), + Edge(0, 4, [(0.0, 0.0), (0.0, 50.0)], SECTION["boulevard"].corridor, "boulevard")]) + assert graph.degree(0) == 4 + corridor = carriageway_area(graph).buffer(3.0, join_style="round") + 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")