From ed17d931a04efd574eb6dec0abe4903dddc683b0 Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Thu, 20 Aug 2026 14:30:37 +0930 Subject: [PATCH 1/8] test: pin training-value/isovalue direction agreement for stratigraphic columns Guards against the swap fixed in 814be12: model_manager.py's per-unit training value and LoopStructural's get_isovalues() must agree on which direction values increase, or extracted isosurfaces get labelled with the wrong unit while keeping correct geometry. Co-Authored-By: Claude Sonnet 5 --- .../test_stratigraphic_value_consistency.py | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 tests/qgis/test_stratigraphic_value_consistency.py diff --git a/tests/qgis/test_stratigraphic_value_consistency.py b/tests/qgis/test_stratigraphic_value_consistency.py new file mode 100644 index 0000000..5135a20 --- /dev/null +++ b/tests/qgis/test_stratigraphic_value_consistency.py @@ -0,0 +1,102 @@ +"""Regression test for the training-value / isovalue direction bug. + +`GeologicalModelManager.update_foliation_features` assigns a scalar `val` to +each unit's basal contact before handing the data to the interpolator. +`StratigraphicColumn.get_isovalues` (LoopStructural core) later decides which +name to stamp on each extracted isosurface, using its own idea of which +value belongs to which unit. + +These two must agree on direction (does value increase from oldest-to- +youngest, or youngest-to-oldest?), or every extracted surface gets labelled +with the wrong unit while keeping correct geometry -- see the "stratigraphic +column was reversed" fixes in model_manager.py (2025-07-21) and the widget +(2025-08-21, reverted 2025-09-08). This has flipped back and forth as this +plugin and LoopStructural evolved independently; this test pins the +invariant so a future change on either side fails loudly here instead of +silently inverting a user's model. +""" + +import pandas as pd +import pytest +from LoopStructural import StratigraphicColumn + +from loopstructural.main.model_manager import GeologicalModelManager + + +def _contact(unit_name): + """A minimal single-point basal contact, tagged with its unit name so + the test can recover which row came from which unit after the group + DataFrames get concatenated.""" + return pd.DataFrame({'X': [0.0], 'Y': [0.0], 'Z': [0.0], 'source_unit': [unit_name]}) + + +@pytest.fixture +def manager(monkeypatch): + manager = GeologicalModelManager() + + captured_calls = [] + + def fake_create_and_add_foliation(name, data=None, **kwargs): + captured_calls.append(data) + return object() # stand-in foliation, only passed back into add_unconformity + + monkeypatch.setattr(manager.model, 'create_and_add_foliation', fake_create_and_add_foliation) + monkeypatch.setattr(manager.model, 'add_unconformity', lambda *a, **k: None) + manager._captured_calls = captured_calls + return manager + + +class TestTrainingValueMatchesIsovalue: + def test_single_group_three_units(self, manager): + column = StratigraphicColumn() + column.clear(basement=False) # single flat group, no unconformities + column.add_unit(name='oldest', thickness=100.0, where='top') + column.add_unit(name='middle', thickness=200.0, where='top') + column.add_unit(name='youngest', thickness=300.0, where='top') + + manager.stratigraphic_column = column + for name in ('oldest', 'middle', 'youngest'): + manager.stratigraphy[name]['contact'] = _contact(name) + + manager.update_foliation_features() + + training_values = self._training_values_by_unit(manager._captured_calls) + expected_values = { + name: entry['value'] for name, entry in column.get_isovalues().items() + } + + for unit_name in ('oldest', 'middle', 'youngest'): + assert training_values[unit_name] == pytest.approx(expected_values[unit_name]), ( + f"'{unit_name}' was trained with val={training_values[unit_name]} but " + f"get_isovalues() will label the value={expected_values[unit_name]} surface " + f"with this unit's name -- the trained field and the isosurface labels " + f"disagree on direction, so extracted surfaces will get the wrong unit name." + ) + + def test_two_groups_split_by_unconformity(self, manager): + column = StratigraphicColumn() + column.clear(basement=False) + column.add_unit(name='basin_floor', thickness=50.0, where='top') + column.add_unit(name='basin_fill', thickness=150.0, where='top') + column.add_unconformity(name='regional_unconformity', where='top') + column.add_unit(name='cover_lower', thickness=80.0, where='top') + column.add_unit(name='cover_upper', thickness=120.0, where='top') + + manager.stratigraphic_column = column + for name in ('basin_floor', 'basin_fill', 'cover_lower', 'cover_upper'): + manager.stratigraphy[name]['contact'] = _contact(name) + + manager.update_foliation_features() + + training_values = self._training_values_by_unit(manager._captured_calls) + expected_values = { + name: entry['value'] for name, entry in column.get_isovalues().items() + } + + for unit_name in ('basin_floor', 'basin_fill', 'cover_lower', 'cover_upper'): + assert training_values[unit_name] == pytest.approx(expected_values[unit_name]) + + @staticmethod + def _training_values_by_unit(captured_calls): + combined = pd.concat(captured_calls, ignore_index=True) + return dict(zip(combined['source_unit'], combined['val'])) From 57e4b3798262ec2f4f9e39ae14f05b92909c72c2 Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Wed, 26 Aug 2026 12:05:55 +0930 Subject: [PATCH 2/8] fix: initialise stratigraphic column from the geological units layer --- .../init_from_field_dialog.py | 52 +++++++++++++++++++ .../stratigraphic_column.py | 33 ++++++++++++ loopstructural/main/data_manager.py | 43 +++++++++++++++ 3 files changed, 128 insertions(+) create mode 100644 loopstructural/gui/modelling/stratigraphic_column/init_from_field_dialog.py diff --git a/loopstructural/gui/modelling/stratigraphic_column/init_from_field_dialog.py b/loopstructural/gui/modelling/stratigraphic_column/init_from_field_dialog.py new file mode 100644 index 0000000..4755fdb --- /dev/null +++ b/loopstructural/gui/modelling/stratigraphic_column/init_from_field_dialog.py @@ -0,0 +1,52 @@ +from qgis.core import QgsMapLayerProxyModel +from qgis.gui import QgsFieldComboBox, QgsMapLayerComboBox +from qgis.PyQt.QtWidgets import QDialog, QDialogButtonBox, QFormLayout, QMessageBox, QVBoxLayout + +from loopstructural.gui.compatibility import configure_layer_combo + + +class InitFromLayerFieldDialog(QDialog): + """Dialog for picking a polygon layer and a field whose unique values + seed the stratigraphic column.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Initialise Stratigraphic Column from Layer Field") + self.setMinimumWidth(350) + + layout = QVBoxLayout(self) + form_layout = QFormLayout() + + self.layerComboBox = QgsMapLayerComboBox() + configure_layer_combo( + self.layerComboBox, QgsMapLayerProxyModel.PolygonLayer, allow_empty=True + ) + self.layerComboBox.setCurrentIndex(-1) + form_layout.addRow("Layer:", self.layerComboBox) + + self.fieldComboBox = QgsFieldComboBox() + self.layerComboBox.layerChanged.connect(self.fieldComboBox.setLayer) + form_layout.addRow("Field:", self.fieldComboBox) + + layout.addLayout(form_layout) + + button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + button_box.accepted.connect(self._on_accept) + button_box.rejected.connect(self.reject) + layout.addWidget(button_box) + + def _on_accept(self): + if self.layerComboBox.currentLayer() is None or not self.fieldComboBox.currentField(): + QMessageBox.warning( + self, + "Initialise Stratigraphic Column from Layer Field", + "Please select a layer and a field.", + ) + return + self.accept() + + def selected_layer(self): + return self.layerComboBox.currentLayer() + + def selected_field(self): + return self.fieldComboBox.currentField() diff --git a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py index 27c66d6..4bf8a5b 100644 --- a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py +++ b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py @@ -6,6 +6,7 @@ from qgis.PyQt.QtWidgets import ( QAbstractItemView, QComboBox, + QDialog, QHBoxLayout, QLabel, QListWidget, @@ -21,6 +22,7 @@ from loopstructural.gui.modelling.stratigraphic_column.unconformity import UnconformityWidget from loopstructural.main.helpers import ColumnMatcher, get_layer_names +from .init_from_field_dialog import InitFromLayerFieldDialog from .stratigraphic_unit import StratigraphicUnitWidget @@ -87,6 +89,16 @@ def __init__(self, parent=None, data_manager=None): 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" ) @@ -96,6 +108,7 @@ def __init__(self, parent=None, data_manager=None): actionsRow.addWidget(addUnitButton) actionsRow.addWidget(addUnconformityButton) actionsRow.addWidget(initFromBasalContactsButton) + actionsRow.addWidget(initFromLayerFieldButton) actionsRow.addWidget(clearButton) actionsRow.addStretch(1) layout.addLayout(actionsRow) @@ -300,6 +313,26 @@ def init_stratigraphic_column_from_basal_contacts(self): else: print("Error: Data manager is not initialized.") + def init_stratigraphic_column_from_layer_field(self): + if not self.data_manager: + print("Error: Data manager is not initialized.") + return + dialog = InitFromLayerFieldDialog(self) + if dialog.exec_() != QDialog.Accepted: + return + layer = dialog.selected_layer() + field_name = dialog.selected_field() + applied = self.data_manager.init_stratigraphic_column_from_layer_field(layer, field_name) + if applied: + self.update_display() + else: + QMessageBox.warning( + self, + "Initialise from Layer Field", + f"Could not initialise the stratigraphic column. No values were found in " + f"field '{field_name}'.", + ) + def _guess_units_layer(self): """Attempt to auto-select the geological units layer and unit name field.""" layer_names = get_layer_names(self.unitsLayerComboBox) diff --git a/loopstructural/main/data_manager.py b/loopstructural/main/data_manager.py index 6ef0f6a..65ba6a1 100644 --- a/loopstructural/main/data_manager.py +++ b/loopstructural/main/data_manager.py @@ -409,6 +409,49 @@ def init_stratigraphic_column_from_basal_contacts(self): if self.stratigraphic_column_callback: self.stratigraphic_column_callback() + def init_stratigraphic_column_from_layer_field(self, layer, field_name): + """Initialise the stratigraphic column from the unique values of a + field on a polygon layer. + + Parameters + ---------- + layer : QgsVectorLayer + The layer to read unit names from. + field_name : str + Name of the field on ``layer`` holding the stratigraphic unit name. + + Returns + ------- + bool + True if any units were found and added, False otherwise. + """ + if layer is None or not field_name: + self.logger(message="No layer/field set, cannot initialise stratigraphic column.") + return False + + unique_names = [] + for feature in layer.getFeatures(): + value = feature[field_name] + if value is None or (hasattr(value, 'isNull') and value.isNull()): + continue + text = str(value).strip() + if text and text not in unique_names: + unique_names.append(text) + + if not unique_names: + self.logger( + message=f"No values found in field '{field_name}' on layer '{layer.name()}'." + ) + return False + + for unit_name in unique_names: + if not self._stratigraphic_column.get_unit_by_name(name=unit_name): + self._stratigraphic_column.add_unit(name=unit_name, colour=None) + self.update_stratigraphy() + if self.stratigraphic_column_callback: + self.stratigraphic_column_callback() + return True + def apply_stratigraphic_colours_to_layer(self, layer, field_name): """Push the stratigraphic column's unit colours onto a map layer. From bade472126b95c98b42fc05cc006d7857e96baa0 Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Wed, 26 Aug 2026 15:24:35 +0930 Subject: [PATCH 3/8] feat: colour basal contacts by stratigraphic unit Look up each unit's colour from the stratigraphic column and attach it to the extracted basal contacts, both from the widget and the processing algorithm, so contact lines are styled to match the units they belong to and are easier to tell apart on the map canvas. --- .../map2loop_tools/basal_contacts_widget.py | 10 +++++++- loopstructural/main/data_manager.py | 23 +++++++++++++++++++ loopstructural/main/m2l_api.py | 10 ++++++++ .../algorithms/extract_basal_contacts.py | 13 +++++++++-- 4 files changed, 53 insertions(+), 3 deletions(-) diff --git a/loopstructural/gui/map2loop_tools/basal_contacts_widget.py b/loopstructural/gui/map2loop_tools/basal_contacts_widget.py index 388810c..0957a20 100644 --- a/loopstructural/gui/map2loop_tools/basal_contacts_widget.py +++ b/loopstructural/gui/map2loop_tools/basal_contacts_widget.py @@ -259,7 +259,11 @@ def _on_extractor_finished(self, payload): addGeoDataFrameToproject(result['all_contacts'], "All contacts") contact_type = "all contacts and basal contacts" elif not all_contacts and result['basal_contacts'].empty is False: - addGeoDataFrameToproject(result['basal_contacts'], "Basal contacts") + basal_layer = addGeoDataFrameToproject(result['basal_contacts'], "Basal contacts") + if self.data_manager: + self.data_manager.apply_stratigraphic_colours_to_layer( + basal_layer, 'basal_unit' + ) else: QMessageBox.information( self, @@ -368,6 +372,9 @@ def _make_extract_contacts_target(self): stratigraphic_order = ( self.data_manager.get_stratigraphic_unit_names() if self.data_manager else [] ) + unit_colours = ( + self.data_manager.get_stratigraphic_unit_colours() if self.data_manager else {} + ) all_contacts = self.allContactsCheckBox.isChecked() target_crs = self.data_manager.get_model_crs() if self.data_manager else None if target_crs is None or not target_crs.isValid(): @@ -415,6 +422,7 @@ def _is_null_like(v): updater=progress_callback, debug_manager=self._debug, target_crs=target_crs, + unit_colours=unit_colours, ) return result, all_contacts diff --git a/loopstructural/main/data_manager.py b/loopstructural/main/data_manager.py index 65ba6a1..a21096e 100644 --- a/loopstructural/main/data_manager.py +++ b/loopstructural/main/data_manager.py @@ -17,6 +17,7 @@ QgsStyle, QgsSymbol, QgsVectorLayer, + QgsWkbTypes, ) from qgis.PyQt.QtGui import QColor @@ -481,6 +482,11 @@ def apply_stratigraphic_colours_to_layer(self, layer, field_name): if unit.element_type != StratigraphicColumnElementType.UNIT: continue symbol = QgsSymbol.defaultSymbol(layer.geometryType()) + if layer.geometryType() == QgsWkbTypes.LineGeometry: + # default line symbols are thin (~0.26mm) and hard to tell + # apart by colour at a glance -- widen so per-unit basal + # contact colours are easy to distinguish on the map canvas. + symbol.setWidth(0.8) qcolour = _colour_to_qcolor(unit.colour) if qcolour is not None: symbol.setColor(qcolour) @@ -580,6 +586,23 @@ def get_stratigraphic_unit_names(self): units.append(u.name) return units + def get_stratigraphic_unit_colours(self): + """Get a mapping of stratigraphic unit name to its assigned colour. + + Colours are normalised to hex strings (e.g. '#89cebc'). The + stratigraphic column stores unit colours in whatever form + LoopStructural gives them (often an RGB float triple in [0, 1] + rather than a hex string); writing that raw value into a layer + attribute breaks QGIS's memory provider, which rejects the whole + addFeatures() batch when a list is stored in a string field. + """ + colours = {} + for u in self._stratigraphic_column.order: + if u.element_type == StratigraphicColumnElementType.UNIT: + qcolour = _colour_to_qcolor(u.colour) + colours[u.name] = qcolour.name() if qcolour is not None else None + return colours + def add_to_stratigraphic_column(self, unit_data): """Add a unit or unconformity to the stratigraphic column.""" stratigraphic_element = None diff --git a/loopstructural/main/m2l_api.py b/loopstructural/main/m2l_api.py index 82aa0a8..725a55b 100644 --- a/loopstructural/main/m2l_api.py +++ b/loopstructural/main/m2l_api.py @@ -45,6 +45,7 @@ def extract_basal_contacts( updater=None, debug_manager=None, target_crs=None, + unit_colours=None, ): """Extract basal contacts from geological data. @@ -69,6 +70,10 @@ def extract_basal_contacts( the two layers line up spatially. If None, geology and faults keep their own source CRS, which silently produces wrong (often empty) results whenever the two layers were digitised in different CRSs. + unit_colours : dict, optional + Mapping of unit name to colour. When given, a 'colour' column is + added to the returned basal contacts, looked up by the 'basal_unit' + each contact was extracted for. Returns ------- @@ -160,6 +165,11 @@ def extract_basal_contacts( basal_contacts = basal_contacts[ ~basal_contacts['basal_unit'].astype(str).str.strip().isin(ignore_units) ].reset_index(drop=True) + if unit_colours and basal_contacts.empty is False and 'basal_unit' in basal_contacts.columns: + colours_by_name = {str(name).strip(): colour for name, colour in unit_colours.items()} + basal_contacts['colour'] = basal_contacts['basal_unit'].astype(str).str.strip().map( + colours_by_name + ) if all_contacts: return {'basal_contacts': basal_contacts, 'all_contacts': all_contacts_result} return {'basal_contacts': basal_contacts} diff --git a/loopstructural/processing/algorithms/extract_basal_contacts.py b/loopstructural/processing/algorithms/extract_basal_contacts.py index 1f72b5e..984295b 100644 --- a/loopstructural/processing/algorithms/extract_basal_contacts.py +++ b/loopstructural/processing/algorithms/extract_basal_contacts.py @@ -134,15 +134,23 @@ def processAlgorithm( strati_column = self.parameterAsSource(parameters, self.INPUT_STRATI_COLUMN, context) # ensure we always have a stratigraphic order list defined strati_order = [] + unit_colours = None ignore_units = self.parameterAsMatrix(parameters, self.INPUT_IGNORE_UNITS, context) if isinstance(strati_column, QgsProcessingParameterMapLayer): raise QgsProcessingException("Invalid stratigraphic column layer") elif strati_column is not None: - # extract unit names from strati_column + # extract unit names (and colours, if present) from strati_column field_name = "unit_name" - strati_order = [f[field_name] for f in strati_column.getFeatures()] + has_colour_field = "colour" in [f.name() for f in strati_column.fields()] + strati_order = [] + unit_colours = {} if has_colour_field else None + for f in strati_column.getFeatures(): + unit_name = f[field_name] + strati_order.append(unit_name) + if has_colour_field: + unit_colours[unit_name] = f["colour"] if not ignore_units or all( isinstance(unit, str) and not unit.strip() for unit in ignore_units @@ -164,6 +172,7 @@ def processAlgorithm( all_contacts=True, updater=feedback.pushInfo, target_crs=geology.crs() if geology else None, + unit_colours=unit_colours, ) basal_contacts = result['basal_contacts'] all_contacts = result['all_contacts'] From 30d971652848c999ae2a544188e96b202ef6764f Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Wed, 26 Aug 2026 15:24:39 +0930 Subject: [PATCH 4/8] chore: bump LoopStructural requirement to 1.8, drop numpy pin --- loopstructural/requirements.txt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/loopstructural/requirements.txt b/loopstructural/requirements.txt index 84bcbfc..498d635 100644 --- a/loopstructural/requirements.txt +++ b/loopstructural/requirements.txt @@ -1,9 +1,8 @@ pyvistaqt pyvista -LoopStructural~=1.7.0 +LoopStructural~=1.8.0 pyqtgraph loopsolver geopandas -numpy==1.26.4 map2loop~=3.3.1 -LoopDataConverter==0.3.0 \ No newline at end of file +LoopDataConverter==0.3.0 \ No newline at end of file From 888d7863eece4f45de7961d81f629d22ed7fe3c6 Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Wed, 26 Aug 2026 15:24:45 +0930 Subject: [PATCH 5/8] fix: stop auto-solving features when interpolator params change Changing nelements, regularisation, contact/orientation point weights, or fault parameters in a feature details panel used to schedule a debounced rebuild that silently re-solved the feature 500ms later. Solving can be expensive, so instead just flag the feature as not up to date (its tick turns red) and leave the actual solve to the user-initiated "Solve Model" action. --- .../feature_details_panel/_base.py | 44 ++++--------------- 1 file changed, 9 insertions(+), 35 deletions(-) diff --git a/loopstructural/gui/modelling/geological_model_tab/feature_details_panel/_base.py b/loopstructural/gui/modelling/geological_model_tab/feature_details_panel/_base.py index 3ece8ec..086b0d5 100644 --- a/loopstructural/gui/modelling/geological_model_tab/feature_details_panel/_base.py +++ b/loopstructural/gui/modelling/geological_model_tab/feature_details_panel/_base.py @@ -1,7 +1,7 @@ from LoopStructural.modelling.features import StructuralFrame from LoopStructural.utils import normal_vector_to_strike_and_dip from qgis.gui import QgsCollapsibleGroupBox, QgsMapLayerComboBox -from qgis.PyQt.QtCore import Qt, QTimer +from qgis.PyQt.QtCore import Qt from qgis.PyQt.QtWidgets import ( QComboBox, QDoubleSpinBox, @@ -98,13 +98,6 @@ def __init__(self, parent=None, *, feature=None, model_manager=None, data_manage # Set the main layout self.setLayout(mainLayout) - # Debounce timer for rebuilds: schedule a single rebuild after user stops - # interacting for a short interval to avoid repeated expensive builds. - self._rebuild_timer = QTimer(self) - self._rebuild_timer.setSingleShot(True) - self._rebuild_timer.setInterval(500) # milliseconds; adjust as desired - self._rebuild_timer.timeout.connect(self._perform_rebuild) - ## define interpolator parameters # Regularisation spin box self.regularisation_spin_box = QDoubleSpinBox() @@ -727,19 +720,18 @@ def _show_constraint_data_on_map(self): mem_layer.updateExtents() QgsProject.instance().addMapLayer(mem_layer) - def schedule_rebuild(self, delay_ms: int = 500): - """Schedule a debounced rebuild of the current feature. - - Multiple calls will reset the timer so only a single rebuild occurs - after user activity has settled. + def schedule_rebuild(self): + """Flag the current feature as not up to date, without solving it. Callers are expected to have already flagged the feature's builder as not up to date (either via `update_build_arguments`, which does this itself, or by calling `builder.set_not_up_to_date(...)` directly). - This notifies observers immediately -- on the GUI thread, since this - is only ever called from a widget's valueChanged handler -- so the - feature list's tick flips to "not built" right away instead of - lagging behind by `delay_ms` until the rebuild actually runs. + This just notifies observers -- on the GUI thread, since this is only + ever called from a widget's valueChanged handler -- so the feature + list's tick flips to "not built" right away. It deliberately does not + trigger an actual rebuild/solve: that stays a user-initiated action + (e.g. the "Solve Model" button), since re-solving can be expensive and + the user may still be adjusting other parameters. """ if self.model_manager is not None: try: @@ -750,22 +742,4 @@ def schedule_rebuild(self, delay_ms: int = 500): obs() except Exception: pass - try: - if self._rebuild_timer is None: - return - self._rebuild_timer.stop() - self._rebuild_timer.setInterval(delay_ms) - self._rebuild_timer.start() - except Exception: - logger.debug('Failed to schedule debounced rebuild', exc_info=True) - def _perform_rebuild(self): - """Perform the actual build operation when the debounce timer fires.""" - try: - if not hasattr(self, 'feature') or self.feature is None: - return - # StructuralFrame consists of three sub-features - self.model_manager.update_feature(self.feature.name) - - except Exception: - logger.debug('Debounced rebuild failed', exc_info=True) From c513b15469e8401c92372a46a124a8722daf7194 Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Wed, 26 Aug 2026 15:24:49 +0930 Subject: [PATCH 6/8] docs: add project CLAUDE.md instructions --- CLAUDE.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..509f5bb --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,3 @@ +# Project instructions + +- Do not add a "Co-Authored-By: Claude" line (or similar Claude attribution) to git commit messages in this repository. From a02dffbb87f780212b1ea8767b2203127bfabbe2 Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Wed, 26 Aug 2026 15:27:41 +0930 Subject: [PATCH 7/8] fix: guard against deleted QGIS layers and empty fault traces - to_dict() crashed with RuntimeError on save if a basal contacts, fault traces, or structural orientations layer had been deleted from the project while still referenced; now caught like the existing dem_layer guard, and dem_layer_name is initialised so a caught DEM error can't leave it undefined either. - AllSampler returned a columnless DataFrame when a fault trace layer had no sampled points, causing a KeyError on 'feature_id' downstream in update_fault_points. --- loopstructural/main/data_manager.py | 22 +++++++++++++++++++--- loopstructural/main/model_manager.py | 2 ++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/loopstructural/main/data_manager.py b/loopstructural/main/data_manager.py index a21096e..6733c13 100644 --- a/loopstructural/main/data_manager.py +++ b/loopstructural/main/data_manager.py @@ -1099,15 +1099,31 @@ def to_dict(self): # Replace layer objects with layer names if basal_contacts and 'layer' in basal_contacts and basal_contacts['layer'] is not None: - basal_contacts['layer'] = basal_contacts['layer'].name() + try: + basal_contacts['layer'] = basal_contacts['layer'].name() + except RuntimeError as e: + self.logger(message=f"Error getting basal contacts layer name: {e}", log_level=2) + basal_contacts['layer'] = None if fault_traces and 'layer' in fault_traces and fault_traces['layer'] is not None: - fault_traces['layer'] = fault_traces['layer'].name() + try: + fault_traces['layer'] = fault_traces['layer'].name() + except RuntimeError as e: + self.logger(message=f"Error getting fault traces layer name: {e}", log_level=2) + fault_traces['layer'] = None if ( structural_orientations and 'layer' in structural_orientations and structural_orientations['layer'] is not None ): - structural_orientations['layer'] = structural_orientations['layer'].name() + try: + structural_orientations['layer'] = structural_orientations['layer'].name() + except RuntimeError as e: + self.logger( + message=f"Error getting structural orientations layer name: {e}", + log_level=2, + ) + structural_orientations['layer'] = None + dem_layer_name = None if self.dem_layer is not None: try: dem_layer_name = self.dem_layer.name() diff --git a/loopstructural/main/model_manager.py b/loopstructural/main/model_manager.py index cbd6074..b3e324f 100644 --- a/loopstructural/main/model_manager.py +++ b/loopstructural/main/model_manager.py @@ -94,6 +94,8 @@ def __call__(self, line: gpd.GeoDataFrame, dem: Callable, use_z: bool) -> pd.Dat {'X': coords[0], 'Y': coords[1], 'Z': z, 'feature_id': feature_id, **attributes} ) feature_id += 1 + if not points: + return pd.DataFrame(points, columns=['X', 'Y', 'Z', 'feature_id']) df = pd.DataFrame(points) return df From d3a020f3643c005bcc56c8921924edf57c103ecc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:29:23 +0930 Subject: [PATCH 8/8] chore(main): release 0.3.0 (#114) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .release-please-manifest.json | 2 +- loopstructural/CHANGELOG.md | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 1b8f536..160b601 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,4 +1,4 @@ { ".": "0.0.1", - "loopstructural": "0.2.0" + "loopstructural": "0.3.0" } diff --git a/loopstructural/CHANGELOG.md b/loopstructural/CHANGELOG.md index 38774d2..fa34cd5 100644 --- a/loopstructural/CHANGELOG.md +++ b/loopstructural/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [0.3.0](https://github.com/Loop3D/plugin_loopstructural/compare/v0.2.0...v0.3.0) (2026-08-26) + + +### Features + +* colour basal contacts by stratigraphic unit ([bade472](https://github.com/Loop3D/plugin_loopstructural/commit/bade472126b95c98b42fc05cc006d7857e96baa0)) + + +### Bug Fixes + +* guard against deleted QGIS layers and empty fault traces ([a02dffb](https://github.com/Loop3D/plugin_loopstructural/commit/a02dffbb87f780212b1ea8767b2203127bfabbe2)) +* initialise stratigraphic column from the geological units layer ([57e4b37](https://github.com/Loop3D/plugin_loopstructural/commit/57e4b3798262ec2f4f9e39ae14f05b92909c72c2)) +* stop auto-solving features when interpolator params change ([888d786](https://github.com/Loop3D/plugin_loopstructural/commit/888d7863eece4f45de7961d81f629d22ed7fe3c6)) + ## [0.2.0](https://github.com/Loop3D/plugin_loopstructural/compare/v0.1.13...v0.2.0) (2026-08-24)