Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -170,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()
Expand Down Expand Up @@ -413,6 +430,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."""
Expand All @@ -424,6 +442,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.
Expand Down Expand Up @@ -454,6 +473,47 @@ 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:
Expand Down Expand Up @@ -514,6 +574,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': ''}
Expand Down Expand Up @@ -545,6 +638,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)
Expand All @@ -561,6 +655,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)

Expand Down Expand Up @@ -608,6 +703,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."""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 (
Expand Down
126 changes: 120 additions & 6 deletions loopstructural/main/data_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 = []
Expand All @@ -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.

Expand Down
Loading
Loading