"""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 innerhalb des 3-cm-Puffers einer Verbindungslinie zwischen zwei aufeinanderfolgenden gematchten PunktortTA am BROR-Umring, 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. Entlang der BROR-Außengrenze aufeinanderfolgende gematchte PunktortTA ermitteln und für jedes konsekutive Paar eine gerade Verbindungslinie bilden; diese um :data:`VERBINDUNGSLINIE_PUFFER_M` puffern. 5. Nicht-gematchte PunktortTA innerhalb der Puffer → orange. Verbindungslinien- und Puffer-Layer werden dem QGIS-Projekt hinzugefügt. **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). #: Wird aus Kompatibilitätsgründen behalten, wird aber nicht mehr verwendet. BOUNDARY_PUFFER_M: float = 0.5 #: Puffer um die Verbindungslinie zwischen zwei aufeinanderfolgenden PunktortTA (m). VERBINDUNGSLINIE_PUFFER_M: float = 0.03 #: 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 _fehlende_knickpunkte_via_verbindungslinien( bror_union, pta_index, geom_map: Dict[int, object], matched_pta_ids: Set[int], punktort_layer, crs_authid: str, ) -> Tuple[int, List[object]]: """Ermittelt fehlende Knickpunkte via Verbindungslinien-Puffer. Bestimmt entlang der BROR-Außengrenze die Reihenfolge der gematchten ``AX_PunktortTA``. Für jedes konsekutive Paar wird eine gerade Verbindungslinie erzeugt und um :data:`VERBINDUNGSLINIE_PUFFER_M` gepuffert. Nicht-gematchte PunktortTA innerhalb dieses Puffers werden als fehlende Knickpunkte (orange) gewertet. Fügt dem QGIS-Projekt zwei Hilfs-Layer hinzu (falls ein Projekt-Kontext vorhanden ist): - *Knickpunktprüfung – Verbindungslinien* - *Knickpunktprüfung – Puffer* Parameters ---------- bror_union : Vereinigungsgeometrie des BROR-Polygons (``QgsGeometry``). pta_index : Spatial-Index der PunktortTA-Features (``QgsSpatialIndex``). geom_map : Feature-ID → ``QgsGeometry``-Mapping für PunktortTA. matched_pta_ids : IDs der bereits gematchten PunktortTA (identisch mit BROR-Knickpunkten). punktort_layer : Geladener ``AX_PunktortTA``-Layer (für zukünftige Erweiterungen reserviert; wird derzeit nicht direkt verwendet). crs_authid : CRS-Kennung des BROR-Layers, z.B. ``"EPSG:25833"``. Returns ------- Tuple[int, List[QgsFeature]] ``(fehlender_knick_count, orange_feats)`` – Anzahl gefundener fehlender Knickpunkte und Liste der zugehörigen ``QgsFeature``-Objekte. """ try: from qgis.core import QgsPointXY, QgsProject # type: ignore[import] except ImportError: return 0, [] if bror_union is None or bror_union.isEmpty(): return 0, [] # ------------------------------------------------------------------ # Außenringe extrahieren # ------------------------------------------------------------------ exterior_rings: List[List[object]] = [] if bror_union.isMultipart(): for poly in bror_union.asMultiPolygon(): if poly: exterior_rings.append([QgsPointXY(x, y) for x, y in poly[0]]) else: poly = bror_union.asPolygon() if poly: exterior_rings.append([QgsPointXY(x, y) for x, y in poly[0]]) # Schlusspunkt (= Startpunkt, Ring geschlossen) entfernen cleaned_rings: List[List[object]] = [] for ring in exterior_rings: if len(ring) >= 2: if ( abs(ring[-1].x() - ring[0].x()) < 1e-9 and abs(ring[-1].y() - ring[0].y()) < 1e-9 ): ring = ring[:-1] if len(ring) >= 2: cleaned_rings.append(ring) line_geoms: List[object] = [] buffer_geoms: List[object] = [] orange_ids: Set[int] = set() # ------------------------------------------------------------------ # Pro Ring: gematchte PunktortTA in Reihenfolge, Paare bilden # ------------------------------------------------------------------ for ring_pts in cleaned_rings: # Gematchte PunktortTA auf diesem Ring lokalisieren pta_on_ring: List[Tuple[int, int, object]] = [] # (ring_idx, fid, pt) for fid in matched_pta_ids: pta_geom = geom_map.get(fid) if pta_geom is None: continue pta_pt = pta_geom.asPoint() for i, ring_pt in enumerate(ring_pts): dist_sq = ( (ring_pt.x() - pta_pt.x()) ** 2 + (ring_pt.y() - pta_pt.y()) ** 2 ) if dist_sq <= KOORDINATEN_TOLERANZ_M ** 2: pta_on_ring.append((i, fid, ring_pt)) break if len(pta_on_ring) < 2: continue pta_on_ring.sort(key=lambda t: t[0]) num_pta = len(pta_on_ring) for k in range(num_pta): _, _, pt1 = pta_on_ring[k] _, _, pt2 = pta_on_ring[(k + 1) % num_pta] # Gerade Verbindungslinie zwischen den zwei PunktortTA line_geom = QgsGeometry.fromPolylineXY([pt1, pt2]) if line_geom is None or line_geom.isEmpty(): continue line_geoms.append(line_geom) buffer_geom = line_geom.buffer(VERBINDUNGSLINIE_PUFFER_M, 8) if buffer_geom is None or buffer_geom.isEmpty(): continue buffer_geoms.append(buffer_geom) # Nicht-gematchte PunktortTA im Puffer suchen (via Spatial-Index) bb = buffer_geom.boundingBox() kandidaten = pta_index.intersects(bb) for fid in kandidaten: if fid in matched_pta_ids or fid in orange_ids: continue candidate_geom = geom_map.get(fid) if candidate_geom is None: continue try: if candidate_geom.within(buffer_geom): orange_ids.add(fid) except Exception: continue # ------------------------------------------------------------------ # Orange Features erstellen # ------------------------------------------------------------------ orange_feats: List[object] = [] for fid in orange_ids: pta_geom = geom_map.get(fid) if pta_geom is None: continue feat = QgsFeature() feat.setGeometry(pta_geom) feat.setAttributes([WERT_FEHLENDER_KNICK]) orange_feats.append(feat) # ------------------------------------------------------------------ # Hilfs-Layer erstellen und zum Projekt hinzufügen # ------------------------------------------------------------------ try: lines_layer = QgsVectorLayer( f"LineString?crs={crs_authid}", "Knickpunktprüfung – Verbindungslinien", "memory", ) lines_pr = lines_layer.dataProvider() line_feats_list = [] for lg in line_geoms: f = QgsFeature() f.setGeometry(lg) line_feats_list.append(f) lines_pr.addFeatures(line_feats_list) lines_layer.updateExtents() buffer_layer = QgsVectorLayer( f"Polygon?crs={crs_authid}", "Knickpunktprüfung – Puffer", "memory", ) buffer_pr = buffer_layer.dataProvider() buffer_feats_list = [] for bg in buffer_geoms: f = QgsFeature() f.setGeometry(bg) buffer_feats_list.append(f) buffer_pr.addFeatures(buffer_feats_list) buffer_layer.updateExtents() project = QgsProject.instance() if project is not None: project.addMapLayer(lines_layer) project.addMapLayer(buffer_layer) except Exception: pass # Kein QGIS-Kontext (Tests) → Layer-Erstellung überspringen return len(orange_feats), orange_feats # --------------------------------------------------------------------------- # Ö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 : Nicht mehr verwendet. Wird aus Kompatibilitätsgründen weiterhin akzeptiert. 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) via Verbindungslinien # ------------------------------------------------------------------ _schritt("Knickpunktprüfung: Fehlende Knickpunkte ermitteln …", n + 2) fehlender_knick_count = 0 try: bror_feats_union = [ f for f in bror_layer.getFeatures() if f.geometry() and not f.geometry().isEmpty() ] bror_union_geom = QgsGeometry.unaryUnion( [f.geometry() for f in bror_feats_union] ) except Exception: bror_union_geom = None if not _canceled(): fehlender_knick_count, orange_feats = _fehlende_knickpunkte_via_verbindungslinien( bror_union_geom, pta_index, geom_map, matched_pta_ids, punktort_layer, crs_authid, ) feats_to_add.extend(orange_feats) 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