fix(extract): Markierungen folgen der Kante und stürzen nicht am Zickzack ab

Befund 3: In junction_marks() kam die Richtung einer Zufahrt aus
origin -> points[1], also nur aus dem ersten Segment der Kante. Haltelinie
und Zebrastreifen wurden aber bis reach + STOP_SETBACK + STOP_DEPTH (bis
16 m) auf dieser Geraden aufgespannt - knickte die Kante innerhalb dieser
Strecke ab, landete die Markierung daneben statt darauf. Am echten Netz
(gemessen über alle 144 committeten Chunks): 452,65 m² von 43.906 m²
Markierungsfläche (1,03 %) liegen außerhalb der Fahrbahn, davon 291,44 m²
Zebra und 147,68 m² Haltelinie; die schlimmsten Einzelfälle sind
vollständige Haltelinien mit 5,41 m². Die Richtung kommt jetzt aus
line.interpolate(min(reach, länge)) - dem tatsächlichen Punkt auf der
Kante bei Streckenlänge reach - statt aus dem zweiten Stützpunkt.

Befund 4: offset_curve() liefert bei einem engen Zickzack eine
MultiLineString statt einer LineString; substring() in _dashed()
akzeptiert nur LineString und wirft dann GeometryTypeError. Am heutigen
Mosaik tritt das nicht auf, aber eine geänderte Korrektur oder eine
eingehängte Kante mit engem Knick hätte den Vier-Minuten-Lauf abgebrochen.
Zweiter, stiller Ast: Eine sehr spitze Kehre lässt offset_curve() leer
zurückkommen; _solid() puffert das ungeprüft zu einem Nullflächen-Polygon.
_offset() normalisiert jetzt auf eine Liste von LineStrings (leer -> [],
MultiLineString -> ihre geoms), _dashed() und _solid() iterieren darüber;
longitudinal() flacht die jetzt listenwertigen _solid()-Aufrufe ab.

Beide Befunde mit Tests, die ohne die jeweilige Korrektur rot sind: der
Zickzack-Fall bricht am unkorrigierten Code mit GeometryTypeError ab, der
Kehre-Fall lieferte zwei Nullflächen-Polygone unter den Rand-/
Doppellinien.
This commit is contained in:
knso committed 2026-09-18 06:35:36 +02:00
1 parent ceb23bf912
commit 83140f89e1
3 files changed
+116 -19

No files matched your search

