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
+
+
+
+ QgsFieldComboBox
+ QComboBox
+
+
+
+
+
+
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
+
+
+
+ QgsDoubleSpinBox
+ QDoubleSpinBox
+
+
+
+
+
+
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
+
+
+
+ QgsFieldComboBox
+ QComboBox
+
+
+
+
+
+
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
+
+
+
+ QgsFieldComboBox
+ QComboBox
+
+
+
+ QgsDoubleSpinBox
+ QDoubleSpinBox
+
+
+
+
+
+
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)