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

+15 -4
View File
@@ -110,15 +110,26 @@ def junction_area(graph: Graph, node: int):
return star.union(rounded.intersection(near)) 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): def carriageway_area(graph: Graph):
"""Alle Bänder und alle Kreuzungsflächen als eine Geometrie.""" """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 = [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 parts += [area for node in range(len(graph.nodes)) if graph.degree(node) >= 3
and (area := junction_area(graph, node)) is not None] 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 # 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 # 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. # 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))
+31 -22
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 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). # Die Tiefe steht hier als dokumentierter Wert; gebaut wird sie im Browser (geometry.js).
DEPTH = 0.15 DEPTH = 0.15
@@ -38,7 +38,7 @@ def build_street(graph: Graph, corridor, bridges) -> Street:
sidewalk = _sidewalk(_walk_source(graph), carriage, corridor) sidewalk = _sidewalk(_walk_source(graph), carriage, corridor)
return Street( return Street(
carriageway=carriage, carriageway=carriage,
kerb=_kerb_lines(carriage, _kerbless_zone(graph), bridges), kerb=_kerb_lines(carriage, _kerbless_zone(graph, 0.2), bridges),
sidewalk=sidewalk, sidewalk=sidewalk,
lots=_lots(carriage, corridor, sidewalk), lots=_lots(carriage, corridor, sidewalk),
) )
@@ -47,31 +47,39 @@ 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 jede Kreuzung, an der mindestens ein solcher Arm hängt. Alle Kanten mit Bordstein, dazu der Kreuzungsanteil jedes Knotens, an dem mindestens ein
Vorher wuchs der Gehweg aus der gesamten Fahrbahn, und die Klassen ohne Bordstein wurden solcher Arm hängt - aber ohne den Gassenstummel darin (Fixrunde 4): junction_area() liefert
hinterher über eine Reichweitenzone wieder abgezogen — die kennt aber ihre Nachbarschaft die ganze Kreuzungsfläche als ein Stück, auch den Anteil der Gasse, die dort einmündet. Ohne
nicht und nahm dabei auch den Gehweg einer Wohnstraße mit, die zufällig nah an einer Gasse den Abzug wüchse der Gehweg die volle Länge dieses Stummels (bis zu setback+1, beim Boulevard
lag. Wer von der richtigen Seite her wächst, braucht den Abzug gar nicht. 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) 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]
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 if graph.degree(node) >= 3
and any(SECTION[graph.edges[k].klass].kerb for k in graph.incident(node)) and any(SECTION[graph.edges[k].klass].kerb for k in graph.incident(node))
and (flaeche := junction_area(graph, node)) is not None] 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
def _kerbless_zone(graph: Graph): def _kerbless_zone(graph: Graph, extra: float):
"""Der Korridor aller Klassen ohne Bordstein, aus den vollen Mittellinien, um 0,2 m über die """Der Korridor aller Klassen ohne Bordstein, aus den vollen Mittellinien, um `extra` über die
halbe Fahrbahnbreite hinaus gepuffert - eine Fuge für einen sauberen Schnitt. halbe Fahrbahnbreite hinaus gepuffert.
Nur noch für _kerb_lines() gebraucht: Der Bordstein hört hier auf. Der Gehweg braucht diesen Zweimal gebraucht, mit derselben Reichweite (Fixrunde 4): Der Bordstein hört hier auf
Abzug seit Fixrunde 3 nicht mehr - er wächst aus _walk_source() und damit von vornherein nur (_kerb_lines()), und der Kreuzungsanteil der Gehwegquelle hört hier ebenfalls auf
dort, wo eine Klasse mit Bordstein liegt, statt aus der ganzen Fahrbahn mit nachträglichem (_walk_source()) - eine Gasse hat laut SECTION weder Bordstein noch Gehweg, in beiden Fällen
Abzug einer Zone, die ihre Nachbarschaft nicht kennt. 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) cap_style="flat", join_style="mitre", mitre_limit=1.5)
for edge in graph.edges for edge in graph.edges
if not SECTION[edge.klass].kerb and len(edge.points) >= 2] 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 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. 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 Normalisiert wie carriageway_area() auf MultiPolygon (Task 6, Fixrunde 4): Ein einseitiger
ein leeres Ergebnis kämen sonst als einfaches Polygon bzw. als leere Geometrie zurück, und Gehweg oder ein leeres Ergebnis kämen sonst als einfaches Polygon bzw. als leere Geometrie
Task 10/12 verlassen sich auf die MultiPolygon-Zusage. 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: if walk_source is None or walk_source.is_empty:
return MultiPolygon() return MultiPolygon()
result = walk_source.buffer(WALK_MAX, join_style="round").intersection(corridor).difference(carriage) result = walk_source.buffer(WALK_MAX, join_style="round").intersection(corridor).difference(carriage)
if result.is_empty: return to_multipolygon(result)
return MultiPolygon()
return result if result.geom_type == "MultiPolygon" else MultiPolygon([result])
def _kerb_lines(carriage, kerbless, bridges) -> list[LineString]: def _kerb_lines(carriage, kerbless, bridges) -> list[LineString]:
+25 -1
View File
@@ -1,8 +1,10 @@
"""Querschnitte und Fahrbahnbänder.""" """Querschnitte und Fahrbahnbänder."""
from __future__ import annotations from __future__ import annotations
from shapely.geometry import GeometryCollection, LineString, box
from leonida.roads import CLASS_WIDTH, Edge, Graph, Node 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: 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 graph.degree(0) == 3 and graph.degree(1) == 3
assert trimmed_line(graph, 0) is None assert trimmed_line(graph, 0) is None
assert ribbon(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 shapely.ops import unary_union
from leonida.roads import Edge, Graph, Node 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 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=[]) 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(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" 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")