diff --git a/loopstructural/gui/compatibility.py b/loopstructural/gui/compatibility.py new file mode 100644 index 0000000..a7d6852 --- /dev/null +++ b/loopstructural/gui/compatibility.py @@ -0,0 +1,20 @@ +# compat.py +from qgis.PyQt.QtCore import QVariant + +try: + from qgis.PyQt.QtCore import QMetaType + + # We create a proxy class to mimic the old QVariant.Type behavior + class QVariantProxy: + Type = QMetaType.Type + # Add common types here if needed + Int = QMetaType.Type.Int + Double = QMetaType.Type.Double + String = QMetaType.Type.QString + Bool = QMetaType.Type.Bool + + # In QGIS 4, we use our proxy + QVariantCompat = QVariantProxy +except (ImportError, AttributeError): + # In QGIS 3, QVariant already has .Type + QVariantCompat = QVariant diff --git a/loopstructural/gui/data_conversion/data_conversion_widget.py b/loopstructural/gui/data_conversion/data_conversion_widget.py index 1ca1832..82fb8a5 100644 --- a/loopstructural/gui/data_conversion/data_conversion_widget.py +++ b/loopstructural/gui/data_conversion/data_conversion_widget.py @@ -8,8 +8,10 @@ from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple from LoopDataConverter import Datatype, InputData, LoopConverter, SurveyName -from PyQt5.QtCore import Qt, QTimer -from PyQt5.QtWidgets import ( +from qgis.core import QgsMapLayerProxyModel, QgsProject, QgsVectorLayer +from qgis.gui import QgsMapLayerComboBox +from qgis.PyQt.QtCore import Qt, QTimer +from qgis.PyQt.QtWidgets import ( QComboBox, QDialog, QDialogButtonBox, @@ -21,8 +23,6 @@ QVBoxLayout, QWidget, ) -from qgis.core import QgsMapLayerProxyModel, QgsProject, QgsVectorLayer -from qgis.gui import QgsMapLayerComboBox from ...main.helpers import ColumnMatcher from ...main.vectorLayerWrapper import QgsLayerFromDataFrame, QgsLayerFromGeoDataFrame diff --git a/loopstructural/gui/dlg_settings.py b/loopstructural/gui/dlg_settings.py index 8f29be9..32695d3 100644 --- a/loopstructural/gui/dlg_settings.py +++ b/loopstructural/gui/dlg_settings.py @@ -12,7 +12,7 @@ from qgis.core import Qgis, QgsApplication from qgis.gui import QgsOptionsPageWidget, QgsOptionsWidgetFactory from qgis.PyQt import uic -from qgis.PyQt.Qt import QUrl +from qgis.PyQt.QtCore import QUrl from qgis.PyQt.QtGui import QDesktopServices, QIcon # project diff --git a/loopstructural/gui/loop_widget.py b/loopstructural/gui/loop_widget.py index 15864dc..262ae05 100644 --- a/loopstructural/gui/loop_widget.py +++ b/loopstructural/gui/loop_widget.py @@ -5,7 +5,7 @@ interface for interacting with LoopStructural features inside QGIS. """ -from PyQt5.QtWidgets import QTabWidget, QVBoxLayout, QWidget +from qgis.PyQt.QtWidgets import QTabWidget, QVBoxLayout, QWidget from .modelling.modelling_widget import ModellingWidget from .visualisation.visualisation_widget import VisualisationWidget diff --git a/loopstructural/gui/map2loop_tools/basal_contacts_widget.py b/loopstructural/gui/map2loop_tools/basal_contacts_widget.py index 86019f4..fee4da2 100644 --- a/loopstructural/gui/map2loop_tools/basal_contacts_widget.py +++ b/loopstructural/gui/map2loop_tools/basal_contacts_widget.py @@ -2,9 +2,9 @@ import os -from PyQt5.QtWidgets import QMessageBox, QWidget from qgis.core import QgsProject, QgsVectorFileWriter from qgis.PyQt import uic +from qgis.PyQt.QtWidgets import QMessageBox, QWidget from ...main.helpers import ColumnMatcher, get_layer_names from ...main.m2l_api import extract_basal_contacts diff --git a/loopstructural/gui/map2loop_tools/dialogs.py b/loopstructural/gui/map2loop_tools/dialogs.py index b335d3b..94c19ff 100644 --- a/loopstructural/gui/map2loop_tools/dialogs.py +++ b/loopstructural/gui/map2loop_tools/dialogs.py @@ -4,7 +4,7 @@ instead of QGIS processing algorithms. """ -from PyQt5.QtWidgets import QDialog, QDialogButtonBox, QVBoxLayout +from qgis.PyQt.QtWidgets import QDialog, QDialogButtonBox, QVBoxLayout class SamplerDialog(QDialog): @@ -23,7 +23,9 @@ def setup_ui(self): from .sampler_widget import SamplerWidget layout = QVBoxLayout(self) - self.widget = SamplerWidget(self, data_manager=self.data_manager, debug_manager=self.debug_manager) + self.widget = SamplerWidget( + self, data_manager=self.data_manager, debug_manager=self.debug_manager + ) layout.addWidget(self.widget) # Replace the run button with dialog buttons diff --git a/loopstructural/gui/map2loop_tools/fault_topology_widget.py b/loopstructural/gui/map2loop_tools/fault_topology_widget.py index 9795f82..fcd9ca2 100644 --- a/loopstructural/gui/map2loop_tools/fault_topology_widget.py +++ b/loopstructural/gui/map2loop_tools/fault_topology_widget.py @@ -3,9 +3,9 @@ import os import geopandas as gpd -from PyQt5.QtWidgets import QDialog, QMessageBox from qgis.core import QgsMapLayerProxyModel from qgis.PyQt import uic +from qgis.PyQt.QtWidgets import QDialog, QMessageBox class FaultTopologyWidget(QDialog): diff --git a/loopstructural/gui/map2loop_tools/paint_stratigraphic_order_widget.py b/loopstructural/gui/map2loop_tools/paint_stratigraphic_order_widget.py index 3a36272..b207e94 100644 --- a/loopstructural/gui/map2loop_tools/paint_stratigraphic_order_widget.py +++ b/loopstructural/gui/map2loop_tools/paint_stratigraphic_order_widget.py @@ -2,10 +2,9 @@ import os -from PyQt5.QtWidgets import QMessageBox, QWidget from qgis.core import QgsMapLayerProxyModel from qgis.PyQt import uic - +from qgis.PyQt.QtWidgets import QMessageBox, QWidget from ...main.m2l_api import paint_stratigraphic_order @@ -185,7 +184,6 @@ def _run_painter(self): # Step 1: create a memory copy of the geology layer and copy attributes/geometry try: - from PyQt5.QtCore import QVariant from qgis.core import ( QgsFeature, QgsField, @@ -198,6 +196,8 @@ def _run_painter(self): QgsWkbTypes, ) + from loopstructural.gui.compatibility import QVariantCompat + geom_type = QgsWkbTypes.displayString(geology_layer.wkbType()) crs_auth = ( geology_layer.crs().authid() if hasattr(geology_layer, 'crs') else None @@ -240,7 +240,7 @@ def _run_painter(self): try: if field_name not in [f.name() for f in mem_layer.fields()]: mem_layer.startEditing() - mem_dp.addAttributes([QgsField(field_name, QVariant.Int)]) + mem_dp.addAttributes([QgsField(field_name, QVariantCompat.Int)]) mem_layer.updateFields() mem_layer.commitChanges() diff --git a/loopstructural/gui/map2loop_tools/sampler_widget.py b/loopstructural/gui/map2loop_tools/sampler_widget.py index bdb913b..00992ad 100644 --- a/loopstructural/gui/map2loop_tools/sampler_widget.py +++ b/loopstructural/gui/map2loop_tools/sampler_widget.py @@ -2,9 +2,9 @@ import os -from PyQt5.QtWidgets import QMessageBox, QWidget from qgis.core import QgsProject, QgsWkbTypes from qgis.PyQt import uic +from qgis.PyQt.QtWidgets import QMessageBox, QWidget from loopstructural.toolbelt.preferences import PlgOptionsManager @@ -226,7 +226,8 @@ def _run_sampler(self): QgsPointXY, QgsVectorLayer, ) - from qgis.PyQt.QtCore import QVariant + + from loopstructural.gui.compatibility import QVariantCompat from ...main.m2l_api import sample_contacts @@ -293,11 +294,11 @@ def _run_sampler(self): dtype_str = str(dtype) if dtype_str in ['float16', 'float32', 'float64']: - field_type = QVariant.Double + field_type = QVariantCompat.Double elif dtype_str in ['int8', 'int16', 'int32', 'int64']: - field_type = QVariant.Int + field_type = QVariantCompat.Int else: - field_type = QVariant.String + field_type = QVariantCompat.String fields.append(QgsField(column_name, field_type)) diff --git a/loopstructural/gui/map2loop_tools/sorter_widget.py b/loopstructural/gui/map2loop_tools/sorter_widget.py index 7ca0b30..66e8ff6 100644 --- a/loopstructural/gui/map2loop_tools/sorter_widget.py +++ b/loopstructural/gui/map2loop_tools/sorter_widget.py @@ -2,11 +2,9 @@ import os -from PyQt5.QtWidgets import QMessageBox, QWidget -from qgis.core import QgsRasterLayer -from qgis.core import QgsMapLayerProxyModel - +from qgis.core import QgsMapLayerProxyModel, QgsRasterLayer from qgis.PyQt import uic +from qgis.PyQt.QtWidgets import QMessageBox, QWidget from loopstructural.main.helpers import get_layer_names from loopstructural.main.m2l_api import PARAMETERS_DICTIONARY, SORTER_LIST diff --git a/loopstructural/gui/map2loop_tools/thickness_calculator_widget.py b/loopstructural/gui/map2loop_tools/thickness_calculator_widget.py index c2aa8cb..ec63f7b 100644 --- a/loopstructural/gui/map2loop_tools/thickness_calculator_widget.py +++ b/loopstructural/gui/map2loop_tools/thickness_calculator_widget.py @@ -3,9 +3,9 @@ import os import pandas as pd -from PyQt5.QtWidgets import QMessageBox, QWidget from qgis.core import QgsMapLayerProxyModel from qgis.PyQt import uic +from qgis.PyQt.QtWidgets import QMessageBox, QWidget from loopstructural.toolbelt.preferences import PlgOptionsManager diff --git a/loopstructural/gui/map2loop_tools/user_defined_sorter_widget.py b/loopstructural/gui/map2loop_tools/user_defined_sorter_widget.py index 595dc33..c0931bb 100644 --- a/loopstructural/gui/map2loop_tools/user_defined_sorter_widget.py +++ b/loopstructural/gui/map2loop_tools/user_defined_sorter_widget.py @@ -2,7 +2,7 @@ from typing import Any -from PyQt5.QtWidgets import QVBoxLayout, QWidget +from qgis.PyQt.QtWidgets import QVBoxLayout, QWidget from loopstructural.gui.modelling.stratigraphic_column import StratColumnWidget diff --git a/loopstructural/gui/modelling/base_tab.py b/loopstructural/gui/modelling/base_tab.py index f930a0a..c7e8fdc 100644 --- a/loopstructural/gui/modelling/base_tab.py +++ b/loopstructural/gui/modelling/base_tab.py @@ -1,6 +1,6 @@ -from PyQt5.QtCore import Qt -from PyQt5.QtWidgets import QScrollArea, QSizePolicy, QVBoxLayout, QWidget from qgis.gui import QgsCollapsibleGroupBox +from qgis.PyQt.QtCore import Qt +from qgis.PyQt.QtWidgets import QScrollArea, QSizePolicy, QVBoxLayout, QWidget class BaseTab(QWidget): diff --git a/loopstructural/gui/modelling/fault_adjacency_tab.py b/loopstructural/gui/modelling/fault_adjacency_tab.py index ed94bd7..39338d4 100644 --- a/loopstructural/gui/modelling/fault_adjacency_tab.py +++ b/loopstructural/gui/modelling/fault_adjacency_tab.py @@ -1,5 +1,6 @@ -from PyQt5.QtCore import Qt -from PyQt5.QtWidgets import ( +from LoopStructural.modelling.core.fault_topology import FaultRelationshipType +from qgis.PyQt.QtCore import Qt +from qgis.PyQt.QtWidgets import ( QGroupBox, QLabel, QPushButton, @@ -9,8 +10,6 @@ QWidget, ) -from LoopStructural.modelling.core.fault_topology import FaultRelationshipType - class FaultAdjacencyTab(QWidget): def __init__(self, parent=None, data_manager=None): diff --git a/loopstructural/gui/modelling/fault_graph/fault_graph.py b/loopstructural/gui/modelling/fault_graph/fault_graph.py index 7d1810d..e125fb3 100644 --- a/loopstructural/gui/modelling/fault_graph/fault_graph.py +++ b/loopstructural/gui/modelling/fault_graph/fault_graph.py @@ -1,7 +1,7 @@ import os -from PyQt5 import QtCore, QtGui, QtWidgets -from PyQt5.QtSvg import QGraphicsSvgItem +from qgis.PyQt import QtCore, QtGui, QtWidgets +from qgis.PyQt.QtSvg import QGraphicsSvgItem class TopologyNode(QtWidgets.QGraphicsItem): diff --git a/loopstructural/gui/modelling/geological_model_tab/add_fault_dialog.py b/loopstructural/gui/modelling/geological_model_tab/add_fault_dialog.py index b26ff3b..7d35d46 100644 --- a/loopstructural/gui/modelling/geological_model_tab/add_fault_dialog.py +++ b/loopstructural/gui/modelling/geological_model_tab/add_fault_dialog.py @@ -1,7 +1,7 @@ import os -from PyQt5.QtWidgets import QDialog -from PyQt5.uic import loadUi +from qgis.PyQt.QtWidgets import QDialog +from qgis.PyQt.uic import loadUi class AddFaultDialog(QDialog): diff --git a/loopstructural/gui/modelling/geological_model_tab/add_foliation_dialog.py b/loopstructural/gui/modelling/geological_model_tab/add_foliation_dialog.py index 504b36c..ce69821 100644 --- a/loopstructural/gui/modelling/geological_model_tab/add_foliation_dialog.py +++ b/loopstructural/gui/modelling/geological_model_tab/add_foliation_dialog.py @@ -1,7 +1,7 @@ import os -from PyQt5.QtWidgets import QDialog, QDialogButtonBox -from PyQt5.uic import loadUi +from qgis.PyQt.QtWidgets import QDialog, QDialogButtonBox +from qgis.PyQt.uic import loadUi from .layer_selection_table import LayerSelectionTable @@ -19,7 +19,7 @@ def __init__(self, parent=None, *, data_manager=None, model_manager=None): self.layer_table = LayerSelectionTable( data_manager=self.data_manager, feature_name_provider=lambda: self.name, - name_validator=lambda: (self.name_valid, self.name_error) + name_validator=lambda: (self.name_valid, self.name_error), ) # Replace or integrate with existing UI @@ -28,7 +28,6 @@ def __init__(self, parent=None, *, data_manager=None, model_manager=None): self.buttonBox.accepted.connect(self.add_foliation) self.buttonBox.rejected.connect(self.cancel) - self.name_valid = False self.name_error = "" @@ -93,7 +92,6 @@ def add_foliation(self): folded_feature_name = None - self.data_manager.add_foliation_to_model(self.name, folded_feature_name=folded_feature_name) self.accept() # Close the dialog @@ -135,7 +133,8 @@ def _integrate_layer_table(self): else: # Fallback: add to parent widget directly if hasattr(table_parent, 'layout') and not table_parent.layout(): - from PyQt5.QtWidgets import QVBoxLayout + from qgis.PyQt.QtWidgets import QVBoxLayout + layout = QVBoxLayout(table_parent) table_parent.setLayout(layout) layout.addWidget(self.layer_table) diff --git a/loopstructural/gui/modelling/geological_model_tab/add_unconformity_dialog.py b/loopstructural/gui/modelling/geological_model_tab/add_unconformity_dialog.py index 6bd683f..dd7f7c1 100644 --- a/loopstructural/gui/modelling/geological_model_tab/add_unconformity_dialog.py +++ b/loopstructural/gui/modelling/geological_model_tab/add_unconformity_dialog.py @@ -1,13 +1,14 @@ -from PyQt5.QtWidgets import ( +from LoopStructural.modelling.features import FeatureType +from qgis.PyQt.QtWidgets import ( QComboBox, QDialog, QDialogButtonBox, QDoubleSpinBox, QFormLayout, QVBoxLayout, - ) -from LoopStructural.modelling.features import FeatureType + + class AddUnconformityDialog(QDialog): def __init__(self, parent=None, data_manager=None, model_manager=None): super().__init__(parent) @@ -61,8 +62,9 @@ def accept(self): self.model_manager.add_unconformity(foliation_name, value, feature_type) super().accept() except ValueError as e: - from PyQt5.QtWidgets import QMessageBox + from qgis.PyQt.QtWidgets import QMessageBox QMessageBox.critical(self, "Error", str(e)) + def reject(self): super().reject() diff --git a/loopstructural/gui/modelling/geological_model_tab/bounding_box_widget.py b/loopstructural/gui/modelling/geological_model_tab/bounding_box_widget.py index aa02c8e..fdc6aac 100644 --- a/loopstructural/gui/modelling/geological_model_tab/bounding_box_widget.py +++ b/loopstructural/gui/modelling/geological_model_tab/bounding_box_widget.py @@ -1,15 +1,16 @@ import numpy as np -from PyQt5.QtCore import Qt -from PyQt5.QtWidgets import ( +from qgis.gui import QgsCollapsibleGroupBox +from qgis.PyQt.QtCore import Qt +from qgis.PyQt.QtWidgets import ( + QDoubleSpinBox, QGridLayout, QLabel, - QDoubleSpinBox, QVBoxLayout, QWidget, ) -from qgis.gui import QgsCollapsibleGroupBox from LoopStructural import getLogger + logger = getLogger(__name__) @@ -108,7 +109,9 @@ def __init__(self, parent=None, *, model_manager=None, data_manager=None): 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'): + 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: @@ -125,7 +128,11 @@ def _get_bounding_box(self): 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: + 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: @@ -154,10 +161,16 @@ def _on_nsteps_changed(self, _): 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())]) + 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())] + 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: @@ -198,7 +211,11 @@ def _on_bounding_box_updated(self, bounding_box): nsteps = list(bounding_box.nsteps) except Exception: try: - nsteps = [int(bounding_box.nsteps[0]), int(bounding_box.nsteps[1]), int(bounding_box.nsteps[2])] + 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: 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 0344eed..8b169e6 100644 --- a/loopstructural/gui/modelling/geological_model_tab/feature_details_panel.py +++ b/loopstructural/gui/modelling/geological_model_tab/feature_details_panel.py @@ -1,5 +1,12 @@ -from PyQt5.QtCore import Qt, QTimer -from PyQt5.QtWidgets import ( +from LoopStructural.modelling.features import StructuralFrame +from LoopStructural.utils import ( + normal_vector_to_strike_and_dip, + plungeazimuth2vector, + strikedip2vector, +) +from qgis.gui import QgsCollapsibleGroupBox, QgsMapLayerComboBox +from qgis.PyQt.QtCore import Qt, QTimer +from qgis.PyQt.QtWidgets import ( QCheckBox, QComboBox, QDoubleSpinBox, @@ -10,16 +17,9 @@ QVBoxLayout, QWidget, ) -from qgis.gui import QgsCollapsibleGroupBox, QgsMapLayerComboBox from qgis.utils import plugins from LoopStructural import getLogger -from LoopStructural.modelling.features import StructuralFrame -from LoopStructural.utils import ( - normal_vector_to_strike_and_dip, - plungeazimuth2vector, - strikedip2vector, -) from .bounding_box_widget import BoundingBoxWidget from .layer_selection_table import LayerSelectionTable @@ -32,11 +32,11 @@ def retrieve_dip_value(fault, model_manager): """ Retrieve dip value from stored fault data or calculate from normal vector. - + Args: fault: The fault object with a name and fault_normal_vector attribute model_manager: The model manager with faults dictionary - + Returns: float: The dip angle in degrees """ @@ -45,25 +45,25 @@ def retrieve_dip_value(fault, model_manager): fault_data = model_manager.faults[fault.name].get('data') if fault_data is not None and 'dip' in fault_data.columns and not fault_data.empty: dip = fault_data['dip'].mean() - + # Fallback: calculate from normal vector if not found in stored data if dip is None: try: dip = normal_vector_to_strike_and_dip(fault.fault_normal_vector)[0, 1] except Exception: dip = 90 # Default value if calculation fails - + return dip def retrieve_pitch_value(fault, model_manager): """ Retrieve pitch value from stored fault data. - + Args: fault: The fault object with a name attribute model_manager: The model manager with faults dictionary - + Returns: float: The pitch angle in degrees (default 0) """ @@ -72,7 +72,7 @@ def retrieve_pitch_value(fault, model_manager): fault_data = model_manager.faults[fault.name].get('data') if fault_data is not None and 'pitch' in fault_data.columns and not fault_data.empty: pitch = fault_data['pitch'].mean() - + return pitch @@ -203,7 +203,7 @@ def addExportBlock(self): # --- Per-feature export controls (for this panel's feature) --- try: - from PyQt5.QtWidgets import QFormLayout + from qgis.PyQt.QtWidgets import QFormLayout except Exception: # imports may fail outside QGIS environment; we'll handle at runtime pass @@ -406,7 +406,8 @@ def _export_scalar_points(self): try: # QGIS imports (guarded) from qgis.core import QgsFeature, QgsField, QgsPoint, QgsProject, QgsVectorLayer - from qgis.PyQt.QtCore import QVariant + + from loopstructural.gui.compatibility import QVariantCompat except Exception as e: # Not running inside QGIS — nothing to do logger.info('Not running inside QGIS, cannot export points') @@ -542,15 +543,15 @@ def _export_scalar_points(self): qfields = [] for c in cols: sample = gdf[c].dropna() - qtype = QVariant.String + qtype = QVariantCompat.String if not sample.empty: v = sample.iloc[0] if isinstance(v, (int,)): - qtype = QVariant.Int + qtype = QVariantCompat.Int elif isinstance(v, (float,)): - qtype = QVariant.Double + qtype = QVariantCompat.Double else: - qtype = QVariant.String + qtype = QVariantCompat.String prov.addAttributes([QgsField(c, qtype)]) qfields.append(c) mem_layer.updateFields() @@ -626,11 +627,11 @@ def __init__(self, parent=None, *, fault=None, model_manager=None, data_manager= if fault is None: raise ValueError("Fault must be provided.") self.fault = fault - + # Retrieve dip and pitch using helper functions dip = retrieve_dip_value(fault, model_manager) pitch = retrieve_pitch_value(fault, model_manager) - + self.fault_parameters = { 'displacement': fault.displacement, 'major_axis_length': fault.fault_major_axis, 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 7cc00a5..8c4085d 100644 --- a/loopstructural/gui/modelling/geological_model_tab/geological_model_tab.py +++ b/loopstructural/gui/modelling/geological_model_tab/geological_model_tab.py @@ -1,5 +1,6 @@ -from PyQt5.QtCore import QObject, Qt, QThread, pyqtSignal, pyqtSlot -from PyQt5.QtWidgets import ( +from LoopStructural.modelling.features import FeatureType +from qgis.PyQt.QtCore import QObject, Qt, QThread, pyqtSignal, pyqtSlot +from qgis.PyQt.QtWidgets import ( QMenu, QMessageBox, QProgressDialog, @@ -11,8 +12,6 @@ QWidget, ) -from LoopStructural.modelling.features import FeatureType - # Import the AddFaultDialog from .add_fault_dialog import AddFaultDialog from .add_foliation_dialog import AddFoliationDialog @@ -150,7 +149,7 @@ def initialize_model(self): "Please set the bounding box before initializing the model.", ) return - + # Validate model CRS if not self.data_manager.is_model_crs_valid(): crs = self.data_manager.get_model_crs() @@ -163,7 +162,7 @@ def initialize_model(self): except Exception: crs_desc = crs.authid() if hasattr(crs, 'authid') else "Unknown" msg = f"Model CRS must be projected (in meters), not geographic.\nSelected CRS: {crs_desc}\n\nPlease select a valid projected CRS in the Model Definition tab." - + QMessageBox.critical( self, "Invalid Model CRS", diff --git a/loopstructural/gui/modelling/geological_model_tab/layer_selection_table.py b/loopstructural/gui/modelling/geological_model_tab/layer_selection_table.py index 5bec3e6..1adb2eb 100644 --- a/loopstructural/gui/modelling/geological_model_tab/layer_selection_table.py +++ b/loopstructural/gui/modelling/geological_model_tab/layer_selection_table.py @@ -1,4 +1,6 @@ -from PyQt5.QtWidgets import ( +from qgis.core import QgsMapLayerProxyModel +from qgis.gui import QgsFieldComboBox, QgsMapLayerComboBox +from qgis.PyQt.QtWidgets import ( QComboBox, QDialog, QDialogButtonBox, @@ -9,8 +11,6 @@ QVBoxLayout, QWidget, ) -from qgis.core import QgsMapLayerProxyModel -from qgis.gui import QgsFieldComboBox, QgsMapLayerComboBox class LayerSelectionTable(QWidget): @@ -155,7 +155,7 @@ def open_layer_dialog(): data_manager=self.data_manager, feature_name=self.get_feature_name(), layer_type=type_combo.currentText(), - existing_data=self._get_existing_data_for_button(btn) + existing_data=self._get_existing_data_for_button(btn), ) if dialog.exec_() == QDialog.Accepted: @@ -344,7 +344,9 @@ def is_add_button_enabled(self): class LayerSelectionDialog(QDialog): """Dialog for selecting layers and configuring their fields.""" - def __init__(self, parent=None, data_manager=None, feature_name="", layer_type="", existing_data=None): + def __init__( + self, parent=None, data_manager=None, feature_name="", layer_type="", existing_data=None + ): super().__init__(parent) self.data_manager = data_manager self.feature_name = feature_name @@ -445,7 +447,7 @@ def update_strike_label(text): self.field_combos = { 'strike_field': self.strike_field_combo, 'dip_field': self.dip_field_combo, - 'format': self.format_combo + 'format': self.format_combo, } def _setup_value_fields(self, layout): @@ -464,9 +466,7 @@ def _setup_value_fields(self, layout): self.layer_combo.layerChanged.connect(self.value_field_combo.setLayer) - self.field_combos = { - 'value_field': self.value_field_combo - } + self.field_combos = {'value_field': self.value_field_combo} def _setup_inequality_fields(self, layout): """Setup fields for inequality data type.""" @@ -495,7 +495,7 @@ def _setup_inequality_fields(self, layout): self.field_combos = { 'lower_field': self.lower_field_combo, - 'upper_field': self.upper_field_combo + 'upper_field': self.upper_field_combo, } def _validate_layer_selection(self): @@ -521,12 +521,15 @@ def _on_accepted(self): self.layer_data = { 'layer': self.layer_combo.currentLayer(), 'layer_name': self.layer_combo.currentLayer().name(), - 'type': self.layer_type + 'type': self.layer_type, } # Add type-specific data if self.layer_type == "Orientation": - if not self.field_combos['strike_field'].currentField() or not self.field_combos['dip_field'].currentField(): + if ( + not self.field_combos['strike_field'].currentField() + or not self.field_combos['dip_field'].currentField() + ): return self.layer_data['strike_field'] = self.field_combos['strike_field'].currentField() self.layer_data['dip_field'] = self.field_combos['dip_field'].currentField() @@ -538,7 +541,10 @@ def _on_accepted(self): self.layer_data['value_field'] = self.field_combos['value_field'].currentField() elif self.layer_type == "Inequality": - if not self.field_combos['lower_field'].currentField() or not self.field_combos['upper_field'].currentField(): + if ( + not self.field_combos['lower_field'].currentField() + or not self.field_combos['upper_field'].currentField() + ): return self.layer_data['lower_field'] = self.field_combos['lower_field'].currentField() self.layer_data['upper_field'] = self.field_combos['upper_field'].currentField() diff --git a/loopstructural/gui/modelling/model_definition/bounding_box.py b/loopstructural/gui/modelling/model_definition/bounding_box.py index 594f70e..2f4de8e 100644 --- a/loopstructural/gui/modelling/model_definition/bounding_box.py +++ b/loopstructural/gui/modelling/model_definition/bounding_box.py @@ -1,9 +1,9 @@ import os import numpy as np -from PyQt5.QtWidgets import QWidget from qgis.core import QgsProject from qgis.PyQt import uic +from qgis.PyQt.QtWidgets import QWidget from loopstructural.main.data_manager import default_bounding_box diff --git a/loopstructural/gui/modelling/model_definition/dem.py b/loopstructural/gui/modelling/model_definition/dem.py index 8879e09..60eb10f 100644 --- a/loopstructural/gui/modelling/model_definition/dem.py +++ b/loopstructural/gui/modelling/model_definition/dem.py @@ -1,8 +1,8 @@ import os -from PyQt5.QtWidgets import QWidget from qgis.core import QgsMapLayerProxyModel from qgis.PyQt import uic +from qgis.PyQt.QtWidgets import QWidget from ....main.helpers import ColumnMatcher, get_layer_names diff --git a/loopstructural/gui/modelling/model_definition/fault_layers.py b/loopstructural/gui/modelling/model_definition/fault_layers.py index aacc65e..8af60e1 100644 --- a/loopstructural/gui/modelling/model_definition/fault_layers.py +++ b/loopstructural/gui/modelling/model_definition/fault_layers.py @@ -1,8 +1,8 @@ import os -from PyQt5.QtWidgets import QWidget from qgis.core import QgsFieldProxyModel, QgsMapLayerProxyModel, QgsWkbTypes from qgis.PyQt import uic +from qgis.PyQt.QtWidgets import QWidget from ....main.helpers import ColumnMatcher, get_layer_names diff --git a/loopstructural/gui/modelling/model_definition/model_definition_tab.py b/loopstructural/gui/modelling/model_definition/model_definition_tab.py index d80cf64..677f881 100644 --- a/loopstructural/gui/modelling/model_definition/model_definition_tab.py +++ b/loopstructural/gui/modelling/model_definition/model_definition_tab.py @@ -1,11 +1,11 @@ -from PyQt5.QtWidgets import QSizePolicy +from qgis.PyQt.QtWidgets import QSizePolicy from loopstructural.gui.modelling.base_tab import BaseTab from .bounding_box import BoundingBoxWidget +from .dem import DEMWidget from .fault_layers import FaultLayersWidget from .stratigraphic_layers import StratigraphicLayersWidget -from .dem import DEMWidget class ModelDefinitionTab(BaseTab): diff --git a/loopstructural/gui/modelling/model_definition/stratigraphic_layers.py b/loopstructural/gui/modelling/model_definition/stratigraphic_layers.py index 8eb3939..84fdced 100644 --- a/loopstructural/gui/modelling/model_definition/stratigraphic_layers.py +++ b/loopstructural/gui/modelling/model_definition/stratigraphic_layers.py @@ -1,9 +1,9 @@ import os -from PyQt5.QtCore import Qt -from PyQt5.QtWidgets import QWidget from qgis.core import QgsMapLayerProxyModel, QgsWkbTypes from qgis.PyQt import uic +from qgis.PyQt.QtCore import Qt +from qgis.PyQt.QtWidgets import QWidget from ....main.helpers import ColumnMatcher, get_layer_names diff --git a/loopstructural/gui/modelling/modelling_widget.py b/loopstructural/gui/modelling/modelling_widget.py index bfd5380..143c579 100644 --- a/loopstructural/gui/modelling/modelling_widget.py +++ b/loopstructural/gui/modelling/modelling_widget.py @@ -1,4 +1,4 @@ -from PyQt5.QtWidgets import QTabWidget, QVBoxLayout, QWidget +from qgis.PyQt.QtWidgets import QTabWidget, QVBoxLayout, QWidget from loopstructural.gui.modelling.fault_adjacency_tab import FaultAdjacencyTab from loopstructural.gui.modelling.geological_history_tab import GeologialHistoryTab diff --git a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py index 6fd4b6e..c664b5b 100644 --- a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py +++ b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py @@ -1,5 +1,5 @@ from LoopStructural.modelling.core.stratigraphic_column import StratigraphicColumnElementType -from PyQt5.QtWidgets import ( +from qgis.PyQt.QtWidgets import ( QAbstractItemView, QListWidget, QListWidgetItem, diff --git a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.py b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.py index 0dd8204..9afb253 100644 --- a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.py +++ b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.py @@ -2,9 +2,9 @@ from typing import Optional import numpy as np -from PyQt5 import uic -from PyQt5.QtCore import pyqtSignal -from PyQt5.QtWidgets import QWidget +from qgis.PyQt import uic +from qgis.PyQt.QtCore import pyqtSignal +from qgis.PyQt.QtWidgets import QWidget class StratigraphicUnitWidget(QWidget): @@ -41,12 +41,12 @@ def __init__( def _convert_colour(self, colour): """Convert colour from various formats to Qt-compatible hex string. - + Parameters ---------- colour : str, tuple, list, np.ndarray, or None Colour in various formats: hex string, RGB tuple/list/array - + Returns ------- str @@ -54,13 +54,15 @@ def _convert_colour(self, colour): """ if colour is None: return "" - + # If it's already a string, return it if isinstance(colour, str): return colour - + # Handle tuple, list, or numpy array of RGB values - if (isinstance(colour, (tuple, list)) or isinstance(colour, np.ndarray)) and len(colour) >= 3: + if (isinstance(colour, (tuple, list)) or isinstance(colour, np.ndarray)) and len( + colour + ) >= 3: # Convert (r, g, b) to "#RRGGBB" # Check if values are normalized floats (0.0-1.0) or integers (0-255) if all(isinstance(c, float) and 0.0 <= c <= 1.0 for c in colour[:3]): @@ -68,7 +70,7 @@ def _convert_colour(self, colour): else: rgb = [int(c) for c in colour[:3]] return "#{:02x}{:02x}{:02x}".format(*rgb) - + # Fallback: try to convert to string return str(colour) @@ -103,7 +105,7 @@ def _update_colour_button(self): def onColourSelectClicked(self): """Open a color dialog to select a color for the stratigraphic unit.""" - from PyQt5.QtWidgets import QColorDialog + from qgis.PyQt.QtWidgets import QColorDialog color = QColorDialog.getColor() if color.isValid(): diff --git a/loopstructural/gui/modelling/stratigraphic_column/unconformity.py b/loopstructural/gui/modelling/stratigraphic_column/unconformity.py index 17c4566..cce7490 100644 --- a/loopstructural/gui/modelling/stratigraphic_column/unconformity.py +++ b/loopstructural/gui/modelling/stratigraphic_column/unconformity.py @@ -1,9 +1,9 @@ import os from typing import Optional -from PyQt5 import uic -from PyQt5.QtCore import pyqtSignal -from PyQt5.QtWidgets import QWidget +from qgis.PyQt import uic +from qgis.PyQt.QtCore import pyqtSignal +from qgis.PyQt.QtWidgets import QWidget class UnconformityWidget(QWidget): diff --git a/loopstructural/gui/visualisation/feature_list_widget.py b/loopstructural/gui/visualisation/feature_list_widget.py index c7134e1..6c03bbd 100644 --- a/loopstructural/gui/visualisation/feature_list_widget.py +++ b/loopstructural/gui/visualisation/feature_list_widget.py @@ -3,7 +3,14 @@ import numpy as np from LoopStructural.datatypes import VectorPoints -from PyQt5.QtWidgets import QMenu, QPushButton, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget +from qgis.PyQt.QtWidgets import ( + QMenu, + QPushButton, + QTreeWidget, + QTreeWidgetItem, + QVBoxLayout, + QWidget, +) logger = logging.getLogger(__name__) diff --git a/loopstructural/gui/visualisation/loop_pyvistaqt_wrapper.py b/loopstructural/gui/visualisation/loop_pyvistaqt_wrapper.py index 9f48c15..b4d338c 100644 --- a/loopstructural/gui/visualisation/loop_pyvistaqt_wrapper.py +++ b/loopstructural/gui/visualisation/loop_pyvistaqt_wrapper.py @@ -1,7 +1,7 @@ from typing import Any, Dict, Optional, Tuple -from PyQt5.QtCore import pyqtSignal from pyvistaqt import QtInteractor +from qgis.PyQt.QtCore import pyqtSignal class LoopPyVistaQTPlotter(QtInteractor): diff --git a/loopstructural/gui/visualisation/object_list_widget.py b/loopstructural/gui/visualisation/object_list_widget.py index c560b16..5764902 100644 --- a/loopstructural/gui/visualisation/object_list_widget.py +++ b/loopstructural/gui/visualisation/object_list_widget.py @@ -1,8 +1,8 @@ import logging import pyvista as pv -from PyQt5.QtCore import Qt -from PyQt5.QtWidgets import ( +from qgis.PyQt.QtCore import Qt +from qgis.PyQt.QtWidgets import ( QCheckBox, QFileDialog, QHBoxLayout, # Add missing import @@ -36,8 +36,10 @@ def __init__(self, parent=None, *, viewer=None, properties_widget=None): 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: @@ -53,6 +55,7 @@ def on_object_selected(self): 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 @@ -262,13 +265,19 @@ def export_selected_object(self): formats = [] try: import geoh5py + has_geoh5py = True except ImportError: has_geoh5py = False # Check if this is a grid/voxel type (UniformGrid, ImageData, StructuredGrid, RectilinearGrid) - is_grid = type(mesh).__name__ in ['UniformGrid', 'ImageData', 'StructuredGrid', 'RectilinearGrid'] - + is_grid = type(mesh).__name__ in [ + 'UniformGrid', + 'ImageData', + 'StructuredGrid', + 'RectilinearGrid', + ] + if is_grid: # Grid/voxel meshes support ASCII export formats = ["vtk", "ascii"] @@ -310,30 +319,20 @@ def export_selected_object(self): try: if selected_format == "obj": - ( - mesh.save(file_path) - if hasattr(mesh, "save") - else pv.save_meshio(file_path, mesh) - ) + (mesh.save(file_path) if hasattr(mesh, "save") else pv.save_meshio(file_path, mesh)) elif selected_format == "vtk": mesh.save(file_path) if hasattr(mesh, "save") else pv.save_meshio(file_path, mesh) elif selected_format == "ply": pv.save_meshio(file_path, mesh) elif selected_format == "vtp": - ( - mesh.save(file_path) - if hasattr(mesh, "save") - else pv.save_meshio(file_path, mesh) - ) + (mesh.save(file_path) if hasattr(mesh, "save") else pv.save_meshio(file_path, mesh)) elif selected_format == "ascii": # Export grid/voxel as ASCII: x, y, z, value format self._export_grid_ascii(mesh, file_path, object_label) elif selected_format == "geoh5": with geoh5py.Geoh5(file_path, overwrite=True) as geoh5: if hasattr(mesh, "faces"): - geoh5.add_surface( - name=object_label, vertices=mesh.points, faces=mesh.faces - ) + geoh5.add_surface(name=object_label, vertices=mesh.points, faces=mesh.faces) else: geoh5.add_points(name=object_label, vertices=mesh.points) logger.info(f"Exported {object_label} to {file_path} as {selected_format}") @@ -343,9 +342,9 @@ def export_selected_object(self): def _export_grid_ascii(self, mesh, file_path, object_label): """Export a grid/voxel mesh to ASCII format. - + Format: x, y, z, value (one line per cell center) - + Parameters ---------- mesh : pyvista grid mesh @@ -356,33 +355,33 @@ def _export_grid_ascii(self, mesh, file_path, object_label): Name of the object (used to determine which scalar array to export) """ import numpy as np - + # Get cell centers cell_centers = mesh.cell_centers() centers = cell_centers.points - + # Get scalar values - try to use the active scalars or the first available array scalar_name = mesh.active_scalars_name if scalar_name is None: # Try to find any cell data array if mesh.cell_data: scalar_name = list(mesh.cell_data.keys())[0] - + if scalar_name is not None: values = mesh.cell_data[scalar_name] else: # If no scalar data, use zeros values = np.zeros(mesh.n_cells) - + # Write to file with open(file_path, 'w') as f: f.write(f"# ASCII Grid Export: {object_label}\n") - f.write(f"# Format: x y z value\n") + f.write("# Format: x y z value\n") f.write(f"# Number of cells: {mesh.n_cells}\n") if scalar_name: f.write(f"# Scalar field: {scalar_name}\n") f.write("#\n") - + for i in range(len(centers)): x, y, z = centers[i] value = values[i] @@ -419,17 +418,19 @@ def show_add_object_menu(self): self.load_feature_from_file() elif action == addQgsLayerAction: self.add_object_from_qgis_layer() + def add_feature_from_geological_model(self): # Logic to add a feature from the geological model print("Adding feature from geological model") + def add_object_from_qgis_layer(self): """Show a dialog to pick a QGIS point vector layer, convert it to a VTK/PyVista point cloud and copy numeric attributes as point scalars. """ # Local imports so the module can still be imported when QGIS GUI isn't available try: - from qgis.gui import QgsMapLayerComboBox from qgis.core import QgsMapLayerProxyModel, QgsWkbTypes + from qgis.gui import QgsMapLayerComboBox except Exception as e: print("QGIS GUI components are not available:", e) return @@ -439,11 +440,11 @@ def add_object_from_qgis_layer(self): except Exception as e: print("Could not import qgsLayerToGeoDataFrame:", e) return - from loopstructural.main.model_manager import AllSampler - - from PyQt5.QtWidgets import QDialog, QDialogButtonBox, QVBoxLayout, QMessageBox import numpy as np import pandas as pd + from qgis.PyQt.QtWidgets import QDialog, QDialogButtonBox, QMessageBox, QVBoxLayout + + from loopstructural.main.model_manager import AllSampler dialog = QDialog(self) dialog.setWindowTitle("Add from QGIS layer") @@ -470,7 +471,10 @@ def add_object_from_qgis_layer(self): # Basic geometry check - ensure the layer contains point geometry try: - if layer.wkbType() != QgsWkbTypes.Point and QgsWkbTypes.geometryType(layer.wkbType()) != QgsWkbTypes.PointGeometry: + if ( + layer.wkbType() != QgsWkbTypes.Point + and QgsWkbTypes.geometryType(layer.wkbType()) != QgsWkbTypes.PointGeometry + ): # Some QGIS versions use different enums; allow via proxy filter primarily # If the check fails, continue but warn print("Selected layer does not appear to be a point layer. Proceeding anyway.") @@ -482,19 +486,23 @@ def add_object_from_qgis_layer(self): gdf = qgsLayerToGeoDataFrame(layer) sampler = AllSampler() # sample the points from the gdf with no DTM and include Z if present - df = sampler(gdf,None,True) + df = sampler(gdf, None, True) if df is None or df.empty: QMessageBox.warning(self, "No data", "Selected layer contains no points.") return # Ensure X,Y,Z columns present if not {"X", "Y", "Z"}.issubset(df.columns): - QMessageBox.warning(self, "Invalid data", "Layer conversion did not produce X/Y/Z columns.") + QMessageBox.warning( + self, "Invalid data", "Layer conversion did not produce X/Y/Z columns." + ) return # Build points array try: - pts = np.vstack([df["X"].to_numpy(), df["Y"].to_numpy(), df["Z"].to_numpy()]).T.astype(float) + pts = np.vstack([df["X"].to_numpy(), df["Y"].to_numpy(), df["Z"].to_numpy()]).T.astype( + float + ) except Exception as e: QMessageBox.warning(self, "Error", f"Failed to build point coordinates: {e}") return diff --git a/loopstructural/gui/visualisation/object_properties_widget.py b/loopstructural/gui/visualisation/object_properties_widget.py index f329a55..ac02d7f 100644 --- a/loopstructural/gui/visualisation/object_properties_widget.py +++ b/loopstructural/gui/visualisation/object_properties_widget.py @@ -1,13 +1,23 @@ -from PyQt5.QtCore import Qt - -from PyQt5.QtWidgets import ( - QWidget, QVBoxLayout, QLabel, QComboBox, QSlider, QCheckBox, QColorDialog, QPushButton, QHBoxLayout, QLineEdit, QSizePolicy -) +import matplotlib.pyplot as plt +import numpy as np # Add plotting imports for scalar histogram from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas -import matplotlib.pyplot as plt -import numpy as np +from qgis.PyQt.QtCore import Qt +from qgis.PyQt.QtWidgets import ( + QCheckBox, + QColorDialog, + QComboBox, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QSizePolicy, + QSlider, + QVBoxLayout, + QWidget, +) + class ObjectPropertiesWidget(QWidget): def __init__(self, parent=None, *, viewer=None): @@ -147,7 +157,11 @@ def choose_color(self): 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()) + self.viewer.meshes[self.current_object_name]['color'] = ( + color.redF(), + color.greenF(), + color.blueF(), + ) except Exception: pass @@ -169,7 +183,13 @@ def set_opacity(self, value: float): 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}) + self.viewer.meshes[self.current_object_name].set( + 'kwargs', + { + **self.viewer.meshes[self.current_object_name].get('kwargs', {}), + 'opacity': value, + }, + ) except Exception: pass @@ -215,7 +235,10 @@ def set_show_edges(self, show: bool): 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: + 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 @@ -263,7 +286,10 @@ def set_line_width(self, value): 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: + 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 @@ -313,7 +339,9 @@ def setCurrentObject(self, object_name: str): 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())]: + 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: @@ -338,8 +366,16 @@ def setCurrentObject(self, object_name: str): 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)) + 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: @@ -434,7 +470,13 @@ def _on_scalar_changed(self, scalar_name: str): 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}) + self.viewer.meshes[self.current_object_name].set( + 'kwargs', + { + **self.viewer.meshes[self.current_object_name].get('kwargs', {}), + 'scalars': None, + }, + ) except Exception: pass return @@ -479,7 +521,15 @@ def _on_scalar_changed(self, scalar_name: str): 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.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: @@ -498,7 +548,9 @@ def _on_color_with_scalar_toggled(self, checked: bool): 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', {}): + if self.current_object_name and self.current_object_name in getattr( + self.viewer, 'meshes', {} + ): current_scalar = self.scalar_combo.currentText() if checked: try: @@ -521,7 +573,9 @@ def _on_color_with_scalar_toggled(self, checked: bool): 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') + 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 @@ -534,7 +588,8 @@ def _on_color_with_scalar_toggled(self, checked: bool): 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.""" + 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 @@ -597,12 +652,22 @@ def _on_colormap_changed(self, cmap: str): 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.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') + self.current_mesh = self.viewer.meshes.get(self.current_object_name, {}).get( + 'mesh' + ) except Exception: pass except Exception: @@ -633,7 +698,14 @@ 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.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: @@ -656,7 +728,12 @@ def _update_actor_mapper(self, mesh_entry, scalars, cmap, clim, values, actor, p # 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) + plotter.update_scalars( + values, + mesh=mesh_entry.get('mesh'), + render=False, + name=self.current_object_name, + ) except Exception: pass @@ -707,6 +784,7 @@ def _update_actor_mapper(self, mesh_entry, scalars, cmap, clim, values, actor, p 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)) @@ -728,10 +806,14 @@ def _update_actor_mapper(self, mesh_entry, scalars, cmap, clim, values, actor, p 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 + from vtkmodules.vtkCommonCore import ( + vtkLookupTable as _vtkLookupTable, # type: ignore + ) + vtkLookupTable = _vtkLookupTable except Exception: vtkLookupTable = None @@ -741,6 +823,7 @@ def _update_actor_mapper(self, mesh_entry, scalars, cmap, clim, values, actor, p 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) @@ -789,7 +872,10 @@ def _update_actor_mapper(self, mesh_entry, scalars, cmap, clim, values, actor, p 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: + 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 diff --git a/loopstructural/gui/visualisation/visualisation_widget.py b/loopstructural/gui/visualisation/visualisation_widget.py index 2be5733..29da98e 100644 --- a/loopstructural/gui/visualisation/visualisation_widget.py +++ b/loopstructural/gui/visualisation/visualisation_widget.py @@ -1,13 +1,13 @@ -from PyQt5.QtCore import Qt -from PyQt5.QtWidgets import ( +from qgis.PyQt.QtCore import Qt +from qgis.PyQt.QtWidgets import ( QSplitter, QVBoxLayout, QWidget, ) +from .feature_list_widget import FeatureListWidget from .loop_pyvistaqt_wrapper import LoopPyVistaQTPlotter from .object_list_widget import ObjectListWidget -from .feature_list_widget import FeatureListWidget from .object_properties_widget import ObjectPropertiesWidget @@ -34,7 +34,9 @@ def __init__(self, parent: QWidget = None, mapCanvas=None, logger=None, model_ma # self.plotter.add_axes() self.objectPropertiesWidget = ObjectPropertiesWidget(viewer=self.plotter) - self.objectList = ObjectListWidget(viewer=self.plotter,properties_widget=self.objectPropertiesWidget) + 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) diff --git a/loopstructural/main/callableToLayer.py b/loopstructural/main/callableToLayer.py index b8e41bd..d03a4d3 100644 --- a/loopstructural/main/callableToLayer.py +++ b/loopstructural/main/callableToLayer.py @@ -4,7 +4,8 @@ QgsRaster, QgsWkbTypes, ) -from qgis.PyQt.QtCore import QVariant + +from loopstructural.gui.compatibility import QVariantCompat def callableToLayer(callable, layer, dtm, name: str): @@ -28,7 +29,7 @@ def callableToLayer(callable, layer, dtm, name: str): """ layer.startEditing() if name not in [field.name() for field in layer.fields()]: - layer.dataProvider().addAttributes([QgsField(name, QVariant.Double)]) + layer.dataProvider().addAttributes([QgsField(name, QVariantCompat.Double)]) layer.updateFields() for feature in layer.getFeatures(): diff --git a/loopstructural/main/geometry/line2point.py b/loopstructural/main/geometry/line2point.py index 17dbe3a..fceecd2 100644 --- a/loopstructural/main/geometry/line2point.py +++ b/loopstructural/main/geometry/line2point.py @@ -1,4 +1,3 @@ -from PyQt5.QtCore import QVariant from qgis.core import ( QgsFeature, QgsField, @@ -8,6 +7,8 @@ QgsVectorLayer, ) +from loopstructural.gui.compatibility import QVariantCompat + def line_to_point(input_layer_path, output_layer_path): # Load the input line layer @@ -18,7 +19,7 @@ def line_to_point(input_layer_path, output_layer_path): # Create an empty point layer fields = QgsFields() - fields.append(QgsField("id", QVariant.Int)) + fields.append(QgsField("id", QVariantCompat.Int)) point_layer = QgsVectorLayer("Point?crs=" + line_layer.crs().toWkt(), "point_layer", "memory") point_layer.dataProvider().addAttributes(fields) point_layer.updateFields() diff --git a/loopstructural/main/m2l_api.py b/loopstructural/main/m2l_api.py index f4aae90..9791d17 100644 --- a/loopstructural/main/m2l_api.py +++ b/loopstructural/main/m2l_api.py @@ -653,9 +653,10 @@ def paint_stratigraphic_order( strat_order_field = "strat_order" if strat_order_field not in geology_fields: from qgis.core import QgsField - from qgis.PyQt.QtCore import QVariant - new_field = QgsField(strat_order_field, QVariant.Int) + from loopstructural.gui.compatibility import QVariantCompat + + new_field = QgsField(strat_order_field, QVariantCompat.Int) geology_layer.dataProvider().addAttributes([new_field]) geology_layer.updateFields() if updater: diff --git a/loopstructural/main/vectorLayerWrapper.py b/loopstructural/main/vectorLayerWrapper.py index 061ae0f..54a8e2b 100644 --- a/loopstructural/main/vectorLayerWrapper.py +++ b/loopstructural/main/vectorLayerWrapper.py @@ -27,9 +27,11 @@ QgsVectorLayer, QgsWkbTypes, ) -from qgis.PyQt.QtCore import QDateTime, QVariant +from qgis.PyQt.QtCore import QDateTime from shapely.geometry import LineString, MultiLineString, MultiPoint, MultiPolygon, Point, Polygon +from loopstructural.gui.compatibility import QVariantCompat + logger = logging.getLogger(__name__) @@ -152,7 +154,7 @@ def qgsLayerToGeoDataFrame(layer, target_crs=None) -> Optional[gpd.GeoDataFrame] geom = feature.geometry() if geom.isEmpty(): continue - + # Transform geometry if needed if transform is not None: geom_copy = QgsGeometry(geom) @@ -181,7 +183,7 @@ def qgsLayerToGeoDataFrame(layer, target_crs=None) -> Optional[gpd.GeoDataFrame] # Copy field values for f in fields: - if f.type() == QVariant.String: + if f.type() == QVariantCompat.String: data[f.name()].append(str(feature[f.name()])) else: data[f.name()].append(feature[f.name()]) @@ -454,16 +456,16 @@ def _infer_wkb(series): fields = QgsFields() non_geom_cols = [c for c in geodataframe.columns if c != geodataframe.geometry.name] - def _qvariant_type(dtype) -> QVariant.Type: + def _qvariant_type(dtype) -> QVariantCompat.Type: if pd.api.types.is_integer_dtype(dtype): - return QVariant.Int + return QVariantCompat.Int if pd.api.types.is_float_dtype(dtype): - return QVariant.Double + return QVariantCompat.Double if pd.api.types.is_bool_dtype(dtype): - return QVariant.Bool + return QVariantCompat.Bool if pd.api.types.is_datetime64_any_dtype(dtype): - return QVariant.DateTime - return QVariant.String + return QVariantCompat.DateTime + return QVariantCompat.String for col in non_geom_cols: fields.append(QgsField(str(col), _qvariant_type(geodataframe[col].dtype))) @@ -537,37 +539,37 @@ def _qvariant_type(dtype) -> QVariant.Type: # ---------- helpers ---------- -def _qvariant_type_from_dtype(dtype) -> QVariant.Type: +def _qvariant_type_from_dtype(dtype) -> QVariantCompat.Type: """Map a pandas dtype to a QVariant type.""" import numpy as np if np.issubdtype(dtype, np.integer): # prefer 64-bit when detected try: - return QVariant.LongLong + return QVariantCompat.LongLong except AttributeError: - return QVariant.Int + return QVariantCompat.Int if np.issubdtype(dtype, np.floating): - return QVariant.Double + return QVariantCompat.Double if np.issubdtype(dtype, np.bool_): - return QVariant.Bool + return QVariantCompat.Bool # datetimes try: import pandas as pd if pd.api.types.is_datetime64_any_dtype(dtype): - return QVariant.DateTime + return QVariantCompat.DateTime if pd.api.types.is_datetime64_ns_dtype(dtype): - return QVariant.DateTime + return QVariantCompat.DateTime if pd.api.types.is_datetime64_dtype(dtype): - return QVariant.DateTime + return QVariantCompat.DateTime if pd.api.types.is_timedelta64_dtype(dtype): # store as string "HH:MM:SS" fallback - return QVariant.String + return QVariantCompat.String except Exception: pass # default to string - return QVariant.String + return QVariantCompat.String def _fields_from_dataframe(df, drop_cols=None) -> QgsFields: @@ -981,12 +983,12 @@ def dataframeToQgsTable(self, df, parameters, context, feedback, param_name): def to_qvariant_type(s): if pd.api.types.is_bool_dtype(s): - return QVariant.Bool + return QVariantCompat.Bool if pd.api.types.is_integer_dtype(s): - return QVariant.LongLong + return QVariantCompat.LongLong if pd.api.types.is_float_dtype(s): - return QVariant.Double - return QVariant.String + return QVariantCompat.Double + return QVariantCompat.String for col in df.columns: fields.append(QgsField(str(col), to_qvariant_type(df[col]))) @@ -1021,9 +1023,10 @@ def to_qvariant_type(s): return sink, dest_id -from PyQt5.QtCore import QVariant from qgis.core import NULL +from loopstructural.gui.compatibility import QVariantCompat + def qvariantToFloat(f, field_name): val = f.attribute(field_name) # usually returns a native Python type @@ -1041,7 +1044,7 @@ def qvariantToFloat(f, field_name): except ValueError: pass # residual QVariant - if isinstance(val, QVariant): + if isinstance(val, QVariantCompat): # toDouble() -> (value, ok) d, ok = val.toDouble() return float(d) if ok else None @@ -1144,16 +1147,16 @@ def _infer_wkb(series): fields = QgsFields() non_geom_cols = [c for c in geodataframe.columns if c != geodataframe.geometry.name] - def _qvariant_type(dtype) -> QVariant.Type: + def _qvariant_type(dtype) -> QVariantCompat.Type: if pd.api.types.is_integer_dtype(dtype): - return QVariant.Int + return QVariantCompat.Int if pd.api.types.is_float_dtype(dtype): - return QVariant.Double + return QVariantCompat.Double if pd.api.types.is_bool_dtype(dtype): - return QVariant.Bool + return QVariantCompat.Bool if pd.api.types.is_datetime64_any_dtype(dtype): - return QVariant.DateTime - return QVariant.String + return QVariantCompat.DateTime + return QVariantCompat.String for col in non_geom_cols: fields.append(QgsField(str(col), _qvariant_type(geodataframe[col].dtype))) diff --git a/loopstructural/metadata.txt b/loopstructural/metadata.txt index ca67a04..4819122 100644 --- a/loopstructural/metadata.txt +++ b/loopstructural/metadata.txt @@ -18,8 +18,7 @@ tracker=https://github.com/Loop3d/plugin_loopstructural/issues/ deprecated=False experimental=True qgisMinimumVersion=3.28 -qgisMaximumVersion=3.99 - +qgisMaximumVersion=4.99 # versioning version=0.1.0 changelog= diff --git a/loopstructural/processing/algorithms/sampler.py b/loopstructural/processing/algorithms/sampler.py index d31abc8..75ee47a 100644 --- a/loopstructural/processing/algorithms/sampler.py +++ b/loopstructural/processing/algorithms/sampler.py @@ -8,35 +8,39 @@ * * *************************************************************************** """ + # Python imports from typing import Any, Optional -from qgis.PyQt.QtCore import QVariant -from osgeo import gdal + import pandas as pd +from map2loop.sampler import SamplerDecimator, SamplerSpacing +from osgeo import gdal # QGIS imports from qgis.core import ( + QgsCoordinateReferenceSystem, + QgsFeature, + QgsField, + QgsFields, + QgsGeometry, + QgsPointXY, QgsProcessing, QgsProcessingAlgorithm, QgsProcessingContext, QgsProcessingException, QgsProcessingFeedback, + QgsProcessingParameterEnum, QgsProcessingParameterFeatureSink, QgsProcessingParameterFeatureSource, - QgsProcessingParameterRasterLayer, - QgsProcessingParameterEnum, QgsProcessingParameterNumber, - QgsFields, - QgsField, - QgsFeature, - QgsGeometry, - QgsPointXY, + QgsProcessingParameterRasterLayer, QgsWkbTypes, - QgsCoordinateReferenceSystem ) + +from loopstructural.gui.compatibility import QVariantCompat + # Internal imports from ...main.vectorLayerWrapper import qgsLayerToGeoDataFrame -from map2loop.sampler import SamplerDecimator, SamplerSpacing class SamplerAlgorithm(QgsProcessingAlgorithm): @@ -70,14 +74,12 @@ def groupId(self) -> str: def initAlgorithm(self, config: Optional[dict[str, Any]] = None) -> None: """Initialize the algorithm parameters.""" - self.addParameter( QgsProcessingParameterEnum( self.INPUT_SAMPLER_TYPE, "SAMPLER_TYPE", - ["Decimator (Point Geometry Data)", - "Spacing (Line Geometry Data)"], - defaultValue=0 + ["Decimator (Point Geometry Data)", "Spacing (Line Geometry Data)"], + defaultValue=0, ) ) @@ -166,7 +168,9 @@ def processAlgorithm( if sampler_type == "Decimator": feedback.pushInfo("Sampling...") - sampler = SamplerDecimator(decimation=decimation, dtm_data=dtm_gdal, geology_data=geology) + sampler = SamplerDecimator( + decimation=decimation, dtm_data=dtm_gdal, geology_data=geology + ) samples = sampler.sample(spatial_data_gdf) if sampler_type == "Spacing": @@ -175,11 +179,11 @@ def processAlgorithm( samples = sampler.sample(spatial_data_gdf) fields = QgsFields() - fields.append(QgsField("ID", QVariant.String)) - fields.append(QgsField("X", QVariant.Double)) - fields.append(QgsField("Y", QVariant.Double)) - fields.append(QgsField("Z", QVariant.Double)) - fields.append(QgsField("featureId", QVariant.String)) + fields.append(QgsField("ID", QVariantCompat.String)) + fields.append(QgsField("X", QVariantCompat.Double)) + fields.append(QgsField("Y", QVariantCompat.Double)) + fields.append(QgsField("Z", QVariantCompat.Double)) + fields.append(QgsField("featureId", QVariantCompat.String)) crs = None if spatial_data_gdf is not None and spatial_data_gdf.crs is not None: @@ -190,8 +194,12 @@ def processAlgorithm( self.OUTPUT, context, fields, - QgsWkbTypes.PointZ if 'Z' in (samples.columns if samples is not None else []) else QgsWkbTypes.Point, - crs + ( + QgsWkbTypes.PointZ + if 'Z' in (samples.columns if samples is not None else []) + else QgsWkbTypes.Point + ), + crs, ) if samples is not None and not samples.empty: @@ -203,16 +211,18 @@ def processAlgorithm( wkt = f"POINT Z ({row['X']} {row['Y']} {row['Z']})" feature.setGeometry(QgsGeometry.fromWkt(wkt)) else: - #spacing has no z values + # spacing has no z values feature.setGeometry(QgsGeometry.fromPointXY(QgsPointXY(row['X'], row['Y']))) - feature.setAttributes([ - str(row.get('ID', '')), - float(row.get('X', 0)), - float(row.get('Y', 0)), - float(row.get('Z', 0)) if pd.notna(row.get('Z')) else 0.0, - str(row.get('featureId', '')) - ]) + feature.setAttributes( + [ + str(row.get('ID', '')), + float(row.get('X', 0)), + float(row.get('Y', 0)), + float(row.get('Z', 0)) if pd.notna(row.get('Z')) else 0.0, + str(row.get('featureId', '')), + ] + ) sink.addFeature(feature) diff --git a/loopstructural/processing/algorithms/sorter.py b/loopstructural/processing/algorithms/sorter.py index bb816fd..d2885ed 100644 --- a/loopstructural/processing/algorithms/sorter.py +++ b/loopstructural/processing/algorithms/sorter.py @@ -1,40 +1,42 @@ +import json from typing import Any, Optional -from osgeo import gdal + import pandas as pd -import json -from PyQt5.QtCore import QVariant +# ──────────────────────────────────────────────── +# map2loop sorters +# ──────────────────────────────────────────────── +from map2loop.sorter import ( + SorterAgeBased, + SorterAlpha, + SorterMaximiseContacts, + SorterObservationProjections, + SorterUseHint, # kept for backwards compatibility + SorterUseNetworkX, +) +from osgeo import gdal from qgis.core import ( + QgsFeature, QgsFeatureSink, - QgsFields, QgsField, - QgsFeature, + QgsFields, QgsProcessing, QgsProcessingAlgorithm, QgsProcessingContext, QgsProcessingException, QgsProcessingFeedback, QgsProcessingParameterEnum, - QgsProcessingParameterFileDestination, QgsProcessingParameterFeatureSink, QgsProcessingParameterFeatureSource, QgsProcessingParameterField, + QgsProcessingParameterFileDestination, QgsProcessingParameterRasterLayer, QgsVectorLayer, - QgsWkbTypes + QgsWkbTypes, ) -# ──────────────────────────────────────────────── -# map2loop sorters -# ──────────────────────────────────────────────── -from map2loop.sorter import ( - SorterAlpha, - SorterAgeBased, - SorterMaximiseContacts, - SorterObservationProjections, - SorterUseNetworkX, - SorterUseHint, # kept for backwards compatibility -) +from loopstructural.gui.compatibility import QVariantCompat + from ...main.vectorLayerWrapper import qgsLayerToGeoDataFrame, qvariantToFloat # a lookup so we don’t need a giant if/else block @@ -47,17 +49,19 @@ "Observation projections": SorterObservationProjections, } + class StratigraphySorterAlgorithm(QgsProcessingAlgorithm): """ Creates a one-column ‘stratigraphic column’ table ordered by the selected map2loop sorter. """ + METHOD = "METHOD" INPUT_GEOLOGY = "INPUT_GEOLOGY" INPUT_STRUCTURE = "INPUT_STRUCTURE" INPUT_DTM = "INPUT_DTM" INPUT_STRATI_COLUMN = "INPUT_STRATI_COLUMN" - SORTING_ALGORITHM = "SORTING_ALGORITHM" + SORTING_ALGORITHM = "SORTING_ALGORITHM" OUTPUT = "OUTPUT" CONTACTS_LAYER = "CONTACTS_LAYER" @@ -81,21 +85,43 @@ def updateParameters(self, parameters): selected_algorithm = parameters.get(self.SORTING_ALGORITHM, 0) if selected_method == 0: # User-Defined selected - self.parameterDefinition(self.INPUT_STRATI_COLUMN).setMetadata({'widget_wrapper': {'visible': True}}) - self.parameterDefinition(self.SORTING_ALGORITHM).setMetadata({'widget_wrapper': {'visible': False}}) - self.parameterDefinition(self.INPUT_GEOLOGY).setMetadata({'widget_wrapper': {'visible': False}}) + self.parameterDefinition(self.INPUT_STRATI_COLUMN).setMetadata( + {'widget_wrapper': {'visible': True}} + ) + self.parameterDefinition(self.SORTING_ALGORITHM).setMetadata( + {'widget_wrapper': {'visible': False}} + ) + self.parameterDefinition(self.INPUT_GEOLOGY).setMetadata( + {'widget_wrapper': {'visible': False}} + ) else: # Automatic selected - self.parameterDefinition(self.INPUT_GEOLOGY).setMetadata({'widget_wrapper': {'visible': True}}) - self.parameterDefinition(self.SORTING_ALGORITHM).setMetadata({'widget_wrapper': {'visible': True}}) - self.parameterDefinition(self.INPUT_STRATI_COLUMN).setMetadata({'widget_wrapper': {'visible': False}}) + self.parameterDefinition(self.INPUT_GEOLOGY).setMetadata( + {'widget_wrapper': {'visible': True}} + ) + self.parameterDefinition(self.SORTING_ALGORITHM).setMetadata( + {'widget_wrapper': {'visible': True}} + ) + self.parameterDefinition(self.INPUT_STRATI_COLUMN).setMetadata( + {'widget_wrapper': {'visible': False}} + ) # observation projects is_observation_projections = selected_algorithm == 5 - self.parameterDefinition(self.INPUT_STRUCTURE).setMetadata({'widget_wrapper': {'visible': is_observation_projections}}) - self.parameterDefinition(self.INPUT_DTM).setMetadata({'widget_wrapper': {'visible': is_observation_projections}}) - self.parameterDefinition('DIP_FIELD').setMetadata({'widget_wrapper': {'visible': is_observation_projections}}) - self.parameterDefinition('DIPDIR_FIELD').setMetadata({'widget_wrapper': {'visible': is_observation_projections}}) - self.parameterDefinition('ORIENTATION_TYPE').setMetadata({'widget_wrapper': {'visible': is_observation_projections}}) + self.parameterDefinition(self.INPUT_STRUCTURE).setMetadata( + {'widget_wrapper': {'visible': is_observation_projections}} + ) + self.parameterDefinition(self.INPUT_DTM).setMetadata( + {'widget_wrapper': {'visible': is_observation_projections}} + ) + self.parameterDefinition('DIP_FIELD').setMetadata( + {'widget_wrapper': {'visible': is_observation_projections}} + ) + self.parameterDefinition('DIPDIR_FIELD').setMetadata( + {'widget_wrapper': {'visible': is_observation_projections}} + ) + self.parameterDefinition('ORIENTATION_TYPE').setMetadata( + {'widget_wrapper': {'visible': is_observation_projections}} + ) return super().updateParameters(parameters) @@ -104,13 +130,13 @@ def updateParameters(self, parameters): # ---------------------------------------------------------- def initAlgorithm(self, config: Optional[dict[str, Any]] = None) -> None: - # enum so the user can pick the strategy from a dropdown + # enum so the user can pick the strategy from a dropdown self.addParameter( QgsProcessingParameterEnum( self.SORTING_ALGORITHM, "Sorting strategy", options=list(SORTER_LIST.keys()), - defaultValue="Observation projections", # Age-based is safest default + defaultValue="Observation projections", # Age-based is safest default ) ) @@ -119,7 +145,7 @@ def initAlgorithm(self, config: Optional[dict[str, Any]] = None) -> None: self.INPUT_GEOLOGY, "Geology polygons", [QgsProcessing.TypeVectorPolygon], - optional=True + optional=True, ) ) @@ -130,7 +156,7 @@ def initAlgorithm(self, config: Optional[dict[str, Any]] = None) -> None: parentLayerParameterName=self.INPUT_GEOLOGY, type=QgsProcessingParameterField.Any, defaultValue='UNITNAME', - optional=True + optional=True, ) ) @@ -141,7 +167,7 @@ def initAlgorithm(self, config: Optional[dict[str, Any]] = None) -> None: parentLayerParameterName=self.INPUT_GEOLOGY, type=QgsProcessingParameterField.Any, defaultValue='MIN_AGE', - optional=True + optional=True, ) ) @@ -152,7 +178,7 @@ def initAlgorithm(self, config: Optional[dict[str, Any]] = None) -> None: parentLayerParameterName=self.INPUT_GEOLOGY, type=QgsProcessingParameterField.Any, defaultValue='MAX_AGE', - optional=True + optional=True, ) ) @@ -163,12 +189,12 @@ def initAlgorithm(self, config: Optional[dict[str, Any]] = None) -> None: parentLayerParameterName=self.INPUT_GEOLOGY, type=QgsProcessingParameterField.Any, defaultValue='GROUP', - optional=True + optional=True, ) ) self.addParameter( - QgsProcessingParameterFeatureSource( + QgsProcessingParameterFeatureSource( self.INPUT_STRUCTURE, "Structure", [QgsProcessing.TypeVectorPoint], @@ -183,7 +209,7 @@ def initAlgorithm(self, config: Optional[dict[str, Any]] = None) -> None: parentLayerParameterName=self.INPUT_STRUCTURE, type=QgsProcessingParameterField.Any, defaultValue='DIP', - optional=True + optional=True, ) ) self.addParameter( @@ -193,7 +219,7 @@ def initAlgorithm(self, config: Optional[dict[str, Any]] = None) -> None: parentLayerParameterName=self.INPUT_STRUCTURE, type=QgsProcessingParameterField.Any, defaultValue='DIPDIR', - optional=True + optional=True, ) ) @@ -201,8 +227,8 @@ def initAlgorithm(self, config: Optional[dict[str, Any]] = None) -> None: QgsProcessingParameterEnum( 'ORIENTATION_TYPE', 'Orientation Type', - options=['','Dip Direction', 'Strike'], - defaultValue=0 + options=['', 'Dip Direction', 'Strike'], + defaultValue=0, ) ) @@ -232,9 +258,7 @@ def initAlgorithm(self, config: Optional[dict[str, Any]] = None) -> None: self.addParameter( QgsProcessingParameterFileDestination( - "JSON_OUTPUT", - "Stratigraphic column json", - fileFilter="JSON files (*.json)" + "JSON_OUTPUT", "Stratigraphic column json", fileFilter="JSON files (*.json)" ) ) @@ -254,19 +278,32 @@ def processAlgorithm( in_layer = self.parameterAsVectorLayer(parameters, self.INPUT_GEOLOGY, context) output_file = self.parameterAsFileOutput(parameters, 'JSON_OUTPUT', context) - units_df, relationships_df, contacts_df= build_input_frames(in_layer,contacts_layer, feedback,parameters) + units_df, relationships_df, contacts_df = build_input_frames( + in_layer, contacts_layer, feedback, parameters + ) if sorter_cls == SorterObservationProjections: geology_gdf = qgsLayerToGeoDataFrame(in_layer) structure = self.parameterAsVectorLayer(parameters, self.INPUT_STRUCTURE, context) dtm = self.parameterAsRasterLayer(parameters, self.INPUT_DTM, context) - if geology_gdf is None or geology_gdf.empty or not structure or not structure.isValid() or not dtm or not dtm.isValid(): - raise QgsProcessingException("Structure and DTM layer are required for observation projections") + if ( + geology_gdf is None + or geology_gdf.empty + or not structure + or not structure.isValid() + or not dtm + or not dtm.isValid() + ): + raise QgsProcessingException( + "Structure and DTM layer are required for observation projections" + ) structure_gdf = qgsLayerToGeoDataFrame(structure) if structure else None dtm_gdal = gdal.Open(dtm.source()) if dtm is not None and dtm.isValid() else None - unit_name_field = parameters.get('UNIT_NAME_FIELD', 'UNITNAME') if parameters else 'UNITNAME' + unit_name_field = ( + parameters.get('UNIT_NAME_FIELD', 'UNITNAME') if parameters else 'UNITNAME' + ) if unit_name_field != 'UNITNAME' and unit_name_field in geology_gdf.columns: geology_gdf = geology_gdf.rename(columns={unit_name_field: 'UNITNAME'}) @@ -276,7 +313,7 @@ def processAlgorithm( if dip_field != 'DIP' and dip_field in structure_gdf.columns: structure_gdf = structure_gdf.rename(columns={dip_field: 'DIP'}) orientation_type = self.parameterAsEnum(parameters, 'ORIENTATION_TYPE', context) - orientation_type_name = ['','Dip Direction', 'Strike'][orientation_type] + orientation_type_name = ['', 'Dip Direction', 'Strike'][orientation_type] if not orientation_type_name: raise QgsProcessingException("Orientation Type is required") dipdir_field = parameters.get('DIPDIR_FIELD', 'DIPDIR') if parameters else 'DIPDIR' @@ -290,24 +327,15 @@ def processAlgorithm( elif orientation_type_name == 'Dip Direction': structure_gdf = structure_gdf.rename(columns={dipdir_field: 'DIPDIR'}) order = sorter_cls().sort( - units_df, - relationships_df, - contacts_df, - geology_gdf, - structure_gdf, - dtm_gdal + units_df, relationships_df, contacts_df, geology_gdf, structure_gdf, dtm_gdal ) else: - order = sorter_cls().sort( - units_df, - relationships_df, - contacts_df - ) + order = sorter_cls().sort(units_df, relationships_df, contacts_df) # 4 ► write an in-memory table with the result sink_fields = QgsFields() - sink_fields.append(QgsField("order", QVariant.Int)) - sink_fields.append(QgsField("unit_name", QVariant.String)) + sink_fields.append(QgsField("order", QVariantCompat.Int)) + sink_fields.append(QgsField("unit_name", QVariantCompat.String)) (sink, dest_id) = self.parameterAsSink( parameters, @@ -335,10 +363,17 @@ def processAlgorithm( def createInstance(self) -> QgsProcessingAlgorithm: return StratigraphySorterAlgorithm() + # ------------------------------------------------------------------------- # Helper stub – you must replace with *your* conversion logic # ------------------------------------------------------------------------- -def build_input_frames(layer: QgsVectorLayer,contacts_layer: QgsVectorLayer, feedback, parameters, user_defined_units=None) -> tuple: +def build_input_frames( + layer: QgsVectorLayer, + contacts_layer: QgsVectorLayer, + feedback, + parameters, + user_defined_units=None, +) -> tuple: """ Placeholder that turns the geology layer (and any other project layers) into the four objects required by the sorter. @@ -352,17 +387,13 @@ def build_input_frames(layer: QgsVectorLayer,contacts_layer: QgsVectorLayer, fee units_record = [] for i, row in enumerate(user_defined_units): units_record.append( - dict( - layerId=i, - name=row[1], - minAge=row[2], - maxAge=row[3], - group=row[4] - ) + dict(layerId=i, name=row[1], minAge=row[2], maxAge=row[3], group=row[4]) ) units_df = pd.DataFrame.from_records(units_record) else: - unit_name_field = parameters.get('UNIT_NAME_FIELD', 'UNITNAME') if parameters else 'UNITNAME' + unit_name_field = ( + parameters.get('UNIT_NAME_FIELD', 'UNITNAME') if parameters else 'UNITNAME' + ) min_age_field = parameters.get('MIN_AGE_FIELD', 'MIN_AGE') if parameters else 'MIN_AGE' max_age_field = parameters.get('MAX_AGE_FIELD', 'MAX_AGE') if parameters else 'MAX_AGE' group_field = parameters.get('GROUP_FIELD', 'GROUP') if parameters else 'GROUP' diff --git a/loopstructural/processing/algorithms/user_defined_sorter.py b/loopstructural/processing/algorithms/user_defined_sorter.py index 7b4c96c..7252eb5 100644 --- a/loopstructural/processing/algorithms/user_defined_sorter.py +++ b/loopstructural/processing/algorithms/user_defined_sorter.py @@ -1,29 +1,35 @@ import numpy as np - -from PyQt5.QtCore import QVariant from qgis.core import ( + QgsCoordinateReferenceSystem, + QgsFeature, QgsFeatureSink, - QgsFields, QgsField, - QgsFeature, + QgsFields, QgsProcessingAlgorithm, QgsProcessingParameterFeatureSink, QgsProcessingParameterMatrix, - QgsCoordinateReferenceSystem, + QgsSettings, QgsWkbTypes, - QgsSettings ) +from loopstructural.gui.compatibility import QVariantCompat class UserDefinedStratigraphyAlgorithm(QgsProcessingAlgorithm): INPUT_STRATI_COLUMN = "INPUT_STRATI_COLUMN" OUTPUT = "OUTPUT" - def name(self): return "loop_sorter_2" - def displayName(self): return "User-Defined Stratigraphic Column" - def group(self): return "Stratigraphy" - def groupId(self): return "Stratigraphy_Column" + def name(self): + return "loop_sorter_2" + + def displayName(self): + return "User-Defined Stratigraphic Column" + + def group(self): + return "Stratigraphy" + + def groupId(self): + return "Stratigraphy_Column" def initAlgorithm(self, config=None): strati_settings = QgsSettings() @@ -34,7 +40,7 @@ def initAlgorithm(self, config=None): description="Stratigraphic Order", headers=["Unit"], numberRows=0, - defaultValue=last_strati_column + defaultValue=last_strati_column, ) ) self.addParameter( @@ -64,13 +70,16 @@ def processAlgorithm(self, parameters, context, feedback): # 3) Prepare sink sink_fields = QgsFields() - sink_fields.append(QgsField("order", QVariant.Int)) # or QVariant.LongLong - sink_fields.append(QgsField("unit_name", QVariant.String)) + sink_fields.append(QgsField("order", QVariantCompat.Int)) # or QVariant.LongLong + sink_fields.append(QgsField("unit_name", QVariantCompat.String)) - crs = context.project().crs() if context and context.project() else QgsCoordinateReferenceSystem() + crs = ( + context.project().crs() + if context and context.project() + else QgsCoordinateReferenceSystem() + ) sink, dest_id = self.parameterAsSink( - parameters, self.OUTPUT, context, - sink_fields, QgsWkbTypes.NoGeometry, crs + parameters, self.OUTPUT, context, sink_fields, QgsWkbTypes.NoGeometry, crs ) # 4) Insert features diff --git a/loopstructural/toolbelt/log_handler.py b/loopstructural/toolbelt/log_handler.py index 8e3104a..1895559 100644 --- a/loopstructural/toolbelt/log_handler.py +++ b/loopstructural/toolbelt/log_handler.py @@ -153,9 +153,9 @@ def _do_push(): try: from qgis.PyQt.QtCore import QTimer as _QTimer except Exception: - # fall back to PyQt5/PySide2 if qgis.PyQt namespace isn't present + # fall back to qgis.PyQt/PySide2 if qgis.PyQt namespace isn't present try: - from PyQt5.QtCore import QTimer as _QTimer # type: ignore + from qgis.PyQt.QtCore import QTimer as _QTimer # type: ignore except Exception: try: from PySide2.QtCore import QTimer as _QTimer # type: ignore diff --git a/tests/qgis/test_grid_export.py b/tests/qgis/test_grid_export.py index db0d531..1ab1e2b 100644 --- a/tests/qgis/test_grid_export.py +++ b/tests/qgis/test_grid_export.py @@ -1,7 +1,7 @@ """QGIS tests for grid export functionality. This module tests grid export features that depend on the visualization -GUI components and PyQt5/QGIS. +GUI components and qgis.PyQt/QGIS. Usage from the repo root folder: @@ -28,56 +28,56 @@ def test_export_grid_ascii_basic(self): # Create a mock mesh object that simulates a PyVista UniformGrid mock_mesh = MagicMock() mock_mesh.n_cells = 8 - + # Mock cell centers mock_cell_centers = MagicMock() - mock_cell_centers.points = np.array([ - [0.5, 0.5, 0.5], - [1.5, 0.5, 0.5], - [0.5, 1.5, 0.5], - [1.5, 1.5, 0.5], - [0.5, 0.5, 1.5], - [1.5, 0.5, 1.5], - [0.5, 1.5, 1.5], - [1.5, 1.5, 1.5], - ]) + mock_cell_centers.points = np.array( + [ + [0.5, 0.5, 0.5], + [1.5, 0.5, 0.5], + [0.5, 1.5, 0.5], + [1.5, 1.5, 0.5], + [0.5, 0.5, 1.5], + [1.5, 0.5, 1.5], + [0.5, 1.5, 1.5], + [1.5, 1.5, 1.5], + ] + ) mock_mesh.cell_centers.return_value = mock_cell_centers - + # Mock scalar data mock_mesh.active_scalars_name = "scalar_field" - mock_mesh.cell_data = { - "scalar_field": np.array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]) - } - + mock_mesh.cell_data = {"scalar_field": np.array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0])} + # Create a temporary file for export with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f: temp_path = f.name - + try: # Import the ObjectListWidget to get the export method from loopstructural.gui.visualisation.object_list_widget import ObjectListWidget - + # Create a minimal instance (viewer and properties_widget can be None for this test) widget = ObjectListWidget(viewer=MagicMock(), properties_widget=None) - + # Call the export method widget._export_grid_ascii(mock_mesh, temp_path, "test_grid") - + # Read the exported file with open(temp_path, 'r') as f: lines = f.readlines() - + # Verify header self.assertEqual(lines[0].strip(), "# ASCII Grid Export: test_grid") self.assertEqual(lines[1].strip(), "# Format: x y z value") self.assertEqual(lines[2].strip(), "# Number of cells: 8") self.assertEqual(lines[3].strip(), "# Scalar field: scalar_field") self.assertEqual(lines[4].strip(), "#") - + # Verify data lines data_lines = lines[5:] self.assertEqual(len(data_lines), 8) - + # Verify first data line first_line = data_lines[0].strip().split() self.assertEqual(len(first_line), 4) @@ -85,7 +85,7 @@ def test_export_grid_ascii_basic(self): self.assertAlmostEqual(float(first_line[1]), 0.5, places=5) self.assertAlmostEqual(float(first_line[2]), 0.5, places=5) self.assertAlmostEqual(float(first_line[3]), 1.0, places=5) - + finally: # Clean up Path(temp_path).unlink(missing_ok=True) @@ -95,36 +95,38 @@ def test_export_grid_ascii_no_scalars(self): # Create a mock mesh without scalar data mock_mesh = MagicMock() mock_mesh.n_cells = 4 - + mock_cell_centers = MagicMock() - mock_cell_centers.points = np.array([ - [0.5, 0.5, 0.5], - [1.5, 0.5, 0.5], - [0.5, 1.5, 0.5], - [1.5, 1.5, 0.5], - ]) + mock_cell_centers.points = np.array( + [ + [0.5, 0.5, 0.5], + [1.5, 0.5, 0.5], + [0.5, 1.5, 0.5], + [1.5, 1.5, 0.5], + ] + ) mock_mesh.cell_centers.return_value = mock_cell_centers - + # No scalar data mock_mesh.active_scalars_name = None mock_mesh.cell_data = {} - + with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f: temp_path = f.name - + try: from loopstructural.gui.visualisation.object_list_widget import ObjectListWidget - + widget = ObjectListWidget(viewer=MagicMock(), properties_widget=None) widget._export_grid_ascii(mock_mesh, temp_path, "test_grid_no_scalars") - + with open(temp_path, 'r') as f: lines = f.readlines() - + # Should create file with header self.assertGreater(len(lines), 0) self.assertIn("# ASCII Grid Export: test_grid_no_scalars", lines[0]) - + # Count non-header lines (data lines) header_count = 0 for line in lines: @@ -132,10 +134,10 @@ def test_export_grid_ascii_no_scalars(self): header_count += 1 else: break - + # We should have at least some header and data lines self.assertGreater(header_count, 0) - + # Verify data lines have zeros for missing scalar data data_lines = lines[header_count:] data_count = 0 @@ -147,10 +149,10 @@ def test_export_grid_ascii_no_scalars(self): value = float(parts[3]) # Should be zero for missing scalar data self.assertAlmostEqual(value, 0.0, places=5) - + # Should have data for the 4 cells self.assertEqual(data_count, 4, f"Expected 4 data lines, got {data_count}") - + finally: Path(temp_path).unlink(missing_ok=True) @@ -164,13 +166,18 @@ def test_mesh_type_detection(self): ("PolyData", False), ("UnstructuredGrid", False), ] - + for mesh_type, expected_is_grid in test_cases: mock_mesh = MagicMock() mock_mesh.__class__.__name__ = mesh_type - - is_grid = type(mock_mesh).__name__ in ['UniformGrid', 'ImageData', 'StructuredGrid', 'RectilinearGrid'] - + + is_grid = type(mock_mesh).__name__ in [ + 'UniformGrid', + 'ImageData', + 'StructuredGrid', + 'RectilinearGrid', + ] + self.assertEqual(is_grid, expected_is_grid, f"Failed for mesh type: {mesh_type}") diff --git a/tests/qgis/test_gui_paint_stratigraphic_order_widget.py b/tests/qgis/test_gui_paint_stratigraphic_order_widget.py index 6835c61..f1c3f2a 100644 --- a/tests/qgis/test_gui_paint_stratigraphic_order_widget.py +++ b/tests/qgis/test_gui_paint_stratigraphic_order_widget.py @@ -2,9 +2,10 @@ import pytest from qgis.core import QgsFeature, QgsField, QgsFields, QgsVectorLayer, QgsWkbTypes -from qgis.PyQt.QtCore import QVariant from qgis.testing import start_app +from loopstructural.gui.compatibility import QVariantCompat + # Monkeypatch uic.loadUi to avoid needing the .ui file and to provide minimal widgets class DummySignal: @@ -122,7 +123,7 @@ def log_params(self, *args, **kwargs): def make_geology_layer(): # create a memory polygon layer with UNITNAME field and 3 polygons fields = QgsFields() - fields.append(QgsField('UNITNAME', QVariant.String)) + fields.append(QgsField('UNITNAME', QVariantCompat.String)) uri = 'Polygon?crs=EPSG:4326' layer = QgsVectorLayer(uri, 'geology', 'memory')