diff --git a/loopstructural/gui/map2loop_tools/README.md b/loopstructural/gui/map2loop_tools/README.md new file mode 100644 index 0000000..5ff9ffc --- /dev/null +++ b/loopstructural/gui/map2loop_tools/README.md @@ -0,0 +1,110 @@ +# Map2Loop Tools Widgets + +This directory contains self-contained GUI widgets for map2loop processing tools. + +## Widgets + +### SorterWidget +GUI interface for automatic stratigraphic column sorting using various map2loop sorting algorithms: +- Age-based sorting +- NetworkX topological sorting +- Adjacency α sorting +- Maximise contacts sorting +- Observation projections sorting + +**Features:** +- Dynamic UI that shows/hides fields based on selected algorithm +- Support for observation projections with structure and DTM data +- Field mapping for geology layers +- Integrated with QGIS processing framework + +### UserDefinedSorterWidget +GUI interface for manually defining stratigraphic columns. + +**Features:** +- Table-based interface for entering unit names +- Move up/down buttons for reordering units +- Add/remove row functionality +- Order from youngest (top) to oldest (bottom) + +### SamplerWidget +GUI interface for contact sampling using Decimator and Spacing algorithms. + +**Features:** +- Support for both Decimator and Spacing samplers +- Dynamic UI based on selected sampler type +- Decimator requires DTM and geology layers +- Spacing works with optional DTM and geology + +### BasalContactsWidget +GUI interface for extracting basal contacts from geology layers. + +**Features:** +- Support for fault layers +- Stratigraphic order integration +- Units to ignore configuration +- Extracts both basal contacts and all contacts + +### ThicknessCalculatorWidget +GUI interface for calculating stratigraphic unit thickness using InterpolatedStructure or StructuralPoint methods. + +**Features:** +- Two calculation methods: InterpolatedStructure and StructuralPoint +- Support for orientation data (dip direction or strike) +- DTM and bounding box configuration +- Integration with sampled contacts and basal contacts + +## Usage + +All widgets follow the same pattern: + +1. They extend `QWidget` +2. Load UI from corresponding `.ui` file +3. Connect signals to update UI state dynamically +4. Provide `get_parameters()` and `set_parameters()` methods for reusability +5. Include a "Run" button that executes the corresponding processing algorithm + +### Example + +```python +from loopstructural.gui.map2loop_tools import SorterWidget + +# Create widget +sorter_widget = SorterWidget(parent=None, data_manager=data_manager) + +# Get parameters +params = sorter_widget.get_parameters() + +# Set parameters +sorter_widget.set_parameters(params) +``` + +## Integration + +The widgets are integrated into the main dock widget through `Map2LoopToolsTab`, which creates a new tab in the `ModellingWidget` containing all map2loop processing tools in collapsible group boxes. + +## File Structure + +``` +map2loop_tools/ +├── __init__.py # Package exports +├── sorter_widget.py # Automatic sorter widget +├── sorter_widget.ui # Automatic sorter UI +├── user_defined_sorter_widget.py # User-defined sorter widget +├── user_defined_sorter_widget.ui # User-defined sorter UI +├── sampler_widget.py # Sampler widget +├── sampler_widget.ui # Sampler UI +├── basal_contacts_widget.py # Basal contacts widget +├── basal_contacts_widget.ui # Basal contacts UI +├── thickness_calculator_widget.py # Thickness calculator widget +└── thickness_calculator_widget.ui # Thickness calculator UI +``` + +## Design Principles + +Following the plugin's architectural guidelines: + +1. **Thin Interface Layer**: Widgets only handle UI and user interaction, delegating to map2loop algorithms +2. **Modularity**: Each widget is self-contained and encapsulated +3. **Object-Oriented Design**: Clear responsibilities and interfaces +4. **Consistency**: Follows existing widget patterns in the codebase diff --git a/loopstructural/gui/map2loop_tools/__init__.py b/loopstructural/gui/map2loop_tools/__init__.py new file mode 100644 index 0000000..ab68076 --- /dev/null +++ b/loopstructural/gui/map2loop_tools/__init__.py @@ -0,0 +1,21 @@ +"""Map2Loop processing tools dialogs. + +This module contains GUI dialogs for map2loop processing tools that can be +accessed from the plugin menu. +""" + +from .dialogs import ( + BasalContactsDialog, + SamplerDialog, + SorterDialog, + ThicknessCalculatorDialog, + UserDefinedSorterDialog, +) + +__all__ = [ + 'BasalContactsDialog', + 'SamplerDialog', + 'SorterDialog', + 'ThicknessCalculatorDialog', + 'UserDefinedSorterDialog', +] diff --git a/loopstructural/gui/map2loop_tools/basal_contacts_widget.py b/loopstructural/gui/map2loop_tools/basal_contacts_widget.py new file mode 100644 index 0000000..c63eb8c --- /dev/null +++ b/loopstructural/gui/map2loop_tools/basal_contacts_widget.py @@ -0,0 +1,165 @@ +"""Widget for extracting basal contacts.""" + +import os + +from PyQt5.QtWidgets import QMessageBox, QWidget +from qgis.PyQt import uic + + +class BasalContactsWidget(QWidget): + """Widget for configuring and running the basal contacts extractor. + + This widget provides a GUI interface for extracting basal contacts + from geology layers. + """ + + def __init__(self, parent=None, data_manager=None): + """Initialize the basal contacts widget. + + Parameters + ---------- + parent : QWidget, optional + Parent widget. + data_manager : object, optional + Data manager for accessing shared data. + """ + super().__init__(parent) + self.data_manager = data_manager + + # Load the UI file + ui_path = os.path.join(os.path.dirname(__file__), "basal_contacts_widget.ui") + uic.loadUi(ui_path, self) + + # Move layer filter setup out of the .ui (QgsMapLayerProxyModel values in .ui + # can cause import errors outside QGIS). Set filters programmatically + # and preserve the allowEmptyLayer setting for the faults combobox. + try: + from qgis.core import QgsMapLayerProxyModel + + # geology layer should only show polygon layers + self.geologyLayerComboBox.setFilters(QgsMapLayerProxyModel.PolygonLayer) + + # faults should show line layers and allow empty selection (as set in .ui) + self.faultsLayerComboBox.setFilters(QgsMapLayerProxyModel.LineLayer) + try: + # QgsMapLayerComboBox has setAllowEmptyLayer method in newer QGIS versions + self.faultsLayerComboBox.setAllowEmptyLayer(True) + except Exception: + # Older QGIS bindings may use allowEmptyLayer property; ignore if unavailable + pass + except Exception: + # If QGIS isn't available (e.g. editing the UI outside QGIS), skip setting filters + pass + + # Connect signals + self.geologyLayerComboBox.layerChanged.connect(self._on_geology_layer_changed) + self.runButton.clicked.connect(self._run_extractor) + + # Set up field combo boxes + self._setup_field_combo_boxes() + + def _setup_field_combo_boxes(self): + """Set up field combo boxes to link to their respective layers.""" + geology = self.geologyLayerComboBox.currentLayer() + if geology is not None: + self.unitNameFieldComboBox.setLayer(geology) + self.formationFieldComboBox.setLayer(geology) + else: + # Ensure combo boxes are cleared if no geology layer selected + try: + self.unitNameFieldComboBox.setLayer(None) + self.formationFieldComboBox.setLayer(None) + except Exception: + pass + + def _on_geology_layer_changed(self): + """Update field combo boxes when geology layer changes.""" + layer = self.geologyLayerComboBox.currentLayer() + self.unitNameFieldComboBox.setLayer(layer) + self.formationFieldComboBox.setLayer(layer) + + def _run_extractor(self): + """Run the basal contacts extraction algorithm.""" + from qgis import processing + from qgis.core import QgsProcessingFeedback + + # Validate inputs + if not self.geologyLayerComboBox.currentLayer(): + QMessageBox.warning(self, "Missing Input", "Please select a geology layer.") + return + + if not self.stratiColumnComboBox.currentLayer(): + QMessageBox.warning(self, "Missing Input", "Please select a stratigraphic order layer.") + return + + # Parse ignore units + ignore_units = [] + if self.ignoreUnitsLineEdit.text().strip(): + ignore_units = [ + unit.strip() for unit in self.ignoreUnitsLineEdit.text().split(',') if unit.strip() + ] + + # Prepare parameters + params = { + 'GEOLOGY': self.geologyLayerComboBox.currentLayer(), + 'UNIT_NAME_FIELD': self.unitNameFieldComboBox.currentField(), + 'FORMATION_FIELD': self.formationFieldComboBox.currentField(), + 'FAULTS': self.faultsLayerComboBox.currentLayer(), + 'STRATIGRAPHIC_COLUMN': self.stratiColumnComboBox.currentLayer(), + 'IGNORE_UNITS': ignore_units, + 'OUTPUT': 'TEMPORARY_OUTPUT', + 'ALL_CONTACTS': 'TEMPORARY_OUTPUT', + } + + # Run the algorithm + try: + feedback = QgsProcessingFeedback() + result = processing.run("plugin_map2loop:basal_contacts", params, feedback=feedback) + + if result: + QMessageBox.information(self, "Success", "Basal contacts extracted successfully!") + else: + QMessageBox.warning(self, "Error", "Failed to extract basal contacts.") + + except Exception as e: + QMessageBox.critical(self, "Error", f"An error occurred: {str(e)}") + + def get_parameters(self): + """Get current widget parameters. + + Returns + ------- + dict + Dictionary of current widget parameters. + """ + ignore_units = [] + if self.ignoreUnitsLineEdit.text().strip(): + ignore_units = [ + unit.strip() for unit in self.ignoreUnitsLineEdit.text().split(',') if unit.strip() + ] + + return { + 'geology_layer': self.geologyLayerComboBox.currentLayer(), + 'unit_name_field': self.unitNameFieldComboBox.currentField(), + 'formation_field': self.formationFieldComboBox.currentField(), + 'faults_layer': self.faultsLayerComboBox.currentLayer(), + 'strati_column': self.stratiColumnComboBox.currentLayer(), + 'ignore_units': ignore_units, + } + + def set_parameters(self, params): + """Set widget parameters. + + Parameters + ---------- + params : dict + Dictionary of parameters to set. + """ + if 'geology_layer' in params and params['geology_layer']: + self.geologyLayerComboBox.setLayer(params['geology_layer']) + if 'faults_layer' in params and params['faults_layer']: + self.faultsLayerComboBox.setLayer(params['faults_layer']) + if 'strati_column' in params and params['strati_column']: + self.stratiColumnComboBox.setLayer(params['strati_column']) + if 'ignore_units' in params and params['ignore_units']: + self.ignoreUnitsLineEdit.setText(', '.join(params['ignore_units'])) diff --git a/loopstructural/gui/map2loop_tools/basal_contacts_widget.ui b/loopstructural/gui/map2loop_tools/basal_contacts_widget.ui new file mode 100644 index 0000000..cfa2e8f --- /dev/null +++ b/loopstructural/gui/map2loop_tools/basal_contacts_widget.ui @@ -0,0 +1,128 @@ + + + BasalContactsWidget + + + + 0 + 0 + 600 + 400 + + + + Basal Contacts Extractor + + + + + + + + Geology Layer: + + + + + + + + + + + + Unit Name Field: + + + + + + + + + + Formation Field: + + + + + + + + + + Faults Layer: + + + + + + + + true + + + + + + + Stratigraphic Order Layer: + + + + + + + + + + Units to Ignore: + + + + + + + Comma-separated list of units to ignore + + + + + + + + + Extract Basal Contacts + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + QgsMapLayerComboBox + QComboBox +
qgsmaplayercombobox.h
+
+ + QgsFieldComboBox + QComboBox +
qgsfieldcombobox.h
+
+
+ + +
diff --git a/loopstructural/gui/map2loop_tools/dialogs.py b/loopstructural/gui/map2loop_tools/dialogs.py new file mode 100644 index 0000000..dedfe4e --- /dev/null +++ b/loopstructural/gui/map2loop_tools/dialogs.py @@ -0,0 +1,168 @@ +"""Dialog wrappers for map2loop processing tools. + +This module provides QDialog wrappers that use map2loop classes directly +instead of QGIS processing algorithms. +""" + +from PyQt5.QtWidgets import QDialog, QDialogButtonBox, QVBoxLayout + + +class SamplerDialog(QDialog): + """Dialog for running samplers using map2loop classes directly.""" + + def __init__(self, parent=None): + """Initialize the sampler dialog.""" + super().__init__(parent) + self.setWindowTitle("Map2Loop Sampler") + self.setup_ui() + + def setup_ui(self): + """Set up the dialog UI.""" + from .sampler_widget import SamplerWidget + + layout = QVBoxLayout(self) + self.widget = SamplerWidget(self) + layout.addWidget(self.widget) + + # Replace the run button with dialog buttons + self.widget.runButton.hide() + + self.button_box = QDialogButtonBox( + QDialogButtonBox.Ok | QDialogButtonBox.Cancel, self + ) + self.button_box.accepted.connect(self._run_and_accept) + self.button_box.rejected.connect(self.reject) + layout.addWidget(self.button_box) + + def _run_and_accept(self): + """Run the sampler and accept dialog if successful.""" + self.widget._run_sampler() + # Dialog stays open so user can see the result + + +class SorterDialog(QDialog): + """Dialog for running stratigraphic sorter using map2loop classes directly.""" + + def __init__(self, parent=None): + """Initialize the sorter dialog.""" + super().__init__(parent) + self.setWindowTitle("Map2Loop Automatic Stratigraphic Sorter") + self.setup_ui() + + def setup_ui(self): + """Set up the dialog UI.""" + from .sorter_widget import SorterWidget + + layout = QVBoxLayout(self) + self.widget = SorterWidget(self) + layout.addWidget(self.widget) + + # Replace the run button with dialog buttons + self.widget.runButton.hide() + + self.button_box = QDialogButtonBox( + QDialogButtonBox.Ok | QDialogButtonBox.Cancel, self + ) + self.button_box.accepted.connect(self._run_and_accept) + self.button_box.rejected.connect(self.reject) + layout.addWidget(self.button_box) + + def _run_and_accept(self): + """Run the sorter and accept dialog if successful.""" + self.widget._run_sorter() + + +class UserDefinedSorterDialog(QDialog): + """Dialog for user-defined stratigraphic column using map2loop classes directly.""" + + def __init__(self, parent=None): + """Initialize the user-defined sorter dialog.""" + super().__init__(parent) + self.setWindowTitle("Map2Loop User-Defined Stratigraphic Column") + self.setup_ui() + + def setup_ui(self): + """Set up the dialog UI.""" + from .user_defined_sorter_widget import UserDefinedSorterWidget + + layout = QVBoxLayout(self) + self.widget = UserDefinedSorterWidget(self) + layout.addWidget(self.widget) + + # Replace the run button with dialog buttons + self.widget.runButton.hide() + + self.button_box = QDialogButtonBox( + QDialogButtonBox.Ok | QDialogButtonBox.Cancel, self + ) + self.button_box.accepted.connect(self._run_and_accept) + self.button_box.rejected.connect(self.reject) + layout.addWidget(self.button_box) + + def _run_and_accept(self): + """Run the sorter and accept dialog if successful.""" + self.widget._run_sorter() + + +class BasalContactsDialog(QDialog): + """Dialog for extracting basal contacts using map2loop classes directly.""" + + def __init__(self, parent=None): + """Initialize the basal contacts dialog.""" + super().__init__(parent) + self.setWindowTitle("Map2Loop Basal Contacts Extractor") + self.setup_ui() + + def setup_ui(self): + """Set up the dialog UI.""" + from .basal_contacts_widget import BasalContactsWidget + + layout = QVBoxLayout(self) + self.widget = BasalContactsWidget(self) + layout.addWidget(self.widget) + + # Replace the run button with dialog buttons + self.widget.runButton.hide() + + self.button_box = QDialogButtonBox( + QDialogButtonBox.Ok | QDialogButtonBox.Cancel, self + ) + self.button_box.accepted.connect(self._run_and_accept) + self.button_box.rejected.connect(self.reject) + layout.addWidget(self.button_box) + + def _run_and_accept(self): + """Run the extractor and accept dialog if successful.""" + self.widget._run_extractor() + + +class ThicknessCalculatorDialog(QDialog): + """Dialog for calculating thickness using map2loop classes directly.""" + + def __init__(self, parent=None): + """Initialize the thickness calculator dialog.""" + super().__init__(parent) + self.setWindowTitle("Map2Loop Thickness Calculator") + self.setup_ui() + + def setup_ui(self): + """Set up the dialog UI.""" + from .thickness_calculator_widget import ThicknessCalculatorWidget + + layout = QVBoxLayout(self) + self.widget = ThicknessCalculatorWidget(self) + layout.addWidget(self.widget) + + # Replace the run button with dialog buttons + self.widget.runButton.hide() + + self.button_box = QDialogButtonBox( + QDialogButtonBox.Ok | QDialogButtonBox.Cancel, self + ) + self.button_box.accepted.connect(self._run_and_accept) + self.button_box.rejected.connect(self.reject) + layout.addWidget(self.button_box) + + def _run_and_accept(self): + """Run the calculator and accept dialog if successful.""" + self.widget._run_calculator() diff --git a/loopstructural/gui/map2loop_tools/sampler_widget.py b/loopstructural/gui/map2loop_tools/sampler_widget.py new file mode 100644 index 0000000..6feb65c --- /dev/null +++ b/loopstructural/gui/map2loop_tools/sampler_widget.py @@ -0,0 +1,256 @@ +"""Widget for running the sampler.""" + +import os + +from PyQt5.QtWidgets import QMessageBox, QWidget +from qgis.PyQt import uic + + +class SamplerWidget(QWidget): + """Widget for configuring and running the sampler. + + This widget provides a GUI interface for the map2loop sampler algorithms + (Decimator and Spacing). + """ + + def __init__(self, parent=None, data_manager=None): + """Initialize the sampler widget. + + Parameters + ---------- + parent : QWidget, optional + Parent widget. + data_manager : object, optional + Data manager for accessing shared data. + """ + super().__init__(parent) + self.data_manager = data_manager + + # Load the UI file + ui_path = os.path.join(os.path.dirname(__file__), "sampler_widget.ui") + uic.loadUi(ui_path, self) + + # Configure layer filters programmatically (avoid QgsMapLayerProxyModel in .ui) + try: + from qgis.core import QgsMapLayerProxyModel + + # DTM should show raster layers, geology polygons + self.dtmLayerComboBox.setFilters(QgsMapLayerProxyModel.RasterLayer) + self.geologyLayerComboBox.setFilters(QgsMapLayerProxyModel.PolygonLayer) + # spatialData can be any type, leave default + except Exception: + # If QGIS isn't available, skip filter setup + pass + + # Initialize sampler types + self.sampler_types = ["Decimator", "Spacing"] + self.samplerTypeComboBox.addItems(self.sampler_types) + + # Connect signals + self.samplerTypeComboBox.currentIndexChanged.connect(self._on_sampler_type_changed) + self.runButton.clicked.connect(self._run_sampler) + + # Initial state update + self._on_sampler_type_changed() + + def _on_sampler_type_changed(self): + """Update UI based on selected sampler type.""" + sampler_type = self.samplerTypeComboBox.currentText() + + if sampler_type == "Decimator": + self.decimationLabel.setVisible(True) + self.decimationSpinBox.setVisible(True) + self.spacingLabel.setVisible(False) + self.spacingSpinBox.setVisible(False) + # Decimator requires DTM and geology + self.dtmLayerComboBox.setAllowEmptyLayer(False) + self.geologyLayerComboBox.setAllowEmptyLayer(False) + else: # Spacing + self.decimationLabel.setVisible(False) + self.decimationSpinBox.setVisible(False) + self.spacingLabel.setVisible(True) + self.spacingSpinBox.setVisible(True) + # Spacing can work with optional DTM and geology + self.dtmLayerComboBox.setAllowEmptyLayer(True) + self.geologyLayerComboBox.setAllowEmptyLayer(True) + + def _run_sampler(self): + """Run the sampler algorithm using map2loop classes directly.""" + import pandas as pd + from map2loop.sampler import SamplerDecimator, SamplerSpacing + from osgeo import gdal + from qgis.core import ( + QgsCoordinateReferenceSystem, + QgsFeature, + QgsField, + QgsFields, + QgsGeometry, + QgsPointXY, + QgsProject, + QgsVectorLayer, + ) + from qgis.PyQt.QtCore import QVariant + + from ...main.vectorLayerWrapper import qgsLayerToGeoDataFrame + + # Validate inputs + if not self.spatialDataLayerComboBox.currentLayer(): + QMessageBox.warning(self, "Missing Input", "Please select a spatial data layer.") + return + + sampler_type = self.samplerTypeComboBox.currentText() + + if sampler_type == "Decimator": + if not self.geologyLayerComboBox.currentLayer(): + QMessageBox.warning( + self, "Missing Input", "Geology layer is required for Decimator." + ) + return + if not self.dtmLayerComboBox.currentLayer(): + QMessageBox.warning(self, "Missing Input", "DTM layer is required for Decimator.") + return + + # Get layers and convert to appropriate formats + try: + spatial_data_layer = self.spatialDataLayerComboBox.currentLayer() + spatial_data_gdf = qgsLayerToGeoDataFrame(spatial_data_layer) + + dtm_layer = self.dtmLayerComboBox.currentLayer() + dtm_gdal = gdal.Open(dtm_layer.source()) if dtm_layer and dtm_layer.isValid() else None + + geology_layer = self.geologyLayerComboBox.currentLayer() + geology_gdf = ( + qgsLayerToGeoDataFrame(geology_layer) + if geology_layer and geology_layer.isValid() + else None + ) + + # Run the appropriate sampler + if sampler_type == "Decimator": + decimation = self.decimationSpinBox.value() + sampler = SamplerDecimator( + decimation=decimation, dtm_data=dtm_gdal, geology_data=geology_gdf + ) + samples = sampler.sample(spatial_data_gdf) + else: # Spacing + spacing = self.spacingSpinBox.value() + sampler = SamplerSpacing( + spacing=spacing, dtm_data=dtm_gdal, geology_data=geology_gdf + ) + samples = sampler.sample(spatial_data_gdf) + + # Convert result back to QGIS layer and add to project + if samples is not None and not samples.empty: + layer_name = f"Sampled Contacts ({sampler_type})" + + fields = QgsFields() + for column_name in samples.columns: + if column_name == 'geometry': + continue + dtype = samples[column_name].dtype + dtype_str = str(dtype) + + if dtype_str in ['float16', 'float32', 'float64']: + field_type = QVariant.Double + elif dtype_str in ['int8', 'int16', 'int32', 'int64']: + field_type = QVariant.Int + else: + field_type = QVariant.String + + fields.append(QgsField(column_name, field_type)) + + crs = None + if spatial_data_gdf is not None and spatial_data_gdf.crs is not None: + crs = QgsCoordinateReferenceSystem.fromWkt(spatial_data_gdf.crs.to_wkt()) + + # Create layer + geom_type = "PointZ" if 'Z' in samples.columns else "Point" + layer = QgsVectorLayer( + f"{geom_type}?crs={crs.authid() if crs else 'EPSG:4326'}", layer_name, "memory" + ) + provider = layer.dataProvider() + provider.addAttributes(fields) + layer.updateFields() + + # Add features + for _index, row in samples.iterrows(): + feature = QgsFeature(fields) + + # Add geometry + if 'Z' in samples.columns and pd.notna(row.get('Z')): + wkt = f"POINT Z ({row['X']} {row['Y']} {row['Z']})" + feature.setGeometry(QgsGeometry.fromWkt(wkt)) + else: + feature.setGeometry(QgsGeometry.fromPointXY(QgsPointXY(row['X'], row['Y']))) + + # Add attributes + attributes = [] + for column_name in samples.columns: + if column_name == 'geometry': + continue + value = row.get(column_name) + dtype = samples[column_name].dtype + + if pd.isna(value): + attributes.append(None) + elif dtype in ['float16', 'float32', 'float64']: + attributes.append(float(value)) + elif dtype in ['int8', 'int16', 'int32', 'int64']: + attributes.append(int(value)) + else: + attributes.append(str(value)) + + feature.setAttributes(attributes) + provider.addFeature(feature) + + layer.updateExtents() + QgsProject.instance().addMapLayer(layer) + + QMessageBox.information( + self, + "Success", + f"Sampling completed! Layer '{layer_name}' added with {len(samples)} features.", + ) + else: + QMessageBox.warning(self, "Warning", "No samples were generated.") + + except Exception as e: + QMessageBox.critical(self, "Error", f"An error occurred: {str(e)}") + + def get_parameters(self): + """Get current widget parameters. + + Returns + ------- + dict + Dictionary of current widget parameters. + """ + return { + 'sampler_type': self.samplerTypeComboBox.currentIndex(), + 'dtm_layer': self.dtmLayerComboBox.currentLayer(), + 'geology_layer': self.geologyLayerComboBox.currentLayer(), + 'spatial_data_layer': self.spatialDataLayerComboBox.currentLayer(), + 'decimation': self.decimationSpinBox.value(), + 'spacing': self.spacingSpinBox.value(), + } + + def set_parameters(self, params): + """Set widget parameters. + + Parameters + ---------- + params : dict + Dictionary of parameters to set. + """ + if 'sampler_type' in params: + self.samplerTypeComboBox.setCurrentIndex(params['sampler_type']) + if 'dtm_layer' in params and params['dtm_layer']: + self.dtmLayerComboBox.setLayer(params['dtm_layer']) + if 'geology_layer' in params and params['geology_layer']: + self.geologyLayerComboBox.setLayer(params['geology_layer']) + if 'spatial_data_layer' in params and params['spatial_data_layer']: + self.spatialDataLayerComboBox.setLayer(params['spatial_data_layer']) + if 'decimation' in params: + self.decimationSpinBox.setValue(params['decimation']) + if 'spacing' in params: + self.spacingSpinBox.setValue(params['spacing']) diff --git a/loopstructural/gui/map2loop_tools/sampler_widget.ui b/loopstructural/gui/map2loop_tools/sampler_widget.ui new file mode 100644 index 0000000..21f1b6d --- /dev/null +++ b/loopstructural/gui/map2loop_tools/sampler_widget.ui @@ -0,0 +1,145 @@ + + + SamplerWidget + + + + 0 + 0 + 600 + 400 + + + + Sampler + + + + + + + + Sampler Type: + + + + + + + + + + DTM Layer: + + + + + + + true + + + + + + + Geology Layer: + + + + + + + true + + + + + + + Spatial Data Layer: + + + + + + + + + + Decimation: + + + + + + + 1 + + + 1 + + + + + + + Spacing: + + + + + + + 2 + + + 0.010000000000000 + + + 100000.000000000000000 + + + 200.000000000000000 + + + + + + + + + Run Sampler + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + QgsMapLayerComboBox + QComboBox +
qgsmaplayercombobox.h
+
+ + QgsDoubleSpinBox + QDoubleSpinBox +
qgsdoublespinbox.h
+
+
+ + +
diff --git a/loopstructural/gui/map2loop_tools/sorter_widget.py b/loopstructural/gui/map2loop_tools/sorter_widget.py new file mode 100644 index 0000000..c7ffb95 --- /dev/null +++ b/loopstructural/gui/map2loop_tools/sorter_widget.py @@ -0,0 +1,222 @@ +"""Widget for running the automatic stratigraphic sorter.""" + +import os + +from PyQt5.QtWidgets import QMessageBox, QWidget +from qgis.PyQt import uic + + +class SorterWidget(QWidget): + """Widget for configuring and running the automatic stratigraphic sorter. + + This widget provides a GUI interface for the map2loop stratigraphic + sorting algorithms. + """ + + def __init__(self, parent=None, data_manager=None): + """Initialize the sorter widget. + + Parameters + ---------- + parent : QWidget, optional + Parent widget. + data_manager : object, optional + Data manager for accessing shared data. + """ + super().__init__(parent) + self.data_manager = data_manager + + # Load the UI file + ui_path = os.path.join(os.path.dirname(__file__), "sorter_widget.ui") + uic.loadUi(ui_path, self) + + # Configure layer filters programmatically (avoid QGIS enums in UI) + try: + from qgis.core import QgsMapLayerProxyModel + + self.geologyLayerComboBox.setFilters(QgsMapLayerProxyModel.PolygonLayer) + self.contactsLayerComboBox.setFilters(QgsMapLayerProxyModel.LineLayer) + self.structureLayerComboBox.setFilters(QgsMapLayerProxyModel.PointLayer) + self.dtmLayerComboBox.setFilters(QgsMapLayerProxyModel.RasterLayer) + # preserve allowEmptyLayer where UI set it + except Exception: + pass + + # Initialize sorting algorithms + self.sorting_algorithms = [ + "Age‐based", + "NetworkX topological", + "Hint (deprecated)", + "Adjacency α", + "Maximise contacts", + "Observation projections", + ] + self.sortingAlgorithmComboBox.addItems(self.sorting_algorithms) + self.sortingAlgorithmComboBox.setCurrentIndex(5) # Default to Observation projections + + # Initialize orientation types + self.orientation_types = ['', 'Dip Direction', 'Strike'] + self.orientationTypeComboBox.addItems(self.orientation_types) + + # Connect signals + self.sortingAlgorithmComboBox.currentIndexChanged.connect(self._on_algorithm_changed) + self.geologyLayerComboBox.layerChanged.connect(self._on_geology_layer_changed) + self.structureLayerComboBox.layerChanged.connect(self._on_structure_layer_changed) + self.runButton.clicked.connect(self._run_sorter) + + # Set up field combo boxes + self._setup_field_combo_boxes() + + # Initial state update + self._on_algorithm_changed() + + def _setup_field_combo_boxes(self): + """Set up field combo boxes to link to their respective layers.""" + self.unitNameFieldComboBox.setLayer(self.geologyLayerComboBox.currentLayer()) + self.minAgeFieldComboBox.setLayer(self.geologyLayerComboBox.currentLayer()) + self.maxAgeFieldComboBox.setLayer(self.geologyLayerComboBox.currentLayer()) + self.groupFieldComboBox.setLayer(self.geologyLayerComboBox.currentLayer()) + self.dipFieldComboBox.setLayer(self.structureLayerComboBox.currentLayer()) + self.dipDirFieldComboBox.setLayer(self.structureLayerComboBox.currentLayer()) + + def _on_geology_layer_changed(self): + """Update field combo boxes when geology layer changes.""" + layer = self.geologyLayerComboBox.currentLayer() + self.unitNameFieldComboBox.setLayer(layer) + self.minAgeFieldComboBox.setLayer(layer) + self.maxAgeFieldComboBox.setLayer(layer) + self.groupFieldComboBox.setLayer(layer) + + def _on_structure_layer_changed(self): + """Update field combo boxes when structure layer changes.""" + layer = self.structureLayerComboBox.currentLayer() + self.dipFieldComboBox.setLayer(layer) + self.dipDirFieldComboBox.setLayer(layer) + + def _on_algorithm_changed(self): + """Update UI based on selected sorting algorithm.""" + algorithm_index = self.sortingAlgorithmComboBox.currentIndex() + is_observation_projections = algorithm_index == 5 + + # Show/hide structure-related fields for observation projections + self.structureLayerLabel.setVisible(is_observation_projections) + self.structureLayerComboBox.setVisible(is_observation_projections) + self.dipFieldLabel.setVisible(is_observation_projections) + self.dipFieldComboBox.setVisible(is_observation_projections) + self.dipDirFieldLabel.setVisible(is_observation_projections) + self.dipDirFieldComboBox.setVisible(is_observation_projections) + self.orientationTypeLabel.setVisible(is_observation_projections) + self.orientationTypeComboBox.setVisible(is_observation_projections) + self.dtmLayerLabel.setVisible(is_observation_projections) + self.dtmLayerComboBox.setVisible(is_observation_projections) + + def _run_sorter(self): + """Run the stratigraphic sorter algorithm.""" + from qgis import processing + from qgis.core import QgsProcessingFeedback + + # Validate inputs + if not self.geologyLayerComboBox.currentLayer(): + QMessageBox.warning(self, "Missing Input", "Please select a geology layer.") + return + + if not self.contactsLayerComboBox.currentLayer(): + QMessageBox.warning(self, "Missing Input", "Please select a contacts layer.") + return + + algorithm_index = self.sortingAlgorithmComboBox.currentIndex() + is_observation_projections = algorithm_index == 5 + + if is_observation_projections: + if not self.structureLayerComboBox.currentLayer(): + QMessageBox.warning( + self, + "Missing Input", + "Structure layer is required for observation projections.", + ) + return + if not self.dtmLayerComboBox.currentLayer(): + QMessageBox.warning( + self, "Missing Input", "DTM layer is required for observation projections." + ) + return + + # Prepare parameters + params = { + 'SORTING_ALGORITHM': algorithm_index, + 'INPUT_GEOLOGY': self.geologyLayerComboBox.currentLayer(), + 'UNIT_NAME_FIELD': self.unitNameFieldComboBox.currentField(), + 'MIN_AGE_FIELD': self.minAgeFieldComboBox.currentField(), + 'MAX_AGE_FIELD': self.maxAgeFieldComboBox.currentField(), + 'GROUP_FIELD': self.groupFieldComboBox.currentField(), + 'CONTACTS_LAYER': self.contactsLayerComboBox.currentLayer(), + 'OUTPUT': 'TEMPORARY_OUTPUT', + 'JSON_OUTPUT': 'TEMPORARY_OUTPUT', + } + + if is_observation_projections: + params['INPUT_STRUCTURE'] = self.structureLayerComboBox.currentLayer() + params['DIP_FIELD'] = self.dipFieldComboBox.currentField() + params['DIPDIR_FIELD'] = self.dipDirFieldComboBox.currentField() + params['ORIENTATION_TYPE'] = self.orientationTypeComboBox.currentIndex() + params['INPUT_DTM'] = self.dtmLayerComboBox.currentLayer() + + # Run the algorithm + try: + feedback = QgsProcessingFeedback() + result = processing.run("plugin_map2loop:loop_sorter", params, feedback=feedback) + + if result: + QMessageBox.information( + self, "Success", "Stratigraphic column created successfully!" + ) + else: + QMessageBox.warning(self, "Error", "Failed to create stratigraphic column.") + + except Exception as e: + QMessageBox.critical(self, "Error", f"An error occurred: {str(e)}") + + def get_parameters(self): + """Get current widget parameters. + + Returns + ------- + dict + Dictionary of current widget parameters. + """ + algorithm_index = self.sortingAlgorithmComboBox.currentIndex() + is_observation_projections = algorithm_index == 5 + + params = { + 'sorting_algorithm': algorithm_index, + 'geology_layer': self.geologyLayerComboBox.currentLayer(), + 'unit_name_field': self.unitNameFieldComboBox.currentField(), + 'min_age_field': self.minAgeFieldComboBox.currentField(), + 'max_age_field': self.maxAgeFieldComboBox.currentField(), + 'group_field': self.groupFieldComboBox.currentField(), + 'contacts_layer': self.contactsLayerComboBox.currentLayer(), + } + + if is_observation_projections: + params['structure_layer'] = self.structureLayerComboBox.currentLayer() + params['dip_field'] = self.dipFieldComboBox.currentField() + params['dipdir_field'] = self.dipDirFieldComboBox.currentField() + params['orientation_type'] = self.orientationTypeComboBox.currentIndex() + params['dtm_layer'] = self.dtmLayerComboBox.currentLayer() + + return params + + def set_parameters(self, params): + """Set widget parameters. + + Parameters + ---------- + params : dict + Dictionary of parameters to set. + """ + if 'sorting_algorithm' in params: + self.sortingAlgorithmComboBox.setCurrentIndex(params['sorting_algorithm']) + if 'geology_layer' in params and params['geology_layer']: + self.geologyLayerComboBox.setLayer(params['geology_layer']) + if 'contacts_layer' in params and params['contacts_layer']: + self.contactsLayerComboBox.setLayer(params['contacts_layer']) diff --git a/loopstructural/gui/map2loop_tools/sorter_widget.ui b/loopstructural/gui/map2loop_tools/sorter_widget.ui new file mode 100644 index 0000000..971985f --- /dev/null +++ b/loopstructural/gui/map2loop_tools/sorter_widget.ui @@ -0,0 +1,187 @@ + + + SorterWidget + + + + 0 + 0 + 600 + 500 + + + + Automatic Stratigraphic Sorter + + + + + + + + Sorting Algorithm: + + + + + + + + + + Geology Layer: + + + + + + + + + + + Unit Name Field: + + + + + + + + + + Min Age Field: + + + + + + + + + + Max Age Field: + + + + + + + + + + Group Field: + + + + + + + + + + Contacts Layer: + + + + + + + + + + + Structure Layer: + + + + + + + true + + + + + + + Dip Field: + + + + + + + + + + Dip Direction Field: + + + + + + + + + + Orientation Type: + + + + + + + + + + DTM Layer: + + + + + + + true + + + + + + + + + Run Sorter + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + QgsMapLayerComboBox + QComboBox +
qgsmaplayercombobox.h
+
+ + QgsFieldComboBox + QComboBox +
qgsfieldcombobox.h
+
+
+ + +
diff --git a/loopstructural/gui/map2loop_tools/thickness_calculator_widget.py b/loopstructural/gui/map2loop_tools/thickness_calculator_widget.py new file mode 100644 index 0000000..ce3a4d6 --- /dev/null +++ b/loopstructural/gui/map2loop_tools/thickness_calculator_widget.py @@ -0,0 +1,200 @@ +"""Widget for thickness calculator.""" + +import os + +from PyQt5.QtWidgets import QMessageBox, QWidget +from qgis.PyQt import uic + + +class ThicknessCalculatorWidget(QWidget): + """Widget for configuring and running the thickness calculator. + + This widget provides a GUI interface for the map2loop thickness + calculation algorithms. + """ + + def __init__(self, parent=None, data_manager=None): + """Initialize the thickness calculator widget. + + Parameters + ---------- + parent : QWidget, optional + Parent widget. + data_manager : object, optional + Data manager for accessing shared data. + """ + super().__init__(parent) + self.data_manager = data_manager + + # Load the UI file + ui_path = os.path.join(os.path.dirname(__file__), "thickness_calculator_widget.ui") + uic.loadUi(ui_path, self) + + # Configure layer filters programmatically (avoid enum values in .ui) + try: + from qgis.core import QgsMapLayerProxyModel + + self.dtmLayerComboBox.setFilters(QgsMapLayerProxyModel.RasterLayer) + self.geologyLayerComboBox.setFilters(QgsMapLayerProxyModel.PolygonLayer) + self.basalContactsComboBox.setFilters(QgsMapLayerProxyModel.LineLayer) + self.sampledContactsComboBox.setFilters(QgsMapLayerProxyModel.PointLayer) + self.structureLayerComboBox.setFilters(QgsMapLayerProxyModel.PointLayer) + except Exception: + pass + + # Initialize calculator types + self.calculator_types = ["InterpolatedStructure", "StructuralPoint"] + self.calculatorTypeComboBox.addItems(self.calculator_types) + + # Initialize orientation types + self.orientation_types = ['Dip Direction', 'Strike'] + self.orientationTypeComboBox.addItems(self.orientation_types) + + # Connect signals + self.calculatorTypeComboBox.currentIndexChanged.connect(self._on_calculator_type_changed) + self.geologyLayerComboBox.layerChanged.connect(self._on_geology_layer_changed) + self.structureLayerComboBox.layerChanged.connect(self._on_structure_layer_changed) + self.runButton.clicked.connect(self._run_calculator) + + # Set up field combo boxes + self._setup_field_combo_boxes() + + # Initial state update + self._on_calculator_type_changed() + + def _setup_field_combo_boxes(self): + """Set up field combo boxes to link to their respective layers.""" + self.unitNameFieldComboBox.setLayer(self.geologyLayerComboBox.currentLayer()) + self.dipFieldComboBox.setLayer(self.structureLayerComboBox.currentLayer()) + self.dipDirFieldComboBox.setLayer(self.structureLayerComboBox.currentLayer()) + + def _on_geology_layer_changed(self): + """Update field combo boxes when geology layer changes.""" + layer = self.geologyLayerComboBox.currentLayer() + self.unitNameFieldComboBox.setLayer(layer) + + def _on_structure_layer_changed(self): + """Update field combo boxes when structure layer changes.""" + layer = self.structureLayerComboBox.currentLayer() + self.dipFieldComboBox.setLayer(layer) + self.dipDirFieldComboBox.setLayer(layer) + + def _on_calculator_type_changed(self): + """Update UI based on selected calculator type.""" + calculator_type = self.calculatorTypeComboBox.currentText() + + if calculator_type == "StructuralPoint": + self.maxLineLengthLabel.setVisible(True) + self.maxLineLengthSpinBox.setVisible(True) + else: # InterpolatedStructure + self.maxLineLengthLabel.setVisible(False) + self.maxLineLengthSpinBox.setVisible(False) + + def _run_calculator(self): + """Run the thickness calculator algorithm.""" + from qgis import processing + from qgis.core import QgsProcessingFeedback + + # Validate inputs + if not self.geologyLayerComboBox.currentLayer(): + QMessageBox.warning(self, "Missing Input", "Please select a geology layer.") + return + + if not self.basalContactsComboBox.currentLayer(): + QMessageBox.warning(self, "Missing Input", "Please select a basal contacts layer.") + return + + if not self.sampledContactsComboBox.currentLayer(): + QMessageBox.warning(self, "Missing Input", "Please select a sampled contacts layer.") + return + + if not self.structureLayerComboBox.currentLayer(): + QMessageBox.warning( + self, "Missing Input", "Please select a structure/orientation layer." + ) + return + + # Prepare parameters + params = { + 'THICKNESS_CALCULATOR_TYPE': self.calculatorTypeComboBox.currentIndex(), + 'DTM': self.dtmLayerComboBox.currentLayer(), + 'GEOLOGY': self.geologyLayerComboBox.currentLayer(), + 'UNIT_NAME_FIELD': self.unitNameFieldComboBox.currentField(), + 'BASAL_CONTACTS': self.basalContactsComboBox.currentLayer(), + 'SAMPLED_CONTACTS': self.sampledContactsComboBox.currentLayer(), + 'STRUCTURE_DATA': self.structureLayerComboBox.currentLayer(), + 'DIP_FIELD': self.dipFieldComboBox.currentField(), + 'DIPDIR_FIELD': self.dipDirFieldComboBox.currentField(), + 'ORIENTATION_TYPE': self.orientationTypeComboBox.currentIndex(), + 'STRATIGRAPHIC_COLUMN_LAYER': self.stratiColumnComboBox.currentLayer(), + 'MAX_LINE_LENGTH': self.maxLineLengthSpinBox.value(), + 'BOUNDING_BOX_TYPE': 0, # Extract from geology layer + 'OUTPUT': 'TEMPORARY_OUTPUT', + } + + # Run the algorithm + try: + feedback = QgsProcessingFeedback() + result = processing.run( + "plugin_map2loop:thickness_calculator", params, feedback=feedback + ) + + if result: + QMessageBox.information( + self, "Success", "Thickness calculation completed successfully!" + ) + else: + QMessageBox.warning(self, "Error", "Failed to calculate thickness.") + + except Exception as e: + QMessageBox.critical(self, "Error", f"An error occurred: {str(e)}") + + def get_parameters(self): + """Get current widget parameters. + + Returns + ------- + dict + Dictionary of current widget parameters. + """ + return { + 'calculator_type': self.calculatorTypeComboBox.currentIndex(), + 'dtm_layer': self.dtmLayerComboBox.currentLayer(), + 'geology_layer': self.geologyLayerComboBox.currentLayer(), + 'unit_name_field': self.unitNameFieldComboBox.currentField(), + 'basal_contacts': self.basalContactsComboBox.currentLayer(), + 'sampled_contacts': self.sampledContactsComboBox.currentLayer(), + 'structure_layer': self.structureLayerComboBox.currentLayer(), + 'dip_field': self.dipFieldComboBox.currentField(), + 'dipdir_field': self.dipDirFieldComboBox.currentField(), + 'orientation_type': self.orientationTypeComboBox.currentIndex(), + 'strati_column': self.stratiColumnComboBox.currentLayer(), + 'max_line_length': self.maxLineLengthSpinBox.value(), + } + + def set_parameters(self, params): + """Set widget parameters. + + Parameters + ---------- + params : dict + Dictionary of parameters to set. + """ + if 'calculator_type' in params: + self.calculatorTypeComboBox.setCurrentIndex(params['calculator_type']) + if 'dtm_layer' in params and params['dtm_layer']: + self.dtmLayerComboBox.setLayer(params['dtm_layer']) + if 'geology_layer' in params and params['geology_layer']: + self.geologyLayerComboBox.setLayer(params['geology_layer']) + if 'basal_contacts' in params and params['basal_contacts']: + self.basalContactsComboBox.setLayer(params['basal_contacts']) + if 'sampled_contacts' in params and params['sampled_contacts']: + self.sampledContactsComboBox.setLayer(params['sampled_contacts']) + if 'structure_layer' in params and params['structure_layer']: + self.structureLayerComboBox.setLayer(params['structure_layer']) + if 'strati_column' in params and params['strati_column']: + self.stratiColumnComboBox.setLayer(params['strati_column']) + if 'orientation_type' in params: + self.orientationTypeComboBox.setCurrentIndex(params['orientation_type']) + if 'max_line_length' in params: + self.maxLineLengthSpinBox.setValue(params['max_line_length']) diff --git a/loopstructural/gui/map2loop_tools/thickness_calculator_widget.ui b/loopstructural/gui/map2loop_tools/thickness_calculator_widget.ui new file mode 100644 index 0000000..74d32ac --- /dev/null +++ b/loopstructural/gui/map2loop_tools/thickness_calculator_widget.ui @@ -0,0 +1,201 @@ + + + ThicknessCalculatorWidget + + + + 0 + 0 + 600 + 700 + + + + Thickness Calculator + + + + + + + + Calculator Type: + + + + + + + + + + DTM Layer: + + + + + + + true + + + + + + + Geology Layer: + + + + + + + + + + + Unit Name Field: + + + + + + + + + + Basal Contacts Layer: + + + + + + + + + + + Sampled Contacts Layer: + + + + + + + + + + + Structure/Orientation Layer: + + + + + + + + + + + Dip Field: + + + + + + + + + + Dip Direction Field: + + + + + + + + + + Orientation Type: + + + + + + + + + + Stratigraphic Order Layer: + + + + + + + true + + + + + + + Max Line Length: + + + + + + + 1000000.000000000000000 + + + 1000.000000000000000 + + + + + + + + + Calculate Thickness + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + QgsMapLayerComboBox + QComboBox +
qgsmaplayercombobox.h
+
+ + QgsFieldComboBox + QComboBox +
qgsfieldcombobox.h
+
+ + QgsDoubleSpinBox + QDoubleSpinBox +
qgsdoublespinbox.h
+
+
+ + +
diff --git a/loopstructural/gui/map2loop_tools/user_defined_sorter_widget.py b/loopstructural/gui/map2loop_tools/user_defined_sorter_widget.py new file mode 100644 index 0000000..9c1e8ce --- /dev/null +++ b/loopstructural/gui/map2loop_tools/user_defined_sorter_widget.py @@ -0,0 +1,163 @@ +"""Widget for user-defined stratigraphic column.""" + +import os + +from PyQt5.QtWidgets import QWidget, QTableWidgetItem, QMessageBox +from qgis.PyQt import uic + + +class UserDefinedSorterWidget(QWidget): + """Widget for creating a user-defined stratigraphic column. + + This widget allows users to manually define the stratigraphic order + of units from youngest to oldest. + """ + + def __init__(self, parent=None, data_manager=None): + """Initialize the user-defined sorter widget. + + Parameters + ---------- + parent : QWidget, optional + Parent widget. + data_manager : object, optional + Data manager for accessing shared data. + """ + super().__init__(parent) + self.data_manager = data_manager + + # Load the UI file + ui_path = os.path.join(os.path.dirname(__file__), "user_defined_sorter_widget.ui") + uic.loadUi(ui_path, self) + + # Connect signals + self.addRowButton.clicked.connect(self._add_row) + self.removeRowButton.clicked.connect(self._remove_row) + self.moveUpButton.clicked.connect(self._move_up) + self.moveDownButton.clicked.connect(self._move_down) + self.runButton.clicked.connect(self._run_sorter) + + # Initialize with a few empty rows + for _ in range(3): + self._add_row() + + def _add_row(self): + """Add a new row to the stratigraphic column table.""" + row_count = self.stratiColumnTable.rowCount() + self.stratiColumnTable.insertRow(row_count) + self.stratiColumnTable.setItem(row_count, 0, QTableWidgetItem("")) + + def _remove_row(self): + """Remove the selected row from the stratigraphic column table.""" + current_row = self.stratiColumnTable.currentRow() + if current_row >= 0: + self.stratiColumnTable.removeRow(current_row) + + def _move_up(self): + """Move the selected row up in the stratigraphic column table.""" + current_row = self.stratiColumnTable.currentRow() + if current_row > 0: + # Get current row data + item = self.stratiColumnTable.takeItem(current_row, 0) + + # Remove current row + self.stratiColumnTable.removeRow(current_row) + + # Insert row above + self.stratiColumnTable.insertRow(current_row - 1) + self.stratiColumnTable.setItem(current_row - 1, 0, item) + + # Select the moved row + self.stratiColumnTable.setCurrentCell(current_row - 1, 0) + + def _move_down(self): + """Move the selected row down in the stratigraphic column table.""" + current_row = self.stratiColumnTable.currentRow() + if current_row >= 0 and current_row < self.stratiColumnTable.rowCount() - 1: + # Get current row data + item = self.stratiColumnTable.takeItem(current_row, 0) + + # Remove current row + self.stratiColumnTable.removeRow(current_row) + + # Insert row below + self.stratiColumnTable.insertRow(current_row + 1) + self.stratiColumnTable.setItem(current_row + 1, 0, item) + + # Select the moved row + self.stratiColumnTable.setCurrentCell(current_row + 1, 0) + + def _run_sorter(self): + """Run the user-defined stratigraphic sorter algorithm.""" + from qgis.core import QgsProcessingFeedback + from qgis import processing + + # Get stratigraphic column data + strati_column = [] + for row in range(self.stratiColumnTable.rowCount()): + item = self.stratiColumnTable.item(row, 0) + if item and item.text().strip(): + strati_column.append(item.text().strip()) + + if not strati_column: + QMessageBox.warning( + self, "Missing Input", "Please define at least one stratigraphic unit." + ) + return + + # Prepare parameters + params = { + 'INPUT_STRATI_COLUMN': strati_column, + 'OUTPUT': 'TEMPORARY_OUTPUT', + } + + # Run the algorithm + try: + feedback = QgsProcessingFeedback() + result = processing.run( + "plugin_map2loop:loop_sorter_2", params, feedback=feedback + ) + + if result: + QMessageBox.information( + self, "Success", "User-defined stratigraphic column created successfully!" + ) + else: + QMessageBox.warning( + self, "Error", "Failed to create user-defined stratigraphic column." + ) + + except Exception as e: + QMessageBox.critical(self, "Error", f"An error occurred: {str(e)}") + + def get_stratigraphic_column(self): + """Get the current stratigraphic column. + + Returns + ------- + list + List of unit names from youngest to oldest. + """ + strati_column = [] + for row in range(self.stratiColumnTable.rowCount()): + item = self.stratiColumnTable.item(row, 0) + if item and item.text().strip(): + strati_column.append(item.text().strip()) + return strati_column + + def set_stratigraphic_column(self, units): + """Set the stratigraphic column. + + Parameters + ---------- + units : list + List of unit names from youngest to oldest. + """ + # Clear existing rows + self.stratiColumnTable.setRowCount(0) + + # Add new rows + for unit in units: + row_count = self.stratiColumnTable.rowCount() + self.stratiColumnTable.insertRow(row_count) + self.stratiColumnTable.setItem(row_count, 0, QTableWidgetItem(unit)) diff --git a/loopstructural/gui/map2loop_tools/user_defined_sorter_widget.ui b/loopstructural/gui/map2loop_tools/user_defined_sorter_widget.ui new file mode 100644 index 0000000..eb164fe --- /dev/null +++ b/loopstructural/gui/map2loop_tools/user_defined_sorter_widget.ui @@ -0,0 +1,95 @@ + + + UserDefinedSorterWidget + + + + 0 + 0 + 600 + 400 + + + + User-Defined Stratigraphic Column + + + + + + Define the stratigraphic order from youngest (top) to oldest (bottom): + + + true + + + + + + + 1 + + + + Unit Name + + + + + + + + + + Add Row + + + + + + + Remove Row + + + + + + + Move Up + + + + + + + Move Down + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + Create Stratigraphic Column + + + + + + + + diff --git a/loopstructural/plugin_main.py b/loopstructural/plugin_main.py index 19d01c8..d18c208 100644 --- a/loopstructural/plugin_main.py +++ b/loopstructural/plugin_main.py @@ -140,6 +140,44 @@ def initGui(self): # -- Menu self.iface.addPluginToMenu(__title__, self.action_settings) self.iface.addPluginToMenu(__title__, self.action_help) + + # -- Map2Loop Tools submenu + self.action_sampler = QAction( + "Sampler", + self.iface.mainWindow(), + ) + self.action_sampler.triggered.connect(self.show_sampler_dialog) + + self.action_sorter = QAction( + "Automatic Stratigraphic Sorter", + self.iface.mainWindow(), + ) + self.action_sorter.triggered.connect(self.show_sorter_dialog) + + self.action_user_sorter = QAction( + "User-Defined Stratigraphic Column", + self.iface.mainWindow(), + ) + self.action_user_sorter.triggered.connect(self.show_user_sorter_dialog) + + self.action_basal_contacts = QAction( + "Extract Basal Contacts", + self.iface.mainWindow(), + ) + self.action_basal_contacts.triggered.connect(self.show_basal_contacts_dialog) + + self.action_thickness = QAction( + "Thickness Calculator", + self.iface.mainWindow(), + ) + self.action_thickness.triggered.connect(self.show_thickness_dialog) + + self.iface.addPluginToMenu(__title__, self.action_sampler) + self.iface.addPluginToMenu(__title__, self.action_sorter) + self.iface.addPluginToMenu(__title__, self.action_user_sorter) + self.iface.addPluginToMenu(__title__, self.action_basal_contacts) + self.iface.addPluginToMenu(__title__, self.action_thickness) + self.initProcessing() # -- Help menu @@ -255,6 +293,41 @@ def initGui(self): self.modelling_dockwidget = None self.visualisation_dockwidget = None + def show_sampler_dialog(self): + """Show the sampler dialog.""" + from loopstructural.gui.map2loop_tools import SamplerDialog + + dialog = SamplerDialog(self.iface.mainWindow()) + dialog.exec_() + + def show_sorter_dialog(self): + """Show the automatic stratigraphic sorter dialog.""" + from loopstructural.gui.map2loop_tools import SorterDialog + + dialog = SorterDialog(self.iface.mainWindow()) + dialog.exec_() + + def show_user_sorter_dialog(self): + """Show the user-defined stratigraphic column dialog.""" + from loopstructural.gui.map2loop_tools import UserDefinedSorterDialog + + dialog = UserDefinedSorterDialog(self.iface.mainWindow()) + dialog.exec_() + + def show_basal_contacts_dialog(self): + """Show the basal contacts extractor dialog.""" + from loopstructural.gui.map2loop_tools import BasalContactsDialog + + dialog = BasalContactsDialog(self.iface.mainWindow()) + dialog.exec_() + + def show_thickness_dialog(self): + """Show the thickness calculator dialog.""" + from loopstructural.gui.map2loop_tools import ThicknessCalculatorDialog + + dialog = ThicknessCalculatorDialog(self.iface.mainWindow()) + dialog.exec_() + def tr(self, message: str) -> str: """Translate a string using Qt translation API. @@ -291,6 +364,11 @@ def unload(self): # -- Clean up menu self.iface.removePluginMenu(__title__, self.action_help) self.iface.removePluginMenu(__title__, self.action_settings) + self.iface.removePluginMenu(__title__, self.action_sampler) + self.iface.removePluginMenu(__title__, self.action_sorter) + self.iface.removePluginMenu(__title__, self.action_user_sorter) + self.iface.removePluginMenu(__title__, self.action_basal_contacts) + self.iface.removePluginMenu(__title__, self.action_thickness) # self.iface.removeMenu(self.menu) # -- Clean up preferences panel in QGIS settings self.iface.unregisterOptionsWidgetFactory(self.options_factory)