Basemap: Punktlinien in Karte und Legende sichtbar machen (#90)

basemap.de zeichnet Punktlinien mit Strichlänge 0 und runder Linienkappe (MapLibre-Muster);
QGIS übernimmt das beim Import 1:1, Qt zeichnet Striche der Länge 0 aber nicht. Baumreihen,
Hecken, Durchlässe, Seilbahnen u. a. (12 Styles) fehlten dadurch in Karte und Legende.

Neues Modul basemap_stilkorrektur: korrigiere_punktlinien(layer) ersetzt Strichlänge 0 durch
0,01 im festen Strichmuster und in den array(...)-Mustern des zoomabhängigen Ausdrucks;
idempotent, andere Styles unverändert. Ausgelöst per Knopf „Punktlinien korrigieren“ im
Basemap-Bereich, sicherbar als Stilvorlage. Wrapper: PROPERTY_CUSTOM_DASH,
QgsProperty.fromExpression im Mock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 12:33:00 +02:00
co-authored by Claude Opus 5.5
parent 8693fedada
commit d6d4403e46
6 changed files with 310 additions and 2 deletions
+1
View File
@@ -1,3 +1,4 @@
- Bugfix: Punktlinien des Basemap-Layers (Baumreihe, Hecke, Durchlass, Seilbahnen u. a., 12 Styles) wurden in Karte und Legende nicht gezeichnet – basemap.de nutzt Strichlänge 0 mit runder Kappe, Qt zeichnet Striche der Länge 0 nicht. Neuer Knopf „Punktlinien korrigieren“ im Basemap-Bereich des Druck-Tabs ersetzt die Strichlänge 0 durch 0,01 (festes Muster und zoomabhängiger Ausdruck); die Korrektur lässt sich als Stilvorlage sichern (#90)
- Basemap-Legende für Drucklayouts (#88): Beim Erstellen einer §38-/§41-Vorlage wird ausgewertet, welche Objekte des basemap.de-Vektorkachel-Layers im Verfahrensgebiet (wahlweise im Druckbereich) tatsächlich dargestellt werden; ein Auswahldialog lässt Kategorien abwählen, die gewählten erscheinen mit der Signatur des Projektlayers in einem eigenen Legendenelement „Kartengrundlage (basemap.de)“ über der Quellenangabe. Zuordnung von 434 Basemap-Styles zu 190 Legendeneinträgen (Beschriftungen, Nummernschilder und Hintergrund ausgenommen) in `data/basemap_legende.json` (erzeugt mit `modules/basemap_katalog.py`, fachlich nachbearbeitet)
- Neuer einklappbarer Bereich „Basemap“ im Druck-Tab: Auswertebereich, Basemap-Legende beim Erstellen an/aus, „Basemap-Legende aktualisieren“ für bestehende Vorlagen und lokale Stilvorlagen des Basemap-Layers (mehrere Varianten, laden, speichern, als Standard markieren, löschen) – z. B. einmal Hausnummern ausschalten und in jedem Projekt wieder laden (#88)
- Neuer allgemeiner Dialog `ask_mehrfachauswahl` (Liste mit Häkchen, Gruppen, Suche, Symbolvorschau) in `functions/dialog_wrapper.py` (#88)
+18
View File
@@ -896,6 +896,7 @@ QgsLayoutUtils: Type[Any]
SELECT_BEHAVIOR_SET: Any = None
SELECT_RELATION_INTERSECT: Any = None
PROPERTY_TYPE_EXPRESSION: Any = None
PROPERTY_CUSTOM_DASH: Any = None
LEGEND_STYLE_HIDDEN: Any = None
LEGEND_STYLE_SUBGROUP: Any = None
STYLE_CATEGORIES_SYMBOLOGIE_BESCHRIFTUNG: Any = None
@@ -919,6 +920,7 @@ try:
QgsSymbolLayerUtils as _QgsSymbolLayerUtils,
QgsLayoutMeasurement as _QgsLayoutMeasurement,
QgsLayoutUtils as _QgsLayoutUtils,
QgsSymbolLayer as _QgsSymbolLayer,
Qgis as _QgisVT,
)
@@ -944,6 +946,12 @@ try:
PROPERTY_TYPE_EXPRESSION = getattr(getattr(_QgsProperty, "Type", None), "ExpressionBasedProperty", None)
if PROPERTY_TYPE_EXPRESSION is None:
PROPERTY_TYPE_EXPRESSION = getattr(_QgsProperty, "ExpressionBasedProperty", None)
# Schlüssel der datendefinierten Eigenschaft „eigenes Strichmuster“ als int (Basis #90);
# propertyKeys() liefert ints, das Enum ist je nach QGIS-Version gescoped oder nicht.
_strichmuster = getattr(getattr(_QgsSymbolLayer, "Property", None), "CustomDash", None)
if _strichmuster is None:
_strichmuster = getattr(_QgsSymbolLayer, "PropertyCustomDash", None)
PROPERTY_CUSTOM_DASH = int(getattr(_strichmuster, "value", _strichmuster))
LEGEND_STYLE_HIDDEN = getattr(getattr(_QgsLegendStyle, "Style", _QgsLegendStyle), "Hidden", None)
LEGEND_STYLE_SUBGROUP = getattr(getattr(_QgsLegendStyle, "Style", _QgsLegendStyle), "Subgroup", None)
_kategorie = getattr(_QgsMapLayer, "StyleCategory", _QgsMapLayer)
@@ -1018,6 +1026,15 @@ except Exception:
def fromValue(value: Any) -> "_MockQgsProperty":
return _MockQgsProperty(value)
@staticmethod
def fromExpression(expression: str, is_active: bool = True) -> "_MockQgsProperty":
eigenschaft = _MockQgsProperty()
eigenschaft._ausdruck = expression
return eigenschaft
def expressionString(self) -> str:
return getattr(self, "_ausdruck", "")
class _MockQgsRendererCategory:
def __init__(self, value: Any = None, symbol: Any = None, label: str = ""):
self._value, self._symbol, self._label = value, symbol, label
@@ -1123,6 +1140,7 @@ except Exception:
SELECT_BEHAVIOR_SET = 0
SELECT_RELATION_INTERSECT = 0
PROPERTY_TYPE_EXPRESSION = 2
PROPERTY_CUSTOM_DASH = 36
LEGEND_STYLE_HIDDEN = _MockQgsLegendStyle.Hidden
LEGEND_STYLE_SUBGROUP = _MockQgsLegendStyle.Subgroup
STYLE_CATEGORIES_SYMBOLOGIE_BESCHRIFTUNG = 3
+99
View File
@@ -0,0 +1,99 @@
"""
sn_basis/modules/basemap_stilkorrektur.py – Darstellungsfehler des Basemap-Layers korrigieren
Punktlinien (Baumreihe, Hecke, Durchlass, Seilbahnen …) zeichnet basemap.de wie in MapLibre
üblich mit einem Strichmuster der Strichlänge 0 und runder Linienkappe. QGIS übernimmt das beim
Import der Style-JSON 1:1, Qt zeichnet Striche der Länge 0 aber nicht – die Objekte fehlen in
Karte und Legende (Basis #90, geprüft mit QGIS 3.44 / PyQt 5.15). ``korrigiere_punktlinien``
ersetzt die Strichlänge 0 durch ``MINDESTSTRICH`` – im festen Strichmuster und im
zoomabhängigen Ausdruck. Mit runder Kappe ergibt das wieder runde Punkte.
Die Korrektur verändert die Darstellung des Layers im Projekt und wird nur auf Wunsch des
Nutzers ausgeführt (Basemap-Bereich im Druck-Tab); sichern lässt sie sich als Stilvorlage.
"""
from __future__ import annotations
import re
from typing import Any
from sn_basis.functions.qgiscore_wrapper import PROPERTY_CUSTOM_DASH, PROPERTY_TYPE_EXPRESSION, QgsProperty
# Kleinste Strichlänge (in Vielfachen der Linienbreite bzw. Mustereinheit), die Qt zeichnet.
MINDESTSTRICH = 0.01
# array(...) mit reiner Zahlenliste, wie sie der QGIS-Konverter für line-dasharray erzeugt
_ZAHLENFELD = re.compile(r"array\(\s*(-?\d+(?:\.\d+)?(?:\s*,\s*-?\d+(?:\.\d+)?)*)\s*\)")
def _ist_null(wert: Any) -> bool:
try:
return float(wert) == 0.0
except (TypeError, ValueError):
return False
def korrigiere_muster(werte: list) -> list:
"""Ersetzt Strichlängen 0 (gerade Positionen) im Strichmuster; Lücken bleiben unverändert."""
return [MINDESTSTRICH if i % 2 == 0 and _ist_null(w) else w for i, w in enumerate(werte)]
def korrigiere_ausdruck(ausdruck: str) -> str:
"""Ersetzt Strichlängen 0 in allen ``array(...)``-Strichmustern eines Ausdrucks."""
def ersetze(treffer: re.Match) -> str:
teile = [t.strip() for t in treffer.group(1).split(",")]
neu = [str(MINDESTSTRICH) if i % 2 == 0 and _ist_null(t) else t for i, t in enumerate(teile)]
return treffer.group(0) if neu == teile else f"array({','.join(neu)})"
return _ZAHLENFELD.sub(ersetze, ausdruck)
def _korrigiere_symbol(symbol: Any) -> bool:
"""Korrigiert alle Symbolebenen eines Symbols (auch Untersymbole). Liefert, ob sich etwas geändert hat."""
geaendert = False
for ebene in symbol.symbolLayers():
nutzt_muster = getattr(ebene, "useCustomDashPattern", None)
if callable(nutzt_muster) and nutzt_muster():
alt = list(ebene.customDashVector())
neu = korrigiere_muster(alt)
if neu != alt:
ebene.setCustomDashVector(neu)
geaendert = True
eigenschaften = ebene.dataDefinedProperties()
if PROPERTY_CUSTOM_DASH in eigenschaften.propertyKeys():
eigenschaft = eigenschaften.property(PROPERTY_CUSTOM_DASH)
if eigenschaft.propertyType() == PROPERTY_TYPE_EXPRESSION:
alt_ausdruck = eigenschaft.expressionString()
neu_ausdruck = korrigiere_ausdruck(alt_ausdruck)
if neu_ausdruck != alt_ausdruck:
eigenschaften.setProperty(PROPERTY_CUSTOM_DASH, QgsProperty.fromExpression(neu_ausdruck))
geaendert = True
unter = ebene.subSymbol()
if unter is not None and _korrigiere_symbol(unter):
geaendert = True
return geaendert
def korrigiere_punktlinien(layer: Any) -> int:
"""Macht Punktlinien des Basemap-Layers sichtbar.
Returns
-------
int
Anzahl der korrigierten Renderer-Styles (0, wenn nichts zu tun war).
"""
renderer = layer.renderer()
if renderer is None:
return 0
anzahl = 0
for index, stil in enumerate(renderer.styles()):
symbol = stil.symbol()
if symbol is None:
continue
kopie = symbol.clone()
if _korrigiere_symbol(kopie):
stil.setSymbol(kopie)
renderer.setStyle(index, stil)
anzahl += 1
if anzahl:
layer.triggerRepaint()
return anzahl
+17
View File
@@ -161,6 +161,23 @@ class TestBasemapBereich(unittest.TestCase):
layer, "Neu", {"ausgeblendet": ["wald"], "eingeblendet": []})
bereich.aktualisieren.assert_called_once()
@patch("sn_basis.ui.widgets.basemap_bereich.success")
@patch("sn_basis.ui.widgets.basemap_bereich.korrigiere_punktlinien", return_value=12)
def test_punktlinien_korrigieren_meldet_anzahl(self, korrektur, erfolg):
"""Prüft, dass „Punktlinien korrigieren“ den Basemap-Layer korrigiert und die Anzahl korrigierter Styles meldet."""
layer = MagicMock()
_bereich(layer=layer)._on_punktlinien_korrigieren()
korrektur.assert_called_once_with(layer)
self.assertIn("12", erfolg.call_args[0][1])
@patch("sn_basis.ui.widgets.basemap_bereich.warning")
@patch("sn_basis.ui.widgets.basemap_bereich.korrigiere_punktlinien")
def test_punktlinien_ohne_basemap_layer_warnt(self, korrektur, warnung):
"""Prüft, dass ohne Basemap-Layer nichts korrigiert, sondern gewarnt wird."""
_bereich(layer=None)._on_punktlinien_korrigieren()
korrektur.assert_not_called()
warnung.assert_called_once()
@patch("sn_basis.ui.widgets.basemap_bereich.warning")
def test_stil_speichern_ohne_basemap_layer_warnt(self, warnung):
"""Prüft, dass ohne Basemap-Layer keine Stilvorlage gespeichert, sondern gewarnt wird."""
+149
View File
@@ -0,0 +1,149 @@
"""
Tests für modules/basemap_stilkorrektur.py – Punktlinien des Basemap-Layers sichtbar machen (Basis #90).
basemap.de zeichnet Punktlinien mit Strichlänge 0 und runder Kappe; Qt zeichnet Striche der
Länge 0 nicht. Die Korrektur ersetzt Strichlänge 0 durch einen kleinen Wert – im festen
Strichmuster und im zoomabhängigen Ausdruck. Symbole und Layer sind Fakes; die Wirkung in
Karte und Legende ist im QGIS-Test geprüft.
"""
import unittest
from sn_basis.functions.qgiscore_wrapper import PROPERTY_CUSTOM_DASH, PROPERTY_TYPE_EXPRESSION
from sn_basis.modules.basemap_stilkorrektur import (
MINDESTSTRICH,
korrigiere_ausdruck,
korrigiere_muster,
korrigiere_punktlinien,
)
# Ausdruck, wie ihn der QGIS-Konverter für line-dasharray mit Stopps erzeugt (gekürzt)
AUSDRUCK_BAUMREIHE = (
"array_to_string(array_foreach(CASE WHEN @vector_tile_zoom <= 12 THEN array(0,1.75) "
"WHEN @vector_tile_zoom > 12 AND @vector_tile_zoom <= 14 THEN array(0,2) "
"ELSE array(0,4) END,@element * (scale_linear(@vector_tile_zoom,14,17,3,8))),';')"
)
class _Eigenschaft:
def __init__(self, ausdruck):
self._ausdruck = ausdruck
def propertyType(self): return PROPERTY_TYPE_EXPRESSION
def expressionString(self): return self._ausdruck
class _Eigenschaften:
def __init__(self, ausdruck=None):
self.werte = {PROPERTY_CUSTOM_DASH: _Eigenschaft(ausdruck)} if ausdruck else {}
def propertyKeys(self): return list(self.werte)
def property(self, schluessel): return self.werte[schluessel]
def setProperty(self, schluessel, eigenschaft):
text = getattr(eigenschaft, "_ausdruck", None) or getattr(eigenschaft, "_value", None)
self.werte[schluessel] = _Eigenschaft(text)
class _Linie:
def __init__(self, muster=None, ausdruck=None):
self.muster = list(muster) if muster is not None else None
self.eigenschaften = _Eigenschaften(ausdruck)
def useCustomDashPattern(self): return self.muster is not None
def customDashVector(self): return list(self.muster or [])
def setCustomDashVector(self, werte): self.muster = list(werte)
def dataDefinedProperties(self): return self.eigenschaften
def subSymbol(self): return None
class _Symbol:
def __init__(self, *ebenen): self.ebenen = list(ebenen)
def symbolLayers(self): return self.ebenen
def clone(self): return self # die Fakes prüfen nur das Ergebnis
class _Stil:
def __init__(self, name, symbol): self._name, self._symbol = name, symbol
def styleName(self): return self._name
def symbol(self): return self._symbol
def setSymbol(self, symbol): self._symbol = symbol
class _Renderer:
def __init__(self, stile):
self.stile = stile
self.gesetzt = []
def styles(self): return list(self.stile)
def setStyle(self, index, stil):
self.gesetzt.append(index)
self.stile[index] = stil
class _Layer:
def __init__(self, stile):
self._renderer = _Renderer(stile)
self.neu_gezeichnet = 0
def renderer(self): return self._renderer
def triggerRepaint(self): self.neu_gezeichnet += 1
def _baumreihe():
return _Stil("VegetationsL_Baumreihe", _Symbol(_Linie([0.0, 0.93], AUSDRUCK_BAUMREIHE)))
class TestMusterUndAusdruck(unittest.TestCase):
def test_strichlaenge_null_im_festen_muster(self):
"""Prüft, dass Strichlängen 0 (gerade Positionen) im Strichmuster ersetzt und Lücken der Länge 0 nicht verändert werden."""
self.assertEqual(korrigiere_muster([0.0, 0.93]), [MINDESTSTRICH, 0.93])
self.assertEqual(korrigiere_muster([2.0, 0.0, 0.0, 1.0]), [2.0, 0.0, MINDESTSTRICH, 1.0])
self.assertEqual(korrigiere_muster([5.0, 2.0]), [5.0, 2.0])
def test_strichlaenge_null_im_zoomabhaengigen_ausdruck(self):
"""Prüft, dass jede Strichlänge 0 in den array(...)-Mustern des Konverter-Ausdrucks ersetzt wird und der übrige Ausdruck gleich bleibt."""
neu = korrigiere_ausdruck(AUSDRUCK_BAUMREIHE)
self.assertNotIn("array(0,", neu)
self.assertEqual(neu.count(f"array({MINDESTSTRICH},"), 3)
self.assertIn("@element * (scale_linear(@vector_tile_zoom,14,17,3,8))", neu)
self.assertEqual(korrigiere_ausdruck("array(3,0,1,2)"), "array(3,0,1,2)")
self.assertEqual(korrigiere_ausdruck("array(1,0,0,2)"), f"array(1,0,{MINDESTSTRICH},2)")
class TestLayerkorrektur(unittest.TestCase):
def test_punktlinien_werden_korrigiert_andere_styles_nicht(self):
"""Prüft, dass nur Styles mit Strichlänge 0 korrigiert werden – Muster und Ausdruck – und die Anzahl zurückkommt."""
weg = _Stil("Kontur_Wirtschaftsweg", _Symbol(_Linie()))
strichlinie = _Stil("Grenze", _Symbol(_Linie([4.0, 2.0], "array_to_string(array(4,2),';')")))
layer = _Layer([weg, _baumreihe(), strichlinie])
self.assertEqual(korrigiere_punktlinien(layer), 1)
linie = layer.renderer().styles()[1].symbol().symbolLayers()[0]
self.assertEqual(linie.customDashVector(), [MINDESTSTRICH, 0.93])
self.assertNotIn("array(0,", linie.dataDefinedProperties().property(PROPERTY_CUSTOM_DASH).expressionString())
self.assertEqual(layer.renderer().gesetzt, [1])
self.assertEqual(layer.neu_gezeichnet, 1)
def test_zweiter_lauf_korrigiert_nichts(self):
"""Prüft, dass ein zweiter Lauf keine Styles mehr korrigiert und nicht neu zeichnen lässt."""
layer = _Layer([_baumreihe()])
korrigiere_punktlinien(layer)
self.assertEqual(korrigiere_punktlinien(layer), 0)
self.assertEqual(layer.neu_gezeichnet, 1)
def test_nur_ausdruck_ohne_festes_muster(self):
"""Prüft, dass auch ein Style korrigiert wird, dessen Strichlänge 0 nur im zoomabhängigen Ausdruck steht."""
layer = _Layer([_Stil("Hecke", _Symbol(_Linie([1.0, 2.0], "array_to_string(array(0,3),';')")))])
self.assertEqual(korrigiere_punktlinien(layer), 1)
def test_layer_ohne_renderer(self):
"""Prüft, dass ein Layer ohne Renderer 0 korrigierte Styles liefert."""
self.assertEqual(korrigiere_punktlinien(type("L", (), {"renderer": lambda s: None})()), 0)
if __name__ == "__main__":
unittest.main()
+26 -2
View File
@@ -2,8 +2,9 @@
sn_basis/ui/widgets/basemap_bereich.py – einklappbarer Bereich „Basemap“ im Druck-Tab
Einstellungen der Basemap-Legende (Auswertebereich, Erzeugen beim Anlegen der Vorlage),
„Basemap-Legende aktualisieren“ für bestehende Vorlagen und lokale Stilvorlagen des
Basemap-Layers (laden, speichern, als Standard markieren, löschen). Basis #88.
„Basemap-Legende aktualisieren“ für bestehende Vorlagen, lokale Stilvorlagen des
Basemap-Layers (laden, speichern, als Standard markieren, löschen) und „Punktlinien
korrigieren“ (Basis #90). Basis #88.
Die Handler enthalten nur Ablauf und Rückfragen; die Logik liegt in
``modules/basemap_legende`` und ``modules/basemap_stilvorlagen``.
@@ -30,6 +31,7 @@ from sn_basis.functions.qt_wrapper import (
)
from sn_basis.modules import basemap_legende as legende
from sn_basis.modules.basemap_legende_layout import layouts_mit_basemap_legende
from sn_basis.modules.basemap_stilkorrektur import korrigiere_punktlinien
from sn_basis.modules.basemap_stilvorlagen import BasemapStilvorlagen
TITEL = "Basemap"
@@ -109,6 +111,14 @@ class BasemapBereich(QWidget):
zeile.addWidget(knopf_stil)
spalte.addLayout(zeile)
self._punktlinien_btn = QPushButton("Punktlinien korrigieren", self)
self._punktlinien_btn.setToolTip(
"Baumreihen, Hecken, Durchlässe u. a. zeichnet QGIS aus dem Basemap-Stil nicht (Strichlänge 0). "
"Korrigiert die Darstellung im Projekt; mit „Speichern unter …“ als Stilvorlage sichern."
)
self._punktlinien_btn.clicked.connect(lambda *_: self._on_punktlinien_korrigieren())
spalte.addWidget(self._punktlinien_btn)
inhalt.setLayout(spalte)
inhalt.setVisible(False)
self._inhalt = inhalt
@@ -162,6 +172,7 @@ class BasemapBereich(QWidget):
self._layout_combo.clear()
self._layout_combo.addItems(layouts_mit_basemap_legende(self._projekt()))
self._aktualisieren_btn.setEnabled(layer is not None and self._layout_combo.count() > 0)
self._punktlinien_btn.setEnabled(layer is not None)
self._stil_combo.clear()
for vorlage in self._vorlagen().liste():
@@ -196,6 +207,19 @@ class BasemapBereich(QWidget):
elif ergebnis["status"] != "abgebrochen":
info(TITEL, ergebnis["meldung"])
# --- Handler: Darstellung korrigieren ------------------------------
def _on_punktlinien_korrigieren(self) -> None:
layer = self.basemap_layer()
if layer is None:
warning(TITEL, KEIN_LAYER_TEXT)
return
anzahl = korrigiere_punktlinien(layer)
if anzahl:
success(TITEL, f"{anzahl} Punktlinien-Styles korrigiert. Basemap-Legende ggf. aktualisieren.")
else:
info(TITEL, "Keine Punktlinien zu korrigieren.")
# --- Handler: Stilvorlagen ------------------------------------------
def _gewaehlte_vorlage(self) -> str: