diff --git a/.release-please-manifest.json b/.release-please-manifest.json index f62adeb..99b1e1e 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,4 +1,4 @@ { ".": "0.0.1", - "loopstructural": "0.5.0" + "loopstructural": "0.6.1" } diff --git a/docs/development/usability-plan.md b/docs/development/usability-plan.md index e14a048..cdb4cbd 100644 --- a/docs/development/usability-plan.md +++ b/docs/development/usability-plan.md @@ -264,18 +264,18 @@ A typed thickness is not overwritten by a calculated thickness. Depends on: the `feat/highlight-strat-unit` branch (map highlight, thickness styling) is merged. -- [ ] Put the geology layer group at the top, with labels. Show a summary of +- [x] Put the geology layer group at the top, with labels. Show a summary of the unit names that have no match. -- [ ] Toolbar: "+ Unit" and "+ Unconformity" as text buttons. A "Build column" +- [x] Toolbar: "+ Unit" and "+ Unconformity" as text buttons. A "Build column" menu for the import actions. An overflow menu for Reverse and Clear. -- [ ] Add "Youngest" and "Oldest" labels above and below the list. -- [ ] Replace the three apply buttons with one "Style map layer" group: a +- [x] Add "Youngest" and "Oldest" labels above and below the list. +- [x] Replace the three apply buttons with one "Style map layer" group: a "Style by" combo (unit colour, stratigraphic order, thickness), a ramp combo and one Apply button. -- [ ] Rows: use `QgsColorButton`, a theme delete icon, and no repeated +- [x] Rows: use `QgsColorButton`, a theme delete icon, and no repeated "Thickness:" label. Give unconformity rows a different style. -- [ ] Show text in the empty list. -- [ ] Remove the outer scroll area from the tab. +- [x] Show text in the empty list. +- [x] Remove the outer scroll area from the tab. Files: `stratigraphic_column/*.py`, `stratigraphic_column/*.ui`, `geological_history_tab.py`. diff --git a/docs/usage/interface.md b/docs/usage/interface.md index 53642bd..5a1c417 100644 --- a/docs/usage/interface.md +++ b/docs/usage/interface.md @@ -41,8 +41,12 @@ The **Source** setting in the basal contacts group selects where the basal conta ## Stratigraphic Column The stratigraphic column defines the order of the contacts and any unconformable relationships between them. The column is defined by a list of units - these units are ordered from oldest at the bottom to youngest at the top. Unconformities can be inserted between units to define an unconformable relationship. The thicknesses define the true thickness of each unit and are used to parameterise the interpolation. The unit names should match the names of the contacts in the basal contacts layer. Units without basal contacts can be included in the stratigraphic column but will not be constrained by any data. -The stratigraphic column can be initialised from the basal contacts layer by clicking the "Initialise from Layer" button. This will create a column with the contacts in the order they are found in the layer. The column can then be edited to add unconformities or change the order of the units. To change the order of units simply drag the units in the list. To add an unconformity, click the "Add Unconformity" button and drag the unconformity the location in the column. +The tab has these parts, from top to bottom: +- **Geology layer**: the layer and the unit name field. The plugin shares this layer with the map2loop tools. Under the pickers, a summary shows how many unit names have no match in the layer, and which names. +- **Buttons**: **+ Unit** and **+ Unconformity** add a row at the top of the column. The **Build column** menu adds units from the map: from the basal contacts, or from a layer field. The **More actions** menu has Reverse and Clear. Clear asks for confirmation. +- **The column**: the labels "Youngest" and "Oldest" show the direction. Each unit row has a colour button, a name, a thickness and a remove button. Unconformity rows have a different background. To change the order of units, drag the grip handle of a row. When the column is empty, the list shows how to start. +- **Style map layer**: choose **Style by** unit colour, stratigraphic order or thickness. For the order and the thickness, choose a colour ramp. **Apply** styles the geology layer. ![Stratigraphic Column](../static/stratigraphic_column_04.png) diff --git a/loopstructural/CHANGELOG.md b/loopstructural/CHANGELOG.md index c0e9f36..2e8dd13 100644 --- a/loopstructural/CHANGELOG.md +++ b/loopstructural/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## [0.6.1](https://github.com/Loop3D/plugin_loopstructural/compare/v0.6.0...v0.6.1) (2026-10-04) + + +### Bug Fixes + +* push log messages to the message bar from the main thread ([#127](https://github.com/Loop3D/plugin_loopstructural/issues/127)) ([cf2ebb9](https://github.com/Loop3D/plugin_loopstructural/commit/cf2ebb9bc72e825b0afe047fa588e2f780ae218c)) + +## [0.6.0](https://github.com/Loop3D/plugin_loopstructural/compare/v0.5.0...v0.6.0) (2026-10-04) + + +### Features + +* flip the polarity of fault-linked column boundaries ([4c7f90e](https://github.com/Loop3D/plugin_loopstructural/commit/4c7f90ef34e7f2fc5475d35411ccae57cc4f99a8)) + + +### Bug Fixes + +* give observation projections a UNITNAME geology and a projection length ([0331b60](https://github.com/Loop3D/plugin_loopstructural/commit/0331b6057d56d6192b7cf98c8e7ca26ba7c28c16)) +* keep the boundary above a domain fault uncropped ([342692a](https://github.com/Loop3D/plugin_loopstructural/commit/342692ac4154c94b6468040fda0621e8851788fc)) +* keep the fold axis settings in the folded feature panel ([09d15f9](https://github.com/Loop3D/plugin_loopstructural/commit/09d15f924d27b22b32c208d6d56e3c3be5299f25)) +* keep the selected feature after the feature list is rebuilt ([8ba7857](https://github.com/Loop3D/plugin_loopstructural/commit/8ba78572eeecfb9d8ce88c3425981d4836430015)) +* qt6 compatibility for scoped enums and exec ([21a28f4](https://github.com/Loop3D/plugin_loopstructural/commit/21a28f459715d5277ddf1166561bc06f28f97e51)) + ## [0.5.0](https://github.com/Loop3D/plugin_loopstructural/compare/v0.4.0...v0.5.0) (2026-09-30) diff --git a/loopstructural/gui/modelling/geological_history_tab.py b/loopstructural/gui/modelling/geological_history_tab.py index c21c216..c927818 100644 --- a/loopstructural/gui/modelling/geological_history_tab.py +++ b/loopstructural/gui/modelling/geological_history_tab.py @@ -4,7 +4,7 @@ class GeologialHistoryTab(BaseTab): def __init__(self, parent=None, data_manager=None): - super().__init__(parent, data_manager, scrollable=True) + super().__init__(parent, data_manager, scrollable=False) # Load the UI file for Tab 1 stratigraphic_column_widget = StratColumnWidget(self, data_manager=data_manager) # Add the loaded UI widget to the container layout diff --git a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py index a4a073c..488ebde 100644 --- a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py +++ b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py @@ -1,16 +1,19 @@ from LoopStructural.modelling.core.stratigraphic_column import StratigraphicColumnElementType -from qgis.core import QgsApplication, QgsMapLayerProxyModel, QgsStyle -from qgis.gui import QgsFieldComboBox, QgsMapLayerComboBox -from qgis.PyQt.QtCore import QSize -from qgis.PyQt.QtGui import QIcon +from qgis.core import QgsApplication, QgsExpression, QgsMapLayerProxyModel, QgsStyle +from qgis.gui import QgsCollapsibleGroupBox, QgsFieldComboBox, QgsMapLayerComboBox +from qgis.PyQt.QtCore import Qt +from qgis.PyQt.QtGui import QBrush, QIcon from qgis.PyQt.QtWidgets import ( QAbstractItemView, QComboBox, QDialog, + QFormLayout, + QGroupBox, QHBoxLayout, QLabel, QListWidget, QListWidgetItem, + QMenu, QMessageBox, QPushButton, QToolButton, @@ -29,6 +32,12 @@ from .stratigraphic_unit import StratigraphicUnitWidget +# The values of the "Style by" combo +STYLE_COLOUR = 'colour' +STYLE_ORDER = 'order' +STYLE_THICKNESS = 'thickness' + + class StratColumnWidget(QWidget): """Widget that controls building the stratigraphic column. @@ -76,110 +85,28 @@ def __init__(self, parent=None, data_manager=None): # The hidden tool widget that an Update button runs self._update_widget = None - # Main list widget - self.unitList = QListWidget() - self.unitList.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove) - self.unitList.model().rowsMoved.connect(self.update_order) - layout.addWidget(self.unitList) - - # A single row of icon-only actions for building/clearing the column. - addUnitButton = self._make_tool_button("mActionAdd.svg", "Add Unit") - addUnitButton.clicked.connect(self.add_unit) - - addUnconformityButton = self._make_custom_icon_tool_button( - "unconformity.svg", "Add Unconformity" - ) - addUnconformityButton.clicked.connect(self.add_unconformity) - - initFromBasalContactsButton = self._make_tool_button( - "mActionSharingImport.svg", "Initialise from map" - ) - initFromBasalContactsButton.clicked.connect( - self.init_stratigraphic_column_from_basal_contacts - ) - - initFromLayerFieldButton = self._make_tool_button( - "mIconFieldText.svg", "Initialise from Layer Field" - ) - initFromLayerFieldButton.setToolTip( - "Initialise from Layer Field\n" - "Pick a polygon layer and a field, and add a unit for each unique " - "value found in that field." - ) - initFromLayerFieldButton.clicked.connect(self.init_stratigraphic_column_from_layer_field) - - clearButton = self._make_tool_button( - "mActionDeleteSelected.svg", "Clear Stratigraphic Column" - ) - clearButton.clicked.connect(self.clearColumn) + # 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 - reverseButton = self._make_tool_button( - "mActionReverseLine.svg", "Reverse Stratigraphic Column" - ) - reverseButton.setToolTip( - "Reverse Stratigraphic Column\n" - "Flip the order of the column so the youngest unit becomes the oldest." - ) - reverseButton.clicked.connect(self.reverseColumn) - - actionsRow = QHBoxLayout() - actionsRow.addWidget(addUnitButton) - actionsRow.addWidget(addUnconformityButton) - actionsRow.addWidget(initFromBasalContactsButton) - actionsRow.addWidget(initFromLayerFieldButton) - actionsRow.addWidget(reverseButton) - actionsRow.addWidget(clearButton) - actionsRow.addStretch(1) - layout.addLayout(actionsRow) - - # Layer/field pickers for pushing colours back onto a map layer, with - # the apply action as an icon button at the end of the same row. - layerRow = QHBoxLayout() - self.unitsLayerComboBox = QgsMapLayerComboBox() - configure_layer_combo( - self.unitsLayerComboBox, QgsMapLayerProxyModel.Filter.PolygonLayer, allow_empty=True - ) - self.unitsLayerComboBox.setCurrentIndex(-1) - self.unitsLayerFieldComboBox = QgsFieldComboBox() - self.unitsLayerComboBox.layerChanged.connect(self._on_units_layer_changed) - self.unitsLayerFieldComboBox.fieldChanged.connect(self._on_units_field_changed) - layerRow.addWidget(self.unitsLayerComboBox) - layerRow.addWidget(self.unitsLayerFieldComboBox) + # The geology layer and its unit name field. The layer is shared with + # the map2loop tools, and the "Style map layer" group writes to it. + layout.addWidget(self._build_geology_group()) - applyColoursButton = self._make_tool_button( - "mIconColorSwatches.svg", "Apply Colours to Map Layer" - ) - applyColoursButton.setToolTip( - "Apply Colours to Map Layer\n" - "Push the colours defined in the stratigraphic column onto the " - "selected layer above as a categorized renderer." - ) - applyColoursButton.clicked.connect(self.apply_colours_to_layer) - layerRow.addWidget(applyColoursButton) - layout.addLayout(layerRow) + layout.addLayout(self._build_actions_row()) - # Colour ramp picker + apply stratigraphic age action, same pattern. - ageRow = QHBoxLayout() - ageRow.addWidget(QLabel("Colour ramp:")) - self.strat_ageColorRampComboBox = QComboBox() - ramp_names = sorted(QgsStyle().defaultStyle().colorRampNames()) - self.strat_ageColorRampComboBox.addItems(ramp_names) - default_ramp_index = self.strat_ageColorRampComboBox.findText('Viridis') - if default_ramp_index >= 0: - self.strat_ageColorRampComboBox.setCurrentIndex(default_ramp_index) - ageRow.addWidget(self.strat_ageColorRampComboBox) + # Main list widget, with the direction of the column above and below it + self.unitList = QListWidget() + self.unitList.setMinimumHeight(120) + self.unitList.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove) + self.unitList.model().rowsMoved.connect(self.update_order) + self.unitList.itemSelectionChanged.connect(self.highlight_selected_unit) + self._add_empty_list_text() + layout.addWidget(QLabel("Youngest")) + layout.addWidget(self.unitList, 1) + layout.addWidget(QLabel("Oldest")) - applyAgeButton = self._make_tool_button( - "rendererGraduatedSymbol.svg", "Apply Stratigraphic Age to Map Layer" - ) - applyAgeButton.setToolTip( - "Apply Stratigraphic Age to Map Layer\n" - "Write a 'strat_order' field (0 = first unit in the column) onto " - "the selected layer above and style it with a graduated colour ramp." - ) - applyAgeButton.clicked.connect(self.apply_age_to_layer) - ageRow.addWidget(applyAgeButton) - layout.addLayout(ageRow) + layout.addWidget(self._build_style_group()) self._add_derived_data_panel(layout) @@ -353,27 +280,178 @@ def _set_update_buttons_enabled(self, enabled): for _row, _label, button in self._derived_rows.values(): button.setEnabled(enabled) - def _make_tool_button(self, theme_icon_name: str, tooltip: str) -> QToolButton: - """Build a small icon-only tool button using a QGIS theme icon, with - the given tooltip standing in for the label text it no longer shows. - """ - return self._build_tool_button(QgsApplication.getThemeIcon(theme_icon_name), tooltip) + def _build_geology_group(self): + """Build the group with the geology layer, the unit name field and a + summary of the unit names that have no match in the layer.""" + group = QGroupBox("Geology layer", self) + form = QFormLayout(group) + self.unitsLayerComboBox = QgsMapLayerComboBox() + configure_layer_combo( + self.unitsLayerComboBox, QgsMapLayerProxyModel.Filter.PolygonLayer, allow_empty=True + ) + self.unitsLayerComboBox.setCurrentIndex(-1) + self.unitsLayerComboBox.setToolTip("The polygon layer that has the geological units.") + self.unitsLayerFieldComboBox = QgsFieldComboBox() + self.unitsLayerFieldComboBox.setToolTip("The field that has the name of each unit.") + self.unitsLayerComboBox.layerChanged.connect(self._on_units_layer_changed) + self.unitsLayerFieldComboBox.fieldChanged.connect(self._on_units_field_changed) + form.addRow("Layer", self.unitsLayerComboBox) + form.addRow("Unit name field", self.unitsLayerFieldComboBox) + self.unitNamesSummaryLabel = QLabel() + self.unitNamesSummaryLabel.setWordWrap(True) + form.addRow(self.unitNamesSummaryLabel) + return group + + def _build_actions_row(self): + """Build the row of buttons that change the column.""" + addUnitButton = QPushButton("+ Unit", self) + addUnitButton.setToolTip("Add a unit to the top of the column.") + addUnitButton.clicked.connect(lambda _checked=False: self.add_unit()) + + addUnconformityButton = QPushButton("+ Unconformity", self) + addUnconformityButton.setIcon( + QIcon(str(DIR_PLUGIN_ROOT / "resources" / "images" / "unconformity.svg")) + ) + addUnconformityButton.setToolTip("Add an unconformity to the top of the column.") + addUnconformityButton.clicked.connect(lambda _checked=False: self.add_unconformity()) + + buildMenu = QMenu(self) + buildMenu.addAction( + QgsApplication.getThemeIcon("mActionSharingImport.svg"), + "From the basal contacts of the map", + self.init_stratigraphic_column_from_basal_contacts, + ).setToolTip("Add the units in the order that the basal contacts give.") + buildMenu.addAction( + QgsApplication.getThemeIcon("mIconFieldText.svg"), + "From a layer field...", + self.init_stratigraphic_column_from_layer_field, + ).setToolTip( + "Pick a polygon layer and a field, and add a unit for each unique value " + "found in that field." + ) + buildButton = QToolButton(self) + buildButton.setText("Build column") + buildButton.setToolTip("Add units to the column from the map data.") + buildButton.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) + buildButton.setIcon(QgsApplication.getThemeIcon("mActionSharingImport.svg")) + buildButton.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) + buildButton.setMenu(buildMenu) + + moreMenu = QMenu(self) + moreMenu.addAction( + QgsApplication.getThemeIcon("mActionReverseLine.svg"), + "Reverse the column", + self.reverseColumn, + ).setToolTip("Flip the order of the column so the youngest unit becomes the oldest.") + moreMenu.addAction( + QgsApplication.getThemeIcon("mActionDeleteSelected.svg"), + "Clear the column...", + self.clearColumn, + ) + moreButton = QToolButton(self) + moreButton.setIcon(QgsApplication.getThemeIcon("mActionOptions.svg")) + moreButton.setToolTip("More actions") + moreButton.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) + moreButton.setMenu(moreMenu) + + row = QHBoxLayout() + row.addWidget(addUnitButton) + row.addWidget(addUnconformityButton) + row.addWidget(buildButton) + row.addStretch(1) + row.addWidget(moreButton) + return row + + def _build_style_group(self): + """Build the group that styles the geology layer by the column.""" + group = QgsCollapsibleGroupBox("Style map layer", self) + form = QFormLayout(group) + self.styleByComboBox = QComboBox() + self.styleByComboBox.addItem("Unit colour", STYLE_COLOUR) + self.styleByComboBox.addItem("Stratigraphic order", STYLE_ORDER) + self.styleByComboBox.addItem("Thickness", STYLE_THICKNESS) + self.styleByComboBox.setToolTip( + "Unit colour: a categorized style with the colour of each unit.\n" + "Stratigraphic order: write a 'strat_order' field (0 = first unit in the " + "column) and use a graduated style.\n" + "Thickness: write a 'strat_thickness' field and use a graduated style." + ) + self.strat_ageColorRampComboBox = QComboBox() + ramp_names = sorted(QgsStyle().defaultStyle().colorRampNames()) + self.strat_ageColorRampComboBox.addItems(ramp_names) + default_ramp_index = self.strat_ageColorRampComboBox.findText('Viridis') + if default_ramp_index >= 0: + self.strat_ageColorRampComboBox.setCurrentIndex(default_ramp_index) + self.styleByComboBox.currentIndexChanged.connect(self._on_style_by_changed) + self.applyStyleButton = QPushButton("Apply") + self.applyStyleButton.setToolTip("Style the geology layer above.") + self.applyStyleButton.clicked.connect(self.apply_style_to_layer) + form.addRow("Style by", self.styleByComboBox) + form.addRow("Colour ramp", self.strat_ageColorRampComboBox) + form.addRow(self.applyStyleButton) + self._on_style_by_changed() + return group + + def _on_style_by_changed(self, _index=None): + """A colour ramp is only for the graduated styles.""" + self.strat_ageColorRampComboBox.setEnabled( + self.styleByComboBox.currentData() != STYLE_COLOUR + ) - def _make_custom_icon_tool_button(self, icon_filename: str, tooltip: str) -> QToolButton: - """Build a small icon-only tool button using one of this plugin's own - icons (see resources/images), for geological concepts QGIS's own - theme has no dedicated icon for. - """ - icon_path = str(DIR_PLUGIN_ROOT / "resources" / "images" / icon_filename) - return self._build_tool_button(QIcon(icon_path), tooltip) + def apply_style_to_layer(self): + """Style the geology layer in the way that the "Style by" combo gives.""" + style = self.styleByComboBox.currentData() + if style == STYLE_ORDER: + self.apply_age_to_layer() + elif style == STYLE_THICKNESS: + self.apply_thickness_to_layer() + else: + self.apply_colours_to_layer() + + def _add_empty_list_text(self): + """Add a text on the list that shows when the list has no rows.""" + self._emptyListLabel = QLabel( + "The column is empty.\nUse '+ Unit' to add a unit, or 'Build column' " + "to add the units from the map.", + self.unitList.viewport(), + ) + self._emptyListLabel.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._emptyListLabel.setWordWrap(True) + self._emptyListLabel.setEnabled(False) + self._emptyListLabel.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents) + viewport_layout = QVBoxLayout(self.unitList.viewport()) + viewport_layout.addWidget(self._emptyListLabel) + + def _update_list_state(self): + """Refresh the parts that depend on the rows of the list.""" + try: + self._emptyListLabel.setVisible(self.unitList.count() == 0) + self._update_unit_names_summary() + except RuntimeError: + # the widget was deleted + pass - def _build_tool_button(self, icon: QIcon, tooltip: str) -> QToolButton: - button = QToolButton(self) - button.setIcon(icon) - button.setIconSize(QSize(22, 22)) - button.setToolTip(tooltip) - button.setAutoRaise(True) - return button + def _update_unit_names_summary(self): + """Show how many unit names have no match in the geology layer.""" + names = [ + widget.name + for widget, _item in self._widget_cache.values() + if isinstance(widget, StratigraphicUnitWidget) and widget.name + ] + if self._known_unit_names is None: + text = "Select a layer and a unit name field to check the unit names." + elif not names: + text = "The column has no units." + else: + missing = [name for name in names if name not in self._known_unit_names] + if missing: + text = ( + f"Warning: {len(missing)} of {len(names)} units have no match in the " + f"layer: {', '.join(missing)}" + ) + else: + text = f"All {len(names)} units match a name in the layer." + self.unitNamesSummaryLabel.setText(text) def clearColumn(self): """Clear the stratigraphic column, after the user confirms.""" @@ -462,6 +540,7 @@ def update_display(self): self._full_rebuild_display(current_order) finally: self._updating = False + self._update_list_state() def _full_rebuild_display(self, current_order): """Perform a full rebuild of the display (called only when necessary). @@ -614,6 +693,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.""" @@ -625,6 +705,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. @@ -654,6 +735,48 @@ def _revalidate_unit_names(self): for widget, _ in self._widget_cache.values(): if hasattr(widget, 'set_known_unit_names'): widget.set_known_unit_names(self._known_unit_names) + self._update_unit_names_summary() + + 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.""" @@ -713,6 +836,38 @@ 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: + push_success( + "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': ''} @@ -744,6 +899,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) @@ -760,11 +916,13 @@ 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) # Cache the widget for efficient updates self._widget_cache[unit_data['uuid']] = (unit_widget, item) + self._update_list_state() def add_unconformity(self, *, unconformity_data=None, create_new=True): if unconformity_data is None: @@ -798,6 +956,8 @@ def add_unconformity(self, *, unconformity_data=None, create_new=True): lambda: self._on_drag_end(unconformity_widget) ) item = QListWidgetItem() + # An unconformity row has another background than a unit row + item.setBackground(QBrush(self.palette().alternateBase())) item.setSizeHint(unconformity_widget.sizeHint()) self._add_list_item(item, at_top=create_new) self.unitList.setItemWidget(item, unconformity_widget) @@ -806,6 +966,17 @@ def add_unconformity(self, *, unconformity_data=None, create_new=True): # Cache the widget for efficient updates self._widget_cache[unconformity.uuid] = (unconformity_widget, item) + self._update_list_state() + + def _on_unit_name_changed(self, unit_widget): + """Update the summary, and the map highlight when the selected unit is renamed.""" + self._update_unit_names_summary() + 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 @@ -841,6 +1012,7 @@ def delete_unit(self, unit_widget): # Remove from cache if unit_widget.uuid in self._widget_cache: del self._widget_cache[unit_widget.uuid] + self._update_list_state() def _on_drag_start(self, widget): """Begin a reorder drag started from a row's grip handle.""" diff --git a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.py b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.py index ecb4601..74efb0f 100644 --- a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.py +++ b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.py @@ -2,8 +2,10 @@ from typing import Optional import numpy as np +from qgis.core import QgsApplication from qgis.PyQt import uic from qgis.PyQt.QtCore import QEvent, QPoint, Qt, pyqtSignal +from qgis.PyQt.QtGui import QColor from qgis.PyQt.QtWidgets import QWidget from loopstructural.gui.compatibility import event_global_pos @@ -17,6 +19,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, @@ -38,8 +41,12 @@ def __init__( # means no layer/field has been selected, so the check is skipped. self._known_unit_names = None # Connect buttons + self.buttonDelete.setIcon(QgsApplication.getThemeIcon("mActionRemove.svg")) + self.buttonDelete.setAutoRaise(True) self.buttonDelete.clicked.connect(self.request_delete) - self.buttonColour.clicked.connect(self.onColourSelectClicked) + self.buttonColour.setAllowOpacity(False) + self.buttonColour.setColorDialogTitle("Unit colour") + self.buttonColour.colorChanged.connect(self.onColourChanged) self.lineEditName.editingFinished.connect(self.onNameChanged) self.spinBoxThickness.valueChanged.connect(self.onThicknessChanged) # Initialize UI widgets with the provided values @@ -53,8 +60,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 ( @@ -129,23 +143,17 @@ def set_thickness(self, thickness: float): self.validateFields() def _update_colour_button(self): - """Update the color button's appearance to show the current color.""" - if self.colour: - self.buttonColour.setStyleSheet( - f"background-color: {self.colour}; border: 1px solid #999;" - ) - else: - self.buttonColour.setStyleSheet("background-color: #cccccc; border: 1px solid #999;") - - def onColourSelectClicked(self): - """Open a color dialog to select a color for the stratigraphic unit.""" - from qgis.PyQt.QtWidgets import QColorDialog - - color = QColorDialog.getColor() - if color.isValid(): - self.colour = color.name() - self._update_colour_button() - self.colourChanged.emit(self.colour) + """Show the current colour on the colour button, without a signal.""" + self.buttonColour.blockSignals(True) + try: + self.buttonColour.setColor(QColor(self.colour) if self.colour else QColor("#cccccc")) + finally: + self.buttonColour.blockSignals(False) + + def onColourChanged(self, colour: QColor): + """Store the colour that the user selected in the colour button.""" + self.colour = colour.name() + self.colourChanged.emit(self.colour) def onThicknessChanged(self, thickness: float): """Handle changes to the thickness spinbox. diff --git a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.ui b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.ui index 306f738..d8908c7 100644 --- a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.ui +++ b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.ui @@ -37,9 +37,9 @@ - + - Click to change color + Click to change colour @@ -62,14 +62,10 @@ - - - Thickness: - - - - + + Thickness of the unit + m @@ -84,18 +80,25 @@ - - + + - Delete this unit + Remove this unit from the column - 🗑️ + + + + QgsColorButton + QToolButton +
qgscolorbutton.h
+
+
diff --git a/loopstructural/gui/modelling/stratigraphic_column/unconformity.py b/loopstructural/gui/modelling/stratigraphic_column/unconformity.py index 8f56274..3c5f285 100644 --- a/loopstructural/gui/modelling/stratigraphic_column/unconformity.py +++ b/loopstructural/gui/modelling/stratigraphic_column/unconformity.py @@ -1,6 +1,7 @@ import os from typing import Optional +from qgis.core import QgsApplication from qgis.PyQt import uic from qgis.PyQt.QtCore import QEvent, QPoint, Qt, pyqtSignal from qgis.PyQt.QtWidgets import QWidget @@ -22,7 +23,8 @@ def __init__( ): super().__init__(parent) uic.loadUi(os.path.join(os.path.dirname(__file__), 'unconformity.ui'), self) - # Add delete button + self.buttonDelete.setIcon(QgsApplication.getThemeIcon("mActionRemove.svg")) + self.buttonDelete.setAutoRaise(True) self.buttonDelete.clicked.connect(self.request_delete) self.uuid = uuid self.unconformity_type = 'erode' diff --git a/loopstructural/gui/modelling/stratigraphic_column/unconformity.ui b/loopstructural/gui/modelling/stratigraphic_column/unconformity.ui index ed9f28f..c5b1b59 100644 --- a/loopstructural/gui/modelling/stratigraphic_column/unconformity.ui +++ b/loopstructural/gui/modelling/stratigraphic_column/unconformity.ui @@ -37,12 +37,12 @@
- + - Delete this unit + Remove this unconformity from the column - 🗑️ + @@ -71,7 +71,7 @@ - Type + Unconformity diff --git a/loopstructural/main/data_manager.py b/loopstructural/main/data_manager.py index 64ae50c..a345dee 100644 --- a/loopstructural/main/data_manager.py +++ b/loopstructural/main/data_manager.py @@ -35,6 +35,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: @@ -873,12 +884,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 = [] @@ -901,6 +907,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/loopstructural/toolbelt/log_handler.py b/loopstructural/toolbelt/log_handler.py index 9a09043..e8645e6 100644 --- a/loopstructural/toolbelt/log_handler.py +++ b/loopstructural/toolbelt/log_handler.py @@ -8,12 +8,14 @@ # standard library import logging +import threading from functools import partial from typing import Callable # PyQGIS from qgis.core import QgsMessageLog, QgsMessageOutput from qgis.gui import QgsMessageBar +from qgis.PyQt.QtCore import QCoreApplication, QObject, Qt, QThread, pyqtSignal, pyqtSlot from qgis.PyQt.QtWidgets import QPushButton, QWidget from qgis.utils import iface @@ -27,6 +29,45 @@ # ################################## +class _MainThreadInvoker(QObject): + """Runs functions on the Qt main thread. + + A signal emitted from a worker thread is delivered to a slot of an object + that lives on the main thread through the main thread's event queue. + """ + + invoke = pyqtSignal(object) + + def __init__(self): + super().__init__() + self.invoke.connect(self._run, Qt.ConnectionType.QueuedConnection) + + @pyqtSlot(object) + def _run(self, func): + func() + + +_invoker = None +_invoker_lock = threading.Lock() + + +def _run_on_main_thread(func): + """Run `func` on the Qt main thread: now if this is the main thread, else queued.""" + global _invoker + app = QCoreApplication.instance() + if app is None or QThread.currentThread() == app.thread(): + func() + return + with _invoker_lock: + if _invoker is None: + invoker = _MainThreadInvoker() + # An object can only be moved from the thread it lives on, which + # is this thread, because it was made here. + invoker.moveToThread(app.thread()) + _invoker = invoker + _invoker.invoke.emit(func) + + class PlgLogger(logging.Handler): """Python logging handler supercharged with QGIS useful methods.""" @@ -146,30 +187,15 @@ def _do_push(): except Exception: logging.exception("Failed to push message to QGIS message bar") - # Try to schedule the UI interaction on the Qt main thread using QTimer + # The message bar is a widget, so only the Qt main thread can change it. + # QTimer.singleShot(0, func) does not do this: it runs func on the + # calling thread, so a call from a background task changed the message + # bar from the worker thread and locked the GUI. try: - try: - from qgis.PyQt.QtCore import QTimer as _QTimer - except Exception: - # fall back to qgis.PyQt/PySide2 if qgis.PyQt namespace isn't present - try: - from qgis.PyQt.QtCore import QTimer as _QTimer # type: ignore - except Exception: - try: - from PySide2.QtCore import QTimer as _QTimer # type: ignore - except Exception: - _QTimer = None - if _QTimer is not None: - _QTimer.singleShot(0, _do_push) - else: - # last resort: call directly (may block if called from background thread) - _do_push() + _run_on_main_thread(_do_push) except Exception: # ensure we do not raise from logging - try: - _do_push() - except Exception: - pass + pass class PlgLoggerHandler(logging.Handler): 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')