Source code for sec_interp.sec_interp_plugin

from __future__ import annotations

# /***************************************************************************
#  SecInterp
#                                  A QGIS plugin
#  Data extraction for geological interpretation.
#  Generated by Plugin Builder: http://g-sherman.github.io/Qgis-Plugin-Builder/.
#                               -------------------
#         begin                : 2025-11-15
#         git sha              : $Format:%H$
#         copyright            : (C) 2025 by Juan M Bernales
#         email                : juanbernales@gmail.com
#  ***************************************************************************/
#
# /***************************************************************************
#  *                                                                         *
#  *   This program is free software; you can redistribute it and/or modify  *
#  *   it under the terms of the GNU General Public License as published by  *
#  *   the Free Software Foundation; either version 2 of the License, or     *
#  *   (at your option) any later version.                                   *
#  *                                                                         *
#  ***************************************************************************/

"""Main Plugin Module.

Orchestrates the lifecycle of the SecInterp QGIS plugin.
"""


from pathlib import Path
from typing import Any

from qgis.core import (
    QgsMapLayer,
    QgsProject,
)
from qgis.PyQt.QtCore import (
    QCoreApplication,
    QSettings,
    QTranslator,
)
from qgis.PyQt.QtGui import QIcon
from qgis.PyQt.QtWidgets import QAction

from sec_interp.core.controller import ProfileController
from sec_interp.core.exceptions import SecInterpError
from sec_interp.core.services.export_service import ExportService
from sec_interp.core.types import PreviewParams
from sec_interp.gui.main_dialog import SecInterpDialog
from sec_interp.gui.preview_renderer import PreviewRenderer
from sec_interp.logger_config import get_logger, setup_logging

logger = get_logger(__name__)


# DataCache has been moved to core/data_cache.py


