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 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Sonnet 5 committed 2026-09-17 06:52:30 +02:00
1 parent ca5710730e
commit 685ac666e6
4 files changed
+127 -28

No files matched your search

+25 -1
View File
@@ -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
+56 -1
View File
@@ -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")