diff --git a/m2l/gui/map2loop_dockwidget.py b/m2l/gui/map2loop_dockwidget.py new file mode 100644 index 0000000..0ee5660 --- /dev/null +++ b/m2l/gui/map2loop_dockwidget.py @@ -0,0 +1,302 @@ +""" + map2loop plugin Docker Widget +""" + +import os +import geopandas + +from qgis.PyQt import uic, QtWidgets +from qgis.PyQt.QtCore import pyqtSignal +from qgis.PyQt.QtCore import QVariant +from qgis.PyQt.QtWidgets import QDockWidget +from qgis.gui import QgsMapLayerComboBox, QgsFieldComboBox +from qgis.core import ( + QgsMapLayerProxyModel, + QgsVectorLayer, + QgsProject, + QgsFeature, + QgsGeometry, + QgsFields, + QgsField, + QgsWkbTypes +) + +FORM_CLASS, _ = uic.loadUiType(os.path.join( + os.path.dirname(__file__), 'map2loop_dockwidget_base.ui')) + + +class Map2loopDockWidget(QDockWidget, FORM_CLASS): + + closingPlugin = pyqtSignal() + + def __init__(self, parent=None): + """Constructor.""" + super(Map2loopDockWidget, self).__init__(parent) + + self.setupUi(self) + self.setWindowTitle("map2loop") + self.setup_qgis_widgets() + + self.extract_contact_button.clicked.connect(self.extract_contact) + self.extract_basal_contact_button.clicked.connect(self.extract_basal_contact) + + + def setup_qgis_widgets(self): + input_data_formLayout = self.input_data_groupBox.findChild(QtWidgets.QFormLayout, 'input_data_formLayout') + + self.geology_layer = QgsMapLayerComboBox() + self.geology_layer.setFilters(QgsMapLayerProxyModel.VectorLayer) + input_data_formLayout.replaceWidget(self.geology_comboBox, self.geology_layer) + self.geology_comboBox.hide() + + self.structure_layer = QgsMapLayerComboBox() + self.structure_layer.setFilters(QgsMapLayerProxyModel.VectorLayer) + input_data_formLayout.replaceWidget(self.structure_comboBox, self.structure_layer) + self.structure_comboBox.hide() + + self.dtm_layer = QgsMapLayerComboBox() + self.dtm_layer.setFilters(QgsMapLayerProxyModel.RasterLayer) + input_data_formLayout.replaceWidget(self.dtm_comboBox, self.dtm_layer) + self.dtm_comboBox.hide() + + self.fault_layer = QgsMapLayerComboBox() + self.fault_layer.setFilters(QgsMapLayerProxyModel.VectorLayer) + input_data_formLayout.replaceWidget(self.fault_comboBox, self.fault_layer) + self.fault_comboBox.hide() + + self.strat_column_layer = QgsMapLayerComboBox() + self.strat_column_layer.setFilters(QgsMapLayerProxyModel.VectorLayer) + input_data_formLayout.replaceWidget(self.strat_column_comboBox, self.strat_column_layer) + self.strat_column_comboBox.hide() + + self.unit_name_field = QgsFieldComboBox() + input_data_formLayout.replaceWidget(self.unit_name_field_comboBox, self.unit_name_field) + self.unit_name_field_comboBox.hide() + + self.geology_layer.layerChanged.connect(self.update_unit_name_field) + + def update_unit_name_field(self): + geology_layer = self.geology_layer.currentLayer() + self.unit_name_field.setLayer(geology_layer) + if geology_layer: + fields = geology_layer.fields() + for field in fields: + if field.name().lower() in ['unitname', 'unit_name']: + self.unit_name_field.setField(field.name()) + break + + def get_strat_column_source(self, context, feedback=None): + from qgis.core import QgsProcessingUtils + + layer = self.strat_column_layer.currentLayer() + if layer: + layer_string = layer.id() + source = QgsProcessingUtils.mapLayerFromString(layer_string, context) + return source + return None + + def extract_contact(self): + geology_layer = self.geology_layer.currentLayer() + fault_layer = self.fault_layer.currentLayer() + + if not geology_layer: + QtWidgets.QMessageBox.warning(self,"No Geology Layer Selected","Please select a geology layer") + return + + try: + geology_gdf = geopandas.read_file(geology_layer.source()) + fault_gdf = geopandas.read_file(fault_layer.source()) if fault_layer else None + + unit_name_field = self.unit_name_field.currentField() + + if unit_name_field and unit_name_field != 'UNITNAME' and unit_name_field in geology_gdf.columns: + geology_gdf = geology_gdf.rename(columns={unit_name_field: 'UNITNAME'}) + elif 'UNITNAME' not in geology_gdf.columns: + QtWidgets.QMessageBox.warning(self,"Missing Unit Name Field","Please select a unit name field from the geology layer.") + return + + try: + from m2l.processing.algorithms.extract_contacts import ContactExtractor + contact_extractor = ContactExtractor(geology_gdf, fault_gdf) + contacts = contact_extractor.extract_all_contacts() + + if contacts is not None and len(contacts) > 0: + contacts_layer = self.create_vector_layer_from_geodataframe(contacts, "Extracted_Contacts") + if contacts_layer: + QgsProject.instance().addMapLayer(contacts_layer) + + QtWidgets.QMessageBox.information(self,"Contact Extraction Complete",f"Extracted {len(contacts)} contacts and exported as '{contacts_layer.name()}'") + else: + QtWidgets.QMessageBox.warning(self,"Layer Creation Failed","Failed to create vector layer from contacts") + else: + QtWidgets.QMessageBox.information(self,"No Contacts Found","No contacts were extracted") + + except ImportError: + QtWidgets.QMessageBox.warning(self,"ContactExtractor Not Found","ContactExtractor class not found.") + except Exception as e: + QtWidgets.QMessageBox.critical(self,"Error",f"An error occurred during contact extraction: {str(e)}") + + def extract_basal_contact(self): + geology_layer = self.geology_layer.currentLayer() + fault_layer = self.fault_layer.currentLayer() + strat_column_layer = self.strat_column_layer.currentLayer() + + if not geology_layer: + QtWidgets.QMessageBox.warning(self,"No Geology Layer Selected","Please select a geology layer") + return + + if not strat_column_layer: + QtWidgets.QMessageBox.warning(self,"No Stratigraphic Column Selected","Please select a stratigraphic column layer") + return + + try: + geology_gdf = geopandas.read_file(geology_layer.source()) + fault_gdf = geopandas.read_file(fault_layer.source()) if fault_layer else None + + unit_name_field = self.unit_name_field.currentField() + + if unit_name_field and unit_name_field != 'UNITNAME' and unit_name_field in geology_gdf.columns: + geology_gdf = geology_gdf.rename(columns={unit_name_field: 'UNITNAME'}) + elif 'UNITNAME' not in geology_gdf.columns: + QtWidgets.QMessageBox.warning(self,"Missing Unit Name Field","Please select a unit name field from the geology layer, or ensure the geology layer has a 'UNITNAME' column.") + return + + stratigraphic_column = self.get_stratigraphic_column_list(strat_column_layer) + if not stratigraphic_column: + QtWidgets.QMessageBox.warning(self,"Empty Stratigraphic Column","The stratigraphic column layer contains no valid unit names.") + return + + try: + from m2l.processing.algorithms.extract_contacts import ContactExtractor + contact_extractor = ContactExtractor(geology_gdf, fault_gdf) + basal_contacts = contact_extractor.extract_basal_contacts(stratigraphic_column) + + if basal_contacts is not None and len(basal_contacts) > 0: + basal_contacts_layer = self.create_vector_layer_from_geodataframe(basal_contacts, "Extracted_Basal_Contacts") + if basal_contacts_layer: + QgsProject.instance().addMapLayer(basal_contacts_layer) + + QtWidgets.QMessageBox.information(self, "Basal Contact Extraction Complete",f"Extracted {len(basal_contacts)} basal contacts and exported as '{basal_contacts_layer.name()}'") + else: + QtWidgets.QMessageBox.warning(self,"Layer Creation Failed","Failed to create vector layer from basal contacts") + else: + QtWidgets.QMessageBox.information(self,"No Basal Contacts Found","No basal contacts were extracted") + + except ImportError: + QtWidgets.QMessageBox.warning(self,"ContactExtractor Not Found","ContactExtractor class not found.") + except Exception as e: + QtWidgets.QMessageBox.critical(self,"Error",f"An error occurred during basal contact extraction: {str(e)}") + + def get_stratigraphic_column_list(self, strat_layer): + try: + field_names = [field.name() for field in strat_layer.fields()] + unit_field = None + + for field_name in field_names: + if field_name.lower() in ['unit_name', 'unitname', 'unit', 'formation', 'name']: + unit_field = field_name + break + + if not unit_field and field_names: + unit_field = field_names[0] + + if not unit_field: + return [] + + unit_names = [] + for feature in strat_layer.getFeatures(): + unit_name = feature[unit_field] + if unit_name and str(unit_name).strip(): + unit_names.append(str(unit_name).strip()) + + seen = set() + stratigraphic_column = [] + for unit in unit_names: + if unit not in seen: + seen.add(unit) + stratigraphic_column.append(unit) + + return stratigraphic_column + + except Exception as e: + print(f"Error extracting stratigraphic column: {str(e)}") + return [] + + def create_vector_layer_from_geodataframe(self, geodataframe, layer_name="Vector_Layer"): + try: + if hasattr(geodataframe, 'geometry') and len(geodataframe) > 0: + first_geom = geodataframe.geometry.iloc[0] + if first_geom.geom_type == 'LineString': + geom_type = "LineString" + elif first_geom.geom_type == 'Point': + geom_type = "Point" + elif first_geom.geom_type == 'Polygon': + geom_type = "Polygon" + else: + geom_type = "LineString" + if not geodataframe.crs: + QtWidgets.QMessageBox.warning(self,"No CRS Found","No CRS found in the geodataframe.") + return None + crs_string = f"EPSG:{geodataframe.crs.to_epsg()}" + else: + geom_type = "LineString" + + layer = QgsVectorLayer(f"{geom_type}?crs={crs_string}", layer_name, "memory") + + if not layer.isValid(): + return None + + provider = layer.dataProvider() + + fields = QgsFields() + if hasattr(geodataframe, 'columns'): + for col in geodataframe.columns: + if col != 'geometry': + if geodataframe[col].dtype == 'object': + fields.append(QgsField(col, QVariant.String)) + elif geodataframe[col].dtype in ['int8', 'int16', 'int32', 'int64']: + fields.append(QgsField(col, QVariant.Int)) + elif geodataframe[col].dtype in ['float16', 'float32', 'float64']: + fields.append(QgsField(col, QVariant.Double)) + else: + fields.append(QgsField(col, QVariant.String)) + else: + fields.append(QgsField("feature_id", QVariant.Int)) + fields.append(QgsField("feature_type", QVariant.String)) + + provider.addAttributes(fields) + layer.updateFields() + + features = [] + if hasattr(geodataframe, 'iterrows'): + for _idx, row in geodataframe.iterrows(): + feature = QgsFeature() + + if hasattr(row, 'geometry') and row.geometry is not None: + geom = QgsGeometry.fromWkt(row.geometry.wkt) + feature.setGeometry(geom) + + attributes = [] + for col in geodataframe.columns: + if col != 'geometry': + attributes.append(row[col]) + feature.setAttributes(attributes) + features.append(feature) + else: + for i, _item in enumerate(geodataframe): + feature = QgsFeature() + feature.setAttributes([i, "feature"]) + features.append(feature) + + provider.addFeatures(features) + layer.updateExtents() + + return layer + + except Exception as e: + print(f"Error creating vector layer from geodataframe: {str(e)}") + return None + + def closeEvent(self, event): + self.closingPlugin.emit() + event.accept() \ No newline at end of file diff --git a/m2l/gui/map2loop_dockwidget_base.ui b/m2l/gui/map2loop_dockwidget_base.ui new file mode 100644 index 0000000..dce09e8 --- /dev/null +++ b/m2l/gui/map2loop_dockwidget_base.ui @@ -0,0 +1,136 @@ + + + Map2loopDockWidgetBase + + + + 0 + 0 + 300 + 400 + + + + map2loop + + + + + + + + 12 + 75 + true + + + + map2loop Plugin + + + Qt::AlignCenter + + + + + + + Input Data + + + + + + Geology Layer: + + + + + + + + + + Structure Layer: + + + + + + + + + + DTM Layer: + + + + + + + + + + Fault Layer: + + + + + + + + + + Stratigraphic Column: + + + + + + + + + + Unit Name Field: + + + + + + + + + + + + + Extract Contact + + + + + + + Extract Basal Contact + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + \ No newline at end of file diff --git a/m2l/plugin_main.py b/m2l/plugin_main.py index a286f1e..e363159 100644 --- a/m2l/plugin_main.py +++ b/m2l/plugin_main.py @@ -10,7 +10,7 @@ # PyQGIS from qgis.core import QgsApplication, QgsSettings from qgis.gui import QgisInterface -from qgis.PyQt.QtCore import QCoreApplication, QLocale, QTranslator, QUrl +from qgis.PyQt.QtCore import QCoreApplication, QLocale, QTranslator, QUrl, Qt from qgis.PyQt.QtGui import QDesktopServices, QIcon from qgis.PyQt.QtWidgets import QAction @@ -22,6 +22,7 @@ __uri_homepage__, ) from m2l.gui.dlg_settings import PlgOptionsFactory +from m2l.gui.map2loop_dockwidget import Map2loopDockWidget from m2l.processing import ( Map2LoopProvider, ) @@ -43,6 +44,7 @@ def __init__(self, iface: QgisInterface): self.iface = iface self.log = PlgLogger().log self.provider: Optional[Map2LoopProvider] = None + self.dockwidget: Optional[Map2loopDockWidget] = None # translation # initialize the locale @@ -89,9 +91,19 @@ def initGui(self): ) ) - # -- Menu + self.action_show_dockwidget = QAction( + QIcon(str(__icon_path__)), + self.tr("Map2Loop"), + self.iface.mainWindow(), + ) + self.action_show_dockwidget.setCheckable(True) + self.action_show_dockwidget.triggered.connect(self.toggle_dockwidget) + + self.iface.addPluginToMenu(__title__, self.action_show_dockwidget) self.iface.addPluginToMenu(__title__, self.action_settings) self.iface.addPluginToMenu(__title__, self.action_help) + + self.iface.addToolBarIcon(self.action_show_dockwidget) # -- Processing self.initProcessing() @@ -117,6 +129,26 @@ def initProcessing(self): self.provider = Map2LoopProvider() QgsApplication.processingRegistry().addProvider(self.provider) + def init_dockwidget(self): + if self.dockwidget is None: + self.dockwidget = Map2loopDockWidget() + self.dockwidget.closingPlugin.connect(self.on_dockwidget_closed) + self.iface.addDockWidget(Qt.RightDockWidgetArea, self.dockwidget) + + def toggle_dockwidget(self): + if self.dockwidget is None: + self.init_dockwidget() + + if self.dockwidget.isVisible(): + self.dockwidget.hide() + self.action_show_dockwidget.setChecked(False) + else: + self.dockwidget.show() + self.action_show_dockwidget.setChecked(True) + + def on_dockwidget_closed(self): + self.action_show_dockwidget.setChecked(False) + def tr(self, message: str) -> str: """Get the translation for a string using Qt translation API. @@ -130,7 +162,13 @@ def tr(self, message: str) -> str: def unload(self): """Cleans up when plugin is disabled/uninstalled.""" - # -- Clean up menu + if self.dockwidget: + self.iface.removeDockWidget(self.dockwidget) + self.dockwidget = None + + self.iface.removeToolBarIcon(self.action_show_dockwidget) + + self.iface.removePluginMenu(__title__, self.action_show_dockwidget) self.iface.removePluginMenu(__title__, self.action_help) self.iface.removePluginMenu(__title__, self.action_settings) @@ -146,6 +184,7 @@ def unload(self): ) # remove actions + del self.action_show_dockwidget del self.action_settings del self.action_help