Schilder setzen wie im Browser: Palette und zwei Kartenwerkzeuge

Bisher konnte man die Zeichen in QGIS nur sehen und ihre Attribute von Hand tippen. Jetzt
gibt es dieselbe Bedienung wie im Web (Erik, 21.09.2026; Nutzerkreis sind TG-Mitarbeiter
und der Landkreis):

* Neues Modul zeichen_werkzeug.py mit der Palette (Suchfeld nach Nummer oder Name,
  Kategorie, Kacheln aus den lokal abgelegten Vorschaubildern, Deckel bei 96 wie im
  Browser) und zwei Kartenwerkzeugen: "Schild setzen" (erster Klick Standort, zweiter
  Klick Blickrichtung; unter 0,5 m bleibt die Drehung 0) und "Zusatzzeichen" (Klick auf ein
  vorhandenes Schild haengt es darunter, kein Doppel, hoechstens zwei).
* Die Rechenregeln sind Zwillinge von vz_geom.js (vzRichtungGrad, vzNormGrad,
  vzZusatzErgaenzen) -- dieselben Werte, hier in Python, mit denselben Pruefpunkten.
* Zeichen am Weg eines Blattes brauchen ein Blatt: die Auswahl fuellt sich aus den
  geladenen Blatt-Layern, eine eigene Abfrage braucht es nicht. In der Verfahrenskarte
  bleibt sie leer.
* Seitenleiste: neue Gruppe "Verkehrszeichen setzen". Der Katalog kommt beim Start und nach
  dem Anmelden in die Palette (oeffentlich, faellt auf die lokale Ablage zurueck).

