diff --git a/assets/atlasobjekte.qml b/assets/atlasobjekte.qml
new file mode 100644
index 0000000..952ddd5
--- /dev/null
+++ b/assets/atlasobjekte.qml
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ui/dockwidget.py b/ui/dockwidget.py
index c004891..718be90 100644
--- a/ui/dockwidget.py
+++ b/ui/dockwidget.py
@@ -1,14 +1,14 @@
#sn_plan41/ui/dockwidget.py
from sn_basis.ui.tabs.settings_tab import SettingsTab
from sn_plan41.ui.tab_a_ui import TabA
-#from sn_plan41.ui.tabs.tab_b import TabB
+from sn_plan41.ui.tab_b_ui import TabB
from sn_basis.ui.base_dockwidget import BaseDockWidget
from sn_basis.functions.qt_wrapper import QTabWidget
from sn_basis.functions.message_wrapper import error
class DockWidget(BaseDockWidget):
- tabs = [TabA, SettingsTab]
+ tabs = [TabA, TabB,SettingsTab]
def __init__(self, parent=None, subtitle="", pruefmanager=None, data_grabber=None):
super().__init__(parent, subtitle)
diff --git a/ui/layout.py b/ui/layout.py
new file mode 100644
index 0000000..4aa52c8
--- /dev/null
+++ b/ui/layout.py
@@ -0,0 +1,431 @@
+"""
+sn_plan41/ui/layout.py – Aufbau von Drucklayouts für Plan41.
+"""
+from __future__ import annotations
+
+import math
+from typing import Any
+
+from sn_basis.functions.qt_wrapper import QFont
+from sn_basis.functions.qgiscore_wrapper import (
+ QgsLayoutItem,
+ QgsLayoutItemLabel,
+ QgsLayoutItemMap,
+ QgsLayoutPoint,
+ QgsLayoutSize,
+ QgsPrintLayout,
+ QgsProject,
+ QgsUnitTypes,
+)
+from sn_basis.functions.qgisui_wrapper import open_layout_designer
+
+
+MM = QgsUnitTypes.LayoutMillimeters
+
+
+class Layout:
+ """Erzeugt ein QGIS-Layout für den Druck."""
+
+ def __init__(self, project: Any | None = None) -> None:
+ self.project = project or QgsProject.instance()
+
+ def create_single_page_layout(
+ self,
+ name: str,
+ page_width_mm: float,
+ page_height_mm: float,
+ map_width_mm: float,
+ map_height_mm: float,
+ extent: Any,
+ plotmassstab: float,
+ thema: str = "",
+ ) -> Any:
+ """Erzeugt ein einseitiges Layout und öffnet es im Designer."""
+ print(f"[Layout] create_single_page_layout: name='{name}', "
+ f"page=({page_width_mm}x{page_height_mm}), map=({map_width_mm:.1f}x{map_height_mm:.1f}), "
+ f"massstab={plotmassstab}, thema='{thema}'")
+ layout_manager = self.project.layoutManager()
+ print(f"[Layout] layoutManager: {layout_manager!r}")
+ existing_layout = layout_manager.layoutByName(name)
+ if existing_layout is not None:
+ raise ValueError(f"Eine Vorlage mit der Bezeichnung '{name}' existiert bereits.")
+
+ layout = QgsPrintLayout(self.project)
+ layout.initializeDefaults()
+ layout.setName(name)
+ print(f"[Layout] QgsPrintLayout erstellt: {layout!r}")
+
+ page = layout.pageCollection().page(0)
+ page.setPageSize(QgsLayoutSize(page_width_mm, page_height_mm, MM))
+ print(f"[Layout] Seitengröße gesetzt: {page_width_mm}x{page_height_mm} mm")
+
+ map_left_mm = 10.0
+ map_top_mm = 10.0
+ map_right_mm = map_left_mm + map_width_mm
+ map_bottom_mm = map_top_mm + map_height_mm
+ print(
+ f"[Layout] Kartenbild-Kanten: rechts={map_right_mm:.1f} mm, unten={map_bottom_mm:.1f} mm"
+ )
+
+ hauptkarte = QgsLayoutItemMap(layout)
+ hauptkarte.setId("Hauptkarte")
+ print(f"[Layout] QgsLayoutItemMap erstellt")
+
+ # Zum Layout hinzufügen, BEVOR Eigenschaften gesetzt werden
+ layout.addLayoutItem(hauptkarte)
+ print("[Layout] Hauptkarte zum Layout hinzugefügt")
+
+ # Position und Größe setzen
+ hauptkarte.attemptMove(QgsLayoutPoint(map_left_mm, map_top_mm, MM))
+ hauptkarte.attemptResize(QgsLayoutSize(map_width_mm, map_height_mm, MM))
+ print(f"[Layout] Position und Größe gesetzt: pos=({map_left_mm}, {map_top_mm}), size=({map_width_mm:.1f}x{map_height_mm:.1f})")
+
+ # Extent und Maßstab setzen
+ x_min = getattr(extent, "xMinimum", lambda: float("nan"))()
+ y_min = getattr(extent, "yMinimum", lambda: float("nan"))()
+ x_max = getattr(extent, "xMaximum", lambda: float("nan"))()
+ y_max = getattr(extent, "yMaximum", lambda: float("nan"))()
+ print(f"[Layout] Extent input: xmin={x_min}, ymin={y_min}, xmax={x_max}, ymax={y_max}")
+
+ if extent is not None and hasattr(extent, "isNull") and callable(extent.isNull) and not extent.isNull():
+ try:
+ hauptkarte.setExtent(extent)
+ print(f"[Layout] setExtent() erfolgreich")
+ except Exception as exc:
+ print(f"[Layout] WARN: setExtent() fehlgeschlagen: {exc}")
+ else:
+ print(f"[Layout] WARN: Extent nicht gültig/callable, setExtent übersprungen")
+
+ # Maßstab setzen (NACH Extent)
+ if isinstance(plotmassstab, (int, float)) and math.isfinite(plotmassstab) and plotmassstab > 0:
+ try:
+ hauptkarte.setScale(plotmassstab)
+ print(f"[Layout] setScale({plotmassstab}) erfolgreich")
+ except Exception as exc:
+ print(f"[Layout] WARN: setScale({plotmassstab}) fehlgeschlagen: {exc}")
+ else:
+ print(f"[Layout] WARN: ungültiger plotmassstab={plotmassstab!r}, setScale übersprungen")
+
+ # Rahmen aktivieren
+ set_frame_enabled = getattr(hauptkarte, "setFrameEnabled", None)
+ if callable(set_frame_enabled):
+ try:
+ set_frame_enabled(True)
+ print(f"[Layout] Rahmen aktiviert")
+ except Exception as exc:
+ print(f"[Layout] WARN: setFrameEnabled fehlgeschlagen: {exc}")
+
+ set_frame_stroke_width = getattr(hauptkarte, "setFrameStrokeWidth", None)
+ if callable(set_frame_stroke_width):
+ try:
+ set_frame_stroke_width(0.5)
+ print(f"[Layout] Rahmenstrichbreite auf 0.5 mm gesetzt")
+ except Exception as exc:
+ print(f"[Layout] WARN: setFrameStrokeWidth(0.5) fehlgeschlagen: {exc}")
+
+ # Kartenthema setzen
+ if thema and thema != "aktuell":
+ follow_theme = getattr(hauptkarte, "setFollowVisibilityPreset", None)
+ set_theme_name = getattr(hauptkarte, "setFollowVisibilityPresetName", None)
+ if callable(follow_theme):
+ try:
+ follow_theme(True)
+ print(f"[Layout] setFollowVisibilityPreset(True)")
+ except Exception as exc:
+ print(f"[Layout] WARN: setFollowVisibilityPreset fehlgeschlagen: {exc}")
+ if callable(set_theme_name):
+ try:
+ set_theme_name(thema)
+ print(f"[Layout] Kartenthema auf '{thema}' gesetzt")
+ except Exception as exc:
+ print(f"[Layout] WARN: setFollowVisibilityPresetName('{thema}') fehlgeschlagen: {exc}")
+ else:
+ print(f"[Layout] Kartenthema nicht gesetzt (thema='{thema}')")
+
+ # Erst nach allen Properties: LayerSet einfrieren für Export
+ set_keep_layer_set = getattr(hauptkarte, "setKeepLayerSet", None)
+ if callable(set_keep_layer_set):
+ try:
+ set_keep_layer_set(True)
+ print("[Layout] setKeepLayerSet(True) – Layerset für Export erhalten")
+ except Exception as exc:
+ print(f"[Layout] WARN: setKeepLayerSet fehlgeschlagen: {exc}")
+
+ # Refresh-Strategie (optional, nur wenn vorhanden)
+ set_refresh_strategy = getattr(hauptkarte, "setRefreshStrategy", None)
+ if callable(set_refresh_strategy):
+ refresh_cache = getattr(hauptkarte, "RefreshLaterOnly", None) or getattr(hauptkarte, "RefreshWhenRequested", None) or 0
+ if refresh_cache is not None:
+ try:
+ set_refresh_strategy(refresh_cache)
+ print(f"[Layout] setRefreshStrategy({refresh_cache}) gesetzt")
+ except Exception as exc:
+ print(f"[Layout] WARN: setRefreshStrategy fehlgeschlagen: {exc}")
+
+ print(f"[Layout] Hauptkarte vollständig konfiguriert")
+
+ quellenangabe = QgsLayoutItemLabel(layout)
+ quellenangabe.setId("Quellenangabe")
+ quellenangabe.setText(
+ "Quelle Geobasisdaten: GeoSN, "
+ "dl-de/by-2-0 "
+ "Quelle Fachdaten: Darstellung auf der Grundlage von Daten und mit Erlaubnis des "
+ "Sächsischen Landesamtes für Umwelt, Landwirtschaft und Geologie "
+ "Basemap: "
+ "© GeoBasis-DE / BKG ([%year($now)%]) "
+ "CC BY 4.0 "
+ "mit teilweise angepasster Signatur "
+ )
+ set_mode = getattr(quellenangabe, "setMode", None)
+ mode_html = getattr(QgsLayoutItemLabel, "ModeHtml", None)
+ print(f"[Layout] QgsLayoutItemLabel.ModeHtml={mode_html!r}")
+ if callable(set_mode) and mode_html is not None:
+ set_mode(mode_html)
+ quellenangabe.setFont(QFont("Arial", 12))
+ set_reference_point = getattr(quellenangabe, "setReferencePoint", None)
+ lower_left = getattr(getattr(QgsLayoutItem, "ReferencePoint", object), "LowerLeft", None)
+ print(f"[Layout] QgsLayoutItem.ReferencePoint.LowerLeft={lower_left!r}")
+ if callable(set_reference_point) and lower_left is not None:
+ set_reference_point(lower_left)
+ quellenangabe_x_mm = map_right_mm + 20.0
+ quellenangabe_y_mm = map_bottom_mm - 120.0
+ quellenangabe.attemptMove(QgsLayoutPoint(quellenangabe_x_mm, quellenangabe_y_mm, MM))
+ quellenangabe.attemptResize(QgsLayoutSize(180.0, 100.0, MM))
+ layout.addLayoutItem(quellenangabe)
+ print("[Layout] Quellenangabe zum Layout hinzugefügt")
+
+ layout_manager.addLayout(layout)
+ print("[Layout] Layout zum LayoutManager hinzugefügt")
+ open_layout_designer(layout)
+ print("[Layout] Layout Designer geöffnet")
+ return layout
+
+ def create_atlas_layout(
+ self,
+ name: str,
+ page_width_mm: float,
+ page_height_mm: float,
+ map_width_mm: float,
+ map_height_mm: float,
+ extent: Any,
+ plotmassstab: float,
+ atlas_layer: Any,
+ thema: str = "",
+ ) -> Any:
+ """Erzeugt ein Atlas-Layout mit Coverage-Layer ``Atlasobjekte``."""
+ layout_manager = self.project.layoutManager()
+ existing_layout = layout_manager.layoutByName(name)
+ if existing_layout is not None:
+ raise ValueError(f"Eine Vorlage mit der Bezeichnung '{name}' existiert bereits.")
+
+ layout = QgsPrintLayout(self.project)
+ layout.initializeDefaults()
+ layout.setName(name)
+
+ page = layout.pageCollection().page(0)
+ page.setPageSize(QgsLayoutSize(page_width_mm, page_height_mm, MM))
+ # Verifiziere, dass QGIS die Größe akzeptiert hat
+ page_size = page.pageSize() if hasattr(page, 'pageSize') else None
+ if page_size is not None and hasattr(page_size, 'width'):
+ actual_w = float(page_size.width())
+ actual_h = float(page_size.height())
+ print(f"[Layout] Atlas Page gesetzt: x=0, y=0, width={page_width_mm:.1f}mm→{actual_w:.1f}mm, height={page_height_mm:.1f}mm→{actual_h:.1f}mm")
+ else:
+ print(f"[Layout] Atlas Page: x=0, y=0, width={page_width_mm:.1f}mm, height={page_height_mm:.1f}mm")
+
+ map_left_mm = 10.0
+ map_top_mm = 10.0
+ map_right_mm = map_left_mm + map_width_mm
+ map_bottom_mm = map_top_mm + map_height_mm
+
+ hauptkarte = QgsLayoutItemMap(layout)
+ hauptkarte.setId("Hauptkarte")
+ layout.addLayoutItem(hauptkarte)
+ hauptkarte.attemptMove(QgsLayoutPoint(map_left_mm, map_top_mm, MM))
+ hauptkarte.attemptResize(QgsLayoutSize(map_width_mm, map_height_mm, MM))
+
+ # Verifiziere mit Units-bewussten Methoden (rect() kann andere Units verwenden).
+ actual_w = None
+ actual_h = None
+ actual_x = None
+ actual_y = None
+
+ # Versuche zuerst, die Größe mit Unit-Methoden zu lesen
+ try:
+ if hasattr(hauptkarte, 'sizeWithUnits'):
+ size_item = hauptkarte.sizeWithUnits()
+ if hasattr(size_item, 'width') and hasattr(size_item, 'height'):
+ actual_w = float(size_item.width())
+ actual_h = float(size_item.height())
+ except Exception:
+ pass
+
+ try:
+ if hasattr(hauptkarte, 'positionWithUnits'):
+ pos_item = hauptkarte.positionWithUnits()
+ if hasattr(pos_item, 'x') and hasattr(pos_item, 'y'):
+ actual_x = float(pos_item.x())
+ actual_y = float(pos_item.y())
+ except Exception:
+ pass
+
+ # Fallback: nutze rect() und teile durch UnitFaktor, falls nötig
+ if actual_w is None or actual_h is None:
+ try:
+ actual_rect = hauptkarte.rect()
+ if actual_rect is not None:
+ actual_w = float(actual_rect.width())
+ actual_h = float(actual_rect.height())
+ actual_x = float(actual_rect.x())
+ actual_y = float(actual_rect.y())
+ except Exception:
+ pass
+
+ if actual_w is not None and actual_h is not None:
+ print(f"[Layout] Atlas Hauptkarte gesetzt: x={map_left_mm:.1f}mm→{actual_x:.1f}mm, y={map_top_mm:.1f}mm→{actual_y:.1f}mm, width={map_width_mm:.1f}mm→{actual_w:.1f}mm, height={map_height_mm:.1f}mm→{actual_h:.1f}mm")
+ else:
+ print(f"[Layout] Atlas Hauptkarte: x={map_left_mm:.1f}mm, y={map_top_mm:.1f}mm, width={map_width_mm:.1f}mm, height={map_height_mm:.1f}mm")
+
+ if extent is not None and hasattr(extent, "isNull") and callable(extent.isNull) and not extent.isNull():
+ try:
+ hauptkarte.setExtent(extent)
+ except Exception:
+ pass
+
+ if isinstance(plotmassstab, (int, float)) and math.isfinite(plotmassstab) and plotmassstab > 0:
+ try:
+ hauptkarte.setScale(plotmassstab)
+ except Exception:
+ pass
+
+ set_frame_enabled = getattr(hauptkarte, "setFrameEnabled", None)
+ if callable(set_frame_enabled):
+ try:
+ set_frame_enabled(True)
+ except Exception:
+ pass
+
+ set_frame_stroke_width = getattr(hauptkarte, "setFrameStrokeWidth", None)
+ if callable(set_frame_stroke_width):
+ try:
+ set_frame_stroke_width(0.5)
+ except Exception:
+ pass
+
+ if thema and thema != "aktuell":
+ follow_theme = getattr(hauptkarte, "setFollowVisibilityPreset", None)
+ set_theme_name = getattr(hauptkarte, "setFollowVisibilityPresetName", None)
+ if callable(follow_theme):
+ try:
+ follow_theme(True)
+ except Exception:
+ pass
+ if callable(set_theme_name):
+ try:
+ set_theme_name(thema)
+ except Exception:
+ pass
+
+ set_atlas_driven = getattr(hauptkarte, "setAtlasDriven", None)
+ if callable(set_atlas_driven):
+ try:
+ set_atlas_driven(True)
+ except Exception:
+ pass
+
+ # Fester Atlas-Maßstab: plotmassstab bleibt unverändert.
+ set_scaling_mode = getattr(hauptkarte, "setAtlasScalingMode", None)
+ if callable(set_scaling_mode):
+ fixed_mode = getattr(QgsLayoutItemMap, "Fixed", None)
+ if fixed_mode is not None:
+ try:
+ set_scaling_mode(fixed_mode)
+ except Exception:
+ pass
+
+ set_atlas_margin = getattr(hauptkarte, "setAtlasMargin", None)
+ if callable(set_atlas_margin):
+ try:
+ set_atlas_margin(0.0)
+ except Exception:
+ pass
+
+ set_keep_layer_set = getattr(hauptkarte, "setKeepLayerSet", None)
+ if callable(set_keep_layer_set):
+ try:
+ set_keep_layer_set(True)
+ except Exception:
+ pass
+
+ # Sicherheit: Größe nochmal nach Atlas-Konfiguration setzen, um sicherzustellen, dass sie nicht von der Atlas-Einstellung überschrieben wurde.
+ hauptkarte.attemptResize(QgsLayoutSize(map_width_mm, map_height_mm, MM))
+ print(f"[Layout] Atlas Hauptkarte Größe nach Atlas-Konfiguration erneut gesetzt: {map_width_mm:.1f}mm × {map_height_mm:.1f}mm")
+
+ quellenangabe = QgsLayoutItemLabel(layout)
+ quellenangabe.setId("Quellenangabe")
+ quellenangabe.setText(
+ "Quelle Geobasisdaten: GeoSN, "
+ "dl-de/by-2-0 "
+ "Quelle Fachdaten: Darstellung auf der Grundlage von Daten und mit Erlaubnis des "
+ "Sächsischen Landesamtes für Umwelt, Landwirtschaft und Geologie "
+ "Basemap: "
+ "© GeoBasis-DE / BKG ([%year($now)%]) "
+ "CC BY 4.0 "
+ "mit teilweise angepasster Signatur "
+ )
+ set_mode = getattr(quellenangabe, "setMode", None)
+ mode_html = getattr(QgsLayoutItemLabel, "ModeHtml", None)
+ if callable(set_mode) and mode_html is not None:
+ set_mode(mode_html)
+ quellenangabe.setFont(QFont("Arial", 12))
+ set_reference_point = getattr(quellenangabe, "setReferencePoint", None)
+ lower_left = getattr(getattr(QgsLayoutItem, "ReferencePoint", object), "LowerLeft", None)
+ if callable(set_reference_point) and lower_left is not None:
+ set_reference_point(lower_left)
+ quellenangabe.attemptMove(QgsLayoutPoint(map_right_mm + 5.0, map_bottom_mm, MM))
+ quellenangabe.attemptResize(QgsLayoutSize(180.0, 100.0, MM))
+ layout.addLayoutItem(quellenangabe)
+
+ seitenzahl_label = QgsLayoutItemLabel(layout)
+ seitenzahl_label.setId("Seitenzahl")
+ seitenzahl_label.setFont(QFont("Arial", 12))
+ set_expr_enabled = getattr(seitenzahl_label, "setExpressionEnabled", None)
+ if callable(set_expr_enabled):
+ try:
+ set_expr_enabled(True)
+ except Exception:
+ pass
+ # Ausdrucksauswertung robust über [% ... %]-Platzhalter.
+ seitenzahl_label.setText(
+ "Seite [% attribute(@atlas_feature, 'Seitenzahl') %] von [% @atlas_totalfeatures %]"
+ )
+ set_reference_point = getattr(seitenzahl_label, "setReferencePoint", None)
+ lower_left = getattr(getattr(QgsLayoutItem, "ReferencePoint", object), "LowerLeft", None)
+ if callable(set_reference_point) and lower_left is not None:
+ set_reference_point(lower_left)
+ seitenzahl_label.attemptMove(QgsLayoutPoint(map_right_mm + 5.0, map_bottom_mm - 2.0, MM))
+ seitenzahl_label.attemptResize(QgsLayoutSize(60.0, 8.0, MM))
+ layout.addLayoutItem(seitenzahl_label)
+
+ atlas = layout.atlas()
+ if atlas is not None:
+ set_enabled = getattr(atlas, "setEnabled", None)
+ set_coverage = getattr(atlas, "setCoverageLayer", None)
+ set_hide_coverage = getattr(atlas, "setHideCoverage", None)
+ set_filter_features = getattr(atlas, "setFilterFeatures", None)
+ set_page_name = getattr(atlas, "setPageNameExpression", None)
+
+ if callable(set_enabled):
+ set_enabled(True)
+ if callable(set_coverage):
+ set_coverage(atlas_layer)
+ if callable(set_hide_coverage):
+ set_hide_coverage(True)
+ if callable(set_filter_features):
+ set_filter_features(False)
+ if callable(set_page_name):
+ set_page_name("attribute(@atlas_feature, 'Seitenzahl')")
+
+ layout_manager.addLayout(layout)
+ open_layout_designer(layout)
+ return layout
diff --git a/ui/tab_b_logic.py b/ui/tab_b_logic.py
new file mode 100644
index 0000000..a1d1b35
--- /dev/null
+++ b/ui/tab_b_logic.py
@@ -0,0 +1,680 @@
+"""
+sn_plan41/ui/tab_b_logic.py – Fachlogik für Tab B (Druck)
+"""
+from __future__ import annotations
+import math
+from typing import Any
+
+from sn_basis.functions.qt_wrapper import QVariant
+from sn_basis.functions.variable_wrapper import set_variable, get_variable
+from sn_basis.functions.qgiscore_wrapper import get_layer_extent
+from sn_basis.functions.qgiscore_wrapper import (
+ QgsProject,
+ QgsVectorLayer,
+ QgsGeometry,
+ QgsFeature,
+ QgsField,
+ QgsVectorFileWriter,
+)
+from sn_basis.functions.sys_wrapper import get_plugin_root, join_path, file_exists
+from sn_basis.modules.Pruefmanager import Pruefmanager
+from sn_basis.modules.layerpruefer import Layerpruefer
+from sn_plan41.ui.layout import Layout
+
+
+KARTENNAME_VAR = "sn_kartenname"
+PLOTMASSSTAB_VAR = "sn_plotmassstab"
+VIEW_VAR = "sn_view"
+ZIELGROESSE_VAR = "sn_zielgroesse"
+FORMFAKTOR_VAR = "sn_formfaktor"
+KARTENNAME_38 = "§38"
+KARTENNAME_41 = "§41"
+MASSSTAB_WIE_KARTENFENSTER = "Wie Kartenfenster"
+THEMA_WIE_KARTENFENSTER = "wie kartenfenster"
+
+KARTENNAME_BY_AUSWAHL = {
+ KARTENNAME_38: "Planungsübersicht §38 FlurbG",
+ KARTENNAME_41: "Karte zum Plan über die gemeinschaftlichen und öffentlichen Anlagen (§ 41 FlurbG)",
+}
+
+PLOTMASSSTAB_BY_AUSWAHL = {
+ "1:5.000": "5000",
+ "1:10.000": "10000",
+ "1:15.000": "15000",
+ "1:20.000": "20000",
+ "1:25.000": "25000",
+ "1:50.000": "50000",
+ "1:100.000": "100000",
+}
+
+# Breite x Höhe in mm (Hochformat, DIN-Standard)
+DIN_GROESSEN: dict[str, tuple[int, int]] = {
+ "DIN A0": (841, 1189),
+ "DIN A1": (594, 841),
+ "DIN A2": (420, 594),
+ "DIN A3": (297, 420),
+ "DIN A4": (210, 297),
+}
+DIN_STANDARD = "DIN A0"
+
+class TabBLogic:
+ """
+ Kapselt die Fachlogik von Tab B.
+ """
+
+ def __init__(self, pruefmanager: Pruefmanager) -> None:
+ self.pruefmanager = pruefmanager
+
+ @staticmethod
+ def _wkt_rect(x_min: float, y_min: float, x_max: float, y_max: float) -> str:
+ return (
+ f"POLYGON(({x_min} {y_min}, {x_max} {y_min}, {x_max} {y_max}, "
+ f"{x_min} {y_max}, {x_min} {y_min}))"
+ )
+
+ @staticmethod
+ def _set_topological_editing_enabled() -> None:
+ project = QgsProject.instance()
+ set_topological = getattr(project, "setTopologicalEditing", None)
+ if callable(set_topological):
+ try:
+ set_topological(True)
+ except Exception:
+ pass
+
+ @staticmethod
+ def _apply_atlas_style(layer: Any) -> None:
+ style_path = join_path(get_plugin_root(), "sn_plan41", "assets", "atlasobjekte.qml")
+ if not file_exists(style_path):
+ return
+ try:
+ ok, _ = layer.loadNamedStyle(str(style_path))
+ if ok:
+ getattr(layer, "triggerRepaint", lambda: None)()
+ except Exception:
+ pass
+
+ @staticmethod
+ def _find_tile_grid_for_roll_atlas(
+ kartenbild_w_mm: float,
+ kartenbild_h_mm: float,
+ din_dims: tuple[int, int],
+ respect_max_sheet_size: bool = False,
+ ) -> tuple[int, int, float, float]:
+ """Bestimmt ein Atlas-Raster für Endlosrolle, das Kacheln statt Streifen erzeugt.
+
+ Ziel: Kachel-Seitenverhältnis möglichst nah am Einzelblatt-Kartenfenster
+ (inkl. Rändern) bei zugleich moderater Seitenanzahl.
+ """
+ if kartenbild_w_mm <= 0 or kartenbild_h_mm <= 0:
+ return 1, 1, max(1.0, kartenbild_w_mm), max(1.0, kartenbild_h_mm)
+
+ # Einzelblatt-Kartenfenster für beide Orientierungen
+ dim_w, dim_h = float(din_dims[0]), float(din_dims[1])
+ orientation_candidates: list[tuple[float, float]] = [
+ (dim_w - 210.0, dim_h - 20.0),
+ (dim_h - 210.0, dim_w - 20.0),
+ ]
+
+ best_score = math.inf
+ best_result: tuple[int, int, float, float] | None = None
+
+ for target_w, target_h in orientation_candidates:
+ if target_w <= 0 or target_h <= 0:
+ continue
+
+ target_aspect = target_w / target_h
+ px0 = max(1, int(round(kartenbild_w_mm / target_w)))
+ py0 = max(1, int(round(kartenbild_h_mm / target_h)))
+
+ for pages_x in range(max(1, px0 - 3), px0 + 4):
+ for pages_y in range(max(1, py0 - 3), py0 + 4):
+ tile_w = kartenbild_w_mm / pages_x
+ tile_h = kartenbild_h_mm / pages_y
+ if tile_w <= 0 or tile_h <= 0:
+ continue
+
+ # Im Blatt-Modus darf die resultierende Atlasseite die
+ # gewählte Zielgröße (inkl. Orientierung) nicht überschreiten.
+ if respect_max_sheet_size and (tile_w > target_w or tile_h > target_h):
+ continue
+
+ tile_aspect = tile_w / tile_h
+ # 0 bei perfekter Übereinstimmung; symmetrisch für >1/<1
+ aspect_error = abs(math.log(tile_aspect / target_aspect))
+
+ # Streifen bestrafen
+ strip_penalty = 0.0
+ if tile_aspect < 0.5:
+ strip_penalty = abs(math.log(tile_aspect / 0.5))
+ elif tile_aspect > 2.0:
+ strip_penalty = abs(math.log(tile_aspect / 2.0))
+
+ page_count = pages_x * pages_y
+
+ # Gewichtung: zuerst Formatnähe, dann Streifenvermeidung,
+ # danach Seitenzahl minimieren.
+ score = (aspect_error * 12.0) + (strip_penalty * 6.0) + (page_count * 0.20)
+
+ if score < best_score:
+ best_score = score
+ best_result = (pages_x, pages_y, tile_w, tile_h)
+
+ if best_result is None:
+ if respect_max_sheet_size:
+ fallback_candidates: list[tuple[int, int, float, float, int]] = []
+ for target_w, target_h in orientation_candidates:
+ if target_w <= 0 or target_h <= 0:
+ continue
+ pages_x = max(1, math.ceil(kartenbild_w_mm / target_w))
+ pages_y = max(1, math.ceil(kartenbild_h_mm / target_h))
+ tile_w = kartenbild_w_mm / pages_x
+ tile_h = kartenbild_h_mm / pages_y
+ fallback_candidates.append((pages_x, pages_y, tile_w, tile_h, pages_x * pages_y))
+
+ if fallback_candidates:
+ fallback_candidates.sort(key=lambda entry: (entry[4], abs(math.log((entry[2] / entry[3]) if entry[3] > 0 else 1.0))))
+ fx, fy, fw, fh, _ = fallback_candidates[0]
+ return fx, fy, fw, fh
+
+ return 1, 1, kartenbild_w_mm, kartenbild_h_mm
+
+ return best_result
+
+ def _create_atlasobjekte_layer(
+ self,
+ layer: Any,
+ extent: Any,
+ pages_x: int,
+ pages_y: int,
+ seite_karte_w: float,
+ seite_karte_h: float,
+ massstab_zahl: float,
+ ) -> Any | None:
+ layer_crs = layer.crs() if hasattr(layer, "crs") else None
+ crs_authid = layer_crs.authid() if layer_crs is not None and hasattr(layer_crs, "authid") else "EPSG:25832"
+ atlas_layer = QgsVectorLayer(f"Polygon?crs={crs_authid}", "Atlasobjekte", "memory")
+ if not atlas_layer or not atlas_layer.isValid():
+ return None
+
+ provider = atlas_layer.dataProvider()
+ provider.addAttributes([
+ QgsField("Seitenzahl", QVariant.Int),
+ ])
+ atlas_layer.updateFields()
+
+ tile_w_m = seite_karte_w * massstab_zahl / 1000.0
+ tile_h_m = seite_karte_h * massstab_zahl / 1000.0
+
+ x_min = extent.xMinimum()
+ y_min = extent.yMinimum()
+ x_max = extent.xMaximum()
+ y_max = extent.yMaximum()
+
+ seitenzahl = 1
+ features = []
+ for row_idx in range(pages_y):
+ tile_y_max = y_max - row_idx * tile_h_m
+ tile_y_min = tile_y_max - tile_h_m
+
+ for col_idx in range(pages_x):
+ tile_x_min = x_min + col_idx * tile_w_m
+ tile_x_max = tile_x_min + tile_w_m
+
+ tile_geom = QgsGeometry.fromWkt(
+ self._wkt_rect(tile_x_min, tile_y_min, tile_x_max, tile_y_max)
+ )
+
+ feat = QgsFeature(atlas_layer.fields())
+ feat.setGeometry(tile_geom)
+ feat["Seitenzahl"] = seitenzahl
+ features.append(feat)
+ seitenzahl += 1
+
+ if not features:
+ return None
+
+ provider.addFeatures(features)
+ atlas_layer.updateExtents()
+ self._set_topological_editing_enabled()
+
+ verfahrens_db = get_variable("verfahrens_db", scope="project") or ""
+ print(f"[TabBLogic] Atlasobjekte: verfahrens_db='{verfahrens_db}'")
+ if not verfahrens_db:
+ QgsProject.instance().addMapLayer(atlas_layer)
+ self._apply_atlas_style(atlas_layer)
+ print("[TabBLogic] Atlasobjekte temporär ins Projekt geladen")
+ return atlas_layer
+
+ opts = QgsVectorFileWriter.SaveVectorOptions()
+ opts.driverName = "GPKG"
+ opts.fileEncoding = "UTF-8"
+ opts.layerName = "Atlasobjekte"
+ if file_exists(verfahrens_db):
+ opts.actionOnExistingFile = QgsVectorFileWriter.CreateOrOverwriteLayer
+ else:
+ opts.actionOnExistingFile = QgsVectorFileWriter.CreateOrOverwriteFile
+
+ err_result = QgsVectorFileWriter.writeAsVectorFormatV3(
+ atlas_layer,
+ verfahrens_db,
+ QgsProject.instance().transformContext(),
+ opts,
+ )
+
+ # QGIS-Versionen liefern hier entweder nur WriterError
+ # oder ein Tupel (WriterError, msg, newPath, layerName).
+ err_code = err_result[0] if isinstance(err_result, tuple) else err_result
+ if err_code != QgsVectorFileWriter.NoError:
+ print(f"[TabBLogic] Atlasobjekte schreiben fehlgeschlagen: err={err_result}")
+ return None
+
+ for existing in QgsProject.instance().mapLayersByName("Atlasobjekte"):
+ try:
+ QgsProject.instance().removeMapLayer(existing.id())
+ except Exception:
+ pass
+
+ uri = f"{verfahrens_db}|layername=Atlasobjekte"
+ loaded_layer = QgsVectorLayer(uri, "Atlasobjekte", "ogr")
+ if not loaded_layer or not loaded_layer.isValid():
+ print(f"[TabBLogic] Atlasobjekte laden aus GPKG fehlgeschlagen: uri='{uri}'")
+ return None
+
+ QgsProject.instance().addMapLayer(loaded_layer)
+ self._apply_atlas_style(loaded_layer)
+ print("[TabBLogic] Atlasobjekte aus Verfahrens-DB geladen und gestylt")
+ return loaded_layer
+
+ def set_kartenname_for_auswahl(self, auswahl: str) -> None:
+ """Setzt die Projektvariable ``sn_kartenname`` anhand der Kartennamen-Auswahl."""
+ kartenname = KARTENNAME_BY_AUSWAHL.get(auswahl, "")
+ set_variable(KARTENNAME_VAR, kartenname, scope="project")
+
+ def set_plotmassstab_for_auswahl(self, auswahl: str, aktueller_massstab: float | None = None) -> None:
+ """Setzt die Projektvariable ``sn_plotmassstab`` anhand der Maßstabsauswahl."""
+ if auswahl == MASSSTAB_WIE_KARTENFENSTER:
+ if aktueller_massstab and aktueller_massstab > 0:
+ set_variable(PLOTMASSSTAB_VAR, str(int(round(aktueller_massstab))), scope="project")
+ else:
+ set_variable(PLOTMASSSTAB_VAR, "", scope="project")
+ return
+
+ value = PLOTMASSSTAB_BY_AUSWAHL.get(auswahl, "")
+ set_variable(PLOTMASSSTAB_VAR, value, scope="project")
+
+ def set_view_for_auswahl(self, auswahl: str) -> None:
+ """Setzt ``sn_view`` auf ``aktuell`` oder den Namen des gewählten Layerthemas."""
+ if auswahl == THEMA_WIE_KARTENFENSTER:
+ set_variable(VIEW_VAR, "aktuell", scope="project")
+ return
+
+ set_variable(VIEW_VAR, auswahl or "", scope="project")
+
+ def set_zielgroesse_for_auswahl(self, auswahl: str) -> None:
+ """Setzt ``sn_zielgroesse`` auf den gewählten DIN-Namen."""
+ set_variable(ZIELGROESSE_VAR, auswahl if auswahl in DIN_GROESSEN else DIN_STANDARD, scope="project")
+
+ def set_formfaktor(self, endlosrolle: bool) -> None:
+ """Setzt ``sn_formfaktor`` auf ``Endlosrolle`` oder ``Blatt``."""
+ set_variable(FORMFAKTOR_VAR, "Endlosrolle" if endlosrolle else "Blatt", scope="project")
+
+ # ─────────────────────────────────────────────────────────────────────────
+ # Pipeline: Druckvorlage_anlegen
+ # ─────────────────────────────────────────────────────────────────────────
+
+ def druckvorlage_anlegen(
+ self,
+ layer: object,
+ kartenname_auswahl: str,
+ massstab_auswahl: str,
+ zielgroesse: str,
+ formfaktor: bool,
+ ) -> dict:
+ """Pipeline 'Druckvorlage_anlegen'.
+
+ Prüft Parameter, berechnet Plotgröße und stellt bei Bedarf Atlas-Rückfrage.
+
+ Parameters
+ ----------
+ layer:
+ Aktuell gewählter Verfahrensgebiet-Layer aus Tab A.
+
+ Returns
+ -------
+ dict
+ ``ok`` (bool): Ob die Pipeline erfolgreich durchlaufen werden soll.
+ ``switch_to_tab_a`` (bool): Ob Tab A aktiviert werden soll.
+ ``atlas_seiten`` (int): Anzahl benötigter Seiten (1 = kein Atlas).
+ """
+ # ─── 1. Verfahrensgebiet-Layer prüfen ─────────────────────────────
+ lp = Layerpruefer(layer=layer)
+ ergebnis = lp.pruefe()
+ if not ergebnis.ok:
+ self.pruefmanager.zeige_hinweis(
+ "Verfahrensgebiets-Layer angeben",
+ "Verfahrensgebiets-Layer angeben",
+ )
+ return {"ok": False, "switch_to_tab_a": True, "atlas_seiten": 0}
+
+ layer_id = getattr(layer, "id", lambda: "")() or ""
+ set_variable("sn_verfahrensgebietslayer", layer_id, scope="project")
+
+ # ─── 2. Kartenname prüfen ─────────────────────────────────────────
+ if kartenname_auswahl not in (KARTENNAME_38, KARTENNAME_41):
+ self.pruefmanager.zeige_hinweis(
+ "Kartennamen wählen",
+ "Kartennamen wählen",
+ )
+ return {"ok": False, "switch_to_tab_a": False, "atlas_seiten": 0}
+
+ # ─── 3. Maßstab ermitteln ─────────────────────────────────────────
+ if massstab_auswahl == MASSSTAB_WIE_KARTENFENSTER:
+ massstab_str = get_variable(PLOTMASSSTAB_VAR, scope="project") or ""
+ try:
+ massstab_zahl = float(massstab_str)
+ except (ValueError, TypeError):
+ massstab_zahl = 0.0
+ else:
+ massstab_zahl = float(PLOTMASSSTAB_BY_AUSWAHL.get(massstab_auswahl, 0))
+
+ if massstab_zahl <= 0:
+ self.pruefmanager.zeige_hinweis(
+ "Maßstab fehlt",
+ "Kein gültiger Maßstab angegeben.",
+ )
+ return {"ok": False, "switch_to_tab_a": False, "atlas_seiten": 0}
+
+ # ─── 4. Kartenbild berechnen ──────────────────────────────────────
+ # Der Layer wird als metrisch projiziert (Einheit: m) vorausgesetzt,
+ # wie es für deutsche Planungslagen (z.B. EPSG:25832) üblich ist.
+ extent = get_layer_extent(layer)
+ if extent is None:
+ self.pruefmanager.zeige_hinweis(
+ "Fehler",
+ "Layer-Ausdehnung konnte nicht ermittelt werden.",
+ )
+ return {"ok": False, "switch_to_tab_a": False, "atlas_seiten": 0}
+
+ # Naturgröße (m) → Papiergröße (mm): mm = m * 1000 / massstab
+ kartenbild_w = extent.width() * 1000.0 / massstab_zahl
+ kartenbild_h = extent.height() * 1000.0 / massstab_zahl
+
+ # ─── 5. Plotgröße = Kartenbild + Randabstand (x+210 mm, y+20 mm) ──
+ plotgroesse_w = kartenbild_w + 210.0
+ plotgroesse_h = kartenbild_h + 20.0
+
+ # ─── 6. Zielgröße bestimmen ───────────────────────────────────────
+ din_dims = DIN_GROESSEN.get(zielgroesse, DIN_GROESSEN[DIN_STANDARD])
+ if formfaktor: # Endlosrolle: X-Richtung entspricht der Plotgröße
+ ziel_w, ziel_h = plotgroesse_w, float(min(din_dims))
+ else:
+ ziel_w, ziel_h = float(din_dims[0]), float(din_dims[1])
+
+ if ziel_w < DIN_GROESSEN["DIN A4"][0] or ziel_h < DIN_GROESSEN["DIN A4"][1]:
+ self.pruefmanager.zeige_hinweis(
+ "Blattgröße zu klein",
+ "Die Zielgröße darf nicht kleiner als DIN A4 sein.",
+ )
+ return {"ok": False, "switch_to_tab_a": False, "atlas_seiten": 0}
+
+ # ─── 7. Passt auf ein Blatt? -> Layout erzeugen ───────────────────
+ print(f"[TabBLogic] plotgroesse=({plotgroesse_w:.1f}x{plotgroesse_h:.1f}), "
+ f"zielgroesse=({ziel_w:.1f}x{ziel_h:.1f}), passt={plotgroesse_w <= ziel_w and plotgroesse_h <= ziel_h}")
+ if plotgroesse_w > ziel_w or plotgroesse_h > ziel_h:
+ if plotgroesse_w <= ziel_h and plotgroesse_h <= ziel_w:
+ ziel_w, ziel_h = ziel_h, ziel_w
+
+ if plotgroesse_w <= ziel_w and plotgroesse_h <= ziel_h:
+ kartenname = get_variable(KARTENNAME_VAR, scope="project") or KARTENNAME_BY_AUSWAHL.get(
+ kartenname_auswahl, "Vorlage"
+ )
+ thema = get_variable(VIEW_VAR, scope="project") or ""
+ print(f"[TabBLogic] frage_text aufrufen, default='{kartenname}', thema='{thema}'")
+ vorlage_name, bestaetigt = self.pruefmanager.frage_text(
+ "Neue Vorlage anlegen",
+ "Bezeichnung der Vorlage:",
+ default_text=kartenname,
+ )
+ print(f"[TabBLogic] frage_text Ergebnis: name='{vorlage_name}', bestaetigt={bestaetigt}")
+ if not bestaetigt:
+ return {"ok": False, "switch_to_tab_a": False, "atlas_seiten": 0}
+
+ vorlage_name = (vorlage_name or "").strip() or kartenname
+
+ print(f"[TabBLogic] Rufe Layout().create_single_page_layout auf: name='{vorlage_name}', "
+ f"page=({ziel_w}x{ziel_h}), map=({kartenbild_w:.1f}x{kartenbild_h:.1f}), "
+ f"massstab={massstab_zahl}, thema='{thema}'")
+ try:
+ Layout().create_single_page_layout(
+ name=vorlage_name,
+ page_width_mm=ziel_w,
+ page_height_mm=ziel_h,
+ map_width_mm=kartenbild_w,
+ map_height_mm=kartenbild_h,
+ extent=extent,
+ plotmassstab=massstab_zahl,
+ thema=thema,
+ )
+ print("[TabBLogic] create_single_page_layout erfolgreich abgeschlossen")
+ except ValueError as exc:
+ print(f"[TabBLogic] ValueError: {exc}")
+ self.pruefmanager.zeige_hinweis("Vorlage anlegen", str(exc))
+ return {"ok": False, "switch_to_tab_a": False, "atlas_seiten": 0}
+ except Exception as exc:
+ print(f"[TabBLogic] Exception: {exc!r}")
+ self.pruefmanager.zeige_hinweis("Vorlage anlegen", f"Die Vorlage konnte nicht angelegt werden: {exc}")
+ return {"ok": False, "switch_to_tab_a": False, "atlas_seiten": 0}
+
+ return {"ok": True, "switch_to_tab_a": False, "atlas_seiten": 1}
+
+ # ─── 8. Atlas: Anzahl Seiten berechnen ────────────────────────────
+ # Nutzbarer Kartenbereich pro Atlasseite (abzüglich gleichem Randabstand)
+ seite_karte_w = ziel_w - 210.0
+ seite_karte_h = ziel_h - 20.0
+
+ if seite_karte_w <= 0 or seite_karte_h <= 0:
+ self.pruefmanager.zeige_hinweis(
+ "Blattgröße zu klein",
+ "Die gewählte Zielgröße ist kleiner als der Mindest-Randabstand. "
+ "Bitte eine größere Blattgröße wählen.",
+ )
+ return {"ok": False, "switch_to_tab_a": False, "atlas_seiten": 0}
+
+ pages_x = math.ceil(kartenbild_w / seite_karte_w)
+ pages_y = math.ceil(kartenbild_h / seite_karte_h)
+
+ # Für Atlas in beiden Modi (Endlosrolle + Blatt): Seitenraster so wählen,
+ # dass Atlasobjekte näher am Einzelblattformat liegen und keine Streifen entstehen.
+ opt_pages_x, opt_pages_y, opt_tile_w_mm, opt_tile_h_mm = self._find_tile_grid_for_roll_atlas(
+ kartenbild_w_mm=kartenbild_w,
+ kartenbild_h_mm=kartenbild_h,
+ din_dims=din_dims,
+ respect_max_sheet_size=(not formfaktor),
+ )
+
+ # Endlosrolle: Nur die Breite darf wachsen, die Höhe muss innerhalb
+ # der gewählten Zielhöhe bleiben.
+ if formfaktor:
+ max_tile_h_mm = max(1.0, ziel_h - 20.0)
+ if opt_tile_h_mm > max_tile_h_mm:
+ required_pages_y = max(1, math.ceil(kartenbild_h / max_tile_h_mm))
+ opt_pages_y = max(opt_pages_y, required_pages_y)
+ opt_tile_h_mm = kartenbild_h / opt_pages_y
+
+ pages_x = max(1, opt_pages_x)
+ pages_y = max(1, opt_pages_y)
+ seite_karte_w = max(1.0, opt_tile_w_mm)
+ seite_karte_h = max(1.0, opt_tile_h_mm)
+ modus = "Endlosrolle" if formfaktor else "Blatt"
+ print(
+ f"[TabBLogic] {modus} Rasteroptimierung: pages_x={pages_x}, pages_y={pages_y}, "
+ f"tile_mm=({seite_karte_w:.1f}x{seite_karte_h:.1f}), "
+ f"tile_aspect={seite_karte_w / seite_karte_h:.3f}"
+ )
+ if formfaktor:
+ max_tile_h_mm = max(1.0, ziel_h - 20.0)
+ print(
+ f"[TabBLogic] Endlosrolle Höhenlimit: tile_h={seite_karte_h:.1f}mm, "
+ f"max_tile_h={max_tile_h_mm:.1f}mm, within_limit={seite_karte_h <= max_tile_h_mm}"
+ )
+
+ anzahl_seiten = pages_x * pages_y
+
+ ja = self.pruefmanager.frage_ja_nein(
+ "Ausdruck als Atlas anlegen?",
+ f"Für die ausgewählten Parameter sind {anzahl_seiten} Einzelseiten erforderlich.\n"
+ "Ausdruck als Atlas anlegen?",
+ default=True,
+ )
+ print(f"[TabBLogic] Atlas-Rückfrage: ja={ja}, geplante_seiten={anzahl_seiten}")
+ if not ja:
+ return {"ok": False, "switch_to_tab_a": False, "atlas_seiten": anzahl_seiten}
+
+ atlas_layer = self._create_atlasobjekte_layer(
+ layer=layer,
+ extent=extent,
+ pages_x=pages_x,
+ pages_y=pages_y,
+ seite_karte_w=seite_karte_w,
+ seite_karte_h=seite_karte_h,
+ massstab_zahl=massstab_zahl,
+ )
+ if atlas_layer is None:
+ self.pruefmanager.zeige_hinweis(
+ "Atlasobjekte",
+ "Atlasobjekte-Layer konnte nicht erzeugt werden.",
+ )
+ return {"ok": False, "switch_to_tab_a": False, "atlas_seiten": 0}
+ print("[TabBLogic] Atlasobjekte-Layer erfolgreich erzeugt")
+
+ try:
+ anzahl_seiten = int(atlas_layer.featureCount())
+ except Exception:
+ pass
+
+ # Berechne die Kachelgröße aus den Atlasobjekten.
+ # Für die Layout-Kartengröße muss die größte Atlas-Kachel passen,
+ # sonst werden größere Atlasobjekte abgeschnitten.
+ total_w_m = 0.0
+ total_h_m = 0.0
+ min_w_m = math.inf
+ min_h_m = math.inf
+ max_w_m = 0.0
+ max_h_m = 0.0
+ feature_count = 0
+ get_features = getattr(atlas_layer, "getFeatures", None)
+ if callable(get_features):
+ try:
+ for feat in get_features():
+ geom = feat.geometry() if hasattr(feat, "geometry") else None
+ if geom is None or geom.isEmpty():
+ continue
+ bbox = geom.boundingBox() if hasattr(geom, "boundingBox") else None
+ if bbox is None:
+ continue
+ feat_w_m = float(bbox.width())
+ feat_h_m = float(bbox.height())
+ total_w_m += feat_w_m
+ total_h_m += feat_h_m
+ min_w_m = min(min_w_m, feat_w_m)
+ min_h_m = min(min_h_m, feat_h_m)
+ max_w_m = max(max_w_m, feat_w_m)
+ max_h_m = max(max_h_m, feat_h_m)
+ feature_count += 1
+ except Exception:
+ pass
+
+ if feature_count > 0:
+ avg_tile_w_m = total_w_m / feature_count
+ avg_tile_h_m = total_h_m / feature_count
+ if not math.isfinite(min_w_m):
+ min_w_m = avg_tile_w_m
+ if not math.isfinite(min_h_m):
+ min_h_m = avg_tile_h_m
+ target_tile_w_m = max_w_m
+ target_tile_h_m = max_h_m
+ else:
+ avg_tile_w_m = seite_karte_w * massstab_zahl / 1000.0
+ avg_tile_h_m = seite_karte_h * massstab_zahl / 1000.0
+ min_w_m = avg_tile_w_m
+ min_h_m = avg_tile_h_m
+ max_w_m = avg_tile_w_m
+ max_h_m = avg_tile_h_m
+ target_tile_w_m = avg_tile_w_m
+ target_tile_h_m = avg_tile_h_m
+
+ # Konvertiere Kachelgrößen zu mm
+ avg_tile_w_mm = avg_tile_w_m * 1000.0 / massstab_zahl
+ avg_tile_h_mm = avg_tile_h_m * 1000.0 / massstab_zahl
+ min_tile_w_mm = min_w_m * 1000.0 / massstab_zahl
+ min_tile_h_mm = min_h_m * 1000.0 / massstab_zahl
+ max_tile_w_mm = max_w_m * 1000.0 / massstab_zahl
+ max_tile_h_mm = max_h_m * 1000.0 / massstab_zahl
+ target_tile_w_mm = target_tile_w_m * 1000.0 / massstab_zahl
+ target_tile_h_mm = target_tile_h_m * 1000.0 / massstab_zahl
+
+ # Layout-Kartengröße = größte Kachelgröße
+ atlas_map_w = max(1.0, target_tile_w_mm)
+ atlas_map_h = max(1.0, target_tile_h_mm)
+ atlas_page_w = atlas_map_w + 210.0
+ atlas_page_h = atlas_map_h + 20.0
+
+ # Debug: Atlasobjekte Geometrien
+ print(
+ f"[TabBLogic] Atlasobjekte Geometrien (Meter): "
+ f"total_w={total_w_m:.1f}m, total_h={total_h_m:.1f}m, "
+ f"min_w={min_w_m:.1f}m, min_h={min_h_m:.1f}m, "
+ f"avg_w={avg_tile_w_m:.1f}m, avg_h={avg_tile_h_m:.1f}m, "
+ f"max_w={max_w_m:.1f}m, max_h={max_h_m:.1f}m, features={feature_count}"
+ )
+ print(
+ f"[TabBLogic] Atlas layout Größen: "
+ f"page=(x=10, y=10, w={atlas_page_w:.1f}mm, h={atlas_page_h:.1f}mm), "
+ f"map=(x=10, y=10, w={atlas_map_w:.1f}mm, h={atlas_map_h:.1f}mm), "
+ f"kachel_min_mm=({min_tile_w_mm:.1f}x{min_tile_h_mm:.1f}), "
+ f"kachel_avg_mm=({avg_tile_w_mm:.1f}x{avg_tile_h_mm:.1f}), "
+ f"kachel_max_mm=({max_tile_w_mm:.1f}x{max_tile_h_mm:.1f})"
+ )
+
+ kartenname = get_variable(KARTENNAME_VAR, scope="project") or KARTENNAME_BY_AUSWAHL.get(
+ kartenname_auswahl, "Atlas"
+ )
+ thema = get_variable(VIEW_VAR, scope="project") or ""
+ vorlage_name, bestaetigt = self.pruefmanager.frage_text(
+ "Neue Atlasvorlage anlegen",
+ "Bezeichnung der Vorlage:",
+ default_text=f"{kartenname} Atlas",
+ )
+ print(f"[TabBLogic] Atlas frage_text Ergebnis: name='{vorlage_name}', bestaetigt={bestaetigt}")
+ if not bestaetigt:
+ return {"ok": False, "switch_to_tab_a": False, "atlas_seiten": anzahl_seiten}
+
+ vorlage_name = (vorlage_name or "").strip() or f"{kartenname} Atlas"
+
+ try:
+ print(
+ f"[TabBLogic] Rufe create_atlas_layout auf: name='{vorlage_name}', "
+ f"page=({atlas_page_w:.1f}x{atlas_page_h:.1f}), "
+ f"map=({atlas_map_w:.1f}x{atlas_map_h:.1f}), massstab={massstab_zahl}, thema='{thema}'"
+ )
+ Layout().create_atlas_layout(
+ name=vorlage_name,
+ page_width_mm=atlas_page_w,
+ page_height_mm=atlas_page_h,
+ map_width_mm=atlas_map_w,
+ map_height_mm=atlas_map_h,
+ extent=extent,
+ plotmassstab=massstab_zahl,
+ atlas_layer=atlas_layer,
+ thema=thema,
+ )
+ print("[TabBLogic] create_atlas_layout erfolgreich abgeschlossen")
+ except ValueError as exc:
+ self.pruefmanager.zeige_hinweis("Atlasvorlage anlegen", str(exc))
+ return {"ok": False, "switch_to_tab_a": False, "atlas_seiten": 0}
+ except Exception as exc:
+ self.pruefmanager.zeige_hinweis("Atlasvorlage anlegen", f"Die Atlasvorlage konnte nicht angelegt werden: {exc}")
+ return {"ok": False, "switch_to_tab_a": False, "atlas_seiten": 0}
+
+ return {"ok": True, "switch_to_tab_a": False, "atlas_seiten": anzahl_seiten}
\ No newline at end of file
diff --git a/ui/tab_b_ui.py b/ui/tab_b_ui.py
new file mode 100644
index 0000000..3f1f98b
--- /dev/null
+++ b/ui/tab_b_ui.py
@@ -0,0 +1,427 @@
+"""
+sn_plan41/ui/tab_b_ui.py – UI für Tab B (Druck)
+"""
+from __future__ import annotations
+
+from typing import Optional
+
+from sn_basis.functions.qt_wrapper import (
+ QWidget,
+ QVBoxLayout,
+ QLabel,
+ QComboBox,
+ QCheckBox,
+ QHBoxLayout,
+ QPushButton,
+)
+from sn_basis.functions.qgiscore_wrapper import QgsProject
+from sn_basis.functions.qgisui_wrapper import iface
+from sn_basis.functions.variable_wrapper import get_variable, set_variable
+
+# Services (werden von DockWidget injiziert)
+from sn_basis.modules.Pruefmanager import Pruefmanager
+from sn_basis.modules.DataGrabber import DataGrabber
+from sn_plan41.ui.tab_b_logic import (
+ TabBLogic,
+ MASSSTAB_WIE_KARTENFENSTER,
+ PLOTMASSSTAB_BY_AUSWAHL,
+ THEMA_WIE_KARTENFENSTER,
+ DIN_GROESSEN,
+ DIN_STANDARD,
+ ZIELGROESSE_VAR,
+ FORMFAKTOR_VAR,
+)
+
+
+KARTENNAME_VAR = "tab_b_kartenname"
+KARTENNAME_PLACEHOLDER = "Kartenname wählen"
+KARTENNAME_38 = "§38"
+KARTENNAME_41 = "§41"
+MASSSTAB_VAR = "tab_b_massstab"
+THEMA_VAR = "tab_b_thema"
+ZIELGROESSE_UI_VAR = "tab_b_zielgroesse"
+FORMFAKTOR_UI_VAR = "tab_b_formfaktor"
+
+class TabB(QWidget):
+ """
+ UI-Klasse für Tab B (Druck) des Plan41-Plugins.
+
+ Zuständig für:
+ - Auswahl des Druckthemas
+ - Auswahl der Druckparameter
+ - Start der Vorlagenanlage (Druck über QGIS-Druckfunktion)
+
+ Services (Pruefmanager, DataGrabber) werden zur Laufzeit vom DockWidget injiziert.
+ Alle fachlichen Prüfungen laufen über den zentralen Pruefmanager.
+ """
+
+ tab_title = "Druck" #: Tab-Titel für BaseDockWidget
+
+ def __init__(self, parent: Optional[QWidget] = None):
+ """
+ Initialisiert die UI-Struktur.
+
+ Services werden später über :meth:`set_services` injiziert.
+
+ :param parent: Parent-Widget (typischerweise DockWidget)
+ """
+ super().__init__(parent)
+
+ # Services (werden von DockWidget gesetzt)
+ self.pruefmanager: Optional[Pruefmanager] = None
+
+ self.logic: Optional[TabBLogic] = None
+ self._kartenname_combo: Optional[QComboBox] = None
+ self._massstab_combo: Optional[QComboBox] = None
+ self._thema_combo: Optional[QComboBox] = None
+ self._theme_signal_connected = False
+ self._connected_theme_collection: object = None # Referenz für sauberes Trennen
+ self._zielgroesse_combo: Optional[QComboBox] = None
+ self._endlosrolle_cb: Optional[QCheckBox] = None
+ self._btn_vorlage_erstellen: Optional[QPushButton] = None
+
+ self._build_ui()
+ self._restore_state()
+ self._connect_theme_collection_signals()
+ self._connect_project_signals()
+
+ def set_services(self, pruefmanager: Pruefmanager, data_grabber: DataGrabber) -> None:
+ """Injiziert Services vom übergeordneten DockWidget."""
+ _ = data_grabber
+ self.pruefmanager = pruefmanager
+ self.logic = TabBLogic(pruefmanager=self.pruefmanager)
+
+ if self._kartenname_combo:
+ self.logic.set_kartenname_for_auswahl(self._kartenname_combo.currentText())
+ if self._massstab_combo:
+ self.logic.set_plotmassstab_for_auswahl(
+ self._massstab_combo.currentText(),
+ self._get_current_canvas_scale(),
+ )
+ if self._thema_combo:
+ self.logic.set_view_for_auswahl(self._thema_combo.currentText())
+ if self._zielgroesse_combo:
+ self.logic.set_zielgroesse_for_auswahl(self._zielgroesse_combo.currentText())
+ if self._endlosrolle_cb:
+ self.logic.set_formfaktor(self._endlosrolle_cb.isChecked())
+
+ def _build_ui(self) -> None:
+ """Erstellt die reduzierte UI für die Themenauswahl."""
+ main_layout = QVBoxLayout()
+ main_layout.setSpacing(4)
+ main_layout.setContentsMargins(4, 4, 4, 4)
+
+ kartenname_label = QLabel("Kartenname")
+ kartenname_label.setStyleSheet("font-weight: bold; margin-top: 6px;")
+ main_layout.addWidget(kartenname_label)
+
+ self._kartenname_combo = QComboBox(self)
+ self._kartenname_combo.addItem(KARTENNAME_PLACEHOLDER)
+ self._kartenname_combo.addItem(KARTENNAME_38)
+ self._kartenname_combo.addItem(KARTENNAME_41)
+ self._kartenname_combo.currentTextChanged.connect(self._on_kartenname_changed)
+ main_layout.addWidget(self._kartenname_combo)
+
+ massstab_label = QLabel("Maßstab")
+ massstab_label.setStyleSheet("font-weight: bold; margin-top: 6px;")
+ main_layout.addWidget(massstab_label)
+
+ self._massstab_combo = QComboBox(self)
+ self._massstab_combo.addItem(MASSSTAB_WIE_KARTENFENSTER)
+ self._massstab_combo.addItems(list(PLOTMASSSTAB_BY_AUSWAHL.keys()))
+ self._massstab_combo.currentTextChanged.connect(self._on_massstab_changed)
+ main_layout.addWidget(self._massstab_combo)
+
+ thema_label = QLabel("Thema")
+ thema_label.setStyleSheet("font-weight: bold; margin-top: 6px;")
+ main_layout.addWidget(thema_label)
+
+ self._thema_combo = QComboBox(self)
+ self._thema_combo.addItem(THEMA_WIE_KARTENFENSTER)
+ self._thema_combo.addItems(self._get_gespeicherte_themen())
+ self._thema_combo.currentTextChanged.connect(self._on_thema_changed)
+ main_layout.addWidget(self._thema_combo)
+
+ zielgroesse_label = QLabel("max. Blattgröße")
+ zielgroesse_label.setStyleSheet("font-weight: bold; margin-top: 6px;")
+ main_layout.addWidget(zielgroesse_label)
+
+ zielgroesse_row = QHBoxLayout()
+ zielgroesse_row.setSpacing(6)
+ self._zielgroesse_combo = QComboBox(self)
+ self._zielgroesse_combo.addItems(list(DIN_GROESSEN.keys()))
+ self._zielgroesse_combo.setCurrentText(DIN_STANDARD)
+ self._zielgroesse_combo.currentTextChanged.connect(self._on_zielgroesse_changed)
+ zielgroesse_row.addWidget(self._zielgroesse_combo)
+ self._endlosrolle_cb = QCheckBox("Endlosrolle", self)
+ self._endlosrolle_cb.setChecked(False)
+ self._endlosrolle_cb.stateChanged.connect(self._on_formfaktor_changed)
+ zielgroesse_row.addWidget(self._endlosrolle_cb)
+ main_layout.addLayout(zielgroesse_row)
+
+ self._btn_vorlage_erstellen = QPushButton("Vorlage erstellen", self)
+ self._btn_vorlage_erstellen.clicked.connect(self._on_vorlage_erstellen)
+ main_layout.addWidget(self._btn_vorlage_erstellen)
+
+ main_layout.addStretch(1)
+ self.setLayout(main_layout)
+
+ def _restore_state(self) -> None:
+ """Stellt die gespeicherten Combobox-Zustände wieder her."""
+ if not self._kartenname_combo or not self._massstab_combo or not self._thema_combo:
+ return
+ if not self._zielgroesse_combo or not self._endlosrolle_cb:
+ return
+
+ saved_kartenname = get_variable(KARTENNAME_VAR, scope="project")
+ if saved_kartenname in (KARTENNAME_38, KARTENNAME_41):
+ self._kartenname_combo.setCurrentText(saved_kartenname)
+ else:
+ self._kartenname_combo.setCurrentText(KARTENNAME_PLACEHOLDER)
+
+ saved_massstab = get_variable(MASSSTAB_VAR, scope="project")
+ valid_massstaebe = [MASSSTAB_WIE_KARTENFENSTER, *PLOTMASSSTAB_BY_AUSWAHL.keys()]
+ if saved_massstab in valid_massstaebe:
+ self._massstab_combo.setCurrentText(saved_massstab)
+ else:
+ self._massstab_combo.setCurrentText(MASSSTAB_WIE_KARTENFENSTER)
+
+ aktuelle_themen = [THEMA_WIE_KARTENFENSTER, *self._get_gespeicherte_themen()]
+ self._thema_combo.clear()
+ self._thema_combo.addItems(aktuelle_themen)
+
+ saved_thema = get_variable(THEMA_VAR, scope="project")
+ if saved_thema in aktuelle_themen:
+ self._thema_combo.setCurrentText(saved_thema)
+ else:
+ self._thema_combo.setCurrentText(THEMA_WIE_KARTENFENSTER)
+
+ saved_zielgroesse = get_variable(ZIELGROESSE_UI_VAR, scope="project")
+ if saved_zielgroesse in DIN_GROESSEN:
+ self._zielgroesse_combo.setCurrentText(saved_zielgroesse)
+ else:
+ self._zielgroesse_combo.setCurrentText(DIN_STANDARD)
+
+ saved_formfaktor = get_variable(FORMFAKTOR_UI_VAR, scope="project")
+ self._endlosrolle_cb.setChecked(saved_formfaktor == "Endlosrolle")
+
+ def _on_kartenname_changed(self, value: str) -> None:
+ """Persistiert die Kartennamen-Auswahl und setzt ``sn_kartenname``."""
+ if value in (KARTENNAME_38, KARTENNAME_41):
+ set_variable(KARTENNAME_VAR, value, scope="project")
+ else:
+ set_variable(KARTENNAME_VAR, "", scope="project")
+
+ if self.logic:
+ self.logic.set_kartenname_for_auswahl(value)
+
+ def _on_massstab_changed(self, value: str) -> None:
+ """Persistiert Maßstabsauswahl und setzt ``sn_plotmassstab``."""
+ set_variable(MASSSTAB_VAR, value, scope="project")
+
+ if self.logic:
+ self.logic.set_plotmassstab_for_auswahl(value, self._get_current_canvas_scale())
+
+ def _on_thema_changed(self, value: str) -> None:
+ """Persistiert die Thema-Auswahl und setzt ``sn_view``."""
+ set_variable(THEMA_VAR, value, scope="project")
+ if self.logic:
+ self.logic.set_view_for_auswahl(value)
+
+ def _on_zielgroesse_changed(self, value: str) -> None:
+ """Persistiert Blattgröße und setzt ``sn_zielgroesse``."""
+ set_variable(ZIELGROESSE_UI_VAR, value, scope="project")
+ if self.logic:
+ self.logic.set_zielgroesse_for_auswahl(value)
+
+ def _on_formfaktor_changed(self, state: int) -> None:
+ """Persistiert Endlosrolle-Zustand und setzt ``sn_formfaktor``."""
+ checked = bool(state)
+ set_variable(FORMFAKTOR_UI_VAR, "Endlosrolle" if checked else "Blatt", scope="project")
+ if self.logic:
+ self.logic.set_formfaktor(checked)
+
+ def _on_vorlage_erstellen(self) -> None:
+ """Startet die Pipeline Druckvorlage_anlegen."""
+ print("[TabB] _on_vorlage_erstellen aufgerufen")
+ if not self.logic or not self.pruefmanager:
+ print(f"[TabB] Abbruch: logic={self.logic}, pruefmanager={self.pruefmanager}")
+ return
+
+ # Layer direkt aus Tab A lesen (unabhängig von Projektvariable)
+ layer = None
+ tab_a = self._get_tab_a_widget()
+ print(f"[TabB] tab_a Widget: {tab_a!r}")
+ if tab_a is not None:
+ layer_combo = getattr(tab_a, "layer_combo", None)
+ print(f"[TabB] layer_combo: {layer_combo!r}")
+ if layer_combo is not None:
+ layer = layer_combo.currentLayer()
+ print(f"[TabB] layer: {layer!r} (Name: {getattr(layer, 'name', lambda: '–')()})")
+
+ print(f"[TabB] Rufe druckvorlage_anlegen auf mit layer={layer!r}, "
+ f"kartenname={self._kartenname_combo.currentText() if self._kartenname_combo else '?'}, "
+ f"massstab={self._massstab_combo.currentText() if self._massstab_combo else '?'}, "
+ f"zielgroesse={self._zielgroesse_combo.currentText() if self._zielgroesse_combo else '?'}, "
+ f"formfaktor={self._endlosrolle_cb.isChecked() if self._endlosrolle_cb else '?'}")
+
+ result = self.logic.druckvorlage_anlegen(
+ layer=layer,
+ kartenname_auswahl=self._kartenname_combo.currentText() if self._kartenname_combo else "",
+ massstab_auswahl=self._massstab_combo.currentText() if self._massstab_combo else "",
+ zielgroesse=self._zielgroesse_combo.currentText() if self._zielgroesse_combo else DIN_STANDARD,
+ formfaktor=self._endlosrolle_cb.isChecked() if self._endlosrolle_cb else False,
+ )
+
+ print(f"[TabB] druckvorlage_anlegen Ergebnis: {result}")
+ if result.get("switch_to_tab_a"):
+ self._aktiviere_tab_a()
+
+ def _get_tab_widget(self):
+ """Findet das übergeordnete QTabWidget anhand des ``tabBar``-Attributs."""
+ try:
+ widget = self.parent()
+ while widget is not None:
+ if hasattr(widget, "tabBar") and hasattr(widget, "setCurrentIndex"):
+ return widget
+ parent_fn = getattr(widget, "parent", None)
+ widget = parent_fn() if callable(parent_fn) else None
+ except Exception:
+ pass
+ return None
+
+ def _get_tab_a_widget(self):
+ """Gibt die Tab-A-Widget-Instanz zurück (Index 0 im übergeordneten QTabWidget)."""
+ tab_widget = self._get_tab_widget()
+ if tab_widget is None:
+ return None
+ try:
+ return tab_widget.widget(0)
+ except Exception:
+ return None
+
+ def _aktiviere_tab_a(self) -> None:
+ """Wechselt den aktiven Reiter auf Tab A im übergeordneten QTabWidget."""
+ tab_widget = self._get_tab_widget()
+ if tab_widget is not None:
+ try:
+ tab_widget.setCurrentIndex(0)
+ except Exception:
+ pass
+
+ def _connect_project_signals(self) -> None:
+ """Verbindet QgsProject-Signale für Projektwechsel/-neuladen."""
+ project = QgsProject.instance()
+ for signal_name in ("readProject", "newProjectCreated", "cleared"):
+ signal = getattr(project, signal_name, None)
+ if signal is None:
+ continue
+ try:
+ signal.connect(self._on_project_changed)
+ except Exception:
+ pass
+
+ def _on_project_changed(self, *args) -> None:
+ """Reagiert auf Projektwechsel: Signale neu binden, Combobox und State auffrischen."""
+ _ = args
+ # Alte Theme-Collection-Signals zuerst trennen
+ self._disconnect_theme_collection_signals()
+ # Neu verbinden für das jetzt geladene Projekt
+ self._theme_signal_connected = False
+ self._connect_theme_collection_signals()
+ # Ansicht-Liste + gespeicherten State wiederherstellen
+ self._restore_state()
+
+ def _disconnect_theme_collection_signals(self) -> None:
+ """Trennt Signale der alten Theme-Collection sauber."""
+ collection = self._connected_theme_collection
+ if collection is None:
+ return
+ for signal_name in ("mapThemesChanged", "changed", "themeChanged"):
+ signal = getattr(collection, signal_name, None)
+ if signal is None:
+ continue
+ try:
+ signal.disconnect(self._refresh_thema_combo_live)
+ except Exception:
+ pass
+ self._connected_theme_collection = None
+
+ def _connect_theme_collection_signals(self) -> None:
+ """Verbindet Signale der Theme-Collection für Live-Aktualisierung der Themenliste."""
+ if self._theme_signal_connected:
+ return
+
+ try:
+ theme_collection = QgsProject.instance().mapThemeCollection()
+ except Exception:
+ return
+
+ if theme_collection is None:
+ return
+
+ connected_any = False
+ for signal_name in ("mapThemesChanged", "changed", "themeChanged"):
+ signal = getattr(theme_collection, signal_name, None)
+ if signal is None:
+ continue
+ try:
+ signal.connect(self._refresh_thema_combo_live)
+ connected_any = True
+ except Exception:
+ pass
+
+ if connected_any:
+ self._connected_theme_collection = theme_collection
+ self._theme_signal_connected = connected_any
+
+ def _refresh_thema_combo_live(self, *args) -> None:
+ """Aktualisiert die Thema-Combobox bei Änderungen gespeicherter Layerthemen."""
+ _ = args
+ if not self._thema_combo:
+ return
+
+ vorherige_auswahl = self._thema_combo.currentText() or THEMA_WIE_KARTENFENSTER
+ eintraege = [THEMA_WIE_KARTENFENSTER, *self._get_gespeicherte_themen()]
+
+ self._thema_combo.blockSignals(True)
+ self._thema_combo.clear()
+ self._thema_combo.addItems(eintraege)
+
+ if vorherige_auswahl in eintraege:
+ self._thema_combo.setCurrentText(vorherige_auswahl)
+ else:
+ self._thema_combo.setCurrentText(THEMA_WIE_KARTENFENSTER)
+ self._thema_combo.blockSignals(False)
+
+ self._on_thema_changed(self._thema_combo.currentText())
+
+ def _get_gespeicherte_themen(self) -> list[str]:
+ """Liefert die Namen der im Projekt gespeicherten Layerthemen (QgsMapThemeCollection)."""
+ try:
+ theme_collection = QgsProject.instance().mapThemeCollection()
+ if theme_collection is None:
+ return []
+ themes = theme_collection.mapThemes()
+ except Exception:
+ return []
+
+ namen: list[str] = []
+ for theme_name in themes:
+ name = str(theme_name or "").strip()
+ if name and name not in namen:
+ namen.append(name)
+
+ return namen
+
+ def _get_current_canvas_scale(self) -> float | None:
+ """Liest den aktuellen Maßstab aus der Kartensicht."""
+ try:
+ canvas = iface.mapCanvas()
+ if canvas is None:
+ return None
+ scale = canvas.scale()
+ return float(scale) if scale else None
+ except Exception:
+ return None
+