From b7c9a38420a159380bd894bd683e39a9cf576fa1 Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Mon, 5 Oct 2026 13:47:29 -0300 Subject: [PATCH 1/3] feat: highlight the selected stratigraphic unit on the map Select a unit row in the stratigraphic column to select the features of that unit in the units layer, so that QGIS highlights them on the map. The match uses the unit-name field set under the column. Select an unconformity row, or clear the selection, to remove the highlight. Focus on a field of a unit row also selects that row, because clicks on the row's fields do not reach the list. --- .../stratigraphic_column.py | 63 ++++++++++++++++++- .../stratigraphic_unit.py | 8 +++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py index 86cbd7d..887aed2 100644 --- a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py +++ b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py @@ -1,5 +1,5 @@ from LoopStructural.modelling.core.stratigraphic_column import StratigraphicColumnElementType -from qgis.core import QgsApplication, QgsMapLayerProxyModel, QgsStyle +from qgis.core import QgsApplication, QgsExpression, QgsMapLayerProxyModel, QgsStyle from qgis.gui import QgsFieldComboBox, QgsMapLayerComboBox from qgis.PyQt.QtCore import QSize from qgis.PyQt.QtGui import QIcon @@ -67,10 +67,15 @@ def __init__(self, parent=None, data_manager=None): # layer/field is selected, so the name-match warning is skipped. self._known_unit_names = None + # The layer whose features were selected to highlight the selected + # unit on the map, so the selection can be cleared later. + self._highlighted_layer = None + # Main list widget self.unitList = QListWidget() self.unitList.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove) self.unitList.model().rowsMoved.connect(self.update_order) + self.unitList.itemSelectionChanged.connect(self.highlight_selected_unit) layout.addWidget(self.unitList) # A single row of icon-only actions for building/clearing the column. @@ -413,6 +418,7 @@ def _on_units_field_changed(self, _field_name): """Persist and re-validate when the unit-name field selection changes.""" self._persist_units_layer_selection() self._revalidate_unit_names() + self.highlight_selected_unit() def _on_units_layer_changed(self, layer): """Update the field combo box when the units layer changes.""" @@ -424,6 +430,7 @@ def _on_units_layer_changed(self, layer): self.unitsLayerFieldComboBox.setField(unit_match) self._persist_units_layer_selection() self._revalidate_unit_names() + self.highlight_selected_unit() def _get_known_unit_names(self): """Return the set of exact unit-name values in the selected geology layer/field. @@ -454,6 +461,49 @@ def _revalidate_unit_names(self): if hasattr(widget, 'set_known_unit_names'): widget.set_known_unit_names(self._known_unit_names) + def highlight_selected_unit(self): + """Select the features of the selected unit in the units layer, so + that QGIS highlights them on the map. + + Clears the highlight when no unit row is selected (for example an + unconformity row) or when no units layer/field is set. + """ + try: + items = self.unitList.selectedItems() + except RuntimeError: + # Widget was deleted + return + widget = self.unitList.itemWidget(items[0]) if items else None + layer = self.unitsLayerComboBox.currentLayer() + field_name = self.unitsLayerFieldComboBox.currentField() + + self._clear_unit_highlight() + if ( + not isinstance(widget, StratigraphicUnitWidget) + or not widget.name + or layer is None + or not field_name + or layer.fields().indexFromName(field_name) < 0 + ): + return + expression = ( + f"{QgsExpression.quotedColumnRef(field_name)} = {QgsExpression.quotedValue(widget.name)}" + ) + layer.selectByExpression(expression) + self._highlighted_layer = layer + + def _clear_unit_highlight(self): + """Remove the feature selection made by highlight_selected_unit.""" + layer = self._highlighted_layer + self._highlighted_layer = None + if layer is None: + return + try: + layer.removeSelection() + except RuntimeError: + # Layer was removed from the project + pass + def apply_colours_to_layer(self): """Push the stratigraphic column's colours onto the selected units layer.""" if not self.data_manager: @@ -545,6 +595,7 @@ def add_unit(self, *, unit_data=None, create_new=True): unit_widget.nameChanged.connect( lambda: self.update_element(unit_widget) ) # Connect name change signal + unit_widget.nameChanged.connect(lambda: self._on_unit_name_changed(unit_widget)) unit_widget.thicknessChanged.connect( lambda: self.update_element(unit_widget) @@ -561,6 +612,7 @@ def add_unit(self, *, unit_data=None, create_new=True): item.setSizeHint(unit_widget.sizeHint()) self._add_list_item(item, at_top=create_new) self.unitList.setItemWidget(item, unit_widget) + unit_widget.focused.connect(lambda: self.unitList.setCurrentItem(item)) unit_widget.setData(unit_data) # Set data for the unit widget unit_widget.set_known_unit_names(self._known_unit_names) @@ -608,6 +660,15 @@ def add_unconformity(self, *, unconformity_data=None, create_new=True): # Cache the widget for efficient updates self._widget_cache[unconformity.uuid] = (unconformity_widget, item) + def _on_unit_name_changed(self, unit_widget): + """Update the map highlight when the selected unit is renamed.""" + try: + items = self.unitList.selectedItems() + except RuntimeError: + return + if items and self.unitList.itemWidget(items[0]) is unit_widget: + self.highlight_selected_unit() + def _add_list_item(self, item, *, at_top): """Add a row to the list. A new element goes on top of the column (the end of `order`), so it is shown in the first row.""" diff --git a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.py b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.py index ecb4601..879a796 100644 --- a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.py +++ b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.py @@ -17,6 +17,7 @@ class StratigraphicUnitWidget(QWidget): dragHandlePressed = pyqtSignal() # Drag handle mouse-down dragHandleMoved = pyqtSignal(QPoint) # Drag handle mouse-move (global pos) dragHandleReleased = pyqtSignal() # Drag handle mouse-up + focused = pyqtSignal() # One of the row's fields got keyboard focus def __init__( self, @@ -53,8 +54,15 @@ def __init__( self._dragging_handle = False self.dragHandle.setCursor(Qt.CursorShape.SizeVerCursor) self.dragHandle.installEventFilter(self) + # Clicks on the row's fields do not reach the QListWidget, so tell the + # column when one of them gets focus, to make this the selected row. + self._focus_widgets = (self.lineEditName, self.spinBoxThickness, self.buttonColour) + for widget in self._focus_widgets: + widget.installEventFilter(self) def eventFilter(self, obj, event): + if obj in self._focus_widgets and event.type() == QEvent.Type.FocusIn: + self.focused.emit() if obj is self.dragHandle: event_type = event.type() if ( From b82dc61ad767b3342c90eae538007c13253dc207 Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Mon, 5 Oct 2026 13:56:58 -0300 Subject: [PATCH 2/3] feat: apply stratigraphic thickness to the map layer Add a button to the stratigraphic column that writes a 'strat_thickness' field onto the selected units layer. The field holds the thickness of the matching unit in the column. Features that do not match a unit stay empty. The layer then gets a graduated renderer with one class for each thickness value, coloured with the selected colour ramp. Move the colour ramp lookup into a helper, so that the age and thickness styling use the same lookup. --- .../stratigraphic_column.py | 45 +++++++ loopstructural/main/data_manager.py | 126 +++++++++++++++++- .../test_apply_stratigraphic_thickness.py | 56 ++++++++ 3 files changed, 221 insertions(+), 6 deletions(-) create mode 100644 tests/qgis/test_apply_stratigraphic_thickness.py diff --git a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py index 887aed2..a092223 100644 --- a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py +++ b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py @@ -175,6 +175,18 @@ def __init__(self, parent=None, data_manager=None): ) applyAgeButton.clicked.connect(self.apply_age_to_layer) ageRow.addWidget(applyAgeButton) + + applyThicknessButton = self._make_tool_button( + "mActionMeasure.svg", "Apply Stratigraphic Thickness to Map Layer" + ) + applyThicknessButton.setToolTip( + "Apply Stratigraphic Thickness to Map Layer\n" + "Write a 'strat_thickness' field (the thickness of each unit in the " + "column) onto the selected layer above and style it with a graduated " + "colour ramp." + ) + applyThicknessButton.clicked.connect(self.apply_thickness_to_layer) + ageRow.addWidget(applyThicknessButton) layout.addLayout(ageRow) self._guess_units_layer() @@ -564,6 +576,39 @@ def apply_age_to_layer(self): "units, or no features matched a stratigraphic unit.", ) + def apply_thickness_to_layer(self): + """Write each unit's thickness onto the selected units layer and style it by a graduated ramp.""" + if not self.data_manager: + print("Error: Data manager is not initialized.") + return + layer = self.unitsLayerComboBox.currentLayer() + field_name = self.unitsLayerFieldComboBox.currentField() + if layer is None or not field_name: + QMessageBox.warning( + self, + "Apply Stratigraphic Thickness to Map Layer", + "Please select a units layer and unit name field above.", + ) + return + ramp_name = self.strat_ageColorRampComboBox.currentText() + applied = self.data_manager.apply_stratigraphic_thickness_to_layer( + layer, field_name, ramp_name=ramp_name + ) + if applied: + QMessageBox.information( + self, + "Apply Stratigraphic Thickness to Map Layer", + f"Applied stratigraphic thickness and graduated styling to layer " + f"'{layer.name()}'.", + ) + else: + QMessageBox.warning( + self, + "Apply Stratigraphic Thickness to Map Layer", + "Could not apply stratigraphic thickness. The stratigraphic column may " + "have no units, or no features matched a stratigraphic unit.", + ) + def add_unit(self, *, unit_data=None, create_new=True): if unit_data is None: unit_data = {'type': 'unit', 'name': ''} diff --git a/loopstructural/main/data_manager.py b/loopstructural/main/data_manager.py index b2f2e3d..c4a4240 100644 --- a/loopstructural/main/data_manager.py +++ b/loopstructural/main/data_manager.py @@ -31,6 +31,17 @@ from .vectorLayerWrapper import qgsLayerToGeoDataFrame +def _lookup_colour_ramp(ramp_name): + """Return the named QGIS colour ramp, or the first available ramp if it is not found.""" + style = QgsStyle().defaultStyle() + ramp = style.colorRamp(ramp_name) if ramp_name else None + if ramp is None: + ramp_names = style.colorRampNames() + if ramp_names: + ramp = style.colorRamp(ramp_names[0]) + return ramp + + def _colour_to_qcolor(colour): """Convert a stratigraphic unit colour (hex string, colour name, or RGB tuple/array) to a QColor.""" if colour is None: @@ -619,12 +630,7 @@ def apply_stratigraphic_age_to_layer(self, layer, field_name, ramp_name=None): ) return False - style = QgsStyle().defaultStyle() - ramp = style.colorRamp(ramp_name) if ramp_name else None - if ramp is None: - ramp_names = style.colorRampNames() - if ramp_names: - ramp = style.colorRamp(ramp_names[0]) + ramp = _lookup_colour_ramp(ramp_name) n = len(unique_values) ranges = [] @@ -642,6 +648,114 @@ def apply_stratigraphic_age_to_layer(self, layer, field_name, ramp_name=None): ) return True + def apply_stratigraphic_thickness_to_layer(self, layer, field_name, ramp_name=None): + """Write each unit's thickness onto a layer and style it with a graduated colour ramp. + + Writes a 'strat_thickness' field to ``layer`` holding the thickness + set for the matching unit in the stratigraphic column (matched via + ``field_name``), then applies a graduated renderer over that field. + Features that do not match a unit are left empty. + + Parameters + ---------- + layer : QgsVectorLayer + The layer to update (e.g. the geological units/geology layer). + field_name : str + Name of the field on ``layer`` holding the stratigraphic unit name. + ramp_name : str, optional + Name of a QGIS colour ramp (from QgsStyle) to use for the + graduated renderer. Falls back to any available ramp if not found. + + Returns + ------- + bool + True if the field was written and the renderer applied, False otherwise. + """ + if layer is None or not field_name: + self.logger( + message="No layer/unit name field set, cannot apply stratigraphic thickness." + ) + return False + if layer.fields().indexFromName(field_name) < 0: + self.logger(message=f"Field '{field_name}' not found on layer '{layer.name()}'.") + return False + + thicknesses = {} + for unit in self._stratigraphic_column.order: + if unit.element_type != StratigraphicColumnElementType.UNIT: + continue + if unit.thickness is None: + continue + thicknesses[unit.name] = float(unit.thickness) + if not thicknesses: + self.logger( + message="Stratigraphic column has no units, cannot apply stratigraphic thickness." + ) + return False + + thickness_field_name = "strat_thickness" + try: + from qgis.core import QgsField + + from loopstructural.gui.compatibility import QVariantCompat + + layer.startEditing() + if layer.fields().indexFromName(thickness_field_name) < 0: + layer.dataProvider().addAttributes( + [QgsField(thickness_field_name, QVariantCompat.Double)] + ) + layer.updateFields() + thickness_index = layer.fields().indexFromName(thickness_field_name) + for feature in layer.getFeatures(): + value = feature[field_name] + unit_name = None if value is None else str(value).strip() + layer.changeAttributeValue( + feature.id(), thickness_index, thicknesses.get(unit_name) + ) + if not layer.commitChanges(): + raise RuntimeError("; ".join(layer.commitErrors())) + except Exception as err: + layer.rollBack() + self.logger(message=f"Failed to write stratigraphic thickness onto layer: {err}") + return False + + unique_values = set() + for feature in layer.getFeatures(): + value = feature[thickness_field_name] + if value is None or (hasattr(value, 'isNull') and value.isNull()): + continue + unique_values.add(float(value)) + unique_values = sorted(unique_values) + + if not unique_values: + self.logger( + message="No features matched a stratigraphic unit, cannot style layer by thickness." + ) + return False + + ramp = _lookup_colour_ramp(ramp_name) + + # One class per thickness value, with the class limits half-way + # between adjacent values so that each value is in only one class. + minimum = unique_values[0] + span = unique_values[-1] - minimum + ranges = [] + for i, value in enumerate(unique_values): + lower = value if i == 0 else (unique_values[i - 1] + value) / 2 + upper = value if i == len(unique_values) - 1 else (value + unique_values[i + 1]) / 2 + symbol = QgsSymbol.defaultSymbol(layer.geometryType()) + if ramp is not None: + symbol.setColor(ramp.color((value - minimum) / span if span > 0 else 0)) + ranges.append(QgsRendererRange(lower, upper, symbol, f"{value:g}")) + + layer.setRenderer(QgsGraduatedSymbolRenderer(thickness_field_name, ranges)) + layer.triggerRepaint() + self.logger( + message=f"Applied stratigraphic thickness field '{thickness_field_name}' and " + f"graduated styling to layer '{layer.name()}'." + ) + return True + def get_stratigraphic_unit_names(self): """Get the names of the stratigraphic units in the column, youngest first. diff --git a/tests/qgis/test_apply_stratigraphic_thickness.py b/tests/qgis/test_apply_stratigraphic_thickness.py new file mode 100644 index 0000000..cd00575 --- /dev/null +++ b/tests/qgis/test_apply_stratigraphic_thickness.py @@ -0,0 +1,56 @@ +"""Tests for painting unit thickness from the stratigraphic column onto a layer.""" + +from unittest.mock import Mock + +import pytest +from qgis.core import QgsFeature, QgsGraduatedSymbolRenderer, QgsProject, QgsVectorLayer + +from loopstructural.main.data_manager import ModellingDataManager + + +@pytest.fixture +def data_manager(): + data_manager = ModellingDataManager( + project=QgsProject.instance(), mapCanvas=Mock(), logger=Mock() + ) + data_manager._stratigraphic_column.clear() + data_manager._stratigraphic_column.add_unit(name='A', colour=None, thickness=10.0) + data_manager._stratigraphic_column.add_unit(name='B', colour=None, thickness=25.0) + data_manager._stratigraphic_column.add_unit(name='C', colour=None, thickness=25.0) + return data_manager + + +@pytest.fixture +def geology_layer(): + layer = QgsVectorLayer("Polygon?crs=EPSG:28350&field=UNITNAME:string", "geology", "memory") + features = [] + for name in ['A', 'B', 'C', 'unknown']: + feature = QgsFeature(layer.fields()) + feature['UNITNAME'] = name + features.append(feature) + layer.dataProvider().addFeatures(features) + return layer + + +def test_thickness_is_written_for_each_unit(data_manager, geology_layer): + assert data_manager.apply_stratigraphic_thickness_to_layer(geology_layer, 'UNITNAME') + + values = {f['UNITNAME']: f['strat_thickness'] for f in geology_layer.getFeatures()} + assert values['A'] == pytest.approx(10.0) + assert values['B'] == pytest.approx(25.0) + assert values['C'] == pytest.approx(25.0) + unknown = values['unknown'] + assert unknown is None or (hasattr(unknown, 'isNull') and unknown.isNull()) + + +def test_layer_is_styled_with_one_class_per_thickness(data_manager, geology_layer): + data_manager.apply_stratigraphic_thickness_to_layer(geology_layer, 'UNITNAME') + + renderer = geology_layer.renderer() + assert isinstance(renderer, QgsGraduatedSymbolRenderer) + assert renderer.classAttribute() == 'strat_thickness' + assert [r.label() for r in renderer.ranges()] == ['10', '25'] + + +def test_missing_unit_name_field_is_refused(data_manager, geology_layer): + assert not data_manager.apply_stratigraphic_thickness_to_layer(geology_layer, 'NOPE') From 045f16b48bf22eec4da5984c2e2085abdd25990c Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Wed, 7 Oct 2026 08:51:38 -0300 Subject: [PATCH 3/3] style: apply black formatting --- .../modelling/stratigraphic_column/stratigraphic_column.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py index a092223..0c98e7d 100644 --- a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py +++ b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py @@ -498,9 +498,7 @@ def highlight_selected_unit(self): or layer.fields().indexFromName(field_name) < 0 ): return - expression = ( - f"{QgsExpression.quotedColumnRef(field_name)} = {QgsExpression.quotedValue(widget.name)}" - ) + expression = f"{QgsExpression.quotedColumnRef(field_name)} = {QgsExpression.quotedValue(widget.name)}" layer.selectByExpression(expression) self._highlighted_layer = layer