@@ -3,6 +3,8 @@ from __future__ import annotations
from collections import Counter
from shapely.geometry import LineString
from leonida.roads import Edge, Graph, Node
from leonida.marking import CROSSWALK_DEPTH, junction_marks, markings
from leonida.section import SECTION, setback
@@ -97,6 +99,42 @@ def test_schleife_bekommt_haltelinie_fuer_beide_richtungen():
assert zahl["haltelinie"] == 4, "zwei gerade Arme plus zwei Richtungen der Schleife"
def test_markierung_folgt_dem_geknickten_arm_nicht_der_ersten_richtung():
"""Befund 3 der Fixwelle: "along" kam aus der Richtung origin -> points[1], also allein aus
dem ersten Segment der Zufahrt. Knickt die Kante innerhalb von reach + STOP_SETBACK +
STOP_DEPTH ab (bis 16 m), blieben Zebrastreifen und Haltelinie auf der Verlängerung des
ersten Segments stehen, statt der tatsächlichen Kante zu folgen. Am echten Netz lagen dadurch
452,65 m² Markierungsfläche (1,03 %) neben der Fahrbahn, darunter ganze Haltelinien.
Nachgebaut mit einer Zufahrt, die 1 m nach dem Knoten um 90° abknickt: reach (5,5 m bei
Wohnstraße) und erst recht die Haltelinie bei 9,7-10,3 m liegen weit hinter dem Knick. Der
zweite Arm zeigt nach Westen, weit weg von jeder möglichen Richtung des ersten - er dient nur
dazu, die Mindestzahl von zwei Zufahrten mit Bordstein zu erfüllen (`echte`).
"""
arm = [(0.0, 0.0), (0.0, -1.0), (20.0, -1.0)]
graph = Graph(
nodes=[Node((0.0, 0.0)), Node((-80.0, 0.0)), Node((20.0, -1.0))],
edges=[Edge(0, 1, [(0.0, 0.0), (-80.0, 0.0)], SECTION["wohnstrasse"].corridor, "wohnstrasse"),
Edge(0, 2, arm, SECTION["wohnstrasse"].corridor, "wohnstrasse")],
)
halb = SECTION["wohnstrasse"].carriageway / 2
fahrbahn = LineString(arm).buffer(halb, cap_style="flat")
# Der Westarm liegt bei x <= -80 * 0,15 (Kreuzungsradius) .. deutlich unter -4; alles mit
# centroid.x > -4 gehört zur geknickten Zufahrt, gleich in welche Richtung sie zeigt.
ziel = [(p, a) for p, a in junction_marks(graph, 0) if p.centroid.x > -4]
haltelinie = next(p for p, a in ziel if a == "haltelinie")
anteil_halt = haltelinie.intersection(fahrbahn).area / haltelinie.area
assert anteil_halt > 0.9, (
f"Haltelinie liegt nur zu {anteil_halt:.0%} auf der (geknickten) Fahrbahn - sie folgt "
"noch der Richtung des ersten Segments statt der tatsächlichen Kante"
)
zebra = [p for p, a in ziel if a == "zebra"]
anteile = [p.intersection(fahrbahn).area / p.area for p in zebra]
mittel = sum(anteile) / len(anteile)
assert mittel > 0.7, f"Zebrastreifen im Mittel nur zu {mittel:.0%} auf der Fahrbahn"
assert sum(1 for a in anteile if a > 0.9) >= len(anteile) - 1, "fast jeder Balken muss klar auf der Fahrbahn liegen"
def test_kurzer_arm_an_breiter_kreuzung_bleibt_ohne_markierung():
# reach = setback() kommt vom breitesten anliegenden Arm (hier Boulevard, halbe Fahrbahn 9 m)
# und gilt für alle Zufahrten gleich - ein kurzer Arm reicht dann nicht mehr bis dorthin. Am
+32
View File
@@ -77,6 +77,38 @@ def test_linien_sind_zehn_zentimeter_breit():
assert round(x1 - x0, 3) == DASH
def test_dashed_stuerzt_bei_engem_zickzack_nicht_ab():
"""Befund 4 der Fixwelle: offset_curve() liefert bei einem engen Zickzack eine
MultiLineString statt einer LineString - substring() in _dashed() akzeptiert aber nur
LineString und wirft sonst GeometryTypeError. Nachgestellt mit einer S-Kurve (1 m Amplitude):
Bei LANE=3,5 m Versatz (Spurtrenner, nur ab Boulevard) spaltet offset_curve(-3,5) sie in zwei
Teilstücke. Vor der Korrektur bricht das hier mit GeometryTypeError ab; das ist die
Bestätigung, dass der Test ohne sie rot ist.
"""
zickzack = [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (2.0, 1.0), (2.0, 0.0), (3.0, 0.0)]
graph = Graph(nodes=[Node((0.0, 0.0)), Node((3.0, 0.0))],
edges=[Edge(0, 1, zickzack, SECTION["boulevard"].corridor, "boulevard")])
ergebnis = longitudinal(graph) # darf nicht mit GeometryTypeError abbrechen
arten = {art for _, art in ergebnis}
assert "weiss_gestrichelt" in arten, "der Spurtrenner entsteht trotz Zickzack (in Teilstücken)"
def test_solid_erzeugt_kein_nullflaechen_polygon_bei_leerer_offset_curve():
"""Befund 4 der Fixwelle, zweiter (stiller) Ast: An einer sehr spitzen Kehre liefert
offset_curve() eine leere LineString. _solid() puffert sie ungeprüft zu einem
Nullflächen-Polygon statt gar nichts zurückzugeben - kein Absturz, aber eine entartete
Geometrie in der Ausgabe. Nachgestellt mit einer Kehre, die bei WIDTH (0,1 m, gelb_doppelt)
und bei rand (7 m beim Boulevard, weiss_rand) je auf einer Seite ins Leere läuft.
"""
kehre = [(0.0, 0.0), (1.0, 0.0), (0.0, 0.01)]
graph = Graph(nodes=[Node((0.0, 0.0)), Node((0.0, 0.01))],
edges=[Edge(0, 1, kehre, SECTION["boulevard"].corridor, "boulevard")])
flaechen = [p.area for p, art in longitudinal(graph) if art in ("gelb_doppelt", "weiss_rand")]
assert all(a > 0.01 for a in flaechen), (
f"eine leere offset_curve() darf kein Nullflächen-Polygon in der Ausgabe hinterlassen: {flaechen}"
)
def test_markierung_endet_am_kreuzungsrand():
graph = Graph(nodes=[Node((0.0, 0.0)), Node((120.0, 0.0)), Node((120.0, 60.0)), Node((180.0, 0.0))],
edges=[Edge(0, 1, [(0.0, 0.0), (120.0, 0.0)], 12.0, "wohnstrasse"),