"""sn_verfahrensgebiet/functions/knickpunkt_pruefung.py – Knickpunktbasierte BROR-Prüfung. Vergleicht die Knickpunkte (Stützpunkte) des BauRaumOderBodenordnungsrecht- Polygons mit den Punktorten der geladenen ``AX_PunktortTA``-Objekte und identifiziert: - **Identische Punkte** (grün): An einem BROR-Knickpunkt liegt ein PunktortTA. - **Fehlende PunktortTA** (rot): Ein BROR-Knickpunkt hat keinen zugehörigen PunktortTA. - **Fehlende Knickpunkte** (orange): Ein PunktortTA liegt nahe des BROR-Umrings auf einer Flurstücksgrenze, hat aber keinen entsprechenden Knickpunkt im BROR. **Algorithmus:** 1. BROR-Polygon-Stützpunkte (Knickpunkte) extrahieren. 2. Spatial-Index auf PunktortTA aufbauen. 3. Für jeden Knickpunkt nächsten PunktortTA im Radius von :data:`KOORDINATEN_TOLERANZ_M` suchen → grün oder rot. Gematchte PunktortTA-Feature-IDs merken. 4. Gepufferter BROR-Umring + Flurstück-Vertex-Set ermitteln. 5. Nicht-gematchte PunktortTA nahe BROR-Umring, die auf einer Flurstücksgrenze liegen → orange. **Rückgabe:** ``(result_layer, identisch, kein_pta, fehlender_knick)`` """ from __future__ import annotations from typing import Dict, List, Optional, Set, Tuple from sn_basis.functions.qgiscore_wrapper import ( QgsVectorLayer, QgsFeature, QgsField, QgsGeometry, ) from sn_basis.functions.qt_wrapper import QVariant # --------------------------------------------------------------------------- # Konstanten # --------------------------------------------------------------------------- #: Koordinatentoleranz für die Knickpunkt ↔ PunktortTA-Übereinstimmung (m). KOORDINATEN_TOLERANZ_M: float = 0.01 #: Puffer um den BROR-Umring für die Suche nach fehlenden Knickpunkten (m). BOUNDARY_PUFFER_M: float = 0.5 #: Rundungsgenauigkeit für Vertex-Koordinaten im Flurstück-Set (Dezimalstellen). _VERTEX_ROUND = 3 ERGEBNIS_LAYER_NAME = "Knickpunktprüfung" FELD_TYP = "typ" WERT_IDENTISCH = "identisch" WERT_KEIN_PTA = "kein_pta" WERT_FEHLENDER_KNICK = "fehlender_knick" # --------------------------------------------------------------------------- # Interne Hilfsfunktionen # --------------------------------------------------------------------------- def _extrahiere_knickpunkte(bror_layer) -> List[object]: """Extrahiert alle Stützpunkte (Knickpunkte) des BROR-Polygons. Doppelte Start/End-Punkte eines Rings werden dedupliziert. Gibt eine flache Liste von ``QgsPointXY``-Objekten zurück. Parameters ---------- bror_layer : BauRaumOderBodenordnungsrecht-Polygon-Layer. Returns ------- List[QgsPointXY] Alle eindeutigen Stützpunkte; leer bei Fehler. """ try: from qgis.core import QgsPointXY # type: ignore[import] bror_feats = [ f for f in bror_layer.getFeatures() if f.geometry() and not f.geometry().isEmpty() ] if not bror_feats: return [] union_geom = QgsGeometry.unaryUnion([f.geometry() for f in bror_feats]) if union_geom is None or union_geom.isEmpty(): return [] punkte: List[object] = [] seen: Set[Tuple[float, float]] = set() def _add_ring(vertices): # Letzter Punkt = erster Punkt (Ring geschlossen) → überspringen ring_pts = list(vertices) n = len(ring_pts) if n >= 2: # Prüfen ob letzter Punkt identisch mit erstem last = n - 1 if ( abs(ring_pts[last].x() - ring_pts[0].x()) < 1e-9 and abs(ring_pts[last].y() - ring_pts[0].y()) < 1e-9 ): ring_pts = ring_pts[:-1] for pt in ring_pts: key = (round(pt.x(), _VERTEX_ROUND), round(pt.y(), _VERTEX_ROUND)) if key not in seen: seen.add(key) punkte.append(pt) geom_type = union_geom.wkbType() # Polygon oder MultiPolygon if union_geom.isMultipart(): for poly in union_geom.asMultiPolygon(): for ring in poly: _add_ring([QgsPointXY(x, y) for x, y in ring]) else: for ring in union_geom.asPolygon(): _add_ring([QgsPointXY(x, y) for x, y in ring]) return punkte except Exception: return [] def _erstelle_ergebnis_layer(crs_authid: str) -> object: """Erzeugt den leeren Punkt-Ergebnislayer mit Feld ``typ``. Parameters ---------- crs_authid : CRS-Kennung, z.B. ``"EPSG:25833"``. Returns ------- QgsVectorLayer Leerer Memory-Layer vom Typ Point. """ layer = QgsVectorLayer( f"Point?crs={crs_authid}", ERGEBNIS_LAYER_NAME, "memory" ) pr = layer.dataProvider() pr.addAttributes([QgsField(FELD_TYP, QVariant.String)]) layer.updateFields() return layer def _setze_kategorien_symbologie(layer) -> None: """Wendet Kategoriesymbologie an: grün/rot/orange Punkte. - ``identisch`` → grün - ``kein_pta`` → rot - ``fehlender_knick`` → orange Wird bei fehlendem QGIS-Kontext still ignoriert. """ try: from qgis.core import ( # type: ignore[import] QgsCategorizedSymbolRenderer, QgsRendererCategory, QgsMarkerSymbol, ) def _marker(color: str, size: str = "2") -> object: return QgsMarkerSymbol.createSimple( {"color": color, "size": size, "outline_style": "no"} ) categories = [ QgsRendererCategory( WERT_IDENTISCH, _marker("0,180,0,255"), "identisch (Knickpunkt mit PunktortTA)", ), QgsRendererCategory( WERT_KEIN_PTA, _marker("220,0,0,255"), "kein PunktortTA an Knickpunkt", ), QgsRendererCategory( WERT_FEHLENDER_KNICK, _marker("255,128,0,255"), "fehlender Knickpunkt (PunktortTA ohne BROR-Knickpunkt)", ), ] renderer = QgsCategorizedSymbolRenderer(FELD_TYP, categories) layer.setRenderer(renderer) except Exception: pass # Kein QGIS-Kontext (Tests) → Standarddarstellung behalten def _baue_pta_index(punktort_layer) -> Tuple[object, Dict[int, object]]: """Baut einen QgsSpatialIndex und eine ID→Geometrie-Map für PunktortTA. Parameters ---------- punktort_layer : Geladener ``AX_PunktortTA``-Layer. Returns ------- Tuple[QgsSpatialIndex, Dict[int, QgsGeometry]] Index und Feature-ID-zu-Geometrie-Dict. """ from qgis.core import QgsSpatialIndex # type: ignore[import] index = QgsSpatialIndex() geom_map: Dict[int, object] = {} for feat in punktort_layer.getFeatures(): geom = feat.geometry() if geom and not geom.isEmpty(): index.insertFeature(feat) geom_map[feat.id()] = geom return index, geom_map def _baue_flst_vertex_set( flst_layer, bror_buffer_geom, ) -> Set[Tuple[float, float]]: """Erstellt ein Set gerundeter (x, y)-Koordinaten aller Flurstück-Vertices innerhalb des gepufferten BROR-Umrings. Parameters ---------- flst_layer : Flurstücke-Layer. bror_buffer_geom : Gepufferter BROR-Umring (Polygon-Geometrie). Returns ------- Set[Tuple[float, float]] Gerundete Vertex-Koordinaten. """ vertex_set: Set[Tuple[float, float]] = set() if flst_layer is None: return vertex_set try: for feat in flst_layer.getFeatures(): geom = feat.geometry() if geom is None or geom.isEmpty(): continue try: # Nur Flurstücke berücksichtigen, die den Puffer berühren if not geom.intersects(bror_buffer_geom): continue boundary = geom.convertToType(1) # 1 = Line if boundary is None or boundary.isEmpty(): continue if boundary.isMultipart(): parts = boundary.asMultiPolyline() else: parts = [boundary.asPolyline()] for line in parts: for pt in line: vertex_set.add( (round(pt.x(), _VERTEX_ROUND), round(pt.y(), _VERTEX_ROUND)) ) except Exception: continue except Exception: pass return vertex_set # --------------------------------------------------------------------------- # Öffentliche API # --------------------------------------------------------------------------- def knickpunkt_pruefung( bror_layer, punktort_layer, flst_layer, progress=None, knickpunkte: Optional[List] = None, ) -> Tuple[Optional[object], int, int, int]: """Knickpunktbasierte BROR-Prüfung. Vergleicht BROR-Knickpunkte mit ``AX_PunktortTA``-Objekten und ermittelt fehlende Knickpunkte anhand von Flurstücksgrenzen. Parameters ---------- bror_layer : BauRaumOderBodenordnungsrecht-Polygon-Layer. punktort_layer : Geladener ``AX_PunktortTA``-Layer (Ergebnis aus ``grenzpunkt_alkis``). flst_layer : Flurstücke-Layer für die Orange-Prüfung. Kann ``None`` sein. progress : Optionaler ``ProgressDialog`` für Fortschrittsanzeige. knickpunkte : Vorberechnete Knickpunktliste (optional). Wenn ``None``, werden die Knickpunkte aus ``bror_layer`` extrahiert. Returns ------- Tuple[Optional[QgsVectorLayer], int, int, int] ``(result_layer, identisch_count, kein_pta_count, fehlender_knick_count)`` - ``result_layer``: Punkt-Layer mit grünen/roten/orangen Punkten, oder ``None`` bei Abbruch oder fehlgeschlagener Verarbeitung. - ``identisch_count``: Knickpunkte mit passendem PunktortTA (grün). - ``kein_pta_count``: Knickpunkte ohne passenden PunktortTA (rot). - ``fehlender_knick_count``: PunktortTA ohne BROR-Knickpunkt (orange). """ def _canceled() -> bool: return progress is not None and progress.is_canceled() def _schritt(label: str, wert: int) -> None: if progress is not None: progress.set_label(label) progress.set_value(wert) # ------------------------------------------------------------------ # Schritt 0: Knickpunkte ermitteln # ------------------------------------------------------------------ _schritt("Knickpunktprüfung: Knickpunkte extrahieren …", 0) if knickpunkte is None: knickpunkte = _extrahiere_knickpunkte(bror_layer) if not knickpunkte: return None, 0, 0, 0 n = len(knickpunkte) crs_authid = bror_layer.crs().authid() if _canceled(): return None, 0, 0, 0 # ------------------------------------------------------------------ # Schritt 1: Spatial-Index für PunktortTA aufbauen # ------------------------------------------------------------------ _schritt("Knickpunktprüfung: PunktortTA-Index aufbauen …", 1) if punktort_layer is None: return None, 0, 0, 0 try: pta_index, geom_map = _baue_pta_index(punktort_layer) except Exception: return None, 0, 0, 0 if _canceled(): return None, 0, 0, 0 # ------------------------------------------------------------------ # Schritte 2…n+1: Jeden Knickpunkt mit PunktortTA vergleichen # ------------------------------------------------------------------ try: from qgis.core import QgsRectangle # type: ignore[import] except ImportError: return None, 0, 0, 0 ergebnis_layer = _erstelle_ergebnis_layer(crs_authid) pr = ergebnis_layer.dataProvider() feats_to_add: List[object] = [] identisch_count = 0 kein_pta_count = 0 matched_pta_ids: Set[int] = set() for i, knick_pt in enumerate(knickpunkte): if _canceled(): return None, 0, 0, 0 _schritt(f"Knickpunktprüfung: Knickpunkt {i + 1}/{n} prüfen …", i + 2) x, y = knick_pt.x(), knick_pt.y() t = KOORDINATEN_TOLERANZ_M suchbox = QgsRectangle(x - t, y - t, x + t, y + t) kandidaten = pta_index.intersects(suchbox) gefunden = False for fid in kandidaten: pta_geom = geom_map.get(fid) if pta_geom is None: continue pta_pt = pta_geom.asPoint() dist = ((pta_pt.x() - x) ** 2 + (pta_pt.y() - y) ** 2) ** 0.5 if dist <= KOORDINATEN_TOLERANZ_M: gefunden = True matched_pta_ids.add(fid) break feat = QgsFeature() try: from qgis.core import QgsPointXY # type: ignore[import] feat.setGeometry(QgsGeometry.fromPointXY(QgsPointXY(x, y))) except Exception: continue feat.setAttributes([WERT_IDENTISCH if gefunden else WERT_KEIN_PTA]) feats_to_add.append(feat) if gefunden: identisch_count += 1 else: kein_pta_count += 1 if _canceled(): return None, 0, 0, 0 # ------------------------------------------------------------------ # Schritt n+2: Fehlende Knickpunkte (orange) ermitteln # ------------------------------------------------------------------ _schritt("Knickpunktprüfung: Fehlende Knickpunkte ermitteln …", n + 2) fehlender_knick_count = 0 # BROR-Umring ermitteln und puffern try: bror_feats = [ f for f in bror_layer.getFeatures() if f.geometry() and not f.geometry().isEmpty() ] bror_union = QgsGeometry.unaryUnion([f.geometry() for f in bror_feats]) bror_boundary = bror_union.convertToType(1) if bror_union else None bror_buffer_geom = ( bror_boundary.buffer(BOUNDARY_PUFFER_M, 8) if bror_boundary else None ) except Exception: bror_buffer_geom = None if bror_buffer_geom is not None and flst_layer is not None: flst_vertex_set = _baue_flst_vertex_set(flst_layer, bror_buffer_geom) for feat in punktort_layer.getFeatures(): if _canceled(): break if feat.id() in matched_pta_ids: continue geom = feat.geometry() if geom is None or geom.isEmpty(): continue try: if not geom.within(bror_buffer_geom): continue pta_pt = geom.asPoint() key = (round(pta_pt.x(), _VERTEX_ROUND), round(pta_pt.y(), _VERTEX_ROUND)) if key in flst_vertex_set: orange_feat = QgsFeature() orange_feat.setGeometry(geom) orange_feat.setAttributes([WERT_FEHLENDER_KNICK]) feats_to_add.append(orange_feat) fehlender_knick_count += 1 except Exception: continue if _canceled(): return None, 0, 0, 0 # ------------------------------------------------------------------ # Schritt n+3: Ergebnislayer fertigstellen # ------------------------------------------------------------------ _schritt("Knickpunktprüfung: Ergebnislayer erstellen …", n + 3) pr.addFeatures(feats_to_add) ergebnis_layer.updateExtents() _setze_kategorien_symbologie(ergebnis_layer) return ergebnis_layer, identisch_count, kein_pta_count, fehlender_knick_count