diff --git a/loopstructural/__about__.py b/loopstructural/__about__.py index 91b7ac0..eb7ed0c 100644 --- a/loopstructural/__about__.py +++ b/loopstructural/__about__.py @@ -1,7 +1,8 @@ #! python3 -"""Metadata about the package to easily retrieve informations about it. -See: https://packaging.python.org/guides/single-sourcing-package-version/ +"""Package metadata. + +Contains package metadata constants used by the plugin (title, icon, etc.). """ # ############################################################################ @@ -36,13 +37,17 @@ # ########## Functions ############# # ################################## def plugin_metadata_as_dict() -> dict: - """Read plugin metadata.txt and returns it as a Python dict. + """Read plugin metadata.txt and return it as a Python dict. - Raises: - IOError: if metadata.txt is not found + Raises + ------ + IOError + If metadata.txt is not found. - Returns: - dict: dict of dicts. + Returns + ------- + dict + Metadata sections as nested dictionaries. """ config = ConfigParser() diff --git a/loopstructural/__init__.py b/loopstructural/__init__.py index e8ae2c9..0c24353 100644 --- a/loopstructural/__init__.py +++ b/loopstructural/__init__.py @@ -1,5 +1,10 @@ #! python3 +"""LoopStructural QGIS plugin package. + +Utilities and metadata for the LoopStructural QGIS plugin. +""" + # ---------------------------------------------------------- # Copyright (C) 2015 Martin Dobias # ---------------------------------------------------------- @@ -15,8 +20,15 @@ def classFactory(iface): """Load the plugin class. - :param iface: A QGIS interface instance. - :type iface: QgsInterface + Parameters + ---------- + iface : QgsInterface + A QGIS interface instance provided by QGIS when loading plugins. + + Returns + ------- + LoopstructuralPlugin + An instance of the plugin class initialized with `iface`. """ from .plugin_main import LoopstructuralPlugin diff --git a/loopstructural/gui/__init__.py b/loopstructural/gui/__init__.py index e69de29..22f1585 100644 --- a/loopstructural/gui/__init__.py +++ b/loopstructural/gui/__init__.py @@ -0,0 +1,7 @@ +#! python3 +"""GUI package for the LoopStructural QGIS plugin. + +Contains widgets, dialogs and visualisation helpers used by the plugin UI. +""" + +# Re-export commonly used GUI classes if needed diff --git a/loopstructural/gui/dlg_settings.py b/loopstructural/gui/dlg_settings.py index dbb975e..5f7bef2 100644 --- a/loopstructural/gui/dlg_settings.py +++ b/loopstructural/gui/dlg_settings.py @@ -80,10 +80,7 @@ def __init__(self, parent): self.load_settings() def apply(self): - """Called to permanently apply the settings shown in the options page (e.g. \ - save them to QgsSettings objects). This is usually called when the options \ - dialog is accepted. - """ + """Apply settings from the form and persist them to QgsSettings.""" settings = self.plg_settings.get_plg_settings() # misc @@ -104,7 +101,7 @@ def apply(self): ) def load_settings(self): - """Load options from QgsSettings into UI form.""" + """Load options from QgsSettings into the UI form.""" settings = self.plg_settings.get_plg_settings() # global @@ -117,7 +114,7 @@ def load_settings(self): self.npw_spin_box.setValue(settings.interpolator_npw) def reset_settings(self): - """Reset settings to default values (set in preferences.py module).""" + """Reset settings in the UI and persisted settings to plugin defaults.""" default_settings = PlgSettingsStructure() # dump default settings into QgsSettings @@ -130,41 +127,57 @@ def reset_settings(self): class PlgOptionsFactory(QgsOptionsWidgetFactory): """Factory for options widget.""" - def __init__(self): - """Constructor.""" + def __init__(self, *args, **kwargs): + """Initialize the options factory. + + Parameters + ---------- + *args, **kwargs + Forwarded to base factory initializer. + """ super().__init__() - def icon(self) -> QIcon: - """Returns plugin icon, used to as tab icon in QGIS options tab widget. + def icon(self): + """Return the icon used for the options page. - :return: _description_ - :rtype: QIcon + Returns + ------- + QIcon + Icon for the options page. """ return QIcon(str(__icon_path__)) def createWidget(self, parent) -> ConfigOptionsPage: """Create settings widget. - :param parent: Qt parent where to include the options page. - :type parent: QObject + Parameters + ---------- + parent : QObject + Qt parent where to include the options page. - :return: options page for tab widget - :rtype: ConfigOptionsPage + Returns + ------- + ConfigOptionsPage + Instantiated options page. """ return ConfigOptionsPage(parent) def title(self) -> str: - """Returns plugin title, used to name the tab in QGIS options tab widget. + """Plugin title used to name options tab. - :return: plugin title from about module - :rtype: str + Returns + ------- + str + Plugin title string. """ return __title__ def helpId(self) -> str: - """Returns plugin help URL. + """Plugin help URL. - :return: plugin homepage url from about module - :rtype: str + Returns + ------- + str + URL of the plugin homepage. """ return __uri_homepage__ diff --git a/loopstructural/gui/loop_widget.py b/loopstructural/gui/loop_widget.py index c807fe2..a0fb574 100644 --- a/loopstructural/gui/loop_widget.py +++ b/loopstructural/gui/loop_widget.py @@ -1,12 +1,32 @@ +#! python3 +"""Main Loop widget used in the plugin dock. + +This module exposes `LoopWidget` which provides the primary user +interface for interacting with LoopStructural features inside QGIS. +""" + from PyQt5.QtWidgets import QTabWidget, QVBoxLayout, QWidget from .modelling.modelling_widget import ModellingWidget from .visualisation.visualisation_widget import VisualisationWidget class LoopWidget(QWidget): + """Main dock widget that contains modelling and visualisation tools. + + The widget composes multiple tabs and controls used to construct and + inspect geological models. + """ + def __init__( self, parent=None, *, mapCanvas=None, logger=None, data_manager=None, model_manager=None ): + """Initialize the Loop widget. + + Parameters + ---------- + *args, **kwargs + Forwarded to the parent widget constructor. + """ super().__init__(parent) self.mapCanvas = mapCanvas self.logger = logger diff --git a/loopstructural/gui/modelling/geological_model_tab/bounding_box_widget.py b/loopstructural/gui/modelling/geological_model_tab/bounding_box_widget.py new file mode 100644 index 0000000..591a319 --- /dev/null +++ b/loopstructural/gui/modelling/geological_model_tab/bounding_box_widget.py @@ -0,0 +1,217 @@ +import numpy as np +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import ( + QGridLayout, + QHBoxLayout, + QLabel, + QDoubleSpinBox, + QVBoxLayout, + QWidget, +) +from qgis.gui import QgsCollapsibleGroupBox + +from LoopStructural import getLogger +logger = getLogger(__name__) + + +class BoundingBoxWidget(QWidget): + """Standalone bounding-box widget used in the export/evaluation panel. + + Shows a compact 3-column layout for X/Y/Z nsteps and a single-row + control for the overall element count (nelements). The widget keeps + itself in sync with the authoritative bounding_box available from a + provided data_manager or model_manager and will call back to update + the model when the user changes values. + """ + + def __init__(self, parent=None, *, model_manager=None, data_manager=None): + super().__init__(parent) + self.model_manager = model_manager + self.data_manager = data_manager + + # Create the inner layout that will be placed inside a collapsible group widget + inner_layout = QVBoxLayout() + inner_layout.setContentsMargins(0, 0, 0, 0) + inner_layout.setSpacing(6) + + grid = QGridLayout() + grid.setSpacing(6) + + # header row: blank, X, Y, Z + grid.addWidget(QLabel(""), 0, 0) + grid.addWidget(QLabel("X"), 0, 1, alignment=Qt.AlignCenter) + grid.addWidget(QLabel("Y"), 0, 2, alignment=Qt.AlignCenter) + grid.addWidget(QLabel("Z"), 0, 3, alignment=Qt.AlignCenter) + + # Nsteps row + grid.addWidget(QLabel("Nsteps:"), 1, 0) + self.nsteps_x = QDoubleSpinBox() + self.nsteps_y = QDoubleSpinBox() + self.nsteps_z = QDoubleSpinBox() + for sb in (self.nsteps_x, self.nsteps_y, self.nsteps_z): + sb.setRange(1, 1_000_000) + sb.setDecimals(0) + sb.setSingleStep(1) + sb.setAlignment(Qt.AlignRight) + grid.addWidget(self.nsteps_x, 1, 1) + grid.addWidget(self.nsteps_y, 1, 2) + grid.addWidget(self.nsteps_z, 1, 3) + + # Elements row (span columns) + grid.addWidget(QLabel("Elements:"), 2, 0) + self.nelements = QDoubleSpinBox() + self.nelements.setRange(1, 1_000_000_000) + self.nelements.setDecimals(0) + self.nelements.setSingleStep(100) + self.nelements.setAlignment(Qt.AlignRight) + grid.addWidget(self.nelements, 2, 1, 1, 3) + + inner_layout.addLayout(grid) + + # Place the inner layout into a QGIS collapsible group box so it matches other sections + group = QgsCollapsibleGroupBox() + group.setTitle("Bounding Box") + group.setLayout(inner_layout) + + # Outer layout for this widget contains the group box (so it can be treated as a single section) + outer_layout = QVBoxLayout(self) + outer_layout.setContentsMargins(0, 0, 0, 0) + outer_layout.setSpacing(0) + outer_layout.addWidget(group) + + # initialise values from bounding box if available + bb = self._get_bounding_box() + if bb is not None: + try: + if getattr(bb, 'nsteps', None) is not None: + self.nsteps_x.setValue(int(bb.nsteps[0])) + self.nsteps_y.setValue(int(bb.nsteps[1])) + self.nsteps_z.setValue(int(bb.nsteps[2])) + except Exception: + self.nsteps_x.setValue(100) + self.nsteps_y.setValue(100) + self.nsteps_z.setValue(1) + try: + if getattr(bb, 'nelements', None) is not None: + self.nelements.setValue(int(getattr(bb, 'nelements'))) + except Exception: + self.nelements.setValue(getattr(bb, 'nelements', 1000) if bb is not None else 1000) + else: + self.nsteps_x.setValue(100) + self.nsteps_y.setValue(100) + self.nsteps_z.setValue(1) + self.nelements.setValue(1000) + + # connect signals + self.nelements.valueChanged.connect(self._on_nelements_changed) + self.nsteps_x.valueChanged.connect(self._on_nsteps_changed) + self.nsteps_y.valueChanged.connect(self._on_nsteps_changed) + self.nsteps_z.valueChanged.connect(self._on_nsteps_changed) + + # register update callback so this widget stays in sync + if self.data_manager is not None and hasattr(self.data_manager, 'set_bounding_box_update_callback'): + try: + self.data_manager.set_bounding_box_update_callback(self._on_bounding_box_updated) + except Exception: + pass + + def _get_bounding_box(self): + bounding_box = None + if self.data_manager is not None: + try: + if hasattr(self.data_manager, 'get_bounding_box'): + bounding_box = self.data_manager.get_bounding_box() + elif hasattr(self.data_manager, 'bounding_box'): + bounding_box = getattr(self.data_manager, 'bounding_box') + except Exception: + logger.debug('Failed to get bounding box from data_manager', exc_info=True) + bounding_box = None + if bounding_box is None and self.model_manager is not None and getattr(self.model_manager, 'model', None) is not None: + try: + bounding_box = getattr(self.model_manager.model, 'bounding_box', None) + except Exception: + logger.debug('Failed to get bounding box from model_manager', exc_info=True) + bounding_box = None + return bounding_box + + def _on_nelements_changed(self, val): + bb = self._get_bounding_box() + if bb is None: + return + try: + bb.nelements = int(val) + except Exception: + bb.nelements = val + if self.model_manager is not None: + try: + self.model_manager.update_bounding_box(bb) + except Exception: + logger.debug('Failed to update bounding_box on model_manager', exc_info=True) + # refresh from authoritative source + self._refresh_bb_ui() + + def _on_nsteps_changed(self, _): + bb = self._get_bounding_box() + if bb is None: + return + try: + bb.nsteps = np.array([int(self.nsteps_x.value()), int(self.nsteps_y.value()), int(self.nsteps_z.value())]) + except Exception: + try: + bb.nsteps = [int(self.nsteps_x.value()), int(self.nsteps_y.value()), int(self.nsteps_z.value())] + except Exception: + pass + if self.model_manager is not None: + try: + self.model_manager.update_bounding_box(bb) + except Exception: + logger.debug('Failed to update bounding_box on model_manager', exc_info=True) + # refresh from authoritative source + self._refresh_bb_ui() + + def _refresh_bb_ui(self): + bb = self._get_bounding_box() + if bb is not None: + try: + self._on_bounding_box_updated(bb) + except Exception: + pass + + def _on_bounding_box_updated(self, bounding_box): + # collect spinboxes + spinboxes = [self.nelements, self.nsteps_x, self.nsteps_y, self.nsteps_z] + for sb in spinboxes: + try: + sb.blockSignals(True) + except Exception: + pass + try: + if getattr(bounding_box, 'nelements', None) is not None: + try: + self.nelements.setValue(int(getattr(bounding_box, 'nelements'))) + except Exception: + try: + self.nelements.setValue(getattr(bounding_box, 'nelements')) + except Exception: + logger.debug('Could not set nelements', exc_info=True) + if getattr(bounding_box, 'nsteps', None) is not None: + try: + nsteps = list(bounding_box.nsteps) + except Exception: + try: + nsteps = [int(bounding_box.nsteps[0]), int(bounding_box.nsteps[1]), int(bounding_box.nsteps[2])] + except Exception: + nsteps = None + if nsteps is not None: + try: + self.nsteps_x.setValue(int(nsteps[0])) + self.nsteps_y.setValue(int(nsteps[1])) + self.nsteps_z.setValue(int(nsteps[2])) + except Exception: + logger.debug('Could not set nsteps', exc_info=True) + finally: + for sb in spinboxes: + try: + sb.blockSignals(False) + except Exception: + pass diff --git a/loopstructural/gui/modelling/geological_model_tab/feature_details_panel.py b/loopstructural/gui/modelling/geological_model_tab/feature_details_panel.py index 5554f8a..3d63f23 100644 --- a/loopstructural/gui/modelling/geological_model_tab/feature_details_panel.py +++ b/loopstructural/gui/modelling/geological_model_tab/feature_details_panel.py @@ -1,4 +1,5 @@ -from PyQt5.QtCore import Qt +import numpy as np +from PyQt5.QtCore import Qt, QVariant from PyQt5.QtWidgets import ( QCheckBox, QComboBox, @@ -10,16 +11,20 @@ QVBoxLayout, QWidget, ) - +from qgis.gui import QgsMapLayerComboBox +from qgis.utils import plugins from .layer_selection_table import LayerSelectionTable from .splot import SPlotDialog +from .bounding_box_widget import BoundingBoxWidget from LoopStructural.modelling.features import StructuralFrame from LoopStructural.utils import normal_vector_to_strike_and_dip, plungeazimuth2vector - - +from LoopStructural import getLogger +logger = getLogger(__name__) class BaseFeatureDetailsPanel(QWidget): def __init__(self, parent=None, *, feature=None, model_manager=None, data_manager=None): super().__init__(parent) + self.plugin = plugins.get('loopstructural') + self.feature = feature self.model_manager = model_manager self.data_manager = data_manager @@ -42,6 +47,7 @@ def __init__(self, parent=None, *, feature=None, model_manager=None, data_manage # Set the main layout self.setLayout(mainLayout) + ## define interpolator parameters # Regularisation spin box self.regularisation_spin_box = QDoubleSpinBox() @@ -50,20 +56,20 @@ def __init__(self, parent=None, *, feature=None, model_manager=None, data_manage feature.builder.build_arguments.get('regularisation', 1.0) ) self.regularisation_spin_box.valueChanged.connect( - lambda value: feature.builder.update_build_arguments({'regularisation': value}) + lambda value: self.feature.builder.update_build_arguments({'regularisation': value}) ) self.cpw_spin_box = QDoubleSpinBox() self.cpw_spin_box.setRange(0, 100) self.cpw_spin_box.setValue(feature.builder.build_arguments.get('cpw', 1.0)) self.cpw_spin_box.valueChanged.connect( - lambda value: feature.builder.update_build_arguments({'cpw': value}) + lambda value: self.feature.builder.update_build_arguments({'cpw': value}) ) self.npw_spin_box = QDoubleSpinBox() self.npw_spin_box.setRange(0, 100) self.npw_spin_box.setValue(feature.builder.build_arguments.get('npw', 1.0)) self.npw_spin_box.valueChanged.connect( - lambda value: feature.builder.update_build_arguments({'npw': value}) + lambda value: self.feature.builder.update_build_arguments({'npw': value}) ) self.interpolator_type_label = QLabel("Interpolator Type:") self.interpolator_type_combo = QComboBox() @@ -92,8 +98,182 @@ def __init__(self, parent=None, *, feature=None, model_manager=None, data_manage QgsCollapsibleGroupBox.setLayout(form_layout) self.layout.addWidget(QgsCollapsibleGroupBox) self.layout.addWidget(self.layer_table) + self.addMidBlock() + self.addExportBlock() + def addMidBlock(self): + """Base mid block is intentionally empty now — bounding-box controls + were moved into the export/evaluation section so they appear alongside + export controls. Subclasses should override this to add feature-specific + mid-panel controls. + """ + return + + def addExportBlock(self): + # Export/Evaluation blocks container + self.export_eval_container = QWidget() + self.export_eval_layout = QVBoxLayout(self.export_eval_container) + self.export_eval_layout.setContentsMargins(0, 0, 0, 0) + self.export_eval_layout.setSpacing(6) + + # --- Bounding box controls (moved here into dedicated widget) --- + bb_widget = BoundingBoxWidget(parent=self, model_manager=self.model_manager, data_manager=self.data_manager) + # keep reference so export handlers can use it + self.bounding_box_widget = bb_widget + self.export_eval_layout.addWidget(bb_widget) + + # --- Per-feature export controls (for this panel's feature) --- + try: + from PyQt5.QtWidgets import QFormLayout, QHBoxLayout + from PyQt5.QtWidgets import QGroupBox + from qgis.core import QgsVectorLayer, QgsFeature, QgsFields, QgsField, QgsProject, QgsGeometry, QgsPointXYZ + except Exception: + # imports may fail outside QGIS environment; we'll handle at runtime + pass - # self.layout.addLayout(form_layout) + export_widget = QWidget() + export_layout = QFormLayout(export_widget) + + # Scalar selector (support scalar and gradient) + self.scalar_field_combo = QComboBox() + self.scalar_field_combo.addItems(["scalar", "gradient"]) + export_layout.addRow("Scalar:", self.scalar_field_combo) + + # Evaluate target: bounding-box centres or project point layer + self.evaluate_target_combo = QComboBox() + self.evaluate_target_combo.addItems(["Bounding box cell centres", "Project point layer","Viewer Object"]) + export_layout.addRow("Evaluate on:", self.evaluate_target_combo) + + # Project layer selector (populated with point vector layers from project) + self.project_layer_combo = QgsMapLayerComboBox() + self.project_layer_combo.setEnabled(False) + # self.project_layer_combo.setFilters(QgsMapLayerComboBox.PointLayer) + self.project_layer_combo.setVisible(False) # initially hidden + self.meshObjectCombo = QComboBox() + export_layout.addRow("Project point layer:", self.project_layer_combo) + export_layout.addRow("Viewer object:", self.meshObjectCombo) + # hide the labels for these rows initially (keep layout spacing until used) + lbl = export_layout.labelForField(self.project_layer_combo) + if lbl is not None: + lbl.setVisible(False) + lbl = export_layout.labelForField(self.meshObjectCombo) + if lbl is not None: + lbl.setVisible(False) + # Connect evaluate target change to enable/disable project layer combo + def _on_evaluate_target_changed(index): + use_project = (index == 1) + use_vtk = (index == 2) + self.project_layer_combo.setVisible(use_project) + self.project_layer_combo.setEnabled(use_project) + self.meshObjectCombo.setVisible(use_vtk) + self.meshObjectCombo.setEnabled(use_vtk) + # also hide/show the labels associated with those fields + lbl = export_layout.labelForField(self.project_layer_combo) + if lbl is not None: + lbl.setVisible(use_project) + lbl = export_layout.labelForField(self.meshObjectCombo) + if lbl is not None: + lbl.setVisible(use_vtk) + if use_vtk: + # populate with pyvista objects from viewer + self.meshObjectCombo.clear() + if self.plugin.loop_widget.visualisation_widget.plotter is not None: + viewer = self.plugin.loop_widget.visualisation_widget.plotter + mesh_names = list(viewer.meshes.keys()) + self.meshObjectCombo.addItems(mesh_names) + self.evaluate_target_combo.currentIndexChanged.connect(_on_evaluate_target_changed) + + + + + + + + # Export button + self.export_points_button = QPushButton("Export to QGIS points") + export_layout.addRow(self.export_points_button) + self.export_points_button.clicked.connect(self._export_scalar_points) + + self.export_eval_layout.addWidget(export_widget) + + # Dictionary to hold per-feature export/eval blocks for later population + self.export_blocks = {} + + # Create a placeholder block for each feature known to the model_manager. + # These blocks are intentionally minimal now (only a disabled label) and + # will be populated with export/evaluate controls later. + if self.model_manager is not None: + for feat in self.model_manager.features(): + block = QWidget() + block.setObjectName(f"export_block_{getattr(feat, 'name', 'feature')}") + block_layout = QVBoxLayout(block) + block_layout.setContentsMargins(0, 0, 0, 0) + # Placeholder label — disabled until controls are added + lbl = QLabel(getattr(feat, 'name', '')) + lbl.setEnabled(False) + block_layout.addWidget(lbl) + self.export_eval_layout.addWidget(block) + self.export_blocks[getattr(feat, 'name', f"feature_{len(self.export_blocks)}")] = block + + self.layout.addWidget(self.export_eval_container) + + + + def _on_bounding_box_updated(self, bounding_box): + """Callback to update UI widgets when bounding box object changes externally. + + Blocks spinbox signals to avoid feedback loops, updates nelements, nsteps, + and then restores signals. + """ + # Collect spinboxes if they exist on this instance + spinboxes = [] + for name in ('bb_nelements_spinbox', 'bb_nsteps_x', 'bb_nsteps_y', 'bb_nsteps_z'): + sb = getattr(self, name, None) + if sb is not None: + spinboxes.append(sb) + + # Block signals + for sb in spinboxes: + try: + sb.blockSignals(True) + except Exception: + pass + + try: + if getattr(bounding_box, 'nelements', None) is not None and hasattr(self, 'bb_nelements_spinbox'): + try: + self.bb_nelements_spinbox.setValue(int(getattr(bounding_box, 'nelements'))) + except Exception: + try: + self.bb_nelements_spinbox.setValue(getattr(bounding_box, 'nelements')) + except Exception: + logger.debug('Could not set nelements spinbox from bounding_box', exc_info=True) + + if getattr(bounding_box, 'nsteps', None) is not None: + try: + nsteps = list(bounding_box.nsteps) + except Exception: + try: + nsteps = [int(bounding_box.nsteps[0]), int(bounding_box.nsteps[1]), int(bounding_box.nsteps[2])] + except Exception: + nsteps = None + if nsteps is not None: + try: + if hasattr(self, 'bb_nsteps_x'): + self.bb_nsteps_x.setValue(int(nsteps[0])) + if hasattr(self, 'bb_nsteps_y'): + self.bb_nsteps_y.setValue(int(nsteps[1])) + if hasattr(self, 'bb_nsteps_z'): + self.bb_nsteps_z.setValue(int(nsteps[2])) + except Exception: + logger.debug('Could not set nsteps spinboxes from bounding_box', exc_info=True) + + finally: + # Unblock signals + for sb in spinboxes: + try: + sb.blockSignals(False) + except Exception: + pass def updateNelements(self, value): """Update the number of elements in the feature's interpolator.""" @@ -120,7 +300,206 @@ def getNelements(self, feature): elif feature.interpolator is not None: return feature.interpolator.n_elements return 1000 - + def _export_scalar_points(self): + """Gather points (bounding-box centres or project point layer), evaluate feature values + using the model_manager and add the resulting GeoDataFrame as a memory layer to the + QGIS project. Imports and QGIS calls are guarded so the module can be imported + outside of QGIS. + """ + # determine scalar type + logger.info('Exporting scalar points') + scalar_type = self.scalar_field_combo.currentText() if hasattr(self, 'scalar_field_combo') else 'scalar' + + # gather points + pts = None + attributes_df = None + crs = self.data_manager.project.crs().authid() + try: + # QGIS imports (guarded) + from qgis.core import QgsProject, QgsVectorLayer, QgsFeature, QgsGeometry, QgsPoint, QgsFields, QgsField + from qgis.PyQt.QtCore import QVariant + except Exception as e: + # Not running inside QGIS — nothing to do + logger.info('Not running inside QGIS, cannot export points') + print(e) + return + + # Evaluate on bounding box grid + if self.evaluate_target_combo.currentIndex() == 0: + # use bounding-box resolution or custom nsteps + logger.info('Using bounding box cell centres for evaluation') + bb = None + try: + bb = getattr(self.model_manager.model, 'bounding_box', None) + except Exception: + bb = None + + + + + pts = self.model_manager.model.bounding_box.cell_centres() + # no extra attributes for grid + attributes_df = None + logger.info(f'Got {len(pts)} points from bounding box cell centres') + elif self.evaluate_target_combo.currentIndex() == 1: + # Evaluate on an existing project point layer + layer_id = None + try: + layer_id = self.project_layer_combo.currentData() + except Exception: + layer_id = None + if layer_id is None: + return + layer = QgsProject.instance().mapLayer(layer_id) + if layer is None: + return + # build points array and attributes + pts_list = [] + attrs = [] + fields = [f.name() for f in layer.fields()] + for feat in layer.getFeatures(): + try: + geom = feat.geometry() + if geom is None or geom.isEmpty(): + continue + # handle point geometries + if geom.type() == 0: # QgsWkbTypes.PointGeometry -> numeric value 0 + try: + p = geom.asPoint() + x, y = p.x(), p.y() + # some QgsPoint has z attribute + try: + z = p.z() + except Exception: + z = self.model_manager.dem_function(x, y) if hasattr(self.model_manager, 'dem_function') else 0 + except Exception: + # fallback to centroid + try: + c = geom.centroid().asPoint() + x, y = c.x(), c.y() + z = self.model_manager.dem_function(x, y) if hasattr(self.model_manager, 'dem_function') else 0 + except Exception: + continue + pts_list.append((x, y, z)) + # collect attributes + row = {k: feat[k] for k in fields} + attrs.append(row) + else: + # skip non-point geometries + continue + except Exception: + continue + if len(pts_list) == 0: + return + import pandas as _pd + pts = _pd.DataFrame(pts_list).values + try: + attributes_df = _pd.DataFrame(attrs) + except Exception: + attributes_df = None + try: + crs = layer.crs().authid() + except Exception: + crs = None + elif self.evaluate_target_combo.currentIndex() == 2: + # Evaluate on an object from the viewer + # These are all pyvista objects and we want to add + # the scalar as a new field to the objects + + viewer = self.plugin.loop_widget.visualisation_widget.plotter + if viewer is None: + return + mesh = self.meshObjectCombo.currentText() + if not mesh: + return + vtk_mesh = viewer.meshes[mesh]['mesh'] + self.model_manager.export_feature_values_to_vtk_mesh( + self.feature.name, + vtk_mesh, + scalar_type=scalar_type + ) + # call model_manager to produce GeoDataFrame + try: + logger.info('Exporting feature values to GeoDataFrame') + gdf = self.model_manager.export_feature_values_to_geodataframe( + self.feature.name, + pts, + scalar_type=scalar_type, + attributes=attributes_df, + crs=crs, + ) + except Exception as e: + logger.debug('Failed to export feature values', exc_info=True) + return + + # convert returned GeoDataFrame to a QGIS memory layer and add to project + if gdf is None or len(gdf) == 0: + return + + # create memory layer + # derive CRS string if available + layer_uri = 'Point' + if hasattr(gdf, 'crs') and gdf.crs is not None: + try: + crs_str = gdf.crs.to_string() + if crs_str: + layer_uri = f"Point?crs={crs_str}" + except Exception: + pass + mem_layer = QgsVectorLayer(layer_uri, f"model_{self.feature.name}", 'memory') + prov = mem_layer.dataProvider() + + # add fields + cols = [c for c in gdf.columns if c != 'geometry'] + qfields = [] + for c in cols: + sample = gdf[c].dropna() + qtype = QVariant.String + if not sample.empty: + v = sample.iloc[0] + if isinstance(v, (int,)): + qtype = QVariant.Int + elif isinstance(v, (float,)): + qtype = QVariant.Double + else: + qtype = QVariant.String + prov.addAttributes([QgsField(c, qtype)]) + qfields.append(c) + mem_layer.updateFields() + + # add features + feats = [] + for _, row in gdf.reset_index(drop=True).iterrows(): + f = QgsFeature() + # set attributes in provider order + attr_vals = [row.get(c) for c in qfields] + try: + f.setAttributes(attr_vals) + except Exception: + pass + # geometry + try: + geom = row.get('geometry') + if geom is not None: + # try to extract x,y,z from shapely Point + try: + x, y = geom.x, geom.y + z = geom.z if hasattr(geom, 'z') else None + if z is None: + qgsp = QgsPoint(x, y, 0) + else: + qgsp = QgsPoint(x, y, z) + f.setGeometry(qgsp) + except Exception: + # fallback: skip geometry + pass + except Exception: + pass + feats.append(f) + if feats: + prov.addFeatures(feats) + mem_layer.updateExtents() + QgsProject.instance().addMapLayer(mem_layer) class FaultFeatureDetailsPanel(BaseFeatureDetailsPanel): @@ -211,6 +590,7 @@ def __init__(self, parent=None, *, feature=None, model_manager=None, data_manage if feature is None: raise ValueError("Feature must be provided.") self.feature = feature + def addMidBlock(self): form_layout = QFormLayout() fold_frame_combobox = QComboBox() fold_frame_combobox.addItems([""] + [f.name for f in self.model_manager.fold_frames]) @@ -229,6 +609,7 @@ def __init__(self, parent=None, *, feature=None, model_manager=None, data_manage # Remove redundant layout setting self.setLayout(self.layout) + def on_fold_frame_changed(self, text): self.model_manager.add_fold_to_feature(self.feature.name, fold_frame_name=text) @@ -241,6 +622,7 @@ def __init__(self, parent=None, *, feature=None, model_manager=None, data_manage class FoldedFeatureDetailsPanel(BaseFeatureDetailsPanel): def __init__(self, parent=None, *, feature=None, model_manager=None, data_manager=None): super().__init__(parent, feature=feature, model_manager=model_manager, data_manager=data_manager) + def addMidBlock(self): # Remove redundant layout setting # self.setLayout(self.layout) form_layout = QFormLayout() @@ -252,10 +634,10 @@ def __init__(self, parent=None, *, feature=None, model_manager=None, data_manage norm_length.setRange(0, 100000) norm_length.setValue(1) # Set a default value norm_length.valueChanged.connect( - lambda value: feature.builder.update_build_arguments( + lambda value: self.feature.builder.update_build_arguments( { 'fold_weights': { - **feature.builder.build_arguments.get('fold_weights', {}), + **self.feature.builder.build_arguments.get('fold_weights', {}), 'fold_norm': value, } } @@ -267,10 +649,10 @@ def __init__(self, parent=None, *, feature=None, model_manager=None, data_manage norm_weight.setRange(0, 100000) norm_weight.setValue(1) norm_weight.valueChanged.connect( - lambda value: feature.builder.update_build_arguments( + lambda value: self.feature.builder.update_build_arguments( { 'fold_weights': { - **feature.builder.build_arguments.get('fold_weights', {}), + **self.feature.builder.build_arguments.get('fold_weights', {}), 'fold_normalisation': value, } } @@ -282,10 +664,10 @@ def __init__(self, parent=None, *, feature=None, model_manager=None, data_manage fold_axis_weight.setRange(0, 100000) fold_axis_weight.setValue(1) fold_axis_weight.valueChanged.connect( - lambda value: feature.builder.update_build_arguments( + lambda value: self.feature.builder.update_build_arguments( { 'fold_weights': { - **feature.builder.build_arguments.get('fold_weights', {}), + **self.feature.builder.build_arguments.get('fold_weights', {}), 'fold_axis_w': value, } } @@ -297,10 +679,10 @@ def __init__(self, parent=None, *, feature=None, model_manager=None, data_manage fold_orientation_weight.setRange(0, 100000) fold_orientation_weight.setValue(1) fold_orientation_weight.valueChanged.connect( - lambda value: feature.builder.update_build_arguments( + lambda value: self.feature.builder.update_build_arguments( { 'fold_weights': { - **feature.builder.build_arguments.get('fold_weights', {}), + **self.feature.builder.build_arguments.get('fold_weights', {}), 'fold_orientation': value, } } @@ -309,31 +691,31 @@ def __init__(self, parent=None, *, feature=None, model_manager=None, data_manage form_layout.addRow("Fold Orientation Weight", fold_orientation_weight) average_fold_axis_checkbox = QCheckBox("Average Fold Axis") - average_fold_axis_checkbox.setChecked(True) + average_fold_axis_checkbox.setChecked(False) average_fold_axis_checkbox.stateChanged.connect( - lambda state: feature.builder.update_build_arguments( + lambda state: self.feature.builder.update_build_arguments( {'av_fold_axis': state != Qt.Checked} ) ) average_fold_axis_checkbox.stateChanged.connect( - lambda state: fold_azimuth.setEnabled(state == Qt.Checked) + lambda state: self.fold_azimuth.setEnabled(state != Qt.Checked) ) average_fold_axis_checkbox.stateChanged.connect( - lambda state: fold_plunge.setEnabled(state == Qt.Checked) + lambda state: self.fold_plunge.setEnabled(state != Qt.Checked) ) - fold_plunge = QDoubleSpinBox() - fold_plunge.setRange(0, 90) - fold_plunge.setValue(0) - fold_azimuth = QDoubleSpinBox() - fold_azimuth.setRange(0, 360) - fold_azimuth.setValue(0) - fold_azimuth.setEnabled(False) - fold_plunge.setEnabled(False) - fold_plunge.valueChanged.connect(self.foldAxisFromPlungeAzimuth) - fold_azimuth.valueChanged.connect(self.foldAxisFromPlungeAzimuth) + self.fold_plunge = QDoubleSpinBox() + self.fold_plunge.setRange(0, 90) + self.fold_plunge.setValue(0) + self.fold_azimuth = QDoubleSpinBox() + self.fold_azimuth.setRange(0, 360) + self.fold_azimuth.setValue(0) + self.fold_azimuth.setEnabled(False) + self.fold_plunge.setEnabled(False) + self.fold_plunge.valueChanged.connect(self.foldAxisFromPlungeAzimuth) + self.fold_azimuth.valueChanged.connect(self.foldAxisFromPlungeAzimuth) form_layout.addRow(average_fold_axis_checkbox) - form_layout.addRow("Fold Plunge", fold_plunge) - form_layout.addRow("Fold Azimuth", fold_azimuth) + form_layout.addRow("Fold Plunge", self.fold_plunge) + form_layout.addRow("Fold Azimuth", self.fold_azimuth) # splot_button = QPushButton("S-Plot") # splot_button.clicked.connect( # lambda: self.open_splot_dialog() @@ -355,11 +737,12 @@ def foldAxisFromPlungeAzimuth(self): """Calculate the fold axis from plunge and azimuth.""" if self.feature: plunge = ( - self.layout().itemAt(0).widget().findChild(QDoubleSpinBox, "fold_plunge").value() + self.fold_plunge.value() ) azimuth = ( - self.layout().itemAt(0).widget().findChild(QDoubleSpinBox, "fold_azimuth").value() - ) + self.fold_azimuth.value()) vector = plungeazimuth2vector(plunge, azimuth)[0] if plunge is not None and azimuth is not None: - self.feature.builder.update_build_arguments({'fold_axis': vector}) + self.feature.builder.update_build_arguments({'fold_axis': vector.tolist()}) + + diff --git a/loopstructural/gui/modelling/geological_model_tab/geological_model_tab.py b/loopstructural/gui/modelling/geological_model_tab/geological_model_tab.py index ce14d6d..8f3832b 100644 --- a/loopstructural/gui/modelling/geological_model_tab/geological_model_tab.py +++ b/loopstructural/gui/modelling/geological_model_tab/geological_model_tab.py @@ -171,6 +171,7 @@ def delete_feature(self, item): # Try model's __delitem__ if supported try: del self.model_manager.model[feature_name] + del self.data_manager.feature_data[feature_name] except Exception: # Fallback: remove object from features list and feature index if present feature = self.model_manager.model.get_feature_by_name(feature_name) diff --git a/loopstructural/gui/modelling/model_definition/bounding_box.py b/loopstructural/gui/modelling/model_definition/bounding_box.py index 126ca52..6a32352 100644 --- a/loopstructural/gui/modelling/model_definition/bounding_box.py +++ b/loopstructural/gui/modelling/model_definition/bounding_box.py @@ -24,9 +24,12 @@ def __init__(self, parent=None, data_manager=None): self.data_manager.set_bounding_box_update_callback(self.set_bounding_box) def set_bounding_box(self, bounding_box): - """ - Set the bounding box values in the UI. - :param bounding_box: BoundingBox object with xmin, xmax, ymin, ymax, zmin, zmax attributes. + """Populate UI controls with values from a BoundingBox object. + + Parameters + ---------- + bounding_box : object + BoundingBox-like object with `origin` and `maximum` sequences of length 3. """ self.originXSpinBox.setValue(bounding_box.origin[0]) self.maxXSpinBox.setValue(bounding_box.maximum[0]) @@ -36,10 +39,7 @@ def set_bounding_box(self, bounding_box): self.maxZSpinBox.setValue(bounding_box.maximum[2]) def useCurrentViewExtent(self): - """ - Use the current view extent from the map canvas. - This method should be connected to a button or action in the UI. - """ + """Set bounding box values from the current map canvas view extent.""" if self.data_manager.map_canvas: extent = self.data_manager.map_canvas.extent() self.originXSpinBox.setValue(extent.xMinimum()) @@ -50,10 +50,7 @@ def useCurrentViewExtent(self): self.maxZSpinBox.setValue(1000) def selectFromCurrentLayer(self): - """ - Select the bounding box from the current layer. - This method should be connected to a button or action in the UI. - """ + """Set bounding box values from the currently selected layer's 3D extent.""" layer = self.data_manager.map_canvas.currentLayer() if layer: extent = layer.extent3D() diff --git a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py index f7383a6..1c9f40a 100644 --- a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py +++ b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py @@ -14,10 +14,19 @@ class StratColumnWidget(QWidget): - """In control of building the stratigraphic column - - :param QWidget: _description_ - :type QWidget: _type_ + """Widget that controls building the stratigraphic column. + + Parameters + ---------- + parent : QWidget, optional + Parent widget, by default None. + data_manager : object + Data manager instance used to manage the stratigraphic column. Must be provided. + + Notes + ----- + The widget updates its display based on the data_manager's stratigraphic column + and registers a callback via data_manager.set_stratigraphic_column_callback. """ def __init__(self, parent=None, data_manager=None): diff --git a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.py b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.py index 05f6d73..7585b96 100644 --- a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.py +++ b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.py @@ -40,18 +40,19 @@ def name(self, value: str): self._name = str(value) def set_thickness(self, thickness: float): - """ - Set the thickness of the stratigraphic unit. - :param thickness: The thickness value to set. + """Set the thickness of the stratigraphic unit. + + Parameters + ---------- + thickness : float + The thickness value to set. """ self.thickness = thickness self.spinBoxThickness.setValue(thickness) self.validateFields() def onColourSelectClicked(self): - """ - Open a color dialog to select a color for the stratigraphic unit. - """ + """Open a color dialog to select a color for the stratigraphic unit.""" from PyQt5.QtWidgets import QColorDialog color = QColorDialog.getColor() @@ -60,19 +61,19 @@ def onColourSelectClicked(self): self.buttonColor.setStyleSheet(f"background-color: {self.colour};") def onThicknessChanged(self, thickness: float): - """ - Update the thickness of the stratigraphic unit. - :param thickness: The new thickness value. + """Handle changes to the thickness spinbox. + + Parameters + ---------- + thickness : float + The new thickness value. """ self.thickness = thickness self.validateFields() self.thicknessChanged.emit(thickness) def onNameChanged(self): - """ - Update the name of the stratigraphic unit. - :param name: The new name value. - """ + """Handle name edit completion and emit nameChanged if modified.""" name = self.lineEditName.text().strip() if name != self.name: self.name = name @@ -84,9 +85,7 @@ def request_delete(self): self.deleteRequested.emit(self) def validateFields(self): - """ - Validate the fields and update the widget's appearance. - """ + """Validate the widget fields and update UI hints.""" # Reset all styles first self.lineEditName.setStyleSheet("") self.spinBoxThickness.setStyleSheet("") @@ -101,9 +100,12 @@ def validateFields(self): self.spinBoxThickness.setToolTip("Thickness must be greater than zero.") def setData(self, data: Optional[dict] = None): - """ - Set the data for the stratigraphic unit widget. - :param data: A dictionary containing 'name' and 'colour' keys. + """Set the data for the stratigraphic unit widget. + + Parameters + ---------- + data : dict or None + Dictionary containing 'name' and 'colour' keys. If None, defaults are used. """ if data: self.name = str(data.get("name", "")) @@ -119,9 +121,12 @@ def setData(self, data: Optional[dict] = None): self.validateFields() def getData(self) -> dict: - """ - Get the data from the stratigraphic unit widget. - :return: A dictionary containing 'name', 'colour', and 'thickness'. + """Return the widget data as a dictionary. + + Returns + ------- + dict + Dictionary containing 'uuid', 'name', 'colour', and 'thickness'. """ return { "uuid": self.uuid, diff --git a/loopstructural/gui/modelling/stratigraphic_column/unconformity.py b/loopstructural/gui/modelling/stratigraphic_column/unconformity.py index 519644f..0f0260a 100644 --- a/loopstructural/gui/modelling/stratigraphic_column/unconformity.py +++ b/loopstructural/gui/modelling/stratigraphic_column/unconformity.py @@ -29,9 +29,12 @@ def request_delete(self): self.deleteRequested.emit(self) def setData(self, data: Optional[dict] = None): - """ - Set the data for the unconformity widget. - :param data: A dictionary containing 'unconformity_type' key. + """Set the data for the unconformity widget. + + Parameters + ---------- + data : dict or None + Dictionary containing 'unconformity_type' key. If None, defaults are used. """ if data: self.unconformity_type = data.get("unconformity_type", "") diff --git a/loopstructural/gui/visualisation/feature_list_widget.py b/loopstructural/gui/visualisation/feature_list_widget.py index ad235c6..fbbc4b3 100644 --- a/loopstructural/gui/visualisation/feature_list_widget.py +++ b/loopstructural/gui/visualisation/feature_list_widget.py @@ -57,10 +57,12 @@ def _get_vector_scale(self, scale: Optional[Union[float, int]] = None) -> float: return scale def add_feature(self, feature): - """Add a feature to the feature list. + """Add a feature to the feature list widget. - :param feature: The feature to add. - :type feature: Feature + Parameters + ---------- + feature : Feature + The feature object to add to the list. """ featureItem = QTreeWidgetItem(self.treeWidget) featureItem.setText(0, feature.name) @@ -92,19 +94,19 @@ def contextMenuEvent(self, event): def add_scalar_field(self, feature_name): scalar_field = self.model_manager.model[feature_name].scalar_field() - self.viewer.add_mesh(scalar_field.vtk(), name=f'{feature_name}_scalar_field') + self.viewer.add_mesh_object(scalar_field.vtk(), name=f'{feature_name}_scalar_field') print(f"Adding scalar field to feature: {feature_name}") def add_surface(self, feature_name): surfaces = self.model_manager.model[feature_name].surfaces() for surface in surfaces: - self.viewer.add_mesh(surface.vtk(), name=f'{feature_name}_surface') + self.viewer.add_mesh_object(surface.vtk(), name=f'{feature_name}_surface') print(f"Adding surface to feature: {feature_name}") def add_vector_field(self, feature_name): vector_field = self.model_manager.model[feature_name].vector_field() scale = self._get_vector_scale() - self.viewer.add_mesh(vector_field.vtk(scale=scale), name=f'{feature_name}_vector_field') + self.viewer.add_mesh_object(vector_field.vtk(scale=scale), name=f'{feature_name}_vector_field') print(f"Adding vector field to feature: {feature_name}") def add_data(self, feature_name): @@ -113,11 +115,11 @@ def add_data(self, feature_name): if issubclass(type(d), VectorPoints): scale = self._get_vector_scale() # tolerance is None means all points are shown - self.viewer.add_mesh( + self.viewer.add_mesh_object( d.vtk(scale=scale, tolerance=None), name=f'{feature_name}_{d.name}_points' ) else: - self.viewer.add_mesh(d.vtk(), name=f'{feature_name}_{d.name}') + self.viewer.add_mesh_object(d.vtk(), name=f'{feature_name}_{d.name}') print(f"Adding data to feature: {feature_name}") def add_model_bounding_box(self): @@ -125,7 +127,7 @@ def add_model_bounding_box(self): print("Model manager is not set.") return bb = self.model_manager.model.bounding_box.vtk().outline() - self.viewer.add_mesh(bb, name='model_bounding_box') + self.viewer.add_mesh_object(bb, name='model_bounding_box') # Logic for adding model bounding box print("Adding model bounding box...") @@ -135,7 +137,7 @@ def add_fault_surfaces(self): return fault_surfaces = self.model_manager.model.get_fault_surfaces() for surface in fault_surfaces: - self.viewer.add_mesh(surface.vtk(), name=f'fault_surface_{surface.name}') + self.viewer.add_mesh_object(surface.vtk(), name=f'fault_surface_{surface.name}') print("Adding fault surfaces...") def add_stratigraphic_surfaces(self): @@ -144,5 +146,5 @@ def add_stratigraphic_surfaces(self): return stratigraphic_surfaces = self.model_manager.model.get_stratigraphic_surfaces() for surface in stratigraphic_surfaces: - self.viewer.add_mesh(surface.vtk(), name=surface.name) + self.viewer.add_mesh_object(surface.vtk(), name=surface.name) print("Adding stratigraphic surfaces...") diff --git a/loopstructural/gui/visualisation/loop_pyvistaqt_wrapper.py b/loopstructural/gui/visualisation/loop_pyvistaqt_wrapper.py index 6c9bca4..d0365d0 100644 --- a/loopstructural/gui/visualisation/loop_pyvistaqt_wrapper.py +++ b/loopstructural/gui/visualisation/loop_pyvistaqt_wrapper.py @@ -1,7 +1,9 @@ import re - +from collections import defaultdict from PyQt5.QtCore import pyqtSignal from pyvistaqt import QtInteractor +from typing import Optional, Any, Dict, Tuple +import pyvista as pv class LoopPyVistaQTPlotter(QtInteractor): @@ -11,6 +13,9 @@ def __init__(self, parent): super().__init__(parent=parent) self.objects = {} self.add_axes() + # maps name -> dict(mesh=..., actor=..., kwargs={...}) + self.meshes = {} + # maintain an internal pyvista plotter def increment_name(self, name): parts = name.split('_') @@ -25,52 +30,113 @@ def increment_name(self, name): name = '_'.join(parts) return name - def add_mesh(self, *args, **kwargs): - """Add a mesh to the plotter.""" - if 'name' not in kwargs or not kwargs['name']: - name = 'unnnamed_object' - kwargs['name'] = name - kwargs['name'] = kwargs['name'].replace(' ', '_') - kwargs['name'] = re.sub(r'[^a-zA-Z0-9_$]', '_', kwargs['name']) - if kwargs['name'][0].isdigit(): - kwargs['name'] = 'ls_' + kwargs['name'] - if kwargs['name'][0] == '_': - kwargs['name'] = 'ls' + kwargs['name'] - kwargs['name'] = self.increment_name(kwargs['name']) - if '__opacity' in kwargs['name']: - raise ValueError('Cannot use __opacity in name') - if '__visibility' in kwargs['name']: - raise ValueError('Cannot use __visibility in name') - if '__control_visibility' in kwargs['name']: - raise ValueError('Cannot use __control_visibility in name') - actor = super().add_mesh(*args, **kwargs) - self.objects[kwargs['name']] = args[0] - self.objectAdded.emit(self) - return actor + def add_mesh_object(self, mesh, name: str, *, scalars: Optional[Any] = None, cmap: Optional[str] = None, clim: Optional[Tuple[float, float]] = None, opacity: Optional[float] = None, show_scalar_bar: bool = False, color: Optional[Tuple[float, float, float]] = None, **kwargs) -> None: + """Add a mesh to the plotter. - def remove_object(self, name): - """Remove an object by name.""" - if name in self.actors: - self.remove_actor(self.actors[name]) - self.update() - else: - raise ValueError(f"Object '{name}' not found in the plotter.") + This wrapper stores metadata to allow robust re-adding and + updating of visualization parameters. + + Parameters + ---------- + mesh : pyvista.PolyData or similar + Mesh-like object to add. + name : str + Unique name for the mesh. + scalars : Optional[Any] + Name of scalar array or scalar values to map (optional). + cmap : Optional[str] + Colormap name (optional). + clim : Optional[tuple(float, float)] + Color limits as (min, max) for the colormap (optional). + opacity : Optional[float] + Surface opacity in the range 0-1 (optional). + show_scalar_bar : bool + Whether to show a scalar bar for mapped scalars. + color : Optional[tuple(float, float, float)] + Solid color as (r, g, b) in 0..1; if provided, overrides scalars. + **kwargs : dict + Additional keyword arguments forwarded to the underlying pyvista add_mesh call. + + Returns + ------- + None + """ + # Remove any previous entry with the same name (to keep metadata consistent) + # if name in self.meshes: + # try: + + # self.remove_object(name) + # except Exception: + # # ignore removal errors and proceed to add + # pass + + # Decide rendering mode: color (solid) if color provided else scalar mapping + scalars = scalars if scalars is not None else mesh.active_scalars_name + use_scalar = color is None and scalars is not None - def change_object_name(self, old_name, new_name): - """Change the name of an object.""" - if old_name in self.actors: - if new_name in self.objects: - raise ValueError(f"Object '{new_name}' already exists.") - self.actors[new_name] = self.actors.pop(old_name) - self.actors[new_name].name = new_name + # Build add_mesh kwargs + add_kwargs: Dict[str, Any] = {} + + if use_scalar: + add_kwargs['scalars'] = scalars + add_kwargs['cmap'] = cmap + if clim is not None: + add_kwargs['clim'] = clim + add_kwargs['show_scalar_bar'] = show_scalar_bar else: - raise ValueError(f"Object '{old_name}' not found in the plotter.") + # solid color + if color is not None: + add_kwargs['color'] = color + # ensure scalar bar is disabled if color is used + add_kwargs['show_scalar_bar'] = False + + if opacity is not None: + add_kwargs['opacity'] = opacity + + # merge any extra kwargs (allow caller to override default choices) + add_kwargs.update(kwargs) + + # attempt to add to the underlying pyvista plotter + actor = self.add_mesh(mesh, name=name, **add_kwargs) + + # store the mesh, actor and kwargs for future re-adds + self.meshes[name] = {'mesh': mesh, 'actor': actor, 'kwargs': {**add_kwargs}} + self.objectAdded.emit(self) + + def remove_object(self, name: str) -> None: + """Remove an object by name and clean up stored metadata. + + This ensures names can be re-used and re-adding works predictably. + """ + if name not in self.meshes: + return + entry = self.meshes[name] + actor = entry.get('actor', None) + try: + if actor is not None: + # pyvista.Plotter has remove_actor or remove_mesh depending on version + if hasattr(self, 'remove_actor'): + try: + self.remove_actor(actor) + except Exception: + # fallback to remove_mesh by name + if hasattr(self, 'remove_mesh'): + self.remove_mesh(name) + elif hasattr(self, 'remove_mesh'): + self.remove_mesh(name) + except Exception: + # ignore errors during actor removal + pass + # finally delete metadata + try: + del self.meshes[name] + except Exception: + pass - def change_object_visibility(self, name, visibility): + def set_object_visibility(self, name: str, visibility): """Change the visibility of an object.""" - if name in self.actors: - self.actors[name].visibility = visibility - self.actors[name].actor.visibility = visibility + if name in self.meshes: + self.meshes[name]['actor'].visibility = visibility self.update() else: raise ValueError(f"Object '{name}' not found in the plotter.") diff --git a/loopstructural/gui/visualisation/object_list_widget.py b/loopstructural/gui/visualisation/object_list_widget.py index 2a47897..f185313 100644 --- a/loopstructural/gui/visualisation/object_list_widget.py +++ b/loopstructural/gui/visualisation/object_list_widget.py @@ -15,36 +15,186 @@ class ObjectListWidget(QWidget): - def __init__(self, parent=None, *, viewer=None): + def __init__(self, parent=None, *, viewer=None, properties_widget=None): super().__init__(parent) self.mainLayout = QVBoxLayout(self) self.treeWidget = QTreeWidget(self) self.treeWidget.setHeaderHidden(True) # Hide the header - self.treeWidget.setSelectionMode(QTreeWidget.MultiSelection) # Enable multi-selection self.mainLayout.addWidget(self.treeWidget) addButton = QPushButton("Add Object", self) addButton.setContextMenuPolicy(Qt.CustomContextMenu) addButton.clicked.connect(self.show_add_object_menu) self.mainLayout.addWidget(addButton) - + self.properties_widget = properties_widget self.setLayout(self.mainLayout) self.viewer = viewer self.viewer.objectAdded.connect(self.update_object_list) self.treeWidget.installEventFilter(self) + self.treeWidget.itemSelectionChanged.connect(self.on_object_selected) + self.treeWidget.itemDoubleClicked.connect(self.onDoubleClick) + def onDoubleClick(self, item, column): + self.viewer.reset_camera() + def on_object_selected(self): + selected_items = self.treeWidget.selectedItems() + if not selected_items: + # if nothing selected keep the previous selection. + # Need to select a new object to change its properties + return + + # For simplicity, just handle the first selected item + item = selected_items[0] + item_widget = self.treeWidget.itemWidget(item, 0) + object_label = item_widget.findChild(QLabel).text() + if hasattr(self, 'properties_widget') and self.properties_widget: + + self.properties_widget.setCurrentObject(object_label) def update_object_list(self, new_object): + """Rebuild the tree so top-level items are the entries in + `viewer.meshes`. Each mesh gets a visibility checkbox and child + items listing its point and cell data arrays. + """ + if not self.viewer: + return + + # Clear and rebuild the tree to reflect current meshes + self.treeWidget.clear() + + meshes = getattr(self.viewer, 'meshes', {}) or {} + for mesh_name in sorted(meshes.keys()): + mesh = meshes[mesh_name] + self.add_mesh_item(mesh_name, mesh) + + def add_mesh_item(self, mesh_name, mesh): + """Add a top-level tree item for a mesh and populate children for + point/cell data arrays. + """ + top = QTreeWidgetItem(self.treeWidget) + + # Determine initial visibility. Prefer viewer.actors entry if available. + initial_visibility = True + try: + if hasattr(self.viewer, 'actors') and mesh_name in getattr(self.viewer, 'actors', {}): + initial_visibility = bool(self.viewer.actors[mesh_name].visibility) + elif hasattr(mesh, 'visibility'): + initial_visibility = bool(getattr(mesh, 'visibility')) + except Exception: + initial_visibility = True + + visibilityCheckbox = QCheckBox() + visibilityCheckbox.setChecked(initial_visibility) + + # Connect checkbox: prefer viewer APIs, fallback to mesh attribute + def _on_vis(state, name=mesh_name, m=mesh): + checked = state == Qt.Checked + if hasattr(self.viewer, 'actors') and name in getattr(self.viewer, 'actors', {}): + self.set_object_visibility(name, checked) + return + if hasattr(self.viewer, 'set_object_visibility'): + try: + self.viewer.set_object_visibility(name, checked) + return + except Exception: + pass + # Fallback: set on mesh if possible + if hasattr(m, 'visibility'): + try: + setattr(m, 'visibility', checked) + except Exception: + pass + + visibilityCheckbox.stateChanged.connect(_on_vis) + + # Compose widget (checkbox + label) + itemWidget = QWidget() + itemLayout = QHBoxLayout(itemWidget) + itemLayout.setContentsMargins(0, 0, 0, 0) + itemLayout.addWidget(visibilityCheckbox) + itemLayout.addWidget(QLabel(mesh_name)) + itemWidget.setLayout(itemLayout) - for object_name in self.viewer.actors: - # Check if object already exists in tree - exists = False - for i in range(self.treeWidget.topLevelItemCount()): - item = self.treeWidget.topLevelItem(i) - widget = self.treeWidget.itemWidget(item, 0) - if widget and widget.findChild(QLabel).text() == object_name: - exists = True - break - if not exists: - self.add_actor(object_name) + self.treeWidget.setItemWidget(top, 0, itemWidget) + top.setExpanded(False) + + # Add children: Point Data and Cell Data groups + try: + point_data = getattr(mesh, 'point_data', None) + cell_data = getattr(mesh, 'cell_data', None) + + if point_data is not None and len(point_data.keys()) > 0: + pd_group = QTreeWidgetItem(top) + pd_group.setText(0, 'Point Data') + for array_name in sorted(point_data.keys()): + arr_item = QTreeWidgetItem(pd_group) + # show name and length/type if available + try: + vals = point_data[array_name] + meta = f" ({len(vals)})" if hasattr(vals, '__len__') else '' + except Exception: + meta = '' + arr_item.setText(0, f"{array_name}{meta}") + + if cell_data is not None and len(cell_data.keys()) > 0: + cd_group = QTreeWidgetItem(top) + cd_group.setText(0, 'Cell Data') + for array_name in sorted(cell_data.keys()): + arr_item = QTreeWidgetItem(cd_group) + try: + vals = cell_data[array_name] + meta = f" ({len(vals)})" if hasattr(vals, '__len__') else '' + except Exception: + meta = '' + arr_item.setText(0, f"{array_name}{meta}") + except Exception: + # If mesh lacks expected attributes, silently continue + pass + + def add_object_item(self, object_name, instance=None): + """Add a generic object entry to the tree. This mirrors add_actor but works + for objects/meshes that are not present in viewer.actors.""" + objectItem = QTreeWidgetItem(self.treeWidget) + + # Determine initial visibility + visibility = False + if instance is not None and hasattr(instance, 'visibility'): + visibility = bool(getattr(instance, 'visibility')) + + visibilityCheckbox = QCheckBox() + visibilityCheckbox.setChecked(visibility) + + # Connect checkbox to toggle visibility. Prefer using viewer APIs if available. + def _on_visibility_change(state, name=object_name, inst=instance): + checked = state == Qt.Checked + # If there's an actor for this name, delegate to set_object_visibility + if hasattr(self.viewer, 'actors') and name in getattr(self.viewer, 'actors', {}): + self.set_object_visibility(name, checked) + return + # If viewer exposes a generic setter use it + if hasattr(self.viewer, 'set_object_visibility'): + try: + self.viewer.set_object_visibility(name, checked) + return + except Exception: + pass + # Fallback: set attribute on the instance if possible + if inst is not None and hasattr(inst, 'visibility'): + try: + setattr(inst, 'visibility', checked) + except Exception: + pass + + visibilityCheckbox.stateChanged.connect(_on_visibility_change) + + # Create a widget to hold the checkbox and name on a single line + itemWidget = QWidget() + itemLayout = QHBoxLayout(itemWidget) + itemLayout.setContentsMargins(0, 0, 0, 0) + itemLayout.addWidget(visibilityCheckbox) + itemLayout.addWidget(QLabel(object_name)) + itemWidget.setLayout(itemLayout) + + self.treeWidget.setItemWidget(objectItem, 0, itemWidget) + objectItem.setExpanded(False) # Initially collapsed def add_actor(self, actor_name): # Create a tree item for the object @@ -98,10 +248,12 @@ def export_selected_object(self): item_widget = self.treeWidget.itemWidget(selected_items[0], 0) object_label = item_widget.findChild(QLabel).text() - object = self.viewer.objects.get(object_label, None) - if object is None: + mesh_dict = self.viewer.meshes.get(object_label, None) + if mesh_dict is None: + return + mesh = mesh_dict.get('mesh', None) + if mesh is None: return - # Determine available formats based on object type and dependencies formats = [] try: @@ -146,28 +298,28 @@ def export_selected_object(self): try: if selected_format == "obj": ( - object.save(file_path) - if hasattr(object, "save") - else pv.save_meshio(file_path, object) + mesh.save(file_path) + if hasattr(mesh, "save") + else pv.save_meshio(file_path, mesh) ) elif selected_format == "vtk": - pv.save_meshio(file_path, object) + mesh.save(file_path) if hasattr(mesh, "save") else pv.save_meshio(file_path, mesh) elif selected_format == "ply": - pv.save_meshio(file_path, object) + pv.save_meshio(file_path, mesh) elif selected_format == "vtp": ( - object.save(file_path) - if hasattr(object, "save") - else pv.save_meshio(file_path, object) + mesh.save(file_path) + if hasattr(mesh, "save") + else pv.save_meshio(file_path, mesh) ) elif selected_format == "geoh5": with geoh5py.Geoh5(file_path, overwrite=True) as geoh5: - if hasattr(object, "faces"): + if hasattr(mesh, "faces"): geoh5.add_surface( - name=object_label, vertices=object.points, faces=object.faces + name=object_label, vertices=mesh.points, faces=mesh.faces ) else: - geoh5.add_points(name=object_label, vertices=object.points) + geoh5.add_points(name=object_label, vertices=mesh.points) print(f"Exported {object_label} to {file_path} as {selected_format}") except Exception as e: print(f"Failed to export object: {e}") @@ -216,12 +368,9 @@ def load_feature_from_file(self): try: mesh = pv.read(file_path) - if not isinstance(mesh, pv.PolyData): - raise ValueError("The file does not contain a valid mesh.") - # Add the mesh to the viewer if self.viewer and hasattr(self.viewer, 'add_mesh'): - self.viewer.add_mesh(mesh, name=file_name) + self.viewer.add_mesh_object(mesh, name=file_name) else: print("Error: Viewer is not initialized or does not support adding meshes.") diff --git a/loopstructural/gui/visualisation/object_properties_widget.py b/loopstructural/gui/visualisation/object_properties_widget.py new file mode 100644 index 0000000..fcda2a7 --- /dev/null +++ b/loopstructural/gui/visualisation/object_properties_widget.py @@ -0,0 +1,859 @@ +from PyQt5.QtCore import Qt + +from PyQt5.QtWidgets import ( + QWidget, QVBoxLayout, QLabel, QComboBox, QSlider, QCheckBox, QColorDialog, QPushButton, QHBoxLayout, QLineEdit, QSizePolicy +) + +# Add plotting imports for scalar histogram +from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas +import matplotlib.pyplot as plt +import numpy as np + +class ObjectPropertiesWidget(QWidget): + def __init__(self, parent=None, *, viewer=None): + super().__init__(parent) + layout = QVBoxLayout() + # Keep widgets close together and provide padding at the bottom + layout.setSpacing(8) + layout.setContentsMargins(8, 8, 8, 20) + + # Title / currently selected object + self.title_label = QLabel("No object selected") + self.title_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + layout.addWidget(self.title_label) + + # Scalar selection + layout.addWidget(QLabel("Active Scalar:")) + self.scalar_combo = QComboBox() + self.scalar_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.scalar_combo.currentTextChanged.connect(self._on_scalar_changed) + layout.addWidget(self.scalar_combo) + + # Color with Scalar checkbox + self.color_with_scalar_checkbox = QCheckBox("Color with Scalar") + self.color_with_scalar_checkbox.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) + self.color_with_scalar_checkbox.toggled.connect(self._on_color_with_scalar_toggled) + layout.addWidget(self.color_with_scalar_checkbox) + + # Scalar Bar + self.scalar_bar_checkbox = QCheckBox("Show Scalar Bar") + self.scalar_bar_checkbox.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) + layout.addWidget(self.scalar_bar_checkbox) + + # Colormap + layout.addWidget(QLabel("Colormap:")) + self.colormap_combo = QComboBox() + self.colormap_combo.addItems(["viridis", "plasma", "inferno", "magma", "greys"]) + self.colormap_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + # apply colormap changes when user selects a different cmap + self.colormap_combo.currentTextChanged.connect(self._on_colormap_changed) + layout.addWidget(self.colormap_combo) + + # Opacity + layout.addWidget(QLabel("Opacity:")) + self.opacity_slider = QSlider(Qt.Horizontal) + self.opacity_slider.setRange(0, 100) + self.opacity_slider.setValue(100) + self.opacity_slider.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.opacity_slider.valueChanged.connect(lambda val: self.set_opacity(val / 100.0)) + layout.addWidget(self.opacity_slider) + + # Show Edges + self.show_edges_checkbox = QCheckBox("Show Edges") + self.show_edges_checkbox.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) + # call set_show_edges when toggled + self.show_edges_checkbox.toggled.connect(self.set_show_edges) + layout.addWidget(self.show_edges_checkbox) + + # Line Width + layout.addWidget(QLabel("Line Width:")) + self.line_width_slider = QSlider(Qt.Horizontal) + # allow 0..20, interpreted as float line width + self.line_width_slider.setRange(0, 20) + self.line_width_slider.setValue(1) + self.line_width_slider.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.line_width_slider.valueChanged.connect(lambda val: self.set_line_width(val)) + layout.addWidget(self.line_width_slider) + + # Colormap Range + range_layout = QHBoxLayout() + range_layout.setSpacing(6) + range_layout.addWidget(QLabel("Colormap Range:")) + self.range_min = QLineEdit() + self.range_min.setPlaceholderText("Min") + self.range_min.setMaximumWidth(120) + self.range_min.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.range_max = QLineEdit() + self.range_max.setPlaceholderText("Max") + self.range_max.setMaximumWidth(120) + self.range_max.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + range_layout.addWidget(self.range_min) + range_layout.addWidget(self.range_max) + layout.addLayout(range_layout) + + # Scalar Histogram (matplotlib canvas) + layout.addWidget(QLabel("Scalar Histogram:")) + self.hist_fig = plt.Figure(figsize=(4, 2)) + self.hist_canvas = FigureCanvas(self.hist_fig) + self.hist_ax = self.hist_fig.subplots() + self.hist_canvas.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + layout.addWidget(self.hist_canvas) + + # Surface Color + surface_color_layout = QHBoxLayout() + surface_color_layout.setSpacing(6) + surface_color_layout.addWidget(QLabel("Surface Color:")) + self.color_button = QPushButton("Choose Color") + self.color_button.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + surface_color_layout.addWidget(self.color_button) + layout.addLayout(surface_color_layout) + + # Add stretch at end so widgets stay grouped at top and bottom has padding + layout.addStretch(1) + + self.setLayout(layout) + + # Internal state + self.current_object_name = None + self.current_mesh = None + self.viewer = viewer + + # Connect color button to color dialog + self.color_button.clicked.connect(self.choose_color) + + # Initialize UI state + self.color_with_scalar_checkbox.setChecked(False) + self._on_color_with_scalar_toggled(False) + + def choose_color(self): + color = QColorDialog.getColor() + if not color.isValid(): + return + self.color_button.setStyleSheet(f"background-color: {color.name()}") + try: + if not self.current_object_name: + return + actor = self.viewer.meshes[self.current_object_name]['actor'] + if hasattr(actor, 'prop'): + try: + actor.prop.color = (color.redF(), color.greenF(), color.blueF()) + except Exception: + pass + elif hasattr(actor, 'GetProperty'): + try: + prop = actor.GetProperty() + prop.SetColor(color.redF(), color.greenF(), color.blueF()) + except Exception: + pass + # store color in metadata + if self.current_object_name in getattr(self.viewer, 'meshes', {}): + self.viewer.meshes[self.current_object_name]['color'] = (color.redF(), color.greenF(), color.blueF()) + except Exception: + pass + + def set_opacity(self, value: float): + if self.current_object_name is None or self.viewer is None: + return + try: + actor = self.viewer.meshes[self.current_object_name]['actor'] + if hasattr(actor, 'prop'): + try: + actor.prop.opacity = value + except Exception: + pass + elif hasattr(actor, 'GetProperty'): + try: + prop = actor.GetProperty() + prop.SetOpacity(value) + except Exception: + pass + # store in metadata + if self.current_object_name in getattr(self.viewer, 'meshes', {}): + self.viewer.meshes[self.current_object_name].set('kwargs', {**self.viewer.meshes[self.current_object_name].get('kwargs', {}), 'opacity': value}) + except Exception: + pass + + def set_show_edges(self, show: bool): + """Enable or disable edge display for the current object. + Best-effort support for both pyvista actor wrappers and raw VTK actors. + """ + if self.current_object_name is None or self.viewer is None: + return + try: + mesh_entry = self.viewer.meshes.get(self.current_object_name, {}) + actor = mesh_entry.get('actor') + if actor is None: + return + # pyvista-style + if hasattr(actor, 'prop'): + try: + actor.prop.edge_visibility = bool(show) + except Exception: + pass + try: + # some wrappers expose setter methods on prop + actor.prop.SetEdgeVisibility(bool(show)) + except Exception: + pass + # raw VTK actor + elif hasattr(actor, 'GetProperty'): + try: + prop = actor.GetProperty() + if hasattr(prop, 'SetEdgeVisibility'): + prop.SetEdgeVisibility(bool(show)) + else: + # fall back to On/Off style methods + if bool(show) and hasattr(prop, 'EdgeVisibilityOn'): + prop.EdgeVisibilityOn() + elif not bool(show) and hasattr(prop, 'EdgeVisibilityOff'): + prop.EdgeVisibilityOff() + except Exception: + pass + # update metadata + try: + kwargs = mesh_entry.get('kwargs', {}) if isinstance(mesh_entry, dict) else {} + kwargs['show_edges'] = bool(show) + mesh_entry['kwargs'] = kwargs + # persist back to viewer.meshes + if hasattr(self.viewer, 'meshes') and self.current_object_name in self.viewer.meshes: + self.viewer.meshes[self.current_object_name] = mesh_entry + except Exception: + pass + # request render + plotter = getattr(self.viewer, 'plotter', None) + if plotter is not None and hasattr(plotter, 'render'): + try: + plotter.render() + except Exception: + pass + except Exception: + pass + + def set_line_width(self, value): + """Set the line width for edge rendering. Value is an integer slider value; interpreted as float width.""" + try: + width = float(value) + except Exception: + return + if self.current_object_name is None or self.viewer is None: + return + try: + mesh_entry = self.viewer.meshes.get(self.current_object_name, {}) + actor = mesh_entry.get('actor') + if actor is None: + return + if hasattr(actor, 'prop'): + try: + actor.prop.line_width = width + except Exception: + pass + try: + actor.prop.SetLineWidth(width) + except Exception: + pass + elif hasattr(actor, 'GetProperty'): + try: + prop = actor.GetProperty() + if hasattr(prop, 'SetLineWidth'): + prop.SetLineWidth(width) + except Exception: + pass + # update metadata + try: + kwargs = mesh_entry.get('kwargs', {}) if isinstance(mesh_entry, dict) else {} + kwargs['line_width'] = width + mesh_entry['kwargs'] = kwargs + if hasattr(self.viewer, 'meshes') and self.current_object_name in self.viewer.meshes: + self.viewer.meshes[self.current_object_name] = mesh_entry + except Exception: + pass + # request render + plotter = getattr(self.viewer, 'plotter', None) + if plotter is not None and hasattr(plotter, 'render'): + try: + plotter.render() + except Exception: + pass + except Exception: + pass + + def setCurrentObject(self, object_name: str): + self.current_object_name = object_name + mesh_entry = self.viewer.meshes.get(object_name, None) + if mesh_entry is None: + self.current_mesh = None + self.title_label.setText(f"Object: {object_name} (not found)") + self._update_histogram(None) + return + self.current_mesh = mesh_entry.get('mesh', None) + self.title_label.setText(f"Object: {object_name}") + + # reset small things + self.scalar_bar_checkbox.setChecked(False) + self.range_min.clear() + self.range_max.clear() + + # populate scalar combo + self.scalar_combo.blockSignals(True) + self.scalar_combo.clear() + + try: + pdata = getattr(self.current_mesh, 'point_data', None) or {} + cdata = getattr(self.current_mesh, 'cell_data', None) or {} + for k in sorted(pdata.keys()): + self.scalar_combo.addItem(k) + for k in sorted(cdata.keys()): + self.scalar_combo.addItem(f"cell:{k}") + except Exception: + pass + + # restore previous scalar selection if available + try: + kwargs = mesh_entry.get('kwargs', {}) if isinstance(mesh_entry, dict) else {} + prev_scalars = kwargs.get('scalars') + if prev_scalars: + sel_name = prev_scalars + if f"cell:{prev_scalars}" in [self.scalar_combo.itemText(i) for i in range(self.scalar_combo.count())]: + sel_name = f"cell:{prev_scalars}" + idx = self.scalar_combo.findText(sel_name) + if idx >= 0: + self.scalar_combo.setCurrentIndex(idx) + else: + idx = self.scalar_combo.findText("") + if idx >= 0: + self.scalar_combo.setCurrentIndex(idx) + except Exception: + pass + self.scalar_combo.blockSignals(False) + + # infer scalar range for display + try: + if self.current_mesh is not None: + pdata = getattr(self.current_mesh, 'point_data', None) or {} + cdata = getattr(self.current_mesh, 'cell_data', None) or {} + vals = None + if len(pdata.keys()) > 0: + vals = next(iter(pdata.values())) + elif len(cdata.keys()) > 0: + vals = next(iter(cdata.values())) + if vals is not None: + try: + mn = float(getattr(vals, 'min', lambda: min(vals))()) if hasattr(vals, 'min') else float(min(vals)) + mx = float(getattr(vals, 'max', lambda: max(vals))()) if hasattr(vals, 'max') else float(max(vals)) + self.range_min.setText(str(mn)) + self.range_max.setText(str(mx)) + except Exception: + self.range_min.clear() + self.range_max.clear() + except Exception: + pass + + # detect scalar bar visibility + try: + actor = mesh_entry.get('actor', None) + mapper = getattr(actor, 'mapper', None) + vis = False + if mapper is not None and hasattr(mapper, 'scalar_visibility'): + vis = bool(getattr(mapper, 'scalar_visibility')) + self.scalar_bar_checkbox.setChecked(vis) + except Exception: + pass + + # restore edge and line width from metadata or actor + try: + kwargs = mesh_entry.get('kwargs', {}) if isinstance(mesh_entry, dict) else {} + show_edges = kwargs.get('show_edges', None) + line_width = kwargs.get('line_width', None) + actor = mesh_entry.get('actor', None) + if show_edges is None and actor is not None: + try: + prop = getattr(actor, 'prop', None) + if prop is not None and hasattr(prop, 'edge_visibility'): + show_edges = bool(getattr(prop, 'edge_visibility')) + elif hasattr(actor, 'GetProperty'): + p = actor.GetProperty() + if hasattr(p, 'GetEdgeVisibility'): + show_edges = bool(p.GetEdgeVisibility()) + except Exception: + show_edges = False + if line_width is None and actor is not None: + try: + prop = getattr(actor, 'prop', None) + if prop is not None and hasattr(prop, 'line_width'): + line_width = float(getattr(prop, 'line_width')) + elif hasattr(actor, 'GetProperty'): + p = actor.GetProperty() + if hasattr(p, 'GetLineWidth'): + line_width = float(p.GetLineWidth()) + except Exception: + line_width = 1.0 + if show_edges is None: + show_edges = False + if line_width is None: + line_width = 1.0 + self.show_edges_checkbox.blockSignals(True) + self.show_edges_checkbox.setChecked(bool(show_edges)) + self.show_edges_checkbox.blockSignals(False) + self.line_width_slider.blockSignals(True) + try: + self.line_width_slider.setValue(int(round(float(line_width)))) + except Exception: + self.line_width_slider.setValue(1) + self.line_width_slider.blockSignals(False) + except Exception: + pass + + # determine initial color-with-scalar + try: + kwargs = mesh_entry.get('kwargs', {}) if isinstance(mesh_entry, dict) else {} + default_color_with_scalar = bool(kwargs.get('scalars') or kwargs.get('cmap')) + self.color_with_scalar_checkbox.blockSignals(True) + self.color_with_scalar_checkbox.setChecked(default_color_with_scalar) + self.color_with_scalar_checkbox.blockSignals(False) + self._on_color_with_scalar_toggled(default_color_with_scalar) + except Exception: + pass + + # update histogram display + try: + current_scalar = self.scalar_combo.currentText() + vals = self._get_scalar_values(current_scalar) + self._update_histogram(vals if self.color_with_scalar_checkbox.isChecked() else None) + except Exception: + self._update_histogram(None) + + def _on_scalar_changed(self, scalar_name: str): + # update histogram preview immediately + try: + vals = self._get_scalar_values(scalar_name) + self._update_histogram(vals if self.color_with_scalar_checkbox.isChecked() else None) + except Exception: + self._update_histogram(None) + + # if not coloring by scalar, only update metadata + if not self.color_with_scalar_checkbox.isChecked(): + try: + if self.current_object_name in getattr(self.viewer, 'meshes', {}): + self.viewer.meshes[self.current_object_name].set('kwargs', {**self.viewer.meshes[self.current_object_name].get('kwargs', {}), 'scalars': None}) + except Exception: + pass + return + + # try in-place update + try: + self._apply_scalar_to_actor(self.current_object_name, scalar_name) + return + except Exception: + pass + + # fallback to remove/add + if not self.current_object_name or self.viewer is None: + return + mesh_entry = self.viewer.meshes.get(self.current_object_name, None) + if mesh_entry is None: + return + mesh = mesh_entry.get('mesh') + old_kwargs = mesh_entry.get('kwargs', {}) if isinstance(mesh_entry, dict) else {} + + scalars = None + if scalar_name and scalar_name != "": + if scalar_name.startswith('cell:'): + scalars = scalar_name.split(':', 1)[1] + else: + scalars = scalar_name + + cmap = self.colormap_combo.currentText() or None + clim = None + try: + if self.range_min.text() and self.range_max.text(): + clim = (float(self.range_min.text()), float(self.range_max.text())) + except Exception: + clim = None + + opacity = old_kwargs.get('opacity', None) + show_scalar_bar = self.scalar_bar_checkbox.isChecked() + + try: + self.viewer.remove_object(self.current_object_name) + except Exception: + pass + + try: + self.viewer.add_mesh_object(mesh, name=self.current_object_name, scalars=scalars, cmap=cmap, clim=clim, opacity=opacity, show_scalar_bar=show_scalar_bar) + self.current_mesh = self.viewer.meshes.get(self.current_object_name, {}).get('mesh') + except Exception: + try: + self.viewer.add_mesh_object(mesh, name=self.current_object_name) + self.current_mesh = self.viewer.meshes.get(self.current_object_name, {}).get('mesh') + except Exception: + pass + + def _on_color_with_scalar_toggled(self, checked: bool): + try: + self.scalar_combo.setEnabled(checked) + self.colormap_combo.setEnabled(checked) + self.range_min.setEnabled(checked) + self.range_max.setEnabled(checked) + self.scalar_bar_checkbox.setEnabled(checked) + self.color_button.setEnabled(not checked) + self.hist_canvas.setVisible(checked) + + if self.current_object_name and self.current_object_name in getattr(self.viewer, 'meshes', {}): + current_scalar = self.scalar_combo.currentText() + if checked: + try: + self._apply_scalar_to_actor(self.current_object_name, current_scalar) + except Exception: + pass + else: + try: + actor = self.viewer.meshes[self.current_object_name].get('actor') + mapper = getattr(actor, 'mapper', None) + if mapper is not None: + try: + if hasattr(mapper, 'scalar_visibility'): + mapper.scalar_visibility = False + except Exception: + pass + try: + if hasattr(mapper, 'ScalarVisibilityOff'): + mapper.ScalarVisibilityOff() + except Exception: + pass + stored = self.viewer.meshes[self.current_object_name].get('kwargs', {}) + color = self.viewer.meshes[self.current_object_name].get('color') or stored.get('color') + if color is not None and hasattr(actor, 'prop'): + try: + actor.prop.color = color + except Exception: + pass + except Exception: + pass + except Exception: + pass + + def _on_colormap_changed(self, cmap: str): + """Apply or persist selected colormap for the current object. + Best-effort: try in-place application via _apply_scalar_to_actor, otherwise remove and re-add the mesh with the new cmap.""" + try: + if not self.current_object_name or self.viewer is None: + return + + # persist cmap in metadata even when not coloring by scalar + try: + if self.current_object_name in getattr(self.viewer, 'meshes', {}): + mesh_entry = self.viewer.meshes[self.current_object_name] + kwargs = mesh_entry.get('kwargs', {}) if isinstance(mesh_entry, dict) else {} + kwargs['cmap'] = cmap or None + mesh_entry['kwargs'] = kwargs + # write back + if hasattr(self.viewer, 'meshes'): + self.viewer.meshes[self.current_object_name] = mesh_entry + except Exception: + pass + + # only need to change rendering if we're coloring by scalar + if not self.color_with_scalar_checkbox.isChecked(): + return + + scalar_name = self.scalar_combo.currentText() + if not scalar_name or scalar_name == "": + return + + # try in-place update first + try: + self._apply_scalar_to_actor(self.current_object_name, scalar_name) + return + except Exception: + pass + + # fallback: remove and re-add mesh with new cmap + mesh_entry = self.viewer.meshes.get(self.current_object_name, None) + if mesh_entry is None: + return + mesh = mesh_entry.get('mesh') + old_kwargs = mesh_entry.get('kwargs', {}) if isinstance(mesh_entry, dict) else {} + + scalars = None + if scalar_name and scalar_name != "": + if scalar_name.startswith('cell:'): + scalars = scalar_name.split(':', 1)[1] + else: + scalars = scalar_name + + clim = None + try: + if self.range_min.text() and self.range_max.text(): + clim = (float(self.range_min.text()), float(self.range_max.text())) + except Exception: + clim = None + + opacity = old_kwargs.get('opacity', None) + show_scalar_bar = self.scalar_bar_checkbox.isChecked() + + try: + self.viewer.remove_object(self.current_object_name) + except Exception: + pass + + try: + self.viewer.add_mesh_object(mesh, name=self.current_object_name, scalars=scalars, cmap=cmap or None, clim=clim, opacity=opacity, show_scalar_bar=show_scalar_bar) + self.current_mesh = self.viewer.meshes.get(self.current_object_name, {}).get('mesh') + except Exception: + try: + self.viewer.add_mesh_object(mesh, name=self.current_object_name) + self.current_mesh = self.viewer.meshes.get(self.current_object_name, {}).get('mesh') + except Exception: + pass + except Exception: + pass + + def _get_scalar_values(self, scalar_name: str): + if not scalar_name or scalar_name == "" or self.current_mesh is None: + return None + try: + if scalar_name.startswith('cell:'): + name = scalar_name.split(':', 1)[1] + cdata = getattr(self.current_mesh, 'cell_data', None) or {} + vals = cdata.get(name, None) + else: + name = scalar_name + pdata = getattr(self.current_mesh, 'point_data', None) or {} + vals = pdata.get(name, None) + if vals is None: + return None + arr = np.asarray(vals) + if arr.size == 0: + return None + return arr + except Exception: + return None + + def _update_histogram(self, values): + try: + self.hist_ax.clear() + if values is None: + self.hist_ax.text(0.5, 0.5, 'No scalar selected', ha='center', va='center', transform=self.hist_ax.transAxes) + self.hist_ax.set_xticks([]) + self.hist_ax.set_yticks([]) + else: + self.hist_ax.hist(values.flatten(), bins=40, color='C0', alpha=0.8) + self.hist_ax.set_xlabel('Value') + self.hist_ax.set_ylabel('Count') + self.hist_canvas.draw_idle() + except Exception: + pass + + def _update_actor_mapper(self, mesh_entry, scalars, cmap, clim, values, actor, plotter): + """Centralized actor/mapper update: + - select/enable scalar array + - set scalar range + - build and assign a LUT from matplotlib cmap when possible + - persist kwargs and trigger render + """ + try: + mapper = getattr(actor, 'mapper', None) + # if plotter can update scalars more directly, prefer that + if plotter is not None and hasattr(plotter, 'update_scalars') and values is not None: + try: + plotter.update_scalars(values, mesh=mesh_entry.get('mesh'), render=False, name=self.current_object_name) + except Exception: + pass + + if mapper is None: + return + + # select color array + try: + if scalars and hasattr(mapper, 'SelectColorArray'): + try: + mapper.SelectColorArray(scalars) + except Exception: + pass + except Exception: + pass + try: + if scalars and hasattr(mapper, 'SetArrayName'): + try: + mapper.SetArrayName(scalars) + except Exception: + pass + except Exception: + pass + + # enable scalar visibility + try: + if hasattr(mapper, 'scalar_visibility'): + try: + mapper.scalar_visibility = True + except Exception: + pass + except Exception: + pass + try: + if hasattr(mapper, 'ScalarVisibilityOn'): + try: + mapper.ScalarVisibilityOn() + except Exception: + pass + except Exception: + pass + + # set scalar range + try: + mn = mx = None + if clim: + mn, mx = float(clim[0]), float(clim[1]) + else: + try: + import numpy as _np + arr = _np.asarray(values) if values is not None else None + if arr is not None and arr.size > 0: + mn = float(_np.nanmin(arr)) + mx = float(_np.nanmax(arr)) + except Exception: + pass + if mn is not None and mx is not None: + try: + if hasattr(mapper, 'SetScalarRange'): + mapper.SetScalarRange(mn, mx) + except Exception: + pass + except Exception: + pass + + # build and assign LUT from matplotlib cmap + try: + if cmap: + vtkLookupTable = None + try: + from vtk import vtkLookupTable as _vtkLookupTable # type: ignore + vtkLookupTable = _vtkLookupTable + except Exception: + try: + from vtkmodules.vtkCommonCore import vtkLookupTable as _vtkLookupTable # type: ignore + vtkLookupTable = _vtkLookupTable + except Exception: + vtkLookupTable = None + if vtkLookupTable is not None: + lut = vtkLookupTable() + lut.SetNumberOfTableValues(256) + lut.Build() + try: + import matplotlib.cm as mcm + cm = mcm.get_cmap(cmap) + for i in range(256): + r, g, b, a = cm(i / 255.0) + try: + lut.SetTableValue(i, float(r), float(g), float(b), float(a)) + except Exception: + try: + lut.SetTableValue(i, r, g, b, a) + except Exception: + pass + except Exception: + pass + + # set LUT range if we know clim + try: + if clim is not None and len(clim) == 2: + try: + lut.SetRange(float(clim[0]), float(clim[1])) + except Exception: + pass + except Exception: + pass + + # assign to mapper + try: + if hasattr(mapper, 'SetLookupTable'): + try: + mapper.SetLookupTable(lut) + except Exception: + pass + if hasattr(mapper, 'SetUseLookupTableScalarRange'): + try: + mapper.SetUseLookupTableScalarRange(True) + except Exception: + pass + except Exception: + pass + except Exception: + pass + + # persist kwargs + try: + kwargs = mesh_entry.get('kwargs', {}) if isinstance(mesh_entry, dict) else {} + kwargs['scalars'] = scalars + kwargs['cmap'] = cmap or None + if clim is not None: + kwargs['clim'] = (float(clim[0]), float(clim[1])) + mesh_entry['kwargs'] = kwargs + if hasattr(self.viewer, 'meshes') and self.current_object_name in self.viewer.meshes: + self.viewer.meshes[self.current_object_name] = mesh_entry + except Exception: + pass + + # request render + try: + if plotter is not None and hasattr(plotter, 'render'): + plotter.render() + except Exception: + pass + except Exception: + pass + + def _apply_scalar_to_actor(self, object_name: str, scalar_name: str): + if not object_name or self.viewer is None: + raise RuntimeError("No viewer or object specified") + mesh_entry = self.viewer.meshes.get(object_name) + if mesh_entry is None: + raise RuntimeError("Object not found in viewer.meshes") + mesh = mesh_entry.get('mesh') + actor = mesh_entry.get('actor') + + # disable mapping if requested + if not scalar_name or scalar_name == "": + mapper = getattr(actor, 'mapper', None) + if mapper is not None: + if hasattr(mapper, 'scalar_visibility'): + mapper.scalar_visibility = False + if hasattr(mapper, 'ScalarVisibilityOff'): + mapper.ScalarVisibilityOff() + mesh_entry.setdefault('kwargs', {})['scalars'] = None + return + + # resolve scalar name + use_cell = False + scalars = scalar_name + if scalar_name.startswith('cell:'): + scalars = scalar_name.split(':', 1)[1] + use_cell = True + + values = self._get_scalar_values(scalar_name) + if values is None: + raise RuntimeError('Failed to retrieve scalar values') + + plotter = getattr(self.viewer, 'plotter', None) + applied = False + if plotter is not None and hasattr(plotter, 'update_scalars'): + try: + plotter.update_scalars(values, mesh=mesh, render=True, name=object_name) + applied = True + except Exception: + applied = False + + # If we didn't use plotter.update_scalars, use the centralized mapper update helper + if not applied: + try: + cmap = self.colormap_combo.currentText() or None + clim = None + try: + if self.range_min.text() and self.range_max.text(): + clim = (float(self.range_min.text()), float(self.range_max.text())) + except Exception: + clim = None + self._update_actor_mapper(mesh_entry, scalars, cmap, clim, values, actor, plotter) + return + except Exception: + pass diff --git a/loopstructural/gui/visualisation/visualisation_widget.py b/loopstructural/gui/visualisation/visualisation_widget.py index 88f9dde..9a6e488 100644 --- a/loopstructural/gui/visualisation/visualisation_widget.py +++ b/loopstructural/gui/visualisation/visualisation_widget.py @@ -8,6 +8,7 @@ from .loop_pyvistaqt_wrapper import LoopPyVistaQTPlotter from .object_list_widget import ObjectListWidget from .feature_list_widget import FeatureListWidget +from .object_properties_widget import ObjectPropertiesWidget class VisualisationWidget(QWidget): @@ -31,8 +32,9 @@ def __init__(self, parent: QWidget = None, mapCanvas=None, logger=None, model_ma # Create the viewer self.plotter = LoopPyVistaQTPlotter(parent) # self.plotter.add_axes() + self.objectPropertiesWidget = ObjectPropertiesWidget(viewer=self.plotter) - self.objectList = ObjectListWidget(viewer=self.plotter) + self.objectList = ObjectListWidget(viewer=self.plotter,properties_widget=self.objectPropertiesWidget) # Modify layout to stack object list and feature list vertically sidebarSplitter = QSplitter(Qt.Vertical, self) @@ -43,3 +45,70 @@ def __init__(self, parent: QWidget = None, mapCanvas=None, logger=None, model_ma sidebarSplitter.addWidget(self.featureList) splitter.addWidget(sidebarSplitter) splitter.addWidget(self.plotter) + # Add properties panel but start it collapsed (size 0) + splitter.addWidget(self.objectPropertiesWidget) + self._main_splitter = splitter + # initial sizes: sidebar, main, properties (collapsed) + splitter.setSizes([200, 600, 0]) + # remember previous sizes so we can restore when blocking expansion + self._previous_splitter_sizes = splitter.sizes() + # Intercept user splitter moves to prevent expanding properties panel when not allowed + try: + splitter.splitterMoved.connect(self._on_splitter_moved) + except Exception: + pass + + def show_properties_panel(self, show: bool): + """Expand or collapse the properties panel in the splitter. + + When collapsed we set its size to 0 so it can't be opened accidentally. + """ + if not hasattr(self, '_main_splitter'): + return + sizes = self._main_splitter.sizes() + # sizes: [sidebar, main, properties] + if show: + # restore a modest width for properties panel, clamp values + sidebar = max(150, sizes[0]) + main = max(300, sizes[1]) + prop = 250 + self._main_splitter.setSizes([sidebar, main, prop]) + self._previous_splitter_sizes = [sidebar, main, prop] + else: + # collapse properties to 0 width + self._main_splitter.setSizes([sizes[0], sizes[1], 0]) + self._previous_splitter_sizes = [sizes[0], sizes[1], 0] + + def is_properties_panel_visible(self) -> bool: + if not hasattr(self, '_main_splitter'): + return False + return self._main_splitter.sizes()[2] > 0 + + def _can_show_properties(self) -> bool: + """Return True when properties panel may be shown (e.g. an object selected).""" + try: + w = self.objectPropertiesWidget + return getattr(w, 'current_object_name', None) is not None + except Exception: + return False + + def _on_splitter_moved(self, pos: int, index: int): + """Handler called after the user moves the splitter handle. + + If the user tries to open the properties panel (third pane) but + _can_show_properties() is False, silently restore the previous sizes + to block expansion. + """ + try: + sizes = self._main_splitter.sizes() + # if properties width > 0 and we are not allowed to show it, restore + if sizes[2] > 0 and not self._can_show_properties(): + # restore previous sizes + self._main_splitter.blockSignals(True) + self._main_splitter.setSizes(self._previous_splitter_sizes) + self._main_splitter.blockSignals(False) + else: + # update remembered sizes for later + self._previous_splitter_sizes = sizes + except Exception: + pass diff --git a/loopstructural/main/callableToLayer.py b/loopstructural/main/callableToLayer.py index 19b2d4e..b8e41bd 100644 --- a/loopstructural/main/callableToLayer.py +++ b/loopstructural/main/callableToLayer.py @@ -10,10 +10,21 @@ def callableToLayer(callable, layer, dtm, name: str): """Convert a feature to a raster and store it in QGIS as a temporary layer. - :param feature: The object that has an `evaluate_value` method for computing values. - :param dtm: Digital terrain model (if needed for processing). - :param bounding_box: Object with `origin`, `maximum`, `step_vector`, and `nsteps`. - :param crs: Coordinate reference system (QGIS CRS object). + Parameters + ---------- + callable : callable + A callable that accepts an (N,3) numpy array of points and returns values. + layer : QgsVectorLayer + QGIS vector layer to update with computed values. + dtm : QgsRaster or None + Digital terrain model used to extract Z values for points (optional). + name : str + Name of the attribute/field to store the computed values. + + Returns + ------- + None + The function updates the provided `layer` in-place. """ layer.startEditing() if name not in [field.name() for field in layer.fields()]: diff --git a/loopstructural/main/geometry/mapGrid.py b/loopstructural/main/geometry/mapGrid.py index a2a39d4..b77f0e8 100644 --- a/loopstructural/main/geometry/mapGrid.py +++ b/loopstructural/main/geometry/mapGrid.py @@ -3,14 +3,19 @@ def createGrid(boundingBox, dtm): - """Create a grid from a bounding box and a digital terrain model + """Create a grid from a bounding box and an optional DEM. - :param boundingBox: Bounding box of the grid - :type boundingBox: tuple - :param dtm: Digital Terrain Model - :type dtm: _type_ - :return: the grid - :rtype: _type_ + Parameters + ---------- + boundingBox : object + Bounding box of the grid. Must provide `corners_global` and `nsteps`. + dtm : QgsRaster or None + Digital Terrain Model used to sample Z values (optional). + + Returns + ------- + numpy.ndarray + Array of shape (N, 3) with X, Y, Z coordinates for grid points. """ minx, miny, maxx, maxy = boundingBox.corners_global[[0, 2], :2].flatten() x = np.linspace(minx, maxx, boundingBox.nsteps[0]) diff --git a/loopstructural/main/model_manager.py b/loopstructural/main/model_manager.py index bf9916d..5c07d86 100644 --- a/loopstructural/main/model_manager.py +++ b/loopstructural/main/model_manager.py @@ -1,3 +1,14 @@ +"""Geological model manager utilities used by the LoopStructural plugin. + +This module exposes the `GeologicalModelManager` which wraps a LoopStructural +`GeologicalModel` and provides helpers to ingest GeoDataFrames, update +stratigraphy and faults, evaluate features on point clouds or meshes, and +export results to GeoDataFrames or VTK meshes. + +The goal of the manager is to isolate data transformation, sampling and +interaction with the LoopStructural model from the GUI code. +""" + from collections import defaultdict from typing import Callable @@ -92,14 +103,20 @@ def set_fault_topology(self, fault_topology): def update_bounding_box(self, bounding_box: BoundingBox): """Update the bounding box of the geological model. - :param bounding_box: The new bounding box. - :type bounding_box: BoundingBox + Parameters + ---------- + bounding_box : BoundingBox + The new bounding box for the internal LoopStructural model. """ self.model.bounding_box = bounding_box def set_dem_function(self, dem_function: Callable): - """Set the function to get the elevation at a point. - :param dem_function: A function that takes x and y coordinates and returns the elevation. + """Set the function used to obtain elevation (Z) values. + + Parameters + ---------- + dem_function : Callable + Callable taking (x, y) and returning an elevation (z). """ self.dem_function = dem_function @@ -115,11 +132,29 @@ def update_fault_points( use_z_coordinate=False, ): """Add fault trace data to the geological model. - :param fault_trace: A GeoDataFrame containing the fault trace data. - :param fault_name_field: The field name for the fault name. - :param fault_dip_field: The field name for the fault dip. - :param fault_displacement_field: The field name for the fault displacement. - :param sampler: A callable that samples the fault trace and returns a DataFrame with X, Y, Z coordinates. + + Parameters + ---------- + fault_trace : geopandas.GeoDataFrame + GeoDataFrame containing the fault trace geometries and attributes. + fault_name_field : str or None + Field name in `fault_trace` indicating the fault identifier. If + not provided the sampler's `feature_id` is used. + fault_dip_field : str or None + Optional field name providing dip values for fault points. + fault_displacement_field : str or None + Optional field name providing displacement values for fault points. + fault_pitch_field : str or None + Optional field name providing fault pitch values. + sampler : callable, optional + Callable used to sample geometries to point rows (default: AllSampler()). + use_z_coordinate : bool + If True, use Z values from geometries when available; otherwise use + the manager's DEM function. + + Returns + ------- + None """ # sample fault trace self.faults.clear() # Clear existing faults @@ -162,6 +197,27 @@ def update_contact_traces( unit_name_field=None, use_z_coordinate=False, ): + """Ingest basal contact traces and populate internal stratigraphy. + + Parameters + ---------- + basal_contacts : geopandas.GeoDataFrame + GeoDataFrame containing basal contact geometries and attributes. + sampler : callable, optional + Callable used to sample geometries to point rows (default: AllSampler()). + unit_name_field : str or None + Field name in `basal_contacts` giving the stratigraphic unit name. If + None the function returns early. + use_z_coordinate : bool + If True, use Z values from geometries when available; otherwise use + the manager's DEM function. + + Notes + ----- + This method clears existing stratigraphy and replaces contact entries + keyed by unit name. It does not notify observers; callers should call + `update_model` or trigger observers as required. + """ self.stratigraphy.clear() # Clear existing stratigraphy unit_points = sampler(basal_contacts, self.dem_function, use_z_coordinate) if len(unit_points) == 0 or unit_points.empty: @@ -345,6 +401,14 @@ def update_model(self): observer() def features(self): + """Return the list of features currently held by the internal model. + + Returns + ------- + list + List-like collection of feature objects contained in the wrapped + LoopStructural `GeologicalModel`. + """ return self.model.features def add_foliation( @@ -355,6 +419,31 @@ def add_foliation( sampler=AllSampler(), use_z_coordinate=False, ): + """Create and add a foliation feature from grouped input layers. + + Parameters + ---------- + name : str + Name for the new foliation feature. + data : dict + Mapping of layer identifiers to dicts describing each layer. Each + layer dict must include a 'type' key (one of 'Orientation', + 'Formline', 'Value', 'Inequality') and the fields required by that + type (e.g. 'strike_field', 'dip_field', 'value_field', ...). + folded_feature_name : str or None + Optional name of a feature to which the foliation should be + associated/converted (currently unused in this helper). + sampler : callable, optional + Callable used to sample provided GeoDataFrames into plain pandas + rows (default: AllSampler()). + use_z_coordinate : bool + Whether to use Z coordinates from input geometries when present. + + Raises + ------ + ValueError + If a layer uses an unknown 'type' value. + """ # for z dfs = [] kwargs={} @@ -391,7 +480,25 @@ def add_foliation( # self.model[folded_feature_name] = folded_feature for observer in self.observers: observer() + def add_unconformity(self, foliation_name: str, value: float, type: FeatureType = FeatureType.UNCONFORMITY): + """Add an unconformity (or onlap unconformity) to a named foliation. + + Parameters + ---------- + foliation_name : str + Name of an existing foliation feature in the model. + value : float + Value (level) at which the unconformity should be inserted. + type : FeatureType + Type of unconformity (default: FeatureType.UNCONFORMITY). Use + FeatureType.ONLAPUNCONFORMITY for onlap-type behaviour. + + Raises + ------ + ValueError + If the foliation named by `foliation_name` cannot be found in the model. + """ foliation = self.model.get_feature_by_name(foliation_name) if foliation is None: raise ValueError(f"Foliation '{foliation_name}' not found in the model.") @@ -401,6 +508,24 @@ def add_unconformity(self, foliation_name: str, value: float, type: FeatureType self.model.add_onlap_unconformity(foliation, value) def add_fold_to_feature(self, feature_name: str, fold_frame_name: str, fold_weights={}): + """Apply a FoldFrame to an existing feature, producing a folded feature. + + Parameters + ---------- + feature_name : str + Name of the feature to fold. + fold_frame_name : str + Name of an existing fold frame feature in the model to use for + folding. + fold_weights : dict + Optional weights passed to the fold conversion; currently forwarded + to the converter implementation. + + Raises + ------ + ValueError + If either the fold frame or the target feature cannot be found. + """ from LoopStructural.modelling.features._feature_converters import add_fold_to_feature @@ -416,6 +541,17 @@ def add_fold_to_feature(self, feature_name: str, fold_frame_name: str, fold_weig self.model[feature_name] = folded_feature def convert_feature_to_structural_frame(self, feature_name: str): + """Convert an interpolated feature into a StructuralFrame. + + This helper constructs a StructuralFrameBuilder from the existing + feature's builder and replaces the feature in the model with the new + frame instance. + + Parameters + ---------- + feature_name : str + Name of the feature to convert. + """ from LoopStructural.modelling.features.builders import StructuralFrameBuilder builder = self.model.get_feature_by_name(feature_name).builder @@ -426,3 +562,152 @@ def convert_feature_to_structural_frame(self, feature_name: str): def fold_frames(self): """Return the fold frames in the model.""" return [f for f in self.model.features if f.type == FeatureType.STRUCTURALFRAME] + + def evaluate_feature_on_points(self, feature_name: str, points: np.ndarray, scalar_type: str = 'scalar') -> np.ndarray: + """Evaluate a model feature at the provided points. + + Parameters + ---------- + feature_name : str + Name of the feature to evaluate. + points : array_like + An (N, 3) array-like of points [x, y, z] at which to evaluate. + scalar_type : {'scalar', 'gradient'}, optional + Whether to evaluate scalar values or gradients. Default is 'scalar'. + + Returns + ------- + numpy.ndarray + Evaluated values. For 'scalar' an (N,) array is returned. For + 'gradient' an (N, 3) array is returned when supported by the model. + """ + if self.model is None: + raise RuntimeError('No model available for evaluation') + pts = np.asarray(points) + if pts.ndim != 2 or pts.shape[1] < 3: + raise ValueError('points must be an Nx3 array') + + try: + if scalar_type == 'gradient': + # Prefer a dedicated gradient evaluation method if available + if hasattr(self.model, 'evaluate_feature_gradient'): + vals = self.model.evaluate_feature_gradient(feature_name, pts) + else: + # Some models may support a gradient flag on the value evaluator + try: + vals = self.model.evaluate_feature_value(feature_name, pts, gradient=True) + except TypeError: + # Not supported by the model + raise RuntimeError('Model does not support gradient evaluation') + else: + vals = self.model.evaluate_feature_value(feature_name, pts) + return np.asarray(vals) + except Exception: + # Re-raise with context preserved for the caller/UI to handle + raise + + def export_feature_values_to_geodataframe( + self, + feature_name: str, + points: np.ndarray, + scalar_type: str = 'scalar', + attributes: 'pd.DataFrame' = None, + crs: str | None = None, + value_field_name: str | None = None, + ) -> 'gpd.GeoDataFrame': + """Evaluate a feature on points and return a GeoDataFrame with results. + + Parameters + ---------- + feature_name : str + Feature name to evaluate. + points : array_like + An (N, 3) array-like of points (x, y, z). + scalar_type : {'scalar', 'gradient'}, optional + Whether to compute scalar values or gradients. + attributes : pandas.DataFrame or None, optional + Optional attributes to attach (must have the same length as `points`). + crs : str or None, optional + Coordinate Reference System for the returned GeoDataFrame (e.g. 'EPSG:4326'). + value_field_name : str or None, optional + Optional name for the value field; defaults to '_value' or '_gradient'. + + Returns + ------- + geopandas.GeoDataFrame + GeoDataFrame containing point geometries and computed value columns + (and any provided attributes). + """ + import pandas as _pd + import geopandas as _gpd + try: + from shapely.geometry import Point as _Point + except Exception: + print("Shapely not available; geometry column will be omitted." ) + _Point = None + + pts = np.asarray(points) + if pts.ndim != 2 or pts.shape[1] < 3: + raise ValueError('points must be an Nx3 array') + + values = self.evaluate_feature_on_points(feature_name, pts, scalar_type=scalar_type) + + # Build a DataFrame + df = _pd.DataFrame({'x': pts[:, 0], 'y': pts[:, 1], 'z': pts[:, 2]}) + + if scalar_type == 'gradient': + vals = np.asarray(values) + if vals.ndim == 2 and vals.shape[1] == 3: + df['gx'] = vals[:, 0] + df['gy'] = vals[:, 1] + df['gz'] = vals[:, 2] + # also provide magnitude + df[f'{feature_name}_gmag'] = np.linalg.norm(vals, axis=1) + else: + # Unexpected shape; attempt to flatten + df[f'{feature_name}_gradient'] = list(vals) + else: + df[value_field_name or f"{feature_name}_value"] = np.asarray(values) + + # Attach attributes if provided + if attributes is not None: + try: + attributes = _pd.DataFrame(attributes).reset_index(drop=True) + df = _pd.concat([df.reset_index(drop=True), attributes.reset_index(drop=True)], axis=1) + except Exception: + # ignore attributes if they cannot be combined + pass + + # Create geometry column + geoms = None + if _Point is not None: + geoms = [_Point(x, y, z) for x, y, z in pts] + gdf = _gpd.GeoDataFrame(df, geometry=geoms, crs=crs) + else: + # shapely not available; return a regular DataFrame inside a GeoDataFrame placeholder + gdf = _gpd.GeoDataFrame(df) + + return gdf + + def export_feature_values_to_vtk_mesh(self, name, mesh, scalar_type='scalar'): + """Evaluate a feature on a mesh's points and attach the values as a field. + + Parameters + ---------- + name : str + Feature name to evaluate. + mesh : pyvista.PolyData or similar + Mesh-like object exposing a `points` array and supporting item + assignment for point data (e.g. mesh[name] = values). + scalar_type : str + 'scalar' or 'gradient' to control what is computed and attached. + + Returns + ------- + mesh + The same mesh instance with added/updated point data named `name`. + """ + pts = mesh.points + values = self.evaluate_feature_on_points(name, pts, scalar_type=scalar_type) + mesh[name] = values + return mesh \ No newline at end of file diff --git a/loopstructural/main/vectorLayerWrapper.py b/loopstructural/main/vectorLayerWrapper.py index 7e146db..f84f6d0 100644 --- a/loopstructural/main/vectorLayerWrapper.py +++ b/loopstructural/main/vectorLayerWrapper.py @@ -22,15 +22,22 @@ def qgsLayerToGeoDataFrame(layer) -> gpd.GeoDataFrame: def qgsLayerToDataFrame(layer, dtm) -> pd.DataFrame: - """Convert a vector layer to a pandas DataFrame - samples the geometry using either points or the vertices of the lines + """Convert a vector layer to a pandas DataFrame by sampling geometries. - :param layer: _description_ - :type layer: _type_ - :param dtm: Digital Terrain Model to evaluate Z values - :type dtm: _type_ or None - :return: the dataframe object - :rtype: pd.DataFrame + Samples point geometries or the vertices of line geometries and optionally + queries a DTM for Z values. + + Parameters + ---------- + layer : QgsVectorLayer + Input QGIS vector layer to sample. + dtm : QgsRaster or None + Digital Terrain Model used to evaluate Z values for points (optional). + + Returns + ------- + pd.DataFrame + DataFrame with columns `X`, `Y`, `Z` and one column per layer field. """ if layer is None: return None diff --git a/loopstructural/plugin_main.py b/loopstructural/plugin_main.py index ddf69cf..12bbd18 100644 --- a/loopstructural/plugin_main.py +++ b/loopstructural/plugin_main.py @@ -43,12 +43,20 @@ class LoopstructuralPlugin: + """QGIS plugin entrypoint for LoopStructural. + + This class initializes plugin resources, UI elements and data/model managers + required for LoopStructural integration with QGIS. + """ + def __init__(self, iface: QgisInterface): - """Constructor. + """Initialize the plugin. - :param iface: 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. - :type iface: QgsInterface + Parameters + ---------- + iface : QgisInterface + An interface instance provided by QGIS which allows the plugin to + manipulate the QGIS application at run time. """ self.iface = iface self.log = PlgLogger().log @@ -71,6 +79,12 @@ def __init__(self, iface: QgisInterface): self.data_manager.set_model_manager(self.model_manager) def injectLogHandler(self): + """Install LoopStructural logging handler that forwards logs to the plugin logger. + + This configures LoopStructural's logging to use the plugin's + PlgLoggerHandler so log records are captured and forwarded to the + plugin's logging infrastructure. + """ import logging import LoopStructural @@ -168,18 +182,22 @@ def initGui(self): self.action_modelling.triggered.connect(self.loop_dockwidget.toggleViewAction().trigger) def tr(self, message: str) -> str: - """Get the translation for a string using Qt translation API. + """Translate a string using Qt translation API. - :param message: string to be translated. - :type message: str + Parameters + ---------- + message : str + String to be translated. - :returns: Translated version of message. - :rtype: str + Returns + ------- + str + Translated version of the input string. """ return QCoreApplication.translate(self.__class__.__name__, message) def unload(self): - """Cleans up when plugin is disabled/uninstalled.""" + """Clean up when plugin is disabled or uninstalled.""" # -- Clean up menu self.iface.removePluginMenu(__title__, self.action_help) self.iface.removePluginMenu(__title__, self.action_settings) @@ -197,9 +215,12 @@ def unload(self): del self.toolbar def run(self): - """Main process. + """Run main process. - :raises Exception: if there is no item in the feed + Raises + ------ + Exception + If there is no item in the feed. """ try: self.log( diff --git a/loopstructural/processing/__init__.py b/loopstructural/processing/__init__.py index ebc83bf..6b649c5 100644 --- a/loopstructural/processing/__init__.py +++ b/loopstructural/processing/__init__.py @@ -1,2 +1,7 @@ #! python3 +"""Processing provider package for LoopStructural. + +Contains provider definitions and registration utilities for the QGIS +processing framework. +""" from .provider import LoopstructuralProvider diff --git a/loopstructural/processing/provider.py b/loopstructural/processing/provider.py index c7b1608..0b970cb 100644 --- a/loopstructural/processing/provider.py +++ b/loopstructural/processing/provider.py @@ -1,7 +1,6 @@ #! python3 -"""Processing provider module. -""" +"""Processing provider for LoopStructural plugin.""" # PyQGIS from qgis.core import QgsProcessingProvider @@ -20,64 +19,72 @@ class LoopstructuralProvider(QgsProcessingProvider): """Processing provider class.""" def loadAlgorithms(self): - """Loads all algorithms belonging to this provider.""" + """Load all algorithms belonging to this provider.""" pass def id(self) -> str: - """Unique provider id, used for identifying it. This string should be unique, \ - short, character only string, eg "qgis" or "gdal". \ - This string should not be localised. + """Return unique provider id. - :return: provider ID - :rtype: str + Returns + ------- + str + Provider ID string used for identifying the provider (must be short and + non-localised). """ return "loopstructural" def name(self) -> str: - """Returns the provider name, which is used to describe the provider - within the GUI. This string should be short (e.g. "Lastools") and localised. + """Return provider name used in the GUI. - :return: provider name - :rtype: str + Returns + ------- + str + Short, localised provider name. """ return __title__ def longName(self) -> str: - """Longer version of the provider name, which can include - extra details such as version numbers. E.g. "Lastools LIDAR tools". This string should be localised. The default - implementation returns the same string as name(). + """Return longer provider name (may include version information). - :return: provider long name - :rtype: str + Returns + ------- + str + Localised long name for display in the GUI. """ return self.tr("{} - Tools".format(__title__)) def icon(self) -> QIcon: - """QIcon used for your provider inside the Processing toolbox menu. + """Return icon used for the provider inside the Processing toolbox menu. - :return: provider icon - :rtype: QIcon + Returns + ------- + QIcon + Icon for the provider. """ return QIcon(str(__icon_path__)) def tr(self, message: str) -> str: - """Get the translation for a string using Qt translation API. + """Translate a string using Qt translation API. - :param message: String for translation. - :type message: str, QString + Parameters + ---------- + message : str + String to be translated. - :returns: Translated version of message. - :rtype: str + Returns + ------- + str + Translated version of message. """ # noinspection PyTypeChecker,PyArgumentList,PyCallByClass return QCoreApplication.translate(self.__class__.__name__, message) def versionInfo(self) -> str: - """Version information for the provider, or an empty string if this is not \ - applicable (e.g. for inbuilt Processing providers). For plugin based providers, \ - this should return the plugin’s version identifier. + """Return provider version information. - :return: version - :rtype: str + Returns + ------- + str + Version string for the provider (plugin version). """ return __version__ diff --git a/loopstructural/toolbelt/__init__.py b/loopstructural/toolbelt/__init__.py index 461211d..1ae1155 100644 --- a/loopstructural/toolbelt/__init__.py +++ b/loopstructural/toolbelt/__init__.py @@ -1,3 +1,9 @@ #! python3 +"""Toolbelt utilities for the LoopStructural plugin. + +This package contains utility modules used by the plugin such as logging +and preferences management. +""" + from .log_handler import PlgLogger from .preferences import PlgOptionsManager diff --git a/loopstructural/toolbelt/log_handler.py b/loopstructural/toolbelt/log_handler.py index af0b6e2..31001a0 100644 --- a/loopstructural/toolbelt/log_handler.py +++ b/loopstructural/toolbelt/log_handler.py @@ -1,4 +1,10 @@ -#! python3 +#!/usr/bin/env python3 +"""Logging helpers that forward Python logging into QGIS messaging systems. + +This module provides a convenience `PlgLogger` for emitting user-facing +messages to the QGIS message log and message bar, and `PlgLoggerHandler` that +bridges Python's `logging` into the plugin's logging facilities. +""" # standard library import logging @@ -38,55 +44,38 @@ def log( # parent parent_location: QWidget = None, ): - """Send messages to QGIS messages windows and to the user as a message bar. \ - Plugin name is used as title. If debug mode is disabled, only warnings (1) and \ - errors (2) or with push are sent. - - :param message: message to display - :type message: str - :param application: name of the application sending the message. \ - Defaults to __about__.__title__ - :type application: str, optional - :param log_level: message level. Possible values: 0 (info), 1 (warning), \ - 2 (critical), 3 (success), 4 (none - grey). Defaults to 0 (info) - :type log_level: int, optional - :param push: also display the message in the QGIS message bar in addition to \ - the log, defaults to False - :type push: bool, optional - :param duration: duration of the message in seconds. If not set, the \ - duration is calculated from the log level: `(log_level + 1) * 3`. seconds. \ - If set to 0, then the message must be manually dismissed by the user. \ - Defaults to None. - :type duration: int, optional - :param button: display a button in the message bar. Defaults to False. - :type button: bool, optional - :param button_text: text label of the button. Defaults to None. - :type button_text: str, optional - :param button_connect: function to be called when the button is pressed. \ - If not set, a simple dialog (QgsMessageOutput) is used to dislay the message. \ - Defaults to None. - :type button_connect: Callable, optional - :param parent_location: parent location widget. \ - If not set, QGIS canvas message bar is used to push message, \ - otherwise if a QgsMessageBar is available in parent_location it is used instead. \ - Defaults to None. - :type parent_location: Widget, optional - - :Example: - - .. code-block:: python - - log(message="Plugin loaded - INFO", log_level=0, push=False) - log(message="Plugin loaded - WARNING", log_level=1, push=1, duration=5) - log(message="Plugin loaded - ERROR", log_level=2, push=1, duration=0) - log( - message="Plugin loaded - SUCCESS", - log_level=3, - push=1, - duration=10, - button=True - ) - log(message="Plugin loaded - TEST", log_level=4, push=0) + """Send messages to QGIS messages windows and to the user as a message bar. + + Plugin name is used as title. If debug mode is disabled, only warnings + and errors (or messages with `push=True`) are shown. + + Parameters + ---------- + message : str + Message to display. + application : str, optional + Name of the application sending the message. Defaults to plugin title. + log_level : int, optional + Message level. Possible values: 0 (info), 1 (warning), 2 (critical), + 3 (success), 4 (none/grey). Defaults to 0 (info). + push : bool, optional + If True, also display the message in the QGIS message bar. + duration : int or None, optional + Duration in seconds for the message. If None, a duration is computed + from the log level. If 0 the message must be dismissed manually. + button : bool, optional + Display a button in the message bar (defaults to False). + button_text : str or None, optional + Text label for the optional button. + button_connect : Callable or None, optional + Callable invoked when the optional button is pressed. + parent_location : QWidget or None, optional + Parent widget in which to search for a `QgsMessageBar`. If not + provided, the QGIS main message bar is used. + + Returns + ------- + None """ # if not debug mode and not push, let's ignore INFO, SUCCESS and TEST debug_mode = plg_prefs_hdlr.PlgOptionsManager.get_plg_settings().debug_mode @@ -149,21 +138,24 @@ def log( class PlgLoggerHandler(logging.Handler): - """ - Standard logging.Handler that forwards logs to PlgLogger.log(). + """Handler that forwards Python log records to the plugin logger. + + The handler calls the provided `plg_logger_class.log()` static method to + forward formatted log messages to QGIS messaging systems. """ def __init__(self, plg_logger_class, level=logging.NOTSET, push=False, duration=None): - """ + """Initialize the log handler. + Parameters ---------- plg_logger_class : class - Class providing a static `log()` method (like your PlgLogger). - level : int - The logging level to handle. - push : bool + Class providing a static `log()` method (like PlgLogger). + level : int, optional + The logging level to handle. Defaults to logging.NOTSET. + push : bool, optional Whether to push messages to the QGIS message bar. - duration : int + duration : int, optional Optional fixed duration for messages. """ super().__init__(level) @@ -172,6 +164,11 @@ def __init__(self, plg_logger_class, level=logging.NOTSET, push=False, duration= self.duration = duration def emit(self, record): + """Emit a logging record by forwarding it to the plugin logger. + + This formats the record, maps the Python logging level to QGIS levels + and calls `plg_logger_class.log()` with the resulting message. + """ try: msg = self.format(record) qgis_level = self._map_log_level(record.levelno) diff --git a/loopstructural/toolbelt/preferences.py b/loopstructural/toolbelt/preferences.py index 1d442d2..108ac87 100644 --- a/loopstructural/toolbelt/preferences.py +++ b/loopstructural/toolbelt/preferences.py @@ -1,6 +1,10 @@ #! python3 -"""Plugin settings.""" +"""Preferences manager helpers for the plugin. + +This module exposes `PlgOptionsManager` which centralises access to plugin +settings used across the UI and background services. +""" # standard from dataclasses import asdict, dataclass, fields @@ -32,13 +36,22 @@ class PlgSettingsStructure: class PlgOptionsManager: + """Manager for accessing and updating plugin configuration values. + + Provides convenience helpers around QGIS settings storage used by the + plugin to persist user preferences such as debug mode and UI options. + """ + @staticmethod def get_plg_settings() -> PlgSettingsStructure: - """Load and return plugin settings as a dictionary. \ + """Load and return plugin settings as a PlgSettingsStructure instance. + Useful to get user preferences across plugin logic. - :return: plugin settings - :rtype: PlgSettingsStructure + Returns + ------- + PlgSettingsStructure + Plugin settings dataclass populated from QGIS settings. """ # get dataclass fields definition settings_fields = fields(PlgSettingsStructure) @@ -63,10 +76,21 @@ def get_plg_settings() -> PlgSettingsStructure: @staticmethod def get_value_from_key(key: str, default=None, exp_type=None): - """Load and return plugin settings as a dictionary. \ - Useful to get user preferences across plugin logic. - - :return: plugin settings value matching key + """Load a single plugin setting value by key. + + Parameters + ---------- + key : str + Settings key to retrieve (must match a field on `PlgSettingsStructure`). + default : any, optional + Default value to return if the key is not set. + exp_type : type, optional + Expected type for the value retrieval. + + Returns + ------- + any + The stored setting value or None if an error occurs or the key is invalid. """ if not hasattr(PlgSettingsStructure, key): log_hdlr.PlgLogger.log( @@ -94,14 +118,19 @@ def get_value_from_key(key: str, default=None, exp_type=None): @classmethod def set_value_from_key(cls, key: str, value) -> bool: - """Set plugin QSettings value using the key. - - :param key: QSettings key - :type key: str - :param value: value to set - :type value: depending on the settings - :return: operation status - :rtype: bool + """Set a plugin setting value in QGIS settings. + + Parameters + ---------- + key : str + Settings key to set (must match a field on `PlgSettingsStructure`). + value : any + Value to store for the given key. + + Returns + ------- + bool + True if the operation succeeded, False otherwise. """ if not hasattr(PlgSettingsStructure, key): log_hdlr.PlgLogger.log( @@ -130,10 +159,16 @@ def set_value_from_key(cls, key: str, value) -> bool: @classmethod def save_from_object(cls, plugin_settings_obj: PlgSettingsStructure): - """Load and return plugin settings as a dictionary. \ - Useful to get user preferences across plugin logic. + """Persist a settings dataclass to QGIS settings. + + Parameters + ---------- + plugin_settings_obj : PlgSettingsStructure + Dataclass instance containing settings to save. - :return: plugin settings value matching key + Returns + ------- + None """ settings = QgsSettings() settings.beginGroup(__title__)