"""sn_verfahrensgebiet/functions/verfahrensgebiet_shape_komplett.py – VG-Lade-Workflow aus Shape (ZIP). Orchestriert den vollständigen Arbeitsablauf zum Laden eines Verfahrensgebiets aus einem ZIP-Archiv mit Shapefile-Set: 1. Dateidialog öffnen (.zip/.shp-Filter, Mehrfachauswahl); Nutzerabbruch beendet den Vorgang. 2. ZIP in ein temporäres Verzeichnis entpacken (danach wieder gelöscht) und auf ein gültiges Shapefile-Set prüfen. 3. Shapefile als QGIS-Vektorlayer laden und validieren (Standard-QGIS-Routinen). 4. Alle Features per ``QgsGeometry.unaryUnion`` zu einem Single-Feature- Memory-Layer „Verfahrensgebiet" verschmelzen. 5. Gemarkungsschlüssel per Geometrie aus ``AX_Gemarkung`` vorschlagen. 6. Flurstücke per Dialog (mit Vorschlag) laden. 7. Flurstücke räumlich auf das Shape-VG einschränken (``selectByLocation``). 8. Temporären enriched Layer aus Auswahl für den Flächenvergleich erzeugen (wird **nicht** ins Projekt eingefügt und **nicht** zurückgegeben). 9. Flächenvergleich zwischen Shape-VG und temporärem enriched Layer. - **Übereinstimmung** (≤ Toleranz): modale OK-Meldung, Ablauf beendet. - **Abweichung**: 2-Optionen-Dialog (Detailprüfung / Abbrechen). 10. Bei Wahl „Detailprüfung" (knickpunktbasiert): - Grenzpunkte (AX_PunktortTA) per BBox laden. - Knickpunkte des Shape-VG mit PunktortTA vergleichen (grün/rot/orange). - Korrigiertes Polygon aus identischen + gefundenen PunktortTA ableiten. - Ergebnislayer (Punkte), Verbindungslinien und Polygon ins Projekt einfügen. - Abschlussmeldung mit Zählung. Das Shape-VG (``Verfahrensgebiet``) ist das finale Rückgabe-Layer. Ein ``enriched Layer aus Flurstücken`` wird nur temporär für den Flächenvergleich erzeugt, aber weder ins Projekt eingefügt noch zurückgegeben. **Rückgabe:** ``Tuple[shape_vg_layer, flst_layer, None, LoadStatus]`` **Abbruchverhalten:** Jeder Schritt, der :attr:`LoadStatus.NONE` zurückgibt oder kein Ergebnis liefert, bricht den Workflow ab. """ from __future__ import annotations import os import re import shutil import tempfile import zipfile from typing import Optional, Tuple from sn_basis.functions import QgsProject, QgsVectorLayer, QgsFeature, QgsField from sn_basis.functions import QMessageBox from sn_basis.functions.message_wrapper import warning from sn_basis.functions.variable_wrapper import get_variable from sn_basis.functions.qt_wrapper import QFileDialog, QVariant from sn_basis.functions.qgiscore_wrapper import QgsGeometry from sn_basis.functions.dialog_wrapper import ( create_progress_dialog, show_info_dialog, ask_detailpruefung_oder_abbrechen, ask_yes_no, ) from sn_verfahrensgebiet.functions.verfahrensgebiet_alkis import LoadStatus, verfahrensgebiet_alkis from sn_verfahrensgebiet.functions.gemarkung_alkis import ( gemarkung_schluessel_und_namen_von_vg_geometrie, ) from sn_verfahrensgebiet.functions.flurstueck_alkis import flurstueck_alkis from sn_verfahrensgebiet.functions.verfahrensgebiet_edit_service import ( erstelle_verfahrensgebiet_aus_auswahl, VERF_LAYER_NAME, ) from sn_verfahrensgebiet.functions.grenzpunkt_alkis import lade_punktort_ta from sn_verfahrensgebiet.functions.knickpunkt_pruefung import ( knickpunkt_pruefung, _extrahiere_knickpunkte, erstelle_korrigiertes_polygon, ) from sn_verfahrensgebiet.functions.verfahrensgebiet_alkis_komplett import ( _select_by_location, _vergleiche_flaechen, _lese_flaechen_toleranz, _entferne_hilfs_layer, MINDEST_FLAECHEN_TOLERANZ_M2, BROR_LAYER_NAME, ) #: Feldname im Shapefile, über den der Gebietstyp bestimmt wird. FBSCHL_FELD = "FBSchl" #: FBSchl-Wert für ein Neuordnungsgebiet. FBSCHL_NEUORDNUNGSGEBIET = "901/10110F" #: FBSchl-Wert für ein Neuvermessungsgebiet. FBSCHL_NEUVERMESSUNGSGEBIET = "901/10120F" #: Anzeigename des Layers für ein Neuordnungsgebiet (NoBIS). NEUORDNUNGSGEBIET_LAYER_NAME = "Neuordnungsgebiet (NoBIS)" #: Anzeigename des Layers für ein Neuvermessungsgebiet (NoBIS). NEUVERMESSUNGSGEBIET_LAYER_NAME = "Neuvermessungsgebiet (NoBIS)" #: Zuordnung Gebietstyp → Layername. _GEBIETSTYP_LAYER_NAMEN = { "Neuordnungsgebiet": NEUORDNUNGSGEBIET_LAYER_NAME, "Neuvermessungsgebiet": NEUVERMESSUNGSGEBIET_LAYER_NAME, } # --------------------------------------------------------------------------- # Interne Hilfsfunktionen # --------------------------------------------------------------------------- def _normalisiere_feldname(name: str) -> str: """Normalisiert einen Feldnamen für den toleranten Vergleich. Entfernt alle Zeichen außer Buchstaben/Ziffern (z.B. Punkte, Leerzeichen, Unterstriche) und wandelt in Kleinbuchstaben um. Hintergrund: manche Shape-Exporte liefern den Feldnamen ``FBSchl`` mit einem angehängten Punkt (``FBSchl.``) oder abweichender Groß-/Kleinschreibung, wodurch ein exakter Namensvergleich das Feld fälschlich als fehlend meldet (#44). Parameters ---------- name : Roher Feldname. Returns ------- str Normalisierter Feldname (nur Kleinbuchstaben/Ziffern). """ return re.sub(r"[^a-zA-Z0-9]", "", name).lower() def _finde_fbschl_index(raw_layer) -> int: """Sucht den Feldindex von ``FBSchl`` tolerant gegenüber Abweichungen im Namen (Groß-/Kleinschreibung, angehängte Sonderzeichen wie ein Punkt). Versucht zuerst den exakten Namen; schlägt das fehl, wird über alle Felder mit normalisiertem Namensvergleich gesucht (siehe :func:`_normalisiere_feldname`). Parameters ---------- raw_layer : Dateibasierter Shapefile-Layer. Returns ------- int Feldindex, oder ``-1`` wenn kein passendes Feld gefunden wurde. """ idx = raw_layer.fields().indexFromName(FBSCHL_FELD) if idx >= 0: return idx ziel = _normalisiere_feldname(FBSCHL_FELD) for feld_idx, feld in enumerate(raw_layer.fields()): if _normalisiere_feldname(feld.name()) == ziel: return feld_idx return -1 def _ermittle_gebietstyp_aus_fbschl(raw_layer) -> Tuple[Optional[str], Optional[str]]: """Prüft das Feld ``FBSchl`` und ermittelt den Gebietstyp. Zeigt selbst keine Fehlermeldung an, sondern gibt einen Fehlertext zurück, damit der Aufrufer Fehler mehrerer Dateien sammeln und am Ende gemeinsam anzeigen kann. Die Feldsuche ist tolerant gegenüber Abweichungen im Namen (z.B. ein angehängter Punkt ``FBSchl.`` in manchen Shape-Exporten, siehe #44) – siehe :func:`_finde_fbschl_index`. Parameters ---------- raw_layer : Dateibasierter Shapefile-Layer (vor dem Verschmelzen zum Memory-Layer). Returns ------- Tuple[Optional[str], Optional[str]] ``(gebietstyp, fehlertext)``. Bei Erfolg ist ``gebietstyp`` ``"Neuordnungsgebiet"`` oder ``"Neuvermessungsgebiet"`` und ``fehlertext`` ``None``. Bei Fehler ist ``gebietstyp`` ``None`` und ``fehlertext`` beschreibt die Ursache (fehlendes Feld oder unbekannter Wert). """ idx = _finde_fbschl_index(raw_layer) if idx < 0: return None, f"Feld '{FBSCHL_FELD}' fehlt (Shape entspricht nicht dem erwarteten Inhalt)." wert = None for feat in raw_layer.getFeatures(): try: roh = feat.attribute(idx) except Exception: roh = None if roh not in (None, ""): wert = str(roh).strip() break if wert == FBSCHL_NEUORDNUNGSGEBIET: return "Neuordnungsgebiet", None if wert == FBSCHL_NEUVERMESSUNGSGEBIET: return "Neuvermessungsgebiet", None return None, ( f"Unbekannter Wert im Feld '{FBSCHL_FELD}': {wert!r} " f"(erwartet: '{FBSCHL_NEUORDNUNGSGEBIET}' oder " f"'{FBSCHL_NEUVERMESSUNGSGEBIET}')." ) def _wende_vergleich_stil_an(layer) -> None: """Wendet einen kategorisierten Renderer auf den Vergleich-Layer an. Farben: - ``identisch`` (Schnittmenge): Gr\u00fcn - ``nur_bror`` (nur im BROR): Gelb - ``nur_vg`` (nur im Verfahrensgebiet): Blau Schl\u00e4gt der Import der QGIS-Rendering-Klassen fehl (z.\u00a0B. im Test-Modus), wird der Fehler still ignoriert. """ try: from qgis.core import ( # type: ignore[import] QgsCategorizedSymbolRenderer, QgsRendererCategory, QgsFillSymbol, ) _FARBEN = [ ("identisch", "#00aa00", 0.5), # gr\u00fcn ("nur_bror", "#ffcc00", 0.5), # gelb ("nur_vg", "#3366ff", 0.5), # blau ] kategorien = [] for wert, farbe, opazitaet in _FARBEN: symbol = QgsFillSymbol.createSimple({ "color": farbe, "outline_color": "#555555", "outline_width": "0.26", }) symbol.setOpacity(opazitaet) kategorien.append(QgsRendererCategory(wert, symbol, wert)) renderer = QgsCategorizedSymbolRenderer("typ", kategorien) layer.setRenderer(renderer) except Exception: pass def _erstelle_vergleich_layer(vg_layer, bror_layer) -> Optional[object]: """Erzeugt einen Vergleich-Layer aus geometrischen Differenzoperationen. F\u00fcr jeden Fl\u00e4chentyp wird ein Feature angelegt: - ``identisch``: Schnittmenge aus VG und BROR (gr\u00fcn). - ``nur_bror``: Fl\u00e4che, die nur im BROR-Objekt liegt (gelb). - ``nur_vg``: Fl\u00e4che, die nur im Verfahrensgebiet liegt (blau). Parameters ---------- vg_layer : Verfahrensgebiet-Layer (Shape). bror_layer : BauRaumOderBodenordnungsrecht-Layer (ALKIS). Returns ------- Optional[QgsVectorLayer] Memory-Layer ``Vergleich`` mit Stil oder ``None`` bei Fehler. """ try: vg_geom = None for feat in vg_layer.getFeatures(): g = feat.geometry() if g and not g.isEmpty(): vg_geom = g break bror_geom = None for feat in bror_layer.getFeatures(): g = feat.geometry() if g and not g.isEmpty(): bror_geom = g break if not vg_geom or not bror_geom: return None geom_identisch = vg_geom.intersection(bror_geom) geom_nur_bror = bror_geom.difference(vg_geom) geom_nur_vg = vg_geom.difference(bror_geom) crs = vg_layer.crs().authid() mem_layer = QgsVectorLayer( f"MultiPolygon?crs={crs}", "Vergleich", "memory" ) pr = mem_layer.dataProvider() pr.addAttributes([QgsField("typ", QVariant.String)]) mem_layer.updateFields() feats = [] for geom, typ in [ (geom_identisch, "identisch"), (geom_nur_bror, "nur_bror"), (geom_nur_vg, "nur_vg"), ]: if geom and not geom.isEmpty(): f = QgsFeature() f.setGeometry(geom) f.setAttributes([typ]) feats.append(f) pr.addFeatures(feats) mem_layer.updateExtents() _wende_vergleich_stil_an(mem_layer) return mem_layer if mem_layer.isValid() else None except Exception: return None def _finde_shp(verzeichnis: str) -> Optional[str]: """Sucht rekursiv nach der ersten ``.shp``-Datei im Verzeichnis. Parameters ---------- verzeichnis : Startverzeichnis für die Suche. Returns ------- Optional[str] Absoluter Pfad zur ersten gefundenen ``.shp``-Datei, oder ``None``. """ for root, _dirs, files in os.walk(verzeichnis): for fname in files: if fname.lower().endswith(".shp"): return os.path.join(root, fname) return None def _finde_alle_shp(verzeichnis: str) -> list: """Sucht rekursiv nach allen ``.shp``-Dateien im Verzeichnis. Ein ZIP-Archiv kann mehrere Shapefile-Sets enthalten (z.B. je eines für Neuordnungs- und Neuvermessungsgebiet) – im Unterschied zu :func:`_finde_shp` werden hier alle gefunden, nicht nur die erste. Parameters ---------- verzeichnis : Startverzeichnis für die Suche. Returns ------- list Sortierte Liste der absoluten Pfade aller gefundenen ``.shp``-Dateien. """ treffer = [] for root, _dirs, files in os.walk(verzeichnis): for fname in files: if fname.lower().endswith(".shp"): treffer.append(os.path.join(root, fname)) return sorted(treffer) def _erstelle_memory_layer_aus_shp(shp_layer) -> Optional[object]: """Vereint alle Features des Shapefile-Layers zu einem Single-Feature- Memory-Layer „Verfahrensgebiet" via ``QgsGeometry.unaryUnion``. Alle Felder des Shapefiles werden in den Memory-Layer übernommen. Die Attributwerte des ersten Features mit gültiger Geometrie werden auf das Union-Feature gesetzt. Parameters ---------- shp_layer : Geladener ``QgsVectorLayer`` aus dem Shapefile. Returns ------- Optional[QgsVectorLayer] Single-Feature-Memory-Layer mit Union-Geometrie und Shapefile-Attributen oder ``None`` bei Fehler. """ geom_list = [] first_feat = None for feat in shp_layer.getFeatures(): try: geom = feat.geometry() if geom and not geom.isEmpty(): geom_list.append(geom) if first_feat is None: first_feat = feat except Exception: pass if not geom_list: return None union_geom = QgsGeometry.unaryUnion(geom_list) if not union_geom or union_geom.isEmpty(): return None crs_authid = shp_layer.crs().authid() mem_layer = QgsVectorLayer( f"MultiPolygon?crs={crs_authid}", VERF_LAYER_NAME, "memory" ) pr = mem_layer.dataProvider() # Felder des Shapefiles übernehmen try: pr.addAttributes(list(shp_layer.fields())) mem_layer.updateFields() except Exception: pass feat_out = QgsFeature(mem_layer.fields()) feat_out.setGeometry(union_geom) # Attributwerte vom ersten Feature übernehmen if first_feat is not None: try: feat_out.setAttributes(first_feat.attributes()) except Exception: pass pr.addFeatures([feat_out]) mem_layer.updateExtents() if not mem_layer.isValid() or mem_layer.featureCount() == 0: return None return mem_layer # --------------------------------------------------------------------------- # Öffentliche Lade-Hilfsfunktion # --------------------------------------------------------------------------- def lade_vg_aus_zip(parent_widget) -> Optional[list]: """Öffnet Dateidialog (Mehrfachauswahl), entpackt ZIPs (bzw. nutzt direkt gewählte ``.shp``-Dateien) und liefert je Gebietstyp einen Union-Memory-Layer. Schrittfolge: 1. ``QFileDialog.getOpenFileNames`` mit Filter für ``.zip`` und ``.shp`` (Mehrfachauswahl möglich). 2. Für jede ausgewählte Datei: bei ``.zip`` in ein temporäres Verzeichnis entpacken (danach wieder gelöscht) und darin **alle** enthaltenen ``.shp``-Dateien finden (ein ZIP kann mehrere Shapefile-Sets bündeln, z.B. je eines für Neuordnungs- und Neuvermessungsgebiet); bei ``.shp`` die Datei direkt verwenden. 3. Jede gefundene Shapefile-Datei einzeln als ``QgsVectorLayer`` laden und ``isValid()`` prüfen. 4. Je Shapefile-Datei das Feld ``FBSchl`` prüfen und den Gebietstyp ermitteln (Neuordnungs- bzw. Neuvermessungsgebiet). 5. Die Features aller Dateien **desselben Gebietstyps** werden gemeinsam per ``QgsGeometry.unaryUnion`` zu einem Single-Feature-Memory-Layer verschmolzen – d.h. mehrere Neuordnungsgebiet-Dateien ergeben einen Layer, mehrere Neuvermessungsgebiet-Dateien einen zweiten. Fehlerverhalten: Schlägt die Prüfung eines einzelnen Shapefiles fehl (ZIP nicht entpackbar, kein Shapefile enthalten, ungültiger Layer, Feld ``FBSchl`` fehlt oder hat einen unbekannten Wert), wird **nur dieses Shapefile** übersprungen und sein Fehler gesammelt – das gilt auch, wenn mehrere Shapefiles in derselben ZIP-Datei liegen: eine fehlerhafte Datei darin bricht nicht die Verarbeitung der übrigen ab. Alle verwertbaren Dateien werden gemäß Vorgabe eingelesen. Am Ende erscheint eine gesammelte Fehlermeldung mit allen fehlerhaften Dateien. Erst wenn **keine** Datei verwertbare Geometrien liefert, wird ``None`` zurückgegeben. Parameters ---------- parent_widget : Eltern-Widget für Dateidialog und Fehlermeldungen. Returns ------- Optional[list] Liste der erzeugten Memory-Layer (ein Layer je in der Auswahl vorkommendem, erfolgreich geprüftem Gebietstyp, max. 2), oder ``None`` wenn keine Datei verwertet werden konnte oder der Dialog abgebrochen wurde. """ pfade, _ = QFileDialog.getOpenFileNames( parent_widget, "Shapefile-Set(s) (ZIP oder .shp) auswählen", "", "Shapefile-Set (*.zip *.shp);;ZIP-Dateien (*.zip);;Shapefile (*.shp)", ) if not pfade: return None # Nutzerabbruch # Je Gebietstyp gesammelte Geometrien/Felder/Attribute über alle # ausgewählten Dateien hinweg (Reihenfolge der ersten Sichtung bleibt # über dict-Einfügereihenfolge erhalten). geometrien_je_typ: dict = {} felder_je_typ: dict = {} attribute_je_typ: dict = {} crs_je_typ: dict = {} temp_dirs: list = [] fehler_liste: list = [] try: # (shp_path, anzeige_name) für jede tatsächlich zu prüfende # Shapefile-Datei sammeln – ein ZIP kann mehrere Shapefile-Sets # enthalten, jedes wird einzeln behandelt. shp_eintraege: list = [] for pfad in pfade: dateiname = os.path.basename(pfad) if pfad.lower().endswith(".shp"): shp_eintraege.append((pfad, dateiname)) continue extract_dir = tempfile.mkdtemp(prefix="sn_vg_shape_") temp_dirs.append(extract_dir) try: with zipfile.ZipFile(pfad, "r") as zf: zf.extractall(extract_dir) except Exception: fehler_liste.append( f"{dateiname}: ZIP-Datei konnte nicht entpackt werden." ) continue shp_pfade_im_zip = _finde_alle_shp(extract_dir) if not shp_pfade_im_zip: fehler_liste.append( f"{dateiname}: kein gültiges Shape-Dateiset enthalten." ) continue for shp_path in shp_pfade_im_zip: anzeige_name = ( f"{dateiname} ({os.path.basename(shp_path)})" if len(shp_pfade_im_zip) > 1 else dateiname ) shp_eintraege.append((shp_path, anzeige_name)) # ------------------------------------------------------------------ # Jede gefundene Shapefile-Datei einzeln prüfen und einlesen. # ------------------------------------------------------------------ for shp_path, anzeige_name in shp_eintraege: raw_layer = QgsVectorLayer(shp_path, "tmp_shape_vg", "ogr") if not raw_layer or not raw_layer.isValid(): fehler_liste.append( f"{anzeige_name}: kein gültiges Shape-Dateiset." ) continue gebietstyp, fehlertext = _ermittle_gebietstyp_aus_fbschl(raw_layer) if gebietstyp is None: fehler_liste.append(f"{anzeige_name}: {fehlertext}") del raw_layer continue geom_list = [] first_feat = None for feat in raw_layer.getFeatures(): try: geom = feat.geometry() except Exception: geom = None if geom and not geom.isEmpty(): geom_list.append(geom) if first_feat is None: first_feat = feat if geom_list: geometrien_je_typ.setdefault(gebietstyp, []).extend(geom_list) if gebietstyp not in felder_je_typ: felder_je_typ[gebietstyp] = list(raw_layer.fields()) attribute_je_typ[gebietstyp] = ( first_feat.attributes() if first_feat is not None else [] ) crs_je_typ[gebietstyp] = raw_layer.crs().authid() else: fehler_liste.append(f"{anzeige_name}: keine gültigen Geometrien enthalten.") # Referenz auf den dateibasierten Layer freigeben, bevor das # temporäre Verzeichnis gelöscht wird (Windows hält sonst ggf. # noch einen Dateihandle auf das Shapefile). del raw_layer if fehler_liste: meldung = ( "Folgende Dateien konnten nicht eingelesen werden:\n\n" + "\n".join(fehler_liste) ) if geometrien_je_typ: meldung += "\n\nDie übrigen Dateien wurden gemäß Vorgabe eingelesen." QMessageBox.warning(parent_widget, "Fehler in ausgewählten Dateien", meldung) if not geometrien_je_typ: return None ergebnis_layer = [] for typ, geometrien in geometrien_je_typ.items(): union_geom = QgsGeometry.unaryUnion(geometrien) if not union_geom or union_geom.isEmpty(): continue mem_layer = QgsVectorLayer( f"MultiPolygon?crs={crs_je_typ[typ]}", _GEBIETSTYP_LAYER_NAMEN[typ], "memory", ) pr = mem_layer.dataProvider() try: pr.addAttributes(felder_je_typ[typ]) mem_layer.updateFields() except Exception: pass feat_out = QgsFeature(mem_layer.fields()) feat_out.setGeometry(union_geom) try: feat_out.setAttributes(attribute_je_typ[typ]) except Exception: pass pr.addFeatures([feat_out]) mem_layer.updateExtents() if mem_layer.isValid() and mem_layer.featureCount() > 0: mem_layer.setCustomProperty("fb_gebietstyp", typ) ergebnis_layer.append(mem_layer) if not ergebnis_layer: QMessageBox.warning( parent_widget, "Fehler", "gültiges Shape-Dateiset angeben", ) return None return ergebnis_layer finally: for d in temp_dirs: shutil.rmtree(d, ignore_errors=True) # --------------------------------------------------------------------------- # Öffentliche API # --------------------------------------------------------------------------- def verfahrensgebiet_shape_komplett( tab_widget, ) -> Tuple[Optional[object], Optional[object], Optional[object], LoadStatus]: """Vollständiger VG-Lade-Workflow aus Shape (ZIP): ZIP → BROR → Gemarkungen → Flurstücke → Verschneidung → Flächenvergleich → opt. Umringsprüfung. Das aus der ZIP geladene Shape-VG ist das finale Verfahrensgebiet-Layer. Ist die Projektvariable ``verfahrensnummer`` gesetzt, wird das zugehörige BROR-Objekt (``AX_BauRaumOderBodenordnungsrecht``) aus ALKIS geladen und als Vergleichsobjekt im Flächenvergleich verwendet. Andernfalls wird ein temporärer enriched Layer aus Flurstücken für den Vergleich herangezogen. Schrittfolge: 1. ``lade_vg_aus_zip(tab_widget)`` – ZIP öffnen, Shapefile validieren, Union-Memory-Layer „Verfahrensgebiet" erzeugen. 2. ``verfahrensgebiet_alkis(tab_widget, skip_existing_check=True)`` – BROR-Objekt als Memory-Layer (sofern VKZ gesetzt). Der BROR wird **vor** dem Shape-VG ins Projekt eingefügt, um den Namenskonflikt mit ``_check_existing_layer`` zu vermeiden. 3. Shape-VG ins Projekt einfügen; BROR umbenennen zu ``BauRaumOderBodenordnungsrecht (ALKIS)`` und zum Projekt hinzufügen. 4. ``gemarkung_schluessel_und_namen_von_vg_geometrie(geom)`` – Schlüssel. 5. ``flurstueck_alkis(...)`` – Flurstücke laden. 6. ``_select_by_location(...)`` – räumliche Selektion. 7. Flächenvergleich: - Mit BROR: ``_vergleiche_flaechen(bror_layer, shape_vg_layer, ...)``. - Ohne BROR: temp. enriched Layer erzeugen und mit Shape-VG vergleichen. 8. Ggf. Detailprüfung (knickpunktbasiert) auf BROR bzw. Shape-VG. Parameters ---------- tab_widget : Eltern-Widget für alle Dialoge. Returns ------- Tuple[Optional[shape_vg_layer], Optional[flst_layer], Optional[bror_layer], LoadStatus] - ``shape_vg_layer``: Memory-Layer „Verfahrensgebiet" (aus Shape). - ``flst_layer``: Flurstücke-Layer (bleibt im Projekt). - ``bror_layer``: BROR-Layer ``BauRaumOderBodenordnungsrecht (ALKIS)`` (wenn geladen, bleibt im Projekt); ``None`` wenn VKZ nicht gesetzt. - :class:`LoadStatus`: ``FIRST`` oder ``NONE``. """ project = QgsProject.instance() shape_vg_layer = None zusatz_shape_layer: list = [] bror_layer = None shape_was_added_by_us = False bror_was_added_by_us = False abgeschlossen = False progress = None try: # ------------------------------------------------------------------ # Schritt 1: Shape-VG aus ZIP laden (noch NICHT ins Projekt einfügen) # Läuft VOR dem Fortschrittsdialog, damit dieser nicht schon während # des Dateidialogs sichtbar ist. # ------------------------------------------------------------------ shape_vg_layers = lade_vg_aus_zip(tab_widget) if not shape_vg_layers: return None, None, None, LoadStatus.NONE # Je Gebietstyp ein Layer (max. 2). Der erste ist das für den # weiteren Ablauf maßgebliche Shape-VG; etwaige weitere Layer # (zweiter Gebietstyp) werden vorerst nur mit ins Projekt geladen – # ihre Einbindung in den weiteren Workflow folgt in einem späteren # Schritt. shape_vg_layer = shape_vg_layers[0] zusatz_shape_layer = shape_vg_layers[1:] # TEMPORÄRER STOPP (wieder entfernen): Pause nach "Verfahrensgebiet # aus ZIP laden" zur Zwischenkontrolle – Layer dafür schon jetzt # sichtbar ins Projekt einfügen (normalerweise erst nach Schritt 2). project.addMapLayer(shape_vg_layer) shape_was_added_by_us = True for lyr in zusatz_shape_layer: project.addMapLayer(lyr) geladene_namen = ", ".join(lyr.name() for lyr in shape_vg_layers) if not ask_yes_no( "Verfahrensgebiet aus ZIP laden", f"Layer '{geladene_namen}' wurde(n) geladen. Weiter?", default=True, parent=tab_widget, ): # abgeschlossen=True, damit der finally-Block den zur Kontrolle # eingefügten Layer NICHT wieder entfernt (kein Fehlerfall). abgeschlossen = True return None, None, None, LoadStatus.NONE vg_status = LoadStatus.FIRST # Fortschrittsdialog erst jetzt erzeugen (nach dem Stop) – er darf # nicht schon während Dateidialog/Stop-Abfrage sichtbar sein. progress = create_progress_dialog( 7, "Verfahrensgebiet aus Shape laden", "Schritt 2/6: BROR aus ALKIS laden …" ) progress.set_value(1) # ------------------------------------------------------------------ # Schritt 2: BROR aus ALKIS laden (vor shape_vg, um Namenskonflikt # in _check_existing_layer zu vermeiden) # ------------------------------------------------------------------ if progress.is_canceled(): return None, None, None, LoadStatus.NONE vkz = get_variable("verfahrensnummer") or "" if str(vkz).strip(): bror_result, bror_status, _ = verfahrensgebiet_alkis( tab_widget, skip_existing_check=True ) progress.raise_to_front() if bror_status != LoadStatus.NONE and bror_result: bror_result.setName(BROR_LAYER_NAME) project.addMapLayer(bror_result) bror_layer = bror_result bror_was_added_by_us = True progress.set_value(2) # Shape-VG erst jetzt ins Projekt einfügen (nach BROR-Ladung) – außer # der TEMPORÄRE STOPP oben hat ihn schon eingefügt. if not project.mapLayer(shape_vg_layer.id()): project.addMapLayer(shape_vg_layer) shape_was_added_by_us = True # ------------------------------------------------------------------ # Zwischenabfrage: Nutzer kann vor dem Gemarkungsschlüssel-Schritt # abbrechen (Shape-VG und ggf. BROR bleiben im Projekt erhalten). # ------------------------------------------------------------------ progress.raise_to_front() if not ask_yes_no( "Verfahrensgebiet aus Shape laden", "Weiter mit Gemarkungsschlüssel ermitteln?", default=True, parent=tab_widget, ): abgeschlossen = True return shape_vg_layer, None, bror_layer, vg_status # ------------------------------------------------------------------ # Schritt 3: Gemarkungsschlüssel per Geometrie-Spatial-Join ermitteln # ------------------------------------------------------------------ progress.set_label("Schritt 3/6: Gemarkungsschlüssel ermitteln …") if progress.is_canceled(): return None, None, None, LoadStatus.NONE shape_feats = list(shape_vg_layer.getFeatures()) vg_geom = shape_feats[0].geometry() if shape_feats else None schluessel_namen = ( gemarkung_schluessel_und_namen_von_vg_geometrie(vg_geom) if vg_geom else [] ) vorgabe_schluessel = [nr for nr, _ in schluessel_namen] vorgabe_namen = [nm for _, nm in schluessel_namen if nm] if not vorgabe_schluessel: warning( "VG laden", "Keine Gemarkungen in der BBox gefunden. Bitte Schlüssel manuell eingeben.", duration=5, ) progress.set_value(3) # ------------------------------------------------------------------ # Schritt 4: Flurstücke laden (mit Nutzer-Dialog, Vorschlag befüllt) # ------------------------------------------------------------------ progress.set_label("Schritt 4/6: Flurstücke aus ALKIS laden …") if progress.is_canceled(): return None, None, None, LoadStatus.NONE flst_layer, flst_status = flurstueck_alkis( tab_widget, vorgabe_schluessel=vorgabe_schluessel, vorgabe_namen=vorgabe_namen if vorgabe_namen else None, ohne_dialog=True, ) if flst_status == LoadStatus.NONE or not flst_layer: return None, None, None, LoadStatus.NONE progress.set_value(4) # ------------------------------------------------------------------ # Schritt 5: Räumliche Selektion # ------------------------------------------------------------------ progress.set_label("Schritt 5/6: Flurstücke räumlich selektieren …") if progress.is_canceled(): return None, None, None, LoadStatus.NONE anzahl = _select_by_location(flst_layer, shape_vg_layer) if anzahl == 0: QMessageBox.warning( tab_widget, "Keine Flurstücke im VG", "Es wurden keine Flurstücke gefunden, die das Verfahrensgebiet schneiden.\n" "Bitte Gemarkungsschlüssel und Ausdehnung prüfen.", ) return None, flst_layer, None, LoadStatus.NONE progress.set_value(5) # ------------------------------------------------------------------ # Schritt 6: Flächenvergleich als Gate # ------------------------------------------------------------------ progress.set_label("Schritt 6/6: Flächenvergleich …") if progress.is_canceled(): return None, None, None, LoadStatus.NONE toleranz = _lese_flaechen_toleranz() if bror_layer and bror_layer.isValid(): # BROR verfügbar → als Referenz-Objekt verwenden ref_flaeche, shape_flaeche, stimmt_ueberein = _vergleiche_flaechen( bror_layer, shape_vg_layer, toleranz ) dialog_ref_label = "BauRaumOderBodenordnungsrecht" dialog_vg_label = "Shape-Verfahrensgebiet " pruef_layer = bror_layer # Vergleich-Layer (grün/gelb/blau) ins Projekt einfügen vergleich_layer = _erstelle_vergleich_layer(shape_vg_layer, bror_layer) if vergleich_layer: project.addMapLayer(vergleich_layer) else: # Kein BROR → temp. enriched Layer aus Flurstücken erzeugen temp_enriched, _, _ = erstelle_verfahrensgebiet_aus_auswahl(flst_layer) ref_flaeche, shape_flaeche, stimmt_ueberein = _vergleiche_flaechen( shape_vg_layer, temp_enriched, toleranz ) dialog_ref_label = "Shape-Verfahrensgebiet " dialog_vg_label = "Verfahrensgebiet (Flurstücke)" pruef_layer = shape_vg_layer progress.set_value(6) if stimmt_ueberein: progress.set_value(7) progress.close() show_info_dialog( "Flächenvergleich", "Verfahrensgebiet mit erwarteter Geometrie erzeugt", parent=tab_widget, ) abgeschlossen = True return shape_vg_layer, flst_layer, bror_layer, vg_status # ------------------------------------------------------------------ # Flächenabweichung → 2-Optionen-Dialog # ------------------------------------------------------------------ progress.close() diff = abs(ref_flaeche - shape_flaeche) entscheidung = ask_detailpruefung_oder_abbrechen( tab_widget, "Abweichung erkannt", f"Abweichung erkannt.\n\n" f"{dialog_ref_label}: {ref_flaeche:.2f} m²\n" f"{dialog_vg_label}: {shape_flaeche:.2f} m²\n" f"Differenz: {diff:.2f} m²\n\n" f"Wie möchten Sie fortfahren?", ) if entscheidung == "abbrechen": abgeschlossen = True return shape_vg_layer, flst_layer, bror_layer, vg_status # ------------------------------------------------------------------ # Option: Detailprüfung (knickpunktbasiert) auf pruef_layer # (= BROR wenn vorhanden, sonst Shape-VG) # ------------------------------------------------------------------ knickpunkte = _extrahiere_knickpunkte(pruef_layer) n_knick = len(knickpunkte) gesamt_schritte = n_knick + 5 detail_progress = create_progress_dialog( gesamt_schritte, "Detailprüfung (Knickpunkte)", "Schritt 1: Grenzpunkte laden …", ) detail_progress.set_value(0) punktort_layer = lade_punktort_ta( vorgabe_schluessel, vg_geom, progress=detail_progress ) if detail_progress.is_canceled(): detail_progress.close() abgeschlossen = True return shape_vg_layer, flst_layer, bror_layer, vg_status detail_progress.set_value(1) if punktort_layer is None: detail_progress.close() abgeschlossen = True if not detail_progress.is_canceled(): QMessageBox.critical( tab_widget, "WFS-Fehler", "Die Grenzpunktdaten (PunktortTA) konnten nicht geladen werden.\n\n" "Mögliche Ursache: Netzwerk- oder Proxy-Fehler.\n" "Bitte prüfen Sie die QGIS-Proxy-Einstellungen " "(Einstellungen -> Optionen -> Netzwerk) und den Log-Eintrag " "unter Ansicht -> Bedienfelder -> Protokollnachrichten " "(Kategorie 'sn_verfahrensgebiet').", ) return shape_vg_layer, flst_layer, bror_layer, vg_status ergebnis_layer, identisch, kein_pta, fehlender_knick = knickpunkt_pruefung( pruef_layer, punktort_layer, flst_layer, progress=detail_progress, knickpunkte=knickpunkte, ) if detail_progress.is_canceled(): detail_progress.close() abgeschlossen = True return shape_vg_layer, flst_layer, bror_layer, vg_status detail_progress.set_value(gesamt_schritte) detail_progress.close() # ------------------------------------------------------------------ # Korrigiertes Polygon aus identischen + fehlenden Knickpunkten # (nur für Visualisierung – shape_vg_layer bleibt das finale VG) # ------------------------------------------------------------------ if ergebnis_layer and ergebnis_layer.isValid(): linien_layer_korr, polygon_layer_korr = erstelle_korrigiertes_polygon( pruef_layer, ergebnis_layer, pruef_layer.crs().authid() ) if polygon_layer_korr is not None and polygon_layer_korr.isValid(): if linien_layer_korr is not None and linien_layer_korr.isValid(): project.addMapLayer(linien_layer_korr) project.addMapLayer(polygon_layer_korr) if ergebnis_layer and ergebnis_layer.isValid(): project.addMapLayer(ergebnis_layer) show_info_dialog( "Detailprüfung abgeschlossen", f"Knickpunkte geprüft: {n_knick}\n" f" Identisch (grün): {identisch}\n" f" Kein PunktortTA (rot): {kein_pta}\n" f" Fehlende Knickpunkte (orange): {fehlender_knick}", parent=tab_widget, ) abgeschlossen = True return shape_vg_layer, flst_layer, bror_layer, vg_status finally: _entferne_hilfs_layer(project, "TempVerfahrensgebiet") # Shape-VG und BROR bei Fehler/Abbruch entfernen if not abgeschlossen: for lyr in zusatz_shape_layer: try: project.removeMapLayer(lyr.id()) except Exception: pass if shape_was_added_by_us and shape_vg_layer is not None: try: project.removeMapLayer(shape_vg_layer.id()) except Exception: pass if bror_was_added_by_us and bror_layer is not None: try: project.removeMapLayer(bror_layer.id()) except Exception: pass