[docs] class SecInterp: """QGIS Plugin Implementation for Geological Data Extraction. This class implements the main logic of the SecInterp plugin, handling initialization, UI integration, and orchestration of data processing tasks. It connects the QGIS interface with the plugin's dialog and processing algorithms. """
[docs] def __init__(self, iface: Any) -> None: """Initialize the plugin. Args: iface (QgsInterface): An interface instance that will be passed to this class which provides the hook by which you can manipulate the QGIS application at run time. """ # Initialize logging first setup_logging() # Save reference to the QGIS interface self.iface = iface # initialize plugin directory self.plugin_dir = Path(__file__).resolve().parent # initialize locale user_locale = QSettings().value("locale/userLocale", "en") # Try full locale first (e.g., pt_BR) locale_path = self.plugin_dir / f"i18n/SecInterp_{user_locale}.qm" if not locale_path.exists() and user_locale and len(user_locale) > 2: # Fallback to language code (e.g., pt) locale_short = user_locale[0:2] locale_path = self.plugin_dir / f"i18n/SecInterp_{locale_short}.qm" if locale_path.exists(): self.translator = QTranslator() self.translator.load(str(locale_path)) QCoreApplication.installTranslator(self.translator) # Prepare core services BEFORE the dialog (required by PreviewManager) # Create preview renderer self.preview_renderer = PreviewRenderer() # Initialize controller self.controller = ProfileController() # Initialize export service self.export_service = ExportService(self.controller) # Create the dialog (after services and translation) and keep reference self.dlg = SecInterpDialog(self.iface, self) self.dlg.plugin_instance = self self.first_start = True # Declare instance attributes self.actions = [] self.menu = self.tr("&Sec Interp") # Create custom toolbar and make it visible self.toolbar = self.iface.addToolBar(self.tr("Sec Interp")) self.toolbar.setObjectName("SecInterp") self.toolbar.setVisible(True) # Ensure toolbar is visible # Check if plugin was started the first time in current QGIS session # Must be set in initGui() to survive plugin reloads self.first_start = None
# noinspection PyMethodMayBeStatic
[docs] def tr(self, message: str) -> str: """Get the translation for a string using Qt translation API. We implement this ourselves since we do not inherit QObject. Args: message (str): String for translation. Returns: str: Translated string (or original if no translation found). """ # noinspection PyTypeChecker,PyArgumentList,PyCallByClass return QCoreApplication.translate("SecInterp", message)
[docs] def add_action( self, icon_path: str, text: str, callback: Any, enabled_flag: bool = True, add_to_menu: bool = True, add_to_toolbar: bool = True, status_tip: str | None = None, whats_this: str | None = None, parent: Any = None, ) -> QAction: """Add a toolbar icon to the toolbar. Args: icon_path (str): Path to the icon for this action. text (str): Text that should be shown in menu items for this action. callback (function): Function to be called when the action is triggered. enabled_flag (bool): A flag indicating if the action should be enabled by default. Defaults to True. add_to_menu (bool): Flag indicating whether the action should also be added to the menu. Defaults to True. add_to_toolbar (bool): Flag indicating whether the action should also be added to the toolbar. Defaults to True. status_tip (str): Optional text to show in a popup when mouse pointer hovers over the action. whats_this (str): Optional text to show in the status bar when the mouse pointer hovers over the action. parent (QWidget): Parent widget for the new action. Defaults None. Returns: QAction: The action that was created. Note that the action is also added to self.actions list. """ icon = QIcon(icon_path) action = QAction(icon, text, parent) action.triggered.connect(callback) action.setEnabled(enabled_flag) if status_tip is not None: action.setStatusTip(status_tip) if whats_this is not None: action.setWhatsThis(whats_this) if add_to_toolbar: # Add to custom toolbar self.toolbar.addAction(action) # Also add to Plugins toolbar for visibility self.iface.addToolBarIcon(action) if add_to_menu: self.iface.addPluginToMenu(self.menu, action) self.actions.append(action) return action
[docs] def initGui(self) -> None: # noqa: N802 """Create the menu entries and toolbar icons inside the QGIS GUI.""" # Use absolute path to icon file to ensure it loads in toolbar icon_path = str(self.plugin_dir / "icon.png") self.add_action( icon_path, text=self.tr("Geological data extraction"), callback=self.run, parent=self.iface.mainWindow(), ) # will be set False in run() self.first_start = True
[docs] def unload(self) -> None: """Remove the plugin menu item and icon from QGIS GUI.""" for action in self.actions: self.iface.removePluginMenu(self.tr("&Sec Interp"), action) self.iface.removeToolBarIcon(action) # Remove custom toolbar if self.toolbar: del self.toolbar self.toolbar = None
[docs] def run(self) -> None: """Run method that performs all the real work.""" if self.first_start: self.first_start = False # Dialog is already initialized in __init__ # Update preview renderer with the dialog's canvas self.preview_renderer.canvas = self.dlg.preview_widget.canvas # Connect dialog accepted signal to final processing self.dlg.accepted.connect(self.process_data) # Reload interpretations and UI settings to reflect current project state self.dlg._load_interpretations() self.dlg._load_user_settings() # Show the dialog self.dlg.show() # Run the dialog event loop result = self.dlg.exec_() # See if OK was pressed if result: # Do something useful here - delete the line containing pass and # substitute with your code. pass
def _resolve_layer_obj(self, value: Any, placeholder_text: str = "") -> QgsMapLayer | None: """Resolve layer object from UI value.""" if isinstance(value, QgsMapLayer): return value if isinstance(value, str) and value: if placeholder_text and value == placeholder_text: return None # Try resolving by ID first (most robust) layer = QgsProject.instance().mapLayer(value) if not layer: # Fallback to name search for lyr in QgsProject.instance().mapLayers().values(): if lyr.name() == value: layer = lyr break return layer return None def _get_and_validate_inputs(self) -> PreviewParams | None: """Retrieve and validate inputs from the dialog. Returns: PreviewParams: Validated parameters, or None if validation fails. """ # Get values from the dialog pages values = self.dlg.get_selected_values() # Get preview/LOD options from the preview widget preview_options = self.dlg.get_preview_options() # 1. Resolve Layer Objects raster_layer = self._resolve_layer_obj( values.get("raster_layer"), self.tr("Select a raster layer") ) line_layer = self._resolve_layer_obj( values.get("crossline_layer"), self.tr("Select a crossline layer") ) outcrop_layer = self._resolve_layer_obj(values.get("outcrop_layer")) structural_layer = self._resolve_layer_obj(values.get("structural_layer")) # 2. Build PreviewParams try: params = PreviewParams( raster_layer=raster_layer, line_layer=line_layer, band_num=values.get("selected_band", 1), buffer_dist=values.get("buffer_distance", 100.0), outcrop_layer=outcrop_layer, outcrop_name_field=values.get("outcrop_name_field"), struct_layer=structural_layer, dip_field=values.get("dip_field"), strike_field=values.get("strike_field"), dip_scale_factor=values.get("dip_scale_factor", 1.0), collar_layer=self._resolve_layer_obj(values.get("collar_layer_obj")), collar_id_field=values.get("collar_id_field"), collar_use_geometry=values.get("collar_use_geometry", True), collar_x_field=values.get("collar_x_field"), collar_y_field=values.get("collar_y_field"), collar_z_field=values.get("collar_z_field"), collar_depth_field=values.get("collar_depth_field"), survey_layer=self._resolve_layer_obj(values.get("survey_layer_obj")), survey_id_field=values.get("survey_id_field"), survey_depth_field=values.get("survey_depth_field"), survey_azim_field=values.get("survey_azim_field"), survey_incl_field=values.get("survey_incl_field"), interval_layer=self._resolve_layer_obj(values.get("interval_layer_obj")), interval_id_field=values.get("interval_id_field"), interval_from_field=values.get("interval_from_field"), interval_to_field=values.get("interval_to_field"), interval_lith_field=values.get("interval_lith_field"), max_points=preview_options.get("max_points", 1000), auto_lod=preview_options.get("auto_lod", True), canvas_width=self.dlg.preview_widget.canvas.width(), ) # Internal Native Validation params.validate() except SecInterpError as e: self.dlg.handle_error(e, self.tr("Configuration Error")) return None except Exception as e: self.dlg.handle_error(e, self.tr("Unexpected Error")) return None # 3. Connect Layer Notifications for Cache Invalidation active_layers = [ l for l in [ params.raster_layer, params.line_layer, params.outcrop_layer, params.struct_layer, params.collar_layer, params.survey_layer, params.interval_layer, ] if l ] self.controller.connect_layer_notifications(active_layers) # Store output path temporarily or return as tuple if needed # but for now we only return params. Callers need the path separately. return params
[docs] def process_data(self, inputs: dict[str, Any] | None = None) -> tuple[Any, Any, Any] | None: """Process profile data by delegating to the dialog's preview manager. Args: inputs: Pre-validated inputs (optional) Returns: Tuple of (profile_data, geol_data, struct_data) or None """ # Simply delegate to the dialog's preview manager if it exists if hasattr(self, "dlg") and self.dlg: success, message = self.dlg.preview_manager.generate_preview() if not success: logger.warning(f"Data processing failed: {message}") return None # Return cached data from manager for backward compatibility if needed cache = self.dlg.preview_manager.cached_data return cache["topo"], cache["geol"], cache["struct"] return None
[docs] def save_profile_line(self) -> None: """Save profile data by delegating to the dialog's export manager.""" if hasattr(self, "dlg") and self.dlg: self.dlg.export_manager.export_data()
[docs] def draw_preview( self, topo_data: list, geol_data: list | None = None, struct_data: list | None = None, drillhole_data: list | None = None, max_points: int = 1000, **kwargs, ) -> None: """Draw enhanced interactive preview using native PyQGIS renderer. Args: topo_data: List of (dist, elev) tuples for topographic profile geol_data: Optional list of (dist, elev, geology_name) tuples struct_data: Optional list of (dist, app_dip) tuples drillhole_data: Optional list of (hole_id, traces, segments) tuples max_points (int): Maximum number of points for simplified preview (LOD) **kwargs: Additional arguments passed to renderer (e.g. preserve_extent) """ logger.debug("draw_preview called with:") logger.debug(" - topo_data: %d points", len(topo_data) if topo_data else 0) logger.debug(" - geol_data: %d points", len(geol_data) if geol_data else 0) logger.debug(" - struct_data: %d points", len(struct_data) if struct_data else 0) logger.debug(" - drillhole_data: %d holes", len(drillhole_data) if drillhole_data else 0) logger.debug(" - max_points: %d", max_points) # Store data in dialog for re-rendering when checkboxes change self.dlg.current_topo_data = topo_data self.dlg.current_geol_data = geol_data self.dlg.current_struct_data = struct_data self.dlg.current_drillhole_data = drillhole_data self.dlg.current_drillhole_data = drillhole_data # Get preview options from dialog checkboxes options = self.dlg.get_preview_options() logger.debug("Preview options: %s", options) # Filter data based on checkbox states filtered_topo = topo_data if options.get("show_topo", True) else None filtered_geol = geol_data if options.get("show_geol", True) else None filtered_struct = struct_data if options.get("show_struct", True) else None filtered_drill = drillhole_data if options.get("show_drillholes", True) else None filtered_interp = ( self.dlg.interpretations if options.get("show_interpretations", True) else None ) logger.debug("Filtered data:") logger.debug(" - filtered_topo: %d points", len(filtered_topo) if filtered_topo else 0) logger.debug(" - filtered_geol: %d points", len(filtered_geol) if filtered_geol else 0) logger.debug( " - filtered_struct: %d points", len(filtered_struct) if filtered_struct else 0, ) logger.debug( " - filtered_interp: %d polygons", len(filtered_interp) if filtered_interp else 0, ) # Get vertical exaggeration from dialog # Use new numeric method or direct accessor from Page vert_exag = self.dlg.page_dem.vertexag_spin.value() logger.debug("Vertical exaggeration: %.2f", vert_exag) # Calculate dip line length based on scale factor and raster resolution dip_line_length = None if filtered_struct: dip_scale = self.dlg.page_struct.scale_spin.value() if dip_scale > 0: raster_layer = self.dlg.page_dem.raster_combo.currentLayer() if raster_layer and raster_layer.isValid(): res = raster_layer.rasterUnitsPerPixelX() if res > 0: dip_line_length = res * dip_scale logger.debug("Dip line length: %s (scale: %.2f)", dip_line_length, dip_scale) # Render using native PyQGIS canvas, layers = self.preview_renderer.render( topo_data=filtered_topo, geol_data=filtered_geol, struct_data=filtered_struct, vert_exag=vert_exag, dip_line_length=dip_line_length, max_points=max_points, preserve_extent=kwargs.get("preserve_extent", False), drillhole_data=filtered_drill, interp_data=filtered_interp, show_legend=options.get("show_legend", True), ) # Store canvas and layers for export self.dlg.current_canvas = canvas self.dlg.current_layers = layers # Update legend if hasattr(self.dlg, "legend_widget"): self.dlg.legend_widget.update_legend( self.preview_renderer, options.get("show_legend", True) ) logger.debug("Preview rendered with %d layers", len(layers) if layers else 0)