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

+46 -19
View File
@@ -40,41 +40,58 @@ def longitudinal(graph: Graph) -> list[tuple[Polygon, str]]:
# Linie ist WIDTH breit (Pufferung um WIDTH/2 je Seite), sodass von den 20 cm genau
# WIDTH = 10 cm als Lücke zwischen den gemalten Linien übrig bleiben - das ist der im
# Entwurf verlangte Abstand, nicht der Achsabstand selbst.
out += [(_solid(line, off), "gelb_doppelt") for off in (-WIDTH, WIDTH)]
out += [(p, "gelb_doppelt") for off in (-WIDTH, WIDTH) for p in _solid(line, off)]
rand = section.carriageway / 2 - section.parking
out += [(_solid(line, off), "weiss_rand") for off in (-rand, rand)]
out += [(p, "weiss_rand") for off in (-rand, rand) for p in _solid(line, off)]
if section.lanes >= 4:
# Zwischen den gleichgerichteten Spuren, je Richtung einer.
out += [(p, "weiss_gestrichelt") for off in (-LANE, LANE) for p in _dashed(line, off)]
return out
def _offset(line: LineString, distance: float) -> LineString:
def _offset(line: LineString, distance: float) -> list[LineString]:
"""Der versetzte Linienzug, als Liste normalisiert (Befund 4 der Fixwelle).
offset_curve() liefert bei einem engen Zickzack keine einzelne LineString, sondern eine
MultiLineString (nachgestellt: eine S-Kurve mit 1 m Amplitude, LANE=3,5 m Versatz spaltet sich
in zwei Teilstücke) - substring() in _dashed() akzeptiert aber nur LineString und wirft sonst
GeometryTypeError. An einer sehr spitzen Kehre kann das Ergebnis auch ganz leer sein; eine
leere LineString ungeprüft gepuffert (_solid()) ergäbe ein Nullflächen-Polygon statt gar
nichts. Heute tritt keiner der beiden Fälle am Mosaik auf, aber eine geänderte Korrektur, ein
anderer Ausschnitt oder eine eingehängte Kante mit engem Knick reicht dafür.
"""
if distance == 0.0:
return line
return [line]
# offset_curve versetzt nach links; negative Werte nach rechts. Bei geraden Straßen ist das
# exakt, bei Knicken folgt die Kurve dem Knick - genau das soll eine Markierung auch.
return line.offset_curve(distance)
versetzt = line.offset_curve(distance)
if versetzt.is_empty:
return []
return list(versetzt.geoms) if versetzt.geom_type == "MultiLineString" else [versetzt]
def _solid(line: LineString, distance: float):
return _offset(line, distance).buffer(WIDTH / 2, cap_style="flat", join_style="mitre", mitre_limit=1.5)
def _solid(line: LineString, distance: float) -> list[Polygon]:
return [teil.buffer(WIDTH / 2, cap_style="flat", join_style="mitre", mitre_limit=1.5)
for teil in _offset(line, distance)]
def _dashed(line: LineString, distance: float) -> list:
"""Striche im Takt DASH/GAP, beginnend am Anfang der Kante.
"""Striche im Takt DASH/GAP, beginnend am Anfang jedes Teilstücks.
Die Phase läuft bewusst nicht über Kreuzungen hinweg weiter: Jede Kante fängt neu an. Alles
andere hieße, eine Ordnung zu behaupten, die die Karte gar nicht hergibt.
andere hieße, eine Ordnung zu behaupten, die die Karte gar nicht hergibt. Ein Zickzack-Versatz
aus mehreren Teilstücken (_offset()) fängt entsprechend in jedem Teilstück neu an - eine Naht,
die am Mosaik nicht vorkommt und die auch die Kreuzungsgrenze schon in Kauf nimmt.
"""
versetzt = _offset(line, distance)
out, at = [], 0.0
while at < versetzt.length:
end = min(at + DASH, versetzt.length)
if end - at > 0.5:
out.append(substring(versetzt, at, end).buffer(
WIDTH / 2, cap_style="flat", join_style="mitre", mitre_limit=1.5))
at = end + GAP
out = []
for versetzt in _offset(line, distance):
at = 0.0
while at < versetzt.length:
end = min(at + DASH, versetzt.length)
if end - at > 0.5:
out.append(substring(versetzt, at, end).buffer(
WIDTH / 2, cap_style="flat", join_style="mitre", mitre_limit=1.5))
at = end + GAP
return out
@@ -116,7 +133,18 @@ def junction_marks(graph: Graph, node: int) -> list[tuple[Polygon, str]]:
richtungen = ([edge.points, list(reversed(edge.points))] if edge.a == edge.b
else [edge.points if edge.a == node else list(reversed(edge.points))])
for points in richtungen:
along = _unit(origin, points[1] if len(points) > 1 else points[0])
line = LineString(points)
länge = line.length
# Die Richtung kommt aus der tatsächlichen Kante bei Streckenlänge "reach", nicht aus
# dem zweiten Stützpunkt (Befund 3 der Fixwelle): Ein Knick konnte innerhalb der ersten
# reach Meter liegen - reach ist der Kreuzungsradius plus Luft, oft mehrere Meter -,
# während die alte Richtung (Anfang -> zweiter Stützpunkt) bis reach + STOP_SETBACK +
# STOP_DEPTH (bis 16 m) unverändert weiterlief. Am echten Netz lagen dadurch 452,65 m²
# Markierungsfläche (1,03 % der Gesamtfläche) neben statt auf der Fahrbahn, darunter
# ganze Haltelinien. min(reach, länge) schützt kurze Arme, die schon vorher an der
# Prüfung unten scheitern und dann gar nicht gezeichnet werden.
punkt = line.interpolate(min(reach, länge))
along = _unit(origin, (punkt.x, punkt.y))
across = (-along[1], along[0])
halb = SECTION[edge.klass].carriageway / 2
# reach kommt vom breitesten Arm der ganzen Kreuzung, gilt hier aber für diesen einen
@@ -128,7 +156,6 @@ def junction_marks(graph: Graph, node: int) -> list[tuple[Polygon, str]]:
# das Ende der Kante hinaus und läge neben der Straße statt darauf. Zebrastreifen und
# Haltelinie brauchen unterschiedlich viel Platz, deshalb getrennt geprüft statt
# gemeinsam verworfen.
länge = LineString(points).length
if länge >= reach + CROSSWALK_DEPTH:
# Balken längs der Fahrtrichtung, quer über die Fahrbahn verteilt.
offset = -halb + BAR / 2