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:
@@ -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
@@ -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()):
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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])
|
||||
Reference in New Issue
Block a user