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>
326 lines
14 KiB
Python
326 lines
14 KiB
Python
# -*- 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])
|