Neuer Test tools/headless_zeichen_test.py (QGIS 4.2 offscreen, ohne Zugangsschluessel):
30 Pruefungen gruen -- Rechenregeln, zusatz aus Text, Katalog, lokale Ablage, Umschreiben
des Stils, Palette mit Suche und Deckel. tools/headless_panel_test.py bleibt gruen; die
Leiste ist jetzt 1372 px hoch und rollt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
KAI
2026-09-21 11:34:48 +02:00
co-authored by Claude Opus 5
parent d79dff1093
commit 9c2c1c57af
4 changed files with 568 additions and 1 deletions
+107
View File
@@ -0,0 +1,107 @@
# -*- coding: utf-8 -*-
"""Verkehrszeichen headless pruefen: Katalog, lokale Grafiken, Palette, Rechenregeln.
Aufruf (macOS, QGIS 4.2 im Bundle):
APP=/Applications/QGIS-final-4_2_0.app; LIB="$APP/Contents/Resources/python3.12"
QT_QPA_PLATFORM=offscreen PROJ_DATA="$APP/Contents/Resources/qgis/proj" \\
PYTHONPATH="$LIB:$LIB/lib-dynload:$LIB/site-packages" \\
"$APP/Contents/MacOS/python3.12" tools/headless_zeichen_test.py
Braucht keinen Zugangsschluessel: Katalog und Grafiken sind oeffentlich
(``/v2/zeichen/...``). Ohne Netz laeuft der Teil aus der lokalen Ablage; fehlt auch die,
werden die betroffenen Pruefungen uebersprungen und gemeldet.
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from qgis.core import QgsApplication
app = QgsApplication([], True)
QgsApplication.setPrefixPath("/Applications/QGIS-final-4_2_0.app/Contents/MacOS", True)
QgsApplication.setPluginPath("/Applications/QGIS-final-4_2_0.app/Contents/PlugIns/qgis")
app.initQgis()
from qgis.PyQt.QtNetwork import QNetworkProxyFactory # noqa: E402
QNetworkProxyFactory.setUseSystemConfiguration(False)
from vln_karten import zeichen_katalog as zk # noqa: E402
from vln_karten import zeichen_werkzeug as zw # noqa: E402
from vln_karten.api_client import KartenApiClient # noqa: E402
FEHLER = []
def pruefe(bed, text):
print((" OK " if bed else " FEHL ") + text)
if not bed:
FEHLER.append(text)
print("== Rechenregeln (Zwilling zu vz_geom.js) ==")
pruefe(zw.richtung_grad((0, 0), (0, 10)) == 0.0, "Nord = 0")
pruefe(zw.richtung_grad((0, 0), (10, 0)) == 90.0, "Ost = 90")
pruefe(zw.richtung_grad((0, 0), (0, -10)) == 180.0, "Sued = 180")
pruefe(zw.richtung_grad((0, 0), (-10, 0)) == 270.0, "West = 270")
pruefe(zw.richtung_grad((0, 0), (10, 10)) == 45.0, "Nordost = 45")
pruefe(zw.richtung_grad((5, 5), (5, 5)) == 0.0, "gleicher Punkt (Doppelklick) = 0")
pruefe(zw.norm_grad(-90) == 270 and zw.norm_grad(450) == 90, "Normierung negativ/ueber 360")
pruefe(zw.norm_grad("quatsch") == 0 and zw.norm_grad(None) == 0, "Unsinn wird 0")
pruefe(zw.zusatz_ergaenzen([], "1020-30") == (["1020-30"], True), "erstes Zusatzzeichen")
pruefe(zw.zusatz_ergaenzen(["1020-30"], "1020-30") == (["1020-30"], False), "kein Doppel")
pruefe(zw.zusatz_ergaenzen(["a", "b"], "c") == (["a", "b"], False), "hoechstens zwei")
pruefe(zw.zusatz_ergaenzen(None, " ") == ([], False), "leerer Code aendert nichts")
print("== zusatz aus Text (QGIS fuehrt das Array als Textfeld) ==")
pruefe(zk._liste('["1020-30","1040-30"]') == ["1020-30", "1040-30"], "JSON-Text")
pruefe(zk._liste("1020-30, 1040-30") == ["1020-30", "1040-30"], "kommagetrennt")
pruefe(zk._liste(["1020-30"]) == ["1020-30"], "echte Liste")
pruefe(zk._liste(None) == [] and zk._liste("NULL") == [], "leer bleibt leer")
print("== Katalog und Grafiken ==")
client = KartenApiClient()
kat = zk.katalog(client, frisch=True)
if not kat.get("zeichen"):
print(" ÜBERSPRUNGEN — weder Server noch lokale Ablage erreichbar")
else:
pruefe(len(kat["zeichen"]) > 800, "Katalog: %d Zeichen, Stand %s"
% (len(kat["zeichen"]), kat.get("stand")))
pruefe(len(kat.get("kategorien", [])) >= 10, "Kategorien: %d" % len(kat.get("kategorien", [])))
ein = zk.eintrag(kat, "250")
pruefe(ein and ein["name"] and len(ein["groessen_mm"]) == 3, "Eintrag 250 vollstaendig")
geholt, fehlend = zk.sichere_svgs(client, ["250", "260", "1020-30", "9999"])
pruefe("9999" in fehlend, "unbekannter Code wird als fehlend gemeldet")
pruefe(os.path.isfile(os.path.join(zk.svg_ordner(), "250.svg")), "250.svg liegt lokal")
wurzel = zk.pfad_registrieren()
pruefe(os.path.isdir(os.path.join(wurzel, zk.ORDNER)), "SVG-Suchpfad eingetragen: " + wurzel)
print("== Stil auf die lokale Ablage umschreiben ==")
qml = "<x>'https://api.flurneuordnung-sachsen.de/v2/zeichen/svg/' || \"vz\" || '.svg'</x>"
neu, ersetzt = zk.stil_lokalisieren(qml, "https://api.flurneuordnung-sachsen.de")
pruefe(ersetzt and "vln/" in neu and "http" not in neu, "Adresse wird lokaler Name: " + neu)
unberuehrt, ersetzt2 = zk.stil_lokalisieren("<x>ohne Adresse</x>", "https://api…")
pruefe(not ersetzt2 and unberuehrt == "<x>ohne Adresse</x>", "fremder Stil bleibt, wie er ist")
print("== Palette ==")
palette = zw.ZeichenPalette()
palette.setze_katalog(kat, None)
pruefe(palette.kategorie.count() >= 12, "Kategorien in der Auswahl: %d" % palette.kategorie.count())
haeufig = palette.treffer()
pruefe(0 < len(haeufig) <= zw.PALETTE_MAX, "haeufig verwendet: %d Kacheln" % len(haeufig))
palette.suche.setText("1020-30")
pruefe([z["code"] for z in palette.treffer()] == ["1020-30"], "Suche nach der Nummer trifft genau")
palette.suche.setText("vorfahrt")
treffer = palette.treffer()
pruefe(treffer and all("vorfahrt" in (z.get("suche") or "") for z in treffer),
"Suche nach dem Namen: %d Treffer" % len(treffer))
palette.suche.clear()
palette.kategorie.setCurrentIndex(1)
pruefe(len(palette.treffer()) == zw.PALETTE_MAX, "Deckel bei %d Kacheln" % zw.PALETTE_MAX)
pruefe(palette.aktueller_eintrag() == {}, "ohne Klick kein gewaehltes Zeichen")
app.exitQgis()
print("== Ergebnis: %d Fehler ==" % len(FEHLER))
for f in FEHLER:
print(" -", f)
sys.exit(1 if FEHLER else 0)
+65 -1
View File
@@ -69,6 +69,7 @@ class VlnPanel(QDockWidget):
aussen.addWidget(self._bau_anmeldung())
aussen.addWidget(self._bau_verfahren())
aussen.addWidget(self._bau_layer())
aussen.addWidget(self._bau_zeichen())
aussen.addWidget(self._bau_dienste())
aussen.addWidget(self._bau_darstellung())
aussen.addStretch(1)
@@ -150,7 +151,7 @@ class VlnPanel(QDockWidget):
("Vorerhebung", "Vorerhebung"),
("Maßnahmenblätter", "Maßnahmenblätter (mit Zeichen am Weg)"),
("Widmungen & Verkehrszeichen",
"Widmungen & Verkehrszeichen")):
"Widmungen && Verkehrszeichen")):
haken = QCheckBox(beschriftung)
haken.setChecked(True)
lage.addWidget(haken)
@@ -173,6 +174,68 @@ class VlnPanel(QDockWidget):
"jede Änderung sofort zurück.")))
return kasten
def _bau_zeichen(self):
"""Verkehrszeichen setzen — Palette, Blattwahl, zwei Werkzeuge.
Die Bedienung ist dieselbe wie im Browser: erst ein Schild waehlen, dann in der Karte
zwei Klicks (Standort, Blickrichtung). Das Blatt braucht nur, wer Zeichen am Weg eines
Massnahmenblatts setzt — in der Verfahrenskarte bleibt die Auswahl leer.
"""
from .zeichen_werkzeug import ZeichenPalette
kasten = QGroupBox("Verkehrszeichen setzen")
lage = QVBoxLayout(kasten)
lage.setSpacing(6)
lage.addWidget(_klein(QLabel("Schild waehlen, dann in der Karte: erster Klick Standort, "
"zweiter Klick Blickrichtung.")))
self.palette = ZeichenPalette()
lage.addWidget(self.palette)
lage.addWidget(_klein(QLabel("Blatt (nur fuer Zeichen am Weg):")))
self.blatt_combo = QComboBox()
self.blatt_combo.setToolTip("An welches Massnahmenblatt das Zeichen gehoert")
self.blatt_combo.addItem("— kein Blatt (Verfahrenskarte) —", None)
lage.addWidget(self.blatt_combo)
zeile = QHBoxLayout()
self.schild_knopf = QPushButton("Schild setzen")
self.schild_knopf.setCheckable(True)
self.schild_knopf.setToolTip("Zwei Klicks in der Karte: Standort, dann Blickrichtung")
self.schild_knopf.clicked.connect(lambda: self._ruf("zeichen_setzen"))
self.zusatz_knopf = QPushButton("Zusatzzeichen")
self.zusatz_knopf.setCheckable(True)
self.zusatz_knopf.setToolTip("Gewaehltes Zusatzzeichen an ein vorhandenes Schild haengen")
self.zusatz_knopf.clicked.connect(lambda: self._ruf("zusatz_anhaengen"))
zeile.addWidget(self.schild_knopf)
zeile.addWidget(self.zusatz_knopf)
lage.addLayout(zeile)
return kasten
def setze_blaetter(self, blaetter):
"""Die Auswahl der Blaetter fuellen — ``[(Beschriftung, uuid)]``."""
vorher = self.blatt_combo.currentData()
self.blatt_combo.blockSignals(True)
self.blatt_combo.clear()
self.blatt_combo.addItem("— kein Blatt (Verfahrenskarte) —", None)
for text, uuid_ in blaetter or []:
self.blatt_combo.addItem(text, uuid_)
if vorher:
i = self.blatt_combo.findData(vorher)
if i >= 0:
self.blatt_combo.setCurrentIndex(i)
self.blatt_combo.blockSignals(False)
def aktuelles_blatt(self):
return self.blatt_combo.currentData()
def aktueller_zeichencode(self):
return str((self.palette.aktueller_eintrag() or {}).get("code") or "")
def werkzeug_zuruecksetzen(self):
"""Beide Werkzeugknoepfe ausrasten (wenn QGIS das Werkzeug wechselt)."""
for knopf in (self.schild_knopf, self.zusatz_knopf):
knopf.blockSignals(True)
knopf.setChecked(False)
knopf.blockSignals(False)
def _bau_dienste(self):
"""Die Linkliste des gewählten Verfahrens — vierstufig gepflegt, hier nur geladen.
@@ -267,6 +330,7 @@ class VlnPanel(QDockWidget):
self.login_knopf.setVisible(not angemeldet)
self.logout_knopf.setVisible(angemeldet)
for widget in (self.filter_feld, self.combo, self.ogc_knopf, self.speichern_knopf,
self.schild_knopf, self.zusatz_knopf, self.palette, self.blatt_combo,
self.stil_knopf, self.vorlage_haken, self.dienste_holen_knopf,
self.dienste_laden_knopf, self.dienste_alle, self.dienste_liste,
*self.gruppen_haken.values()):
+71
View File
@@ -34,6 +34,7 @@ class VlnKartenPlugin:
self.client = KartenApiClient()
self.actions = []
self.panel = None
self._werkzeuge = {"schild": None, "zusatz": None} # Kartenwerkzeuge, faul gebaut
# ------------------------------------------------------------------
# Plugin-Lebenszyklus
@@ -54,6 +55,8 @@ class VlnKartenPlugin:
"dienste_holen": self.run_dienste_holen,
"dienste_laden": self.run_dienste_laden,
"save_style": self.run_save_style,
"zeichen_setzen": self.run_zeichen_setzen,
"zusatz_anhaengen": self.run_zusatz_anhaengen,
},
)
self.iface.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self.panel)
@@ -83,6 +86,9 @@ class VlnKartenPlugin:
if api_key:
self.client = KartenApiClient(api_key=api_key, mail=mail)
QTimer.singleShot(0, self.populate_verfahren)
# Der Zeichenkatalog ist oeffentlich und liegt lokal — er kostet nichts und die
# Palette ist damit gleich nach dem Start bedienbar.
QTimer.singleShot(0, self._katalog_laden)
else:
QTimer.singleShot(1500, self.run_login)
@@ -126,6 +132,7 @@ class VlnKartenPlugin:
settings.setValue("mail", client.mail or dialog.mail())
settings.endGroup()
self._katalog_laden() # Palette fuellen (oeffentlicher Katalog)
self.iface.messageBar().pushMessage(
"VLN Karten",
"Angemeldet als %s — der API-Key ist gespeichert und gilt auch nach einem "
@@ -260,6 +267,70 @@ class VlnKartenPlugin:
self.iface, self.client.base_url, vkz, self.panel.aktueller_text(), collections,
authcfg, api_key=None if authcfg else self.client.api_key, client=self.client)
# ------------------------------------------------------------------
# Verkehrszeichen setzen
# ------------------------------------------------------------------
def _zeichen_melden(self, text, fehler=False):
self.panel.set_status(text)
if fehler:
self.iface.messageBar().pushMessage("VLN Karten", text, level=Qgis.Warning,
duration=6)
def _werkzeug(self, art):
"""Das Kartenwerkzeug bauen (einmal je Art) — beide teilen Palette und Blattwahl."""
from . import zeichen_werkzeug as zwm
if self._werkzeuge.get(art) is None:
canvas = self.iface.mapCanvas()
code = self.panel.aktueller_zeichencode
if art == "schild":
self._werkzeuge[art] = zwm.SchildSetzen(canvas, self.iface, code,
self._zeichen_melden,
self.panel.aktuelles_blatt)
else:
self._werkzeuge[art] = zwm.ZusatzAnhaengen(canvas, self.iface, code,
self._zeichen_melden)
return self._werkzeuge[art]
def _werkzeug_schalten(self, art, knopf):
canvas = self.iface.mapCanvas()
if not knopf.isChecked():
canvas.unsetMapTool(self._werkzeuge.get(art))
return
# Nur eines von beiden zur Zeit.
self.panel.werkzeug_zuruecksetzen()
knopf.setChecked(True)
werkzeug = self._werkzeug(art)
if hasattr(werkzeug, "reset"):
werkzeug.reset()
canvas.setMapTool(werkzeug)
def run_zeichen_setzen(self):
"""Werkzeug „Schild setzen" ein- oder ausschalten."""
self._blaetter_auffrischen()
self._werkzeug_schalten("schild", self.panel.schild_knopf)
def run_zusatz_anhaengen(self):
"""Werkzeug „Zusatzzeichen anhaengen" ein- oder ausschalten."""
self._werkzeug_schalten("zusatz", self.panel.zusatz_knopf)
def _blaetter_auffrischen(self):
"""Die Blattwahl aus den geladenen Layern fuellen (keine eigene Abfrage noetig)."""
try:
from . import zeichen_werkzeug as zwm
self.panel.setze_blaetter(zwm.blaetter_im_projekt())
except Exception: # noqa: BLE001
pass
def _katalog_laden(self):
"""Den Zeichenkatalog in die Palette holen (Server, sonst lokale Ablage)."""
try:
from . import zeichen_katalog as zk
self.panel.palette.setze_katalog(zk.katalog(self.client), self.client)
except Exception: # noqa: BLE001
pass # ohne Katalog bleibt die Palette leer
def run_save_edits(self):
"""Offene Bearbeitungssitzungen aller geladenen VLN-Layer schließen.
+325
View File
@@ -0,0 +1,325 @@
# -*- coding: utf-8 -*-
"""Verkehrszeichen setzen — Palette und Kartenwerkzeuge.
Im Browser wählt man links ein Schild aus und setzt es mit zwei Klicks: der erste bestimmt
den Standort, der zweite die Blickrichtung. Dieselbe Bedienung gibt es hier für QGIS, damit
niemand Codes in eine Attributtabelle tippen muss (Erik, 21.09.2026 — Nutzerkreis sind
TG-Mitarbeiter und der Landkreis).
Drei Teile:
* :class:`ZeichenPalette` — Suchfeld, Kategorie, Kacheln aus den lokal abgelegten Bildern
(:mod:`vln_karten.zeichen_katalog`).
* :class:`SchildSetzen` — zwei Klicks, legt das Feature im aktiven Layer an.
* :class:`ZusatzAnhaengen` — ein Klick auf ein vorhandenes Schild ergänzt sein ``zusatz``.
Die Rechenregeln sind Zwillinge der Browser-Fassung (``servicetermin/web_assets/vz_geom.js``:
``vzRichtungGrad``, ``vzNormGrad``, ``vzZusatzErgaenzen``) — dieselben Werte, hier in Python.
"""
import math
from qgis.core import QgsFeature, QgsGeometry, QgsPointXY, QgsProject, QgsWkbTypes
from qgis.gui import QgsMapTool
from qgis.PyQt.QtCore import QSize, Qt, pyqtSignal
from qgis.PyQt.QtGui import QIcon
from qgis.PyQt.QtWidgets import (QComboBox, QLabel, QLineEdit, QListWidget, QListWidgetItem,
QVBoxLayout, QWidget)
from . import zeichen_katalog as zk
#: Wie viele Kacheln die Palette höchstens zeigt — mehr macht sie unübersichtlich und
#: zwänge uns, Hunderte Vorschaubilder auf einmal zu laden (Browser: VZ_PALETTE_MAX).
PALETTE_MAX = 96
#: Höchstens zwei Zusatzzeichen je Schild (Browser: VZ_MAX_ZUSATZ).
MAX_ZUSATZ = 2
#: Kürzer als das gilt der zweite Klick als Wackeln, nicht als Richtung.
MIN_RICHTUNG_M = 0.5
# ----------------------------------------------------------------------------- Rechnen
def norm_grad(g):
"""Gradzahl auf 0 … 359,999 bringen (auch negativ, Text, ``None``)."""
try:
g = float(g)
except (TypeError, ValueError):
return 0.0
if math.isinf(g) or math.isnan(g):
return 0.0
return ((g % 360) + 360) % 360
def richtung_grad(p0, p1):
"""Blickrichtung aus zwei Punkten in Grad, im Uhrzeigersinn ab Nord (0 = Nord, 90 = Ost).
Punkte in Metern. Liegen sie aufeinander (Doppelklick), ist die Richtung 0.
"""
dx, dy = p1[0] - p0[0], p1[1] - p0[1]
if math.hypot(dx, dy) < 1e-9:
return 0.0
return round(norm_grad(math.degrees(math.atan2(dx, dy))) * 10) / 10
def zusatz_ergaenzen(liste, code, hoechstens=MAX_ZUSATZ):
"""Ein Zusatzzeichen anhängen — ohne Doppel, ohne Verdrängen.
:returns: ``(neue_liste, geaendert)``
"""
aus = [str(x).strip() for x in (liste or []) if str(x or "").strip()]
code = str(code or "").strip()
if not code or code in aus or len(aus) >= hoechstens:
return aus, False
return aus + [code], True
# ----------------------------------------------------------------------------- Palette
class ZeichenPalette(QWidget):
"""Kacheln zum Auswählen eines Zeichens; meldet den gewählten Code."""
gewaehlt = pyqtSignal(str)
def __init__(self, parent=None):
super().__init__(parent)
self._daten = {}
self._client = None
lage = QVBoxLayout(self)
lage.setContentsMargins(0, 0, 0, 0)
lage.setSpacing(4)
self.suche = QLineEdit()
self.suche.setPlaceholderText("Schild suchen (Nummer oder Name) …")
self.suche.setClearButtonEnabled(True)
self.suche.textChanged.connect(self.auffrischen)
self.kategorie = QComboBox()
self.kategorie.currentIndexChanged.connect(self.auffrischen)
self.gitter = QListWidget()
self.gitter.setViewMode(QListWidget.ViewMode.IconMode)
self.gitter.setIconSize(QSize(40, 40))
self.gitter.setGridSize(QSize(52, 56))
self.gitter.setResizeMode(QListWidget.ResizeMode.Adjust)
self.gitter.setMovement(QListWidget.Movement.Static)
self.gitter.setMinimumHeight(120)
self.gitter.itemClicked.connect(self._geklickt)
self.zeile = QLabel("kein Zeichen gewählt")
self.zeile.setWordWrap(True)
lage.addWidget(self.suche)
lage.addWidget(self.kategorie)
lage.addWidget(self.gitter)
lage.addWidget(self.zeile)
# ------------------------------------------------------------------
def setze_katalog(self, daten, client=None):
"""Katalog übernehmen und die Kategorien füllen."""
self._daten = daten or {}
self._client = client
self.kategorie.blockSignals(True)
self.kategorie.clear()
self.kategorie.addItem("häufig verwendet", "haeufig")
self.kategorie.addItem("alle Zeichen", "")
for k in self._daten.get("kategorien", []):
self.kategorie.addItem(str(k.get("name") or k.get("id")), str(k.get("id")))
self.kategorie.blockSignals(False)
self.auffrischen()
def treffer(self):
"""Die Zeichen, die Suche und Kategorie überstehen (höchstens ``PALETTE_MAX``)."""
text = self.suche.text().strip().lower()
kat = self.kategorie.currentData()
aus = []
for z in self._daten.get("zeichen", []):
if kat == "haeufig" and not z.get("haeufig") and not text:
continue
if kat and kat != "haeufig" and str(z.get("kategorie")) != kat:
continue
if text and text not in str(z.get("suche") or "").lower():
continue
aus.append(z)
if len(aus) >= PALETTE_MAX:
break
return aus
def auffrischen(self):
"""Kacheln neu aufbauen; fehlende Vorschaubilder werden dabei nachgeladen."""
treffer = self.treffer()
if self._client is not None and treffer:
try:
zk.sichere_pngs(self._client, [z["code"] for z in treffer])
except Exception: # noqa: BLE001
pass # ohne Bild bleibt der Code
self.gitter.clear()
for z in treffer:
eintrag = QListWidgetItem(str(z.get("code")))
eintrag.setToolTip("%s — %s" % (z.get("code"), z.get("name")))
eintrag.setData(Qt.ItemDataRole.UserRole, z)
pfad = zk.png_pfad(z.get("code"))
symbol = QIcon(pfad)
if not symbol.isNull():
eintrag.setIcon(symbol)
eintrag.setText("")
self.gitter.addItem(eintrag)
def _geklickt(self, eintrag):
z = eintrag.data(Qt.ItemDataRole.UserRole) or {}
self.zeile.setText("gewählt: %s — %s" % (z.get("code"), z.get("name")))
self.gewaehlt.emit(str(z.get("code") or ""))
def aktueller_eintrag(self):
eintrag = self.gitter.currentItem()
return (eintrag.data(Qt.ItemDataRole.UserRole) or {}) if eintrag else {}
# ------------------------------------------------------------------------- Kartenwerkzeuge
def _punkt_layer(iface):
"""Der aktive Layer, wenn er Punkte hält und ein Feld ``vz`` hat — sonst ``None``."""
layer = iface.activeLayer()
if layer is None or not hasattr(layer, "fields"):
return None
if QgsWkbTypes.geometryType(layer.wkbType()) != QgsWkbTypes.GeometryType.PointGeometry:
return None
return layer if "vz" in [f.name() for f in layer.fields()] else None
class SchildSetzen(QgsMapTool):
"""Zwei Klicks: Standort, dann Blickrichtung — wie im Browser.
Der zweite Klick landet nicht in der Karte; er dreht nur das Schild. Liegt er auf dem
ersten (Doppelklick), bleibt die Drehung 0.
"""
def __init__(self, canvas, iface, code_holen, melden=None, blatt_holen=None):
super().__init__(canvas)
self.iface = iface
self._code_holen = code_holen
self._melden = melden or (lambda text, fehler=False: None)
self._blatt_holen = blatt_holen or (lambda: None)
self._erster = None
def canvasReleaseEvent(self, event):
punkt = self.toLayerCoordinates(self.iface.activeLayer(), event.pos()) \
if self.iface.activeLayer() else self.toMapCoordinates(event.pos())
if self._erster is None:
layer = _punkt_layer(self.iface)
if layer is None:
self._melden("Bitte zuerst einen Zeichen-Layer aktiv schalten "
"(Punkte mit Feld „vz“).", True)
return
if not self._code_holen():
self._melden("Bitte links ein Schild in der Palette wählen.", True)
return
self._erster = QgsPointXY(punkt)
self._melden("Standort gesetzt — zweiter Klick gibt die Blickrichtung.")
return
drehung = richtung_grad((self._erster.x(), self._erster.y()), (punkt.x(), punkt.y()))
if self._erster.distance(QgsPointXY(punkt)) < MIN_RICHTUNG_M:
drehung = 0.0
self._setzen(self._erster, drehung)
self._erster = None
def canvasMoveEvent(self, event):
pass
def reset(self):
self._erster = None
def _setzen(self, punkt, drehung):
layer = _punkt_layer(self.iface)
if layer is None:
return
if not layer.isEditable() and not layer.startEditing():
self._melden("Der Layer lässt sich nicht bearbeiten.", True)
return
namen = [f.name() for f in layer.fields()]
f = QgsFeature(layer.fields())
f.setGeometry(QgsGeometry.fromPointXY(punkt))
werte = {"vz": self._code_holen(), "drehung": drehung, "typ": "verkehrszeichen",
"vz_status": "neu", "groesse": 2}
blatt = self._blatt_holen()
if "blatt_uuid" in namen:
if not blatt:
self._melden("Diese Sammlung gehört zu den Maßnahmenblättern — bitte oben "
"das Blatt wählen, an dessen Weg das Zeichen steht.", True)
return
werte["blatt_uuid"] = blatt
for name, wert in werte.items():
if name in namen:
f.setAttribute(name, wert)
if not layer.addFeature(f):
self._melden("Das Zeichen konnte nicht angelegt werden.", True)
return
layer.triggerRepaint()
self._melden("Zeichen %s gesetzt (Blickrichtung %.0f°). Mit „Änderungen speichern“ "
"geht es an den Server." % (werte["vz"], drehung))
class ZusatzAnhaengen(QgsMapTool):
"""Ein Klick auf ein vorhandenes Schild hängt das gewählte Zusatzzeichen darunter."""
def __init__(self, canvas, iface, code_holen, melden=None, radius_px=12):
super().__init__(canvas)
self.iface = iface
self._code_holen = code_holen
self._melden = melden or (lambda text, fehler=False: None)
self._radius_px = radius_px
def canvasReleaseEvent(self, event):
layer = _punkt_layer(self.iface)
if layer is None:
self._melden("Bitte zuerst einen Zeichen-Layer aktiv schalten.", True)
return
code = self._code_holen()
if not code:
self._melden("Bitte links ein Zusatzzeichen in der Palette wählen.", True)
return
punkt = self.toLayerCoordinates(layer, event.pos())
toleranz = self.canvas().mapUnitsPerPixel() * self._radius_px
treffer, abstand = None, None
for f in layer.getFeatures():
g = f.geometry()
if g is None or g.isEmpty():
continue
d = g.asPoint().distance(QgsPointXY(punkt))
if abstand is None or d < abstand:
treffer, abstand = f, d
if treffer is None or abstand > toleranz:
self._melden("Kein Schild in der Nähe des Klicks.", True)
return
namen = [fd.name() for fd in layer.fields()]
if "zusatz" not in namen:
self._melden("Diesem Layer fehlt das Feld „zusatz“.", True)
return
alt = zk._liste(treffer["zusatz"])
neu, geaendert = zusatz_ergaenzen(alt, code)
if not geaendert:
self._melden("Schon vorhanden oder schon zwei Zusatzzeichen — nichts geändert.", True)
return
if not layer.isEditable() and not layer.startEditing():
self._melden("Der Layer lässt sich nicht bearbeiten.", True)
return
layer.changeAttributeValue(treffer.id(), layer.fields().indexOf("zusatz"),
", ".join(neu))
layer.triggerRepaint()
self._melden("Zusatzzeichen %s angehängt (jetzt %d)." % (code, len(neu)))
def blaetter_im_projekt():
"""``[(Beschriftung, uuid)]`` aller Maßnahmenblätter, die im Projekt geladen sind.
Gelesen wird aus den Blatt-Layern (Eigenschaften ``blatt_uuid``/``blatt_name``); so
braucht das Werkzeug keine eigene Abfrage, und es stehen genau die Blätter zur Wahl,
die der Nutzer ohnehin geladen hat.
"""
aus = {}
for layer in QgsProject.instance().mapLayers().values():
if not hasattr(layer, "fields"):
continue
namen = [f.name() for f in layer.fields()]
if "blatt_uuid" not in namen:
continue
for f in layer.getFeatures():
uuid_ = str(f["blatt_uuid"] or "").strip()
if not uuid_ or uuid_ in aus:
continue
name = str(f["blatt_name"] or "").strip() if "blatt_name" in namen else ""
mkz = str(f["blatt_mkz"] or "").strip() if "blatt_mkz" in namen else ""
aus[uuid_] = " · ".join(x for x in (mkz, name) if x) or uuid_[:8]
return sorted(((text, uuid_) for uuid_, text in aus.items()), key=lambda x: x[0])