From a42203287b0443e93d4b68432c0fba98c740d50f Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Thu, 7 Aug 2025 09:24:06 +1000 Subject: [PATCH 01/26] fix: dip direction now works --- loopstructural/main/data_manager.py | 2 +- loopstructural/main/model_manager.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/loopstructural/main/data_manager.py b/loopstructural/main/data_manager.py index fdfac15..cdb98b1 100644 --- a/loopstructural/main/data_manager.py +++ b/loopstructural/main/data_manager.py @@ -345,7 +345,7 @@ def update_stratigraphy(self): unit_name_field=self._structural_orientations['unitname_field'], dip_direction=( True - if self._structural_orientations['orientation_type'] == "Dip Direction" + if self._structural_orientations['orientation_type'] == "Dip Direction/Dip" else False ), ) diff --git a/loopstructural/main/model_manager.py b/loopstructural/main/model_manager.py index e65161e..6d1e8da 100644 --- a/loopstructural/main/model_manager.py +++ b/loopstructural/main/model_manager.py @@ -2,6 +2,7 @@ from typing import Callable import geopandas as gpd +import numpy as np import pandas as pd from LoopStructural import GeologicalModel @@ -208,8 +209,7 @@ def update_structural_data( ['X', 'Y', 'Z', 'dip', 'strike', 'unit_name'] ] if dip_direction: - structural_orientations['dip'] = structural_orientations[dip_field] - structural_orientations['strike'] = structural_orientations[strike_field] - 90 + structural_orientations['strike'] = structural_orientations['strike'] - 90 for unit_name in structural_orientations['unit_name'].unique(): orientations = structural_orientations.loc[ structural_orientations['unit_name'] == unit_name, ['X', 'Y', 'Z', 'dip', 'strike'] @@ -249,7 +249,7 @@ def update_foliation_features(self): if 'orientations' in unit_data: orientations = unit_data['orientations'] if not orientations.empty: - orientations['val'] = val + orientations['val'] = np.nan orientations['feature_name'] = groupname data.append(orientations) From 2cf97e4f84700f00152a977a17f51bcd01f5fe87 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Thu, 7 Aug 2025 09:50:14 +1000 Subject: [PATCH 02/26] chore: remove old unused code --- .../gui/modelling/modelling_widget_back.py | 1050 ----------------- loopstructural/main/loopstructuralwrapper.py | 149 --- loopstructural/main/projectmanager.py | 13 - loopstructural/main/rasterFromModel.py | 66 -- 4 files changed, 1278 deletions(-) delete mode 100644 loopstructural/gui/modelling/modelling_widget_back.py delete mode 100644 loopstructural/main/loopstructuralwrapper.py delete mode 100644 loopstructural/main/projectmanager.py delete mode 100644 loopstructural/main/rasterFromModel.py diff --git a/loopstructural/gui/modelling/modelling_widget_back.py b/loopstructural/gui/modelling/modelling_widget_back.py deleted file mode 100644 index a0773fe..0000000 --- a/loopstructural/gui/modelling/modelling_widget_back.py +++ /dev/null @@ -1,1050 +0,0 @@ -import json -import os -import random - -import numpy as np -from LoopStructural.utils import random_hex_colour -from pyvistaqt import QtInteractor -from qgis.core import ( - QgsEllipse, - QgsFeature, - QgsFieldProxyModel, - QgsMapLayerProxyModel, - QgsPoint, - QgsProject, - QgsVectorLayer, -) -from qgis.PyQt import uic -from qgis.PyQt.QtGui import QColor -from qgis.PyQt.QtWidgets import ( - QCheckBox, - QColorDialog, - QComboBox, - QDoubleSpinBox, - QFileDialog, - QLabel, - QLineEdit, - QListWidgetItem, - QPushButton, - QWidget, -) - -from ...main import QgsProcessInputData -from ...main.callableToLayer import callableToLayer -from ...main.geometry.calculateLineAzimuth import calculateAverageAzimuth -from ...main.rasterFromModel import callableToRaster - -# from .feature_widget import FeatureWidget -# from LoopStructural.visualisation import Loop3DView -# from loopstructural.gui.modelling.stratigraphic_column import StratigraphicColumnWidget -__title__ = "LoopStructural" - - -class ModellingWidget(QWidget): - def __init__(self, parent: QWidget = None, mapCanvas=None, logger=None): - super().__init__(parent) - uic.loadUi(os.path.join(os.path.dirname(__file__), "modelling_widget.ui"), self) - self.project = QgsProject.instance() - self.mapCanvas = mapCanvas - self.rotationDoubleSpinBox.setValue(mapCanvas.rotation()) - self._set_layer_filters() - # self.unitNameField.setLayer(self.basalContactsLayer.currentLayer()) - self.logger = logger - self._basalContacts = None - self._units = {} - self._faults = {} - self._connectSignals() - self.view = None - self.model = None - self.outputPath = "" - self.activeFeature = None - self.groups = [] - self.plotter = QtInteractor(parent) - self.plotter.add_axes() - self.pyvista_layout.addWidget(self.plotter) - self.loadFromProject() - - def setLayerComboBoxFromProject(self, comboBox: QComboBox, layerKey: str): - layerName, flag = self.project.readEntry(__title__, layerKey) - if flag: - - layers = self.project.mapLayersByName(layerName) - print(layerName, layers) - if len(layers) == 0: - self.logger( - message=f"Layer {layerName} not found in project", - log_level=2, - push=True, - ) - return - comboBox.setLayer(None) - comboBox.setLayer(layers[0]) - - def setLayerFieldComboBoxFromProject( - self, comboBox: QComboBox, fieldKey: str, layer: QgsVectorLayer - ): - if layer is None: - self.logger(message="Layer is None", log_level=2, push=True) - return - fieldName, flag = self.project.readEntry(__title__, fieldKey) - if not flag or not fieldName: - self.logger(message=f"Field {fieldKey} not found in project", log_level=0, push=True) - return - field_names = [field.name() for field in layer.fields()] - if fieldName not in field_names: - self.logger( - message=f"Field {fieldName} not found in layer {layer.name()}", - log_level=0, - push=True, - ) - return - comboBox.setField(fieldName) - - def loadFromProject(self): - # Load settings from project - self.setLayerComboBoxFromProject(self.basalContactsLayer, "basal_contacts_layer") - self.setLayerFieldComboBoxFromProject( - self.unitNameField, "unitname_field", self.basalContactsLayer.currentLayer() - ) - self.setLayerComboBoxFromProject(self.structuralDataLayer, "structural_data_layer") - self.setLayerFieldComboBoxFromProject( - self.dipField, "dip_field", self.structuralDataLayer.currentLayer() - ) - self.setLayerFieldComboBoxFromProject( - self.orientationField, "orientation_field", self.structuralDataLayer.currentLayer() - ) - self.setLayerFieldComboBoxFromProject( - self.structuralDataUnitName, - "structuraldata_unitname_field", - self.structuralDataLayer.currentLayer(), - ) - self.setLayerComboBoxFromProject(self.faultTraceLayer, "fault_trace_layer") - self.setLayerFieldComboBoxFromProject( - self.faultNameField, "faultname_field", self.faultTraceLayer.currentLayer() - ) - self.setLayerFieldComboBoxFromProject( - self.faultDipField, "fault_dip_field", self.faultTraceLayer.currentLayer() - ) - self.setLayerFieldComboBoxFromProject( - self.faultDisplacementField, - "fault_displacement_field", - self.faultTraceLayer.currentLayer(), - ) - self.setLayerFieldComboBoxFromProject( - self.faultPitchField, "fault_pitch_field", self.faultTraceLayer.currentLayer() - ) - self.setLayerComboBoxFromProject(self.DtmLayer, "dtm_layer") - self.setLayerComboBoxFromProject(self.roiLayer, "roi_layer") - label, flag = self.project.readEntry(__title__, "orientation_label", "Strike") - if flag: - self.orientationType.setCurrentText(label) - resp, flag = self.project.readEntry(__title__, "units", "") - if flag: - self._units = json.loads(resp) - if len(self._units) > 0: - self._initialiseStratigraphicColumn() - resp, flag = self.project.readEntry(__title__, "faults", "") - if flag: - # try: - self._faults = json.loads(resp) - self.initFaultNetwork() - # except: - # self.logger(message="Faults not loaded", log_level=2, push=True) - - def _set_layer_filters(self): - # Set filters for the layer selection comboboxes - # basal contacts can be line or points - self.basalContactsLayer.setFilters( - QgsMapLayerProxyModel.LineLayer | QgsMapLayerProxyModel.PointLayer - ) - self.basalContactsLayer.setAllowEmptyLayer(True) - # Structural data can only be points - self.structuralDataLayer.setFilters(QgsMapLayerProxyModel.PointLayer) - self.basalContactsLayer.setAllowEmptyLayer(True) - # fault traces can be lines or points - self.faultTraceLayer.setFilters( - QgsMapLayerProxyModel.LineLayer | QgsMapLayerProxyModel.PointLayer - ) - self.faultTraceLayer.setAllowEmptyLayer(True) - # dtm can only be a raster - self.DtmLayer.setFilters(QgsMapLayerProxyModel.RasterLayer) - self.DtmLayer.setAllowEmptyLayer(True) - - # evaluate on model layer - self.evaluateModelOnLayerSelector.setFilters(QgsMapLayerProxyModel.PointLayer) - self.evaluateModelOnLayerSelector.setAllowEmptyLayer(True) - # evaluate on feature layer - self.evaluateFeatureLayerSelector.setFilters(QgsMapLayerProxyModel.PointLayer) - self.evaluateFeatureLayerSelector.setAllowEmptyLayer(True) - # orientation field can only be double or int - self.orientationField.setFilters(QgsFieldProxyModel.Numeric) - self.dipField.setFilters(QgsFieldProxyModel.Numeric) - # fault dip field can only be double or int - self.faultDipField.setFilters(QgsFieldProxyModel.Numeric) - # fault displacement field can only be double or int - self.faultDisplacementField.setFilters(QgsFieldProxyModel.Numeric) - - def saveLayerComboBoxState(self, comboBox: QComboBox, layerKey: str): - layer = comboBox.currentLayer() - if layer is not None: - self.project.writeEntry(__title__, layerKey, layer.name()) - - def saveLayerFieldComboBoxState(self, comboBox: QComboBox, fieldKey: str): - field = comboBox.currentField() - if field is not None: - self.project.writeEntry(__title__, fieldKey, field) - - def saveSettingToProject(self, key: str, value: str): - self.project.writeEntry(__title__, key, value) - - def _connectSignals(self): - self.basalContactsLayer.layerChanged.connect(self.onBasalContactsChanged) - self.structuralDataLayer.layerChanged.connect(self.onStructuralDataLayerChanged) - self.unitNameField.fieldChanged.connect(self.onUnitFieldChanged) - self.faultTraceLayer.layerChanged.connect(self.onFaultTraceLayerChanged) - self.faultNameField.fieldChanged.connect(self.onFaultFieldChanged) - self.faultDipField.fieldChanged.connect(self.onFaultFieldChanged) - self.faultDisplacementField.fieldChanged.connect(self.onFaultFieldChanged) - self.orientationType.currentIndexChanged.connect(self.onOrientationTypeChanged) - self.orientationField.fieldChanged.connect(self.onOrientationFieldChanged) - self.initModel.clicked.connect(self.onInitialiseModel) - self.rotationDoubleSpinBox.valueChanged.connect(self.onRotationChanged) - self.runModelButton.clicked.connect(self.onRunModel) - self.pathButton.clicked.connect(self.onClickPath) - self.saveButton.clicked.connect(self.onSaveModel) - self.path.textChanged.connect(self.onPathTextChanged) - self.faultSelection.currentIndexChanged.connect(self.onSelectedFaultChanged) - - self.basalContactsLayer.layerChanged.connect( - lambda: self.saveLayerComboBoxState(self.basalContactsLayer, 'basal_contacts_layer') - ) - self.unitNameField.fieldChanged.connect( - lambda: self.saveLayerFieldComboBoxState(self.unitNameField, 'unitname_field') - ) - self.structuralDataLayer.layerChanged.connect( - lambda: self.saveLayerComboBoxState(self.structuralDataLayer, 'structural_data_layer') - ) - self.orientationField.fieldChanged.connect( - lambda: self.saveLayerFieldComboBoxState(self.orientationField, 'orientation_field') - ) - - self.dipField.fieldChanged.connect( - lambda: self.saveLayerFieldComboBoxState(self.dipField, 'dip_field') - ) - self.roiLayer.layerChanged.connect( - lambda: self.saveLayerComboBoxState(self.roiLayer, 'roi_layer') - ) - self.faultTraceLayer.layerChanged.connect( - lambda: self.saveLayerComboBoxState(self.faultTraceLayer, 'fault_trace_layer') - ) - self.faultNameField.fieldChanged.connect( - lambda: self.saveLayerFieldComboBoxState(self.faultNameField, 'faultname_field') - ) - self.faultDipField.fieldChanged.connect( - lambda: self.saveLayerFieldComboBoxState(self.faultDipField, 'fault_dip_field') - ) - self.faultDisplacementField.fieldChanged.connect( - lambda: self.saveLayerFieldComboBoxState( - self.faultDisplacementField, 'fault_displacement_field' - ) - ) - self.faultPitchField.fieldChanged.connect( - lambda: self.saveLayerFieldComboBoxState(self.faultPitchField, 'fault_pitch_field') - ) - self.faultDipValue.valueChanged.connect( - lambda value: self.updateFaultProperty('fault_dip', value) - ) - - self.structuralDataUnitName.fieldChanged.connect( - lambda: self.saveLayerFieldComboBoxState( - self.structuralDataUnitName, 'structuraldata_unitname_field' - ) - ) - self.faultPitchValue.valueChanged.connect( - lambda value: self.updateFaultProperty('fault_pitch', value) - ) - self.faultDisplacementValue.valueChanged.connect( - lambda value: self.updateFaultProperty('displacement', value) - ) - self.faultActiveCheckBox.stateChanged.connect( - lambda value: self.updateFaultProperty('active', value) - ) - self.faultMajorAxisLength.valueChanged.connect( - lambda value: self.updateFaultProperty('major_axis', value) - ) - self.faultIntermediateAxisLength.valueChanged.connect( - lambda value: self.updateFaultProperty('intermediate_axis', value) - ) - self.faultMinorAxisLength.valueChanged.connect( - lambda value: self.updateFaultProperty('minor_axis', value) - ) - - self.orientationType.currentIndexChanged.connect( - lambda value: self.saveSettingToProject( - 'orientation_type', self.orientationLabel.text() - ) - ) - - # self.faultCentreX.valueChanged.connect(lambda value: self.updateFaultProperty('centre', value)) - # self.faultCentreY.valueChanged.connect(lambda value: self.updateFaultProperty('centre', value)) - # self.faultCentreZ.valueChanged.connect(lambda value: self.updateFaultProperty('centre', value)) - self.addFaultElipseToMap.clicked.connect(self.drawFaultElipse) - self.addModelContactsToProject.clicked.connect(self.onAddModelContactsToProject) - self.addFaultDisplacementsToProject.clicked.connect(self.onAddFaultDisplacmentsToProject) - self.evaluateModelOnLayer.clicked.connect(self.onEvaluateModelOnLayer) - self.evaluateFeatureOnLayer.clicked.connect(self.onEvaluateFeatureOnLayer) - self.addMappedLithologiesToProject.clicked.connect(self.onAddModelledLithologiesToProject) - self.addFaultTracesToProject.clicked.connect(self.onAddFaultTracesToProject) - self.addScalarFieldToProject.clicked.connect(self.onAddScalarFieldToProject) - # self.saveThicknessOrderButton.clicked.connect(self.saveThicknessOrder) - self.addUnitButton.clicked.connect(self.addUnitToStratigraphicColumn) - self.addBlockModelToPyvistaButton.clicked.connect(self.addBlockModelToPyvista) - self.clearPyvistaButton.clicked.connect(self.clearPyvista) - self.addSurfacesToPyvistaButton.clicked.connect(self.addModelSurfacesToPyvista) - self.addDataToPyvistaButton.clicked.connect(self.addDataToPyvista) - QgsProject.instance().readProject.connect(self.loadFromProject) - - def onModelListItemClicked(self, feature): - self.activeFeature = self.model[feature.text()] - self.numberOfElementsSpinBox.setValue( - self.activeFeature.builder.build_arguments['nelements'] - ) - self.numberOfElementsSpinBox.valueChanged.connect( - lambda nelements: self.activeFeature.builder.update_build_arguments( - {'nelements': nelements} - ) - ) - self.regularisationSpin.setValue( - self.activeFeature.builder.build_arguments['regularisation'] - ) - self.regularisationSpin.valueChanged.connect( - lambda regularisation: self.activeFeature.builder.update_builupdate_build_argumentsd_args( - {'regularisation': regularisation} - ) - ) - self.npwSpin.setValue(self.activeFeature.builder.build_arguments['npw']) - self.npwSpin.valueChanged.connect( - lambda npw: self.activeFeature.builder.update_build_arguments({'npw': npw}) - ) - self.cpwSpin.setValue(self.activeFeature.builder.build_arguments['cpw']) - self.cpwSpin.valueChanged.connect( - lambda cpw: self.activeFeature.builder.update_build_arguments({'cpw': cpw}) - ) - # self.updateButton.clicked.connect(lambda : feature.builder.update()) - - def onInitialiseModel(self): - - columnmap = { - 'unitname': self.unitNameField.currentField(), - 'faultname': self.faultNameField.currentField(), - 'dip': self.dipField.currentField(), - 'orientation': self.orientationField.currentField(), - 'structure_unitname': self.structuralDataUnitName.currentField(), - # 'pitch': self.faultPitchField.currentField() - } - faultNetwork = np.zeros((len(self._faults), len(self._faults))) - for i in range(len(self._faults)): - for j in range(len(self._faults)): - if i != j: - item = self.faultNetworkTable.cellWidget(i, j) - if item.currentText() == 'Abuts': - faultNetwork[i, j] = 1 - elif item.currentText() == 'Cuts': - faultNetwork[i, j] = -1 - faultStratigraphy = np.zeros((len(self._faults), len(self.groups))) - for i in range(len(self._faults)): - for j in range(len(self.groups)): - item = self.faultStratigraphyTable.cellWidget(i, j) - faultStratigraphy[i, j] = item.isChecked() - - processor = QgsProcessInputData( - basal_contacts=self.basalContactsLayer.currentLayer(), - groups=self.groups, - fault_trace=self.faultTraceLayer.currentLayer(), - fault_properties=self._faults, - structural_data=self.structuralDataLayer.currentLayer(), - dtm=self.DtmLayer.currentLayer(), - columnmap=columnmap, - roi=self.roiLayer.currentLayer(), - top=self.heightSpinBox.value(), - bottom=self.depthSpinBox.value(), - dip_direction=self.orientationType.currentIndex() == 1, - rotation=self.rotationDoubleSpinBox.value(), - faultNetwork=faultNetwork, - faultStratigraphy=faultStratigraphy, - faultlist=list(self._faults.keys()), - ) - self.processor = processor - self.model = processor.get_model() - self.logger(message="Model initialised", log_level=0, push=True) - self.modelList.clear() - for feature in self.model.features: - if feature.name[0] == '_': - continue - item = QListWidgetItem() - item.setText(feature.name) - item.setBackground( - QColor(random.randint(0, 255), random.randint(0, 255), random.randint(0, 255)) - ) - - self.modelList.addItem(item) - self.modelList.itemClicked.connect(self.onModelListItemClicked) - self.plotter.add_mesh(self.model.bounding_box.vtk().outline()) - - def onOrientationTypeChanged(self, index): - if index == 0: - self.orientationLabel.setText("Strike") - else: - self.orientationLabel.setText("Dip Direction") - - def onRotationChanged(self, rotation): - self.mapCanvas.setRotation(rotation) - - def onOrientationFieldChanged(self, field): - pass - - def onStructuralDataLayerChanged(self, layer): - self.orientationField.setLayer(layer) - self.dipField.setLayer(layer) - self.structuralDataUnitName.setLayer(layer) - # self.saveLayersToProject() - # self.dipField.setValidator(QDoubleValidator(0.0, 360.0, 2)) - # self.orientationField.setValidator(QDoubleValidator(0.0, 360.0, 2)) - - def onRunModel(self): - try: - self.model.update(progressbar=False) - self._model_updated() - self.logger(message="Model run", log_level=0, push=True) - - except Exception as e: - self.logger( - message=str(e), - log_level=2, - push=True, - ) - - def _model_updated(self): - self.addScalarFieldComboBox.clear() - self.evaluateFeatureFeatureSelector.clear() - for feature in self.model.features: - ## make sure that private features are not added to the list - if feature.name[0] != "_": - self.addScalarFieldComboBox.addItem(feature.name) - self.evaluateFeatureFeatureSelector.addItem(feature.name) - self.addScalarFieldComboBox.setCurrentIndex(0) - self.evaluateFeatureFeatureSelector.setCurrentIndex(0) - - def onAddModelContactsToProject(self): - pass - - def onAddFaultDisplacmentsToProject(self): - pass - - def addBlockModelToPyvista(self): - if self.model is None: - self.logger(message="Model not initialised", log_level=2, push=True) - return - self.plotter.add_mesh(self.model.get_block_model()[0].vtk(), show_scalar_bar=False) - - def addModelSurfacesToPyvista(self): - if self.model is None: - self.logger(message="Model not initialised", log_level=2, push=True) - return - surfaces = self.model.get_stratigraphic_surfaces() - for surface in surfaces: - self.plotter.add_mesh(surface.vtk(), show_scalar_bar=False, color=surface.colour) - fault_surfaces = self.model.get_fault_surfaces() - for surface in fault_surfaces: - self.plotter.add_mesh(surface.vtk(), show_scalar_bar=False, color='black') - - def addDataToPyvista(self): - if self.model is None: - self.logger(message="Model not initialised", log_level=2, push=True) - return - for f in self.model.features: - if f.name[0] != "_": - for d in f.get_data(): - self.plotter.add_mesh(d.vtk(), show_scalar_bar=False) - - def clearPyvista(self): - self.plotter.clear() - if self.model is not None: - self.plotter.add_mesh(self.model.bounding_box.vtk().outline()) - - def onEvaluateModelOnLayer(self): - layer = self.evaluateModelOnLayerSelector.currentLayer() - - callableToLayer( - lambda xyz: self.model.evaluate_model(xyz), - layer, - self.DtmLayer.currentLayer(), - 'unit_id', - ) - - def onEvaluateFeatureOnLayer(self): - feature_name = self.evaluateFeatureFeatureSelector.currentText() - layer = self.evaluateFeatureLayerSelector.currentLayer() - callableToLayer( - lambda xyz: self.model.evaluate_feature_value(feature_name, xyz), - layer, - self.DtmLayer.currentLayer(), - feature_name, - ) - pass - - def onAddModelledLithologiesToProject(self): - if self.model is None: - self.logger(message="Model not initialised", log_level=2, push=True) - return - bounding_box = self.model.bounding_box - feature_layer = callableToRaster( - lambda xyz: self.model.evaluate_model(xyz), - dtm=self.DtmLayer.currentLayer(), - bounding_box=bounding_box, - crs=QgsProject.instance().crs(), - layer_name='modelled_lithologies', - ) - if feature_layer.isValid(): - QgsProject.instance().addMapLayer(feature_layer) - else: - self.logger(message="Failed to add scalar field to project", log_level=2, push=True) - pass - - def onAddFaultTracesToProject(self): - pass - - def onAddScalarFieldToProject(self): - feature_name = self.addScalarFieldComboBox.currentText() - if self.model is None: - self.logger(message="Model not initialised", log_level=2, push=True) - return - bounding_box = self.model.bounding_box - feature_layer = callableToRaster( - lambda xyz: self.model.evaluate_feature_value(feature_name, xyz), - dtm=self.DtmLayer.currentLayer(), - bounding_box=bounding_box, - crs=QgsProject.instance().crs(), - layer_name=f'{feature_name}_scalar_field', - ) - if feature_layer.isValid(): - QgsProject.instance().addMapLayer(feature_layer) - else: - self.logger(message="Failed to add scalar field to project", log_level=2, push=True) - - def onBasalContactsChanged(self, layer): - self.unitNameField.setLayer(layer) - # self.saveLayersToProject() - - def onFaultTraceLayerChanged(self, layer): - self.faultNameField.setLayer(layer) - self.faultDipField.setLayer(layer) - self.faultDisplacementField.setLayer(layer) - self._faults = {} # reset faults - self.onSelectedFaultChanged(-1) - self.initFaultSelector() - self.initFaultNetwork() - # self.saveLayersToProject() - - def onUnitFieldChanged(self, field): - if len(self._units) == 0: - - unique_values = set() - attributes = {} - layer = self.unitNameField.layer() - if layer: - fields = {} - fields['unitname'] = layer.fields().indexFromName(field) - if '_ls_th' in [field.name() for field in layer.fields()]: - fields['thickness'] = layer.fields().indexFromName('_ls_th') - if '_ls_or' in [field.name() for field in layer.fields()]: - fields['order'] = layer.fields().indexFromName('_ls_or') - if '_ls_col' in [field.name() for field in layer.fields()]: - fields['colour'] = layer.fields().indexFromName('_ls_col') - field_index = layer.fields().indexFromName(field) - - for feature in layer.getFeatures(): - unique_values.add(str(feature[field_index])) - attributes[str(feature[field_index])] = {} - for k in fields: - if feature[fields[k]] is not None: - attributes[str(feature[field_index])][k] = feature[fields[k]] - - colours = random_hex_colour(n=len(unique_values)) - self._units = dict( - zip( - list(unique_values), - [ - { - 'thickness': ( - attributes[u]['thickness'] if 'thickness' in attributes[u] else 10.0 - ), - 'order': int(attributes[u]['order']) if 'order' in attributes[u] else i, - 'name': u, - 'colour': ( - str(attributes[u]['colour']) - if 'colour' in attributes[u] - else colours[i] - ), - 'contact': ( - str(attributes[u]['contact']) - if 'contact' in attributes[u] - else 'Conformable' - ), - } - for i, u in enumerate(unique_values) - ], - ) - ) - self._initialiseStratigraphicColumn() - - def initFaultSelector(self): - self.faultSelection.clear() - self.resetFaultField() - if self._faults: - faults = list(self._faults.keys()) - self.faultSelection.addItems(faults) - - def initFaultNetwork(self): - # faultNetwork - self.faultNetworkTable.clear() - self.faultNetworkTable.setRowCount(0) - self.faultNetworkTable.setColumnCount(0) - self.faultStratigraphyTable.clear() - self.faultStratigraphyTable.setRowCount(0) - self.faultStratigraphyTable.setColumnCount(0) - if not self._faults: - return - - faults = list(self._faults.keys()) - self.faultNetworkTable.setRowCount(len(faults)) - self.faultNetworkTable.setColumnCount(len(faults)) - - # Set headers - self.faultNetworkTable.setHorizontalHeaderLabels(faults) - self.faultNetworkTable.setVerticalHeaderLabels(faults) - - # Fill table with empty items - for i in range(len(faults)): - for j in range(len(faults)): - if i == j: - flag = QLabel() - flag.setText('') - else: - flag = QComboBox() - flag.addItem('') - flag.addItem('Abuts') - flag.addItem('Cuts') - # item = QTableWidgetItem(flag) - self.faultNetworkTable.setCellWidget(i, j, flag) - - # Make cells more visible - self.faultNetworkTable.setShowGrid(True) - self.faultNetworkTable.resizeColumnsToContents() - self.faultNetworkTable.resizeRowsToContents() - - self.faultStratigraphyTable.clear() - - faults = list(self._faults.keys()) - groups = [g['name'] for g in self.groups] - self.faultStratigraphyTable.setRowCount(len(faults)) - self.faultStratigraphyTable.setColumnCount(len(groups)) - - # Set headers - self.faultStratigraphyTable.setHorizontalHeaderLabels(groups) - self.faultStratigraphyTable.setVerticalHeaderLabels(faults) - - # Fill table with empty items - for j in range(len(groups)): - for i in range(len(faults)): - flag = QCheckBox() - flag.setChecked(True) - self.faultStratigraphyTable.setCellWidget(i, j, flag) - - # Make cells more visible - self.faultStratigraphyTable.setShowGrid(True) - self.faultStratigraphyTable.resizeColumnsToContents() - self.faultStratigraphyTable.resizeRowsToContents() - - def onFaultFieldChanged(self, field): - name_field = self.faultNameField.currentField() - dip_field = self.faultDipField.currentField() - displacement_field = self.faultDisplacementField.currentField() - layer = self.faultNameField.layer() - - if name_field and layer: - self._faults = {} - for feature in layer.getFeatures(): - self._faults[str(feature[name_field])] = { - 'fault_dip': feature.attributeMap().get(dip_field, 90), - 'displacement': feature.attributeMap().get( - displacement_field, 0.1 * feature.geometry().length() - ), - 'fault_centre': { - 'x': feature.geometry().centroid().asPoint().x(), - 'y': feature.geometry().centroid().asPoint().y(), - }, - 'major_axis': feature.geometry().length(), - 'intermediate_axis': feature.geometry().length(), - 'minor_axis': feature.geometry().length() / 3, - 'active': True, - "azimuth": calculateAverageAzimuth(feature.geometry()), - "fault_pitch": feature.attributeMap().get('pitch', 90), - "crs": layer.crs().authid(), - } - self.initFaultSelector() - self.initFaultNetwork() - # self.saveLayersToProject() - - def saveLayersToProject(self): - if self.basalContactsLayer.currentLayer() is not None: - self.project.writeEntry( - __title__, "basal_contacts_layer", self.basalContactsLayer.currentLayer().name() - ) - if self.structuralDataLayer.currentLayer() is not None: - self.project.writeEntry( - __title__, "structural_data_layer", self.structuralDataLayer.currentLayer().name() - ) - if self.faultTraceLayer.currentLayer() is not None: - self.project.writeEntry( - __title__, "fault_trace_layer", self.faultTraceLayer.currentLayer().name() - ) - if self.DtmLayer.currentLayer() is not None: - self.project.writeEntry(__title__, "dtm_layer", self.DtmLayer.currentLayer().name()) - if self.roiLayer.currentLayer() is not None: - self.project.writeEntry(__title__, "roi_layer", self.roiLayer.currentLayer().name()) - if self.unitNameField.currentField() is not None: - self.project.writeEntry(__title__, "unitname_field", self.unitNameField.currentField()) - if self.dipField.currentField() is not None: - self.project.writeEntry(__title__, "dip_field", self.dipField.currentField()) - if self.orientationField.currentField() is not None: - self.project.writeEntry( - __title__, "orientation_field", self.orientationField.currentField() - ) - if self.faultNameField.currentField() is not None: - self.project.writeEntry( - __title__, "faultname_field", self.faultNameField.currentField() - ) - if self.faultDipField.currentField() is not None: - self.project.writeEntry(__title__, "fault_dip_field", self.faultDipField.currentField()) - if self.faultDisplacementField.currentField() is not None: - self.project.writeEntry( - __title__, "fault_displacement_field", self.faultDisplacementField.currentField() - ) - if self.faultPitchField.currentField() is not None: - self.project.writeEntry( - __title__, "fault_pitch_field", self.faultPitchField.currentField() - ) - if self._units: - self.project.writeEntry(__title__, "units", json.dumps(self._units)) - if self._faults: - self.project.writeEntry(__title__, "faults", json.dumps(self._faults)) - - def onSelectedFaultChanged(self, index): - if index >= 0: - fault = self.faultSelection.currentText() - self.faultDipValue.setValue(self._faults[fault]['fault_dip']) - self.faultPitchValue.setValue(self._faults[fault]['fault_pitch']) - self.faultDisplacementValue.setValue(self._faults[fault]['displacement']) - self.faultActiveCheckBox.setChecked(self._faults[fault]['active']) - self.faultMajorAxisLength.setValue(self._faults[fault]['major_axis']) - self.faultIntermediateAxisLength.setValue(self._faults[fault]['intermediate_axis']) - self.faultMinorAxisLength.setValue(self._faults[fault]['minor_axis']) - self.faultCentreX.setValue(self._faults[fault]['fault_centre']['x']) - self.faultCentreY.setValue(self._faults[fault]['fault_centre']['y']) - # self.faultCentreZ.setValue(self._faults[fault]['centre'].z()) - self._onActiveFaultChanged(self._faults[fault]['active']) - - def saveFaultsToProject(self): - if self._faults: - self.project.writeEntry(__title__, "faults", json.dumps(self._faults)) - - def saveUnitsToProject(self): - if self._units: - self.project.writeEntry(__title__, "units", json.dumps(self._units)) - - def resetFaultField(self): - self.faultDipValue.setValue(0) - self.faultPitchValue.setValue(0) - self.faultDisplacementValue.setValue(0) - self.faultActiveCheckBox.setChecked(0) - self.faultMajorAxisLength.setValue(0) - self.faultIntermediateAxisLength.setValue(0) - self.faultMinorAxisLength.setValue(0) - self.faultCentreX.setValue(0) - self.faultCentreY.setValue(0) - # self.faultCentreZ.setValue(self._faults[fault]['centre'].z()) - self._onActiveFaultChanged(False) - - def _onActiveFaultChanged(self, value): - self.faultDipValue.setEnabled(value) - self.faultPitchValue.setEnabled(value) - self.faultDisplacementValue.setEnabled(value) - self.faultMajorAxisLength.setEnabled(value) - self.faultIntermediateAxisLength.setEnabled(value) - self.faultMinorAxisLength.setEnabled(value) - self.faultCentreX.setEnabled(value) - self.faultCentreY.setEnabled(value) - # self.faultCentreZ.setEnabled(value) - - def updateFaultProperty(self, prop, value): - fault = self.faultSelection.currentText() - if fault not in self._faults: - return - self._faults[fault][prop] = value - if prop == 'active': - self._onActiveFaultChanged(value) - self.saveFaultsToProject() - - def drawFaultElipse(self): - fault = self.faultSelection.currentText() - if fault: - centre = self._faults[fault]['centre'] - major_axis = self._faults[fault]['major_axis'] - - minor_axis = self._faults[fault]['minor_axis'] - azimuth = self._faults[fault].get('azimuth', 0) - crs = self._faults[fault].get('crs', 'EPSG:4326') - # Create an ellipsoid centered at the fault center - ellipsoid = QgsEllipse( - QgsPoint(centre.x(), centre.y()), major_axis / 2, minor_axis / 2, azimuth - ) - - # Add the ellipsoid to the map canvas - ellipsoid_layer = QgsVectorLayer(f"Polygon?crs={crs}", f"{fault}: Ellipsoid", "memory") - ellipsoid_layer_provider = ellipsoid_layer.dataProvider() - ellipsoid_feature = QgsFeature() - ellipsoid_feature.setGeometry(ellipsoid.toPolygon()) - ellipsoid_layer_provider.addFeatures([ellipsoid_feature]) - - QgsProject.instance().addMapLayer(ellipsoid_layer) - - def _getSortedStratigraphicColumn(self): - - return sorted(self._units.items(), key=lambda x: x[1]['order']) - - def _initialiseStratigraphicColumn(self): - while self.stratigraphicColumnContainer.count(): - child = self.stratigraphicColumnContainer.takeAt(0) - if child.widget(): - child.widget().deleteLater() - - def create_lambda(i, direction): - return lambda: self.onOrderChanged(i, i + direction) - - def create_color_picker(unit): - def pick_color(): - color = QColorDialog.getColor() - if color.isValid(): - self._units[unit]['colour'] = color.name() - self._initialiseStratigraphicColumn() - - return pick_color - - for i, (unit, value) in enumerate(self._getSortedStratigraphicColumn()): - # Add stretch factor to first column - - label = QLineEdit(unit) - label.editingFinished.connect( - lambda unit=unit, label=label: self.stratigraphicColumnUnitNameChanged( - unit, label.text() - ) - ) - spin_box = QDoubleSpinBox(maximum=100000, minimum=0) - spin_box.setValue(value['thickness']) - order = QLabel() - order.setText(str(value['order'])) - up = QPushButton("↑") - down = QPushButton("↓") - color_picker = QPushButton("Pick Colour") - # Set background color for the row - background_color = value.get('colour', "#ffffff") - label.setStyleSheet(f"background-color: {background_color};") - spin_box.setStyleSheet(f"background-color: {background_color};") - order.setStyleSheet(f"background-color: {background_color};") - up.setStyleSheet(f"background-color: {background_color};") - down.setStyleSheet(f"background-color: {background_color};") - color_picker.setStyleSheet(f"background-color: {background_color};") - self.stratigraphicColumnContainer.addWidget(label, i, 0) - self.stratigraphicColumnContainer.addWidget(spin_box, i, 1) - self.stratigraphicColumnContainer.addWidget(up, i, 2) - self.stratigraphicColumnContainer.addWidget(down, i, 3) - self.stratigraphicColumnContainer.addWidget(color_picker, i, 4) - unconformity = QComboBox() - unconformity.addItem('Conformable') - unconformity.addItem('Erode') - unconformity.addItem('Onlap') - if 'contact' in value: - unconformity.setCurrentText(value['contact']) - - unconformity.currentTextChanged.connect( - lambda text, unit=unit: self.stratigraphicColumnChanged(text, unit) - ) - - self.stratigraphicColumnContainer.addWidget(unconformity, i, 5) - up.clicked.connect(create_lambda(i, -1)) - down.clicked.connect(create_lambda(i, 1)) - color_picker.clicked.connect(create_color_picker(unit)) - spin_box.valueChanged.connect( - lambda value, unit=unit: self.onThicknessChanged(unit, value) - ) - remove_button = QPushButton("Remove") - remove_button.setStyleSheet(f"background-color: {background_color};") - remove_button.clicked.connect( - lambda value, unit=unit: self.stratigraphicColumnRemoveClicked(unit) - ) - self.stratigraphicColumnContainer.addWidget(remove_button, i, 6) - - self.updateGroups() - - def stratigraphicColumnChanged(self, text, unit): - self._units[unit]['contact'] = text - self.updateGroups() - self.saveUnitsToProject() - - def stratigraphicColumnRemoveClicked(self, unit): - if unit in self._units: - del self._units[unit] - self._initialiseStratigraphicColumn() - self.saveUnitsToProject() - - def addUnitToStratigraphicColumn(self): - name = 'New Unit' - if len(self._units) > 0: - name = f'New Unit {len(self._units) + 1}' - colour = random_hex_colour(n=1)[0] - self._units[name] = { - 'thickness': 10.0, - 'order': len(self._units), - 'name': name, - 'colour': colour, - 'contact': 'Conformable', - } - self._initialiseStratigraphicColumn() - self.saveUnitsToProject() - - def stratigraphicColumnUnitNameChanged(self, unit, name): - - old_name = unit - if unit == name: - return - if unit not in self._units: - return - if name in self._units and name != unit: - self.logger(message="Cannot rename, unit name already exists", log_level=2, push=True) - return - unit = self._units[unit] - unit['name'] = name - self._units[name] = unit - del self._units[old_name] - self._initialiseStratigraphicColumn() - self.saveUnitsToProject() - - def updateGroups(self): - columns = self._getSortedStratigraphicColumn() - - self.groups = [] - group = [] - ii = 0 - for _i, (_unit, value) in enumerate(columns): - group.append(value) - if value['contact'] != 'Conformable': - self.groups.append({'name': f'group_{ii}', 'units': group}) - ii += 1 - group = [] - - self.groups.append({'name': f'group_{ii}', 'units': group}) - self.initFaultNetwork() - - def onOrderChanged(self, old_index, new_index): - if new_index < 0 or new_index >= len(self._units): - return - units = dict(self._units) # update a copy - for unit, value in self._units.items(): - if value['order'] == old_index: - units[unit]['order'] = new_index - elif value['order'] == new_index: - units[unit]['order'] = old_index - self._units = units # set to copy - self._initialiseStratigraphicColumn() - self.saveUnitsToProject() - - def onThicknessChanged(self, unit, value): - self._units[unit]['thickness'] = value - self.saveUnitsToProject() - - def onSaveModel(self): - if self.model is None: - self.logger(message="Cannot save model, model not initialised", log_level=2, push=True) - return - try: - - fileFormat = self.fileFormatCombo.currentText() - path = self.path.text() # - name = self.modelNameLineEdit.text() - if fileFormat == 'python': - fileFormat = 'pkl' - self.model.to_file(os.path.join(path, name + "." + fileFormat)) - with open(os.path.join(path, name + "." + 'py'), 'w') as f: - f.write("from loopstructural import GeologicalModel\n") - f.write(f"model = GeologicalModel.from_file('{name + '.' + fileFormat}')\n") - return - - self.model.save( - filename=os.path.join(path, name + "." + fileFormat), - block_model=self.blockModelCheckBox.isChecked(), - stratigraphic_surfaces=self.stratigraphicSurfacesCheckBox.isChecked(), - fault_surfaces=self.faultSurfacesCheckBox.isChecked(), - stratigraphic_data=self.stratigraphicDataCheckBox.isChecked(), - fault_data=self.faultDataCheckBox.isChecked(), - ) - self.logger(message=f"Model saved to {path}", log_level=0, push=True) - except Exception as e: - self.logger( - message=str(e), - log_level=2, - push=True, - ) - - def saveThicknessOrder(self): - pass - # if self._units is None: - # self.logger(message="No units found", log_level=2, push=True) - # return - # self.project.writeEntry( - # "LoopStructural", "units", json.dumps(self._units) - # ) - # layer = self.basalContactsLayer.currentLayer() - # layer.startEditing() - # field_names = ["_ls_th", "_ls_or", "_ls_col"] - # field_types = [QVariant.Double, QVariant.Int, QVariant.String] - # for field_name, field_type in zip(field_names, field_types): - - # if field_name not in [field.name() for field in layer.fields()]: - # layer.dataProvider().addAttributes([QgsField(field_name, field_type)]) - # layer.updateFields() - # for unit, value in self._units.items(): - # for feature in layer.getFeatures(): - # if feature.attributeMap().get(self.unitNameField.currentField()) == unit: - # feature[field_names[0]] = value['thickness'] - # feature[field_names[1]] = value['order'] - # feature[field_names[2]] = value['colour'] - # layer.updateFeature(feature) - # layer.commitChanges() - # layer.updateFields() - # self.logger( - # message=f"Thickness, colour and order saved to {layer.name()}", log_level=0, push=True - # ) - - def onPathTextChanged(self, text): - self.outputPath = text - - def onClickPath(self): - self.outputPath = QFileDialog.getExistingDirectory(None, "Select output path for model") - - self.path.setText(self.outputPath) - # if self.path: - # if os.path.exists(self.gridDirectory): - # self.output_directory = os.path.split( - # self.dlg.lineEdit_gridOutputDir.text() - # )[-1] diff --git a/loopstructural/main/loopstructuralwrapper.py b/loopstructural/main/loopstructuralwrapper.py deleted file mode 100644 index 75b742a..0000000 --- a/loopstructural/main/loopstructuralwrapper.py +++ /dev/null @@ -1,149 +0,0 @@ -from typing import List - -import numpy as np -import pandas as pd -from LoopStructural import GeologicalModel -from LoopStructural.modelling.input import ProcessInputData - -from .vectorLayerWrapper import qgsLayerToDataFrame - - -class QgsProcessInputData(ProcessInputData): - def __init__( - self, - basal_contacts, - groups: List[dict], - fault_trace, - fault_properties, - structural_data, - dtm, - columnmap: dict, - roi, - top: float, - bottom: float, - dip_direction: bool, - rotation, - faultNetwork: np.ndarray = None, - faultStratigraphy: np.ndarray = None, - faultlist: List[str] = None, - ): - i, j = np.where(faultNetwork == 1) - edges = [] - edgeproperties = [] - for ii, jj in zip(i, j): - edges.append((faultlist[jj], faultlist[ii])) - edgeproperties.append({'type': 'abuts'}) - - contact_locations = qgsLayerToDataFrame(basal_contacts, dtm) - fault_data = qgsLayerToDataFrame(fault_trace, dtm) - contact_orientations = qgsLayerToDataFrame(structural_data, dtm) - thicknesses = {} - stratigraphic_order = [] - colours = {} - for g in groups: - stratigraphic_order.append((g['name'], [u['name'] for u in g['units']])) - for u in g['units']: - thicknesses[u['name']] = u['thickness'] - colours[u['name']] = u['colour'] - # for key in stratigraphic_column.keys(): - # thicknesses[key] = stratigraphic_column[key]['thickness'] - # stratigraphic_order = [None] * len(thicknesses) - # for key in stratigraphic_column.keys(): - # stratigraphic_order[stratigraphic_column[key]['order']] = key - # print(stratigraphic_column) - # stratigraphic_order = [('sg', stratigraphic_order)] - roi_rectangle = roi.extent() - minx = roi_rectangle.xMinimum() - maxx = roi_rectangle.xMaximum() - miny = roi_rectangle.yMinimum() - maxy = roi_rectangle.yMaximum() - # transformer = EuclideanTransformation(minx,miny,rotation) - - origin = (minx, miny, bottom) - maximum = (maxx, maxy, top) - if contact_locations is not None and columnmap['unitname'] in contact_locations: - contact_locations = contact_locations.rename(columns={columnmap['unitname']: 'name'})[ - ['X', 'Y', 'Z', 'name'] - ] - else: - contact_locations = None - if fault_data is not None and columnmap['faultname'] in fault_data: - fault_data = fault_data.rename(columns={columnmap['faultname']: 'fault_name'})[ - ['X', 'Y', 'Z', 'fault_name'] - ] - if np.all(fault_data['fault_name'].isna()): - raise ValueError('Fault column name is all None. Check the column name') - else: - fault_data = None - - if ( - contact_orientations is not None - and columnmap['structure_unitname'] in contact_orientations - and columnmap['dip'] in contact_orientations - and columnmap['orientation'] in contact_orientations - ): - contact_orientations = contact_orientations.rename( - columns={ - columnmap['structure_unitname']: 'name', - columnmap['dip']: 'dip', - columnmap['orientation']: 'strike', - } - )[['X', 'Y', 'Z', 'dip', 'strike', 'name']] - contact_orientations['dip'] = contact_orientations['dip'].astype(float) - contact_orientations['strike'] = contact_orientations['strike'].astype(float) - if np.all(contact_orientations['name'].isna()): - raise ValueError('Unit column name is all None. Check the column name') - if np.all(contact_orientations['dip'].isna()): - raise ValueError('Dip column name is all None. Check the column name') - if np.all(contact_orientations['strike'].isna()): - raise ValueError('Strike column name is all None. Check the column name') - if dip_direction: - contact_orientations['strike'] = contact_orientations['strike'] - 90 - else: - contact_orientations = None - faults = [] - for fault_name in fault_properties.keys(): - fault = fault_properties[fault_name] - if fault['active']: - faults.append( - { - 'fault_name': fault_name, - 'fault_dip': fault['fault_dip'], - 'displacement': fault['displacement'], - 'major_axis': fault['major_axis'], - 'intermediate_axis': fault['intermediate_axis'], - 'minor_axis': fault['minor_axis'], - 'centreEasting': fault['fault_centre']['x'], - 'centreNorthing': fault['fault_centre']['y'], - 'centreElevation': 0, # if fault['centre']fault['centre'].z(), - 'fault_pitch': fault['fault_pitch'], - # 'active': fault['active'], - # 'azimuth': fault['azimuth'], - # 'crs': fault['crs'], - } - ) - fault_properties = None - if len(faults) > 0: - - fault_properties = pd.DataFrame(faults) - fault_properties['fault_name'] = fault_properties['fault_name'].astype(str) - fault_properties = fault_properties.set_index('fault_name') - fault_data['fault_name'] = fault_data['fault_name'].astype(str) - super().__init__( - contacts=contact_locations, - stratigraphic_order=stratigraphic_order, - thicknesses=thicknesses, - fault_locations=fault_data, - contact_orientations=contact_orientations, - fault_orientations=None, - fault_properties=fault_properties, - origin=origin, - maximum=maximum, - fault_edges=edges if len(edges) > 0 else None, - fault_edge_properties=edgeproperties if len(edgeproperties) > 0 else None, - colours=colours, - # fault_edges=[(fault,None) for fault in fault_data['fault_name'].unique()], - ) - - def get_model(self): - return GeologicalModel.from_processor(self) diff --git a/loopstructural/main/projectmanager.py b/loopstructural/main/projectmanager.py deleted file mode 100644 index 955003a..0000000 --- a/loopstructural/main/projectmanager.py +++ /dev/null @@ -1,13 +0,0 @@ -class ProjectManager: - def __init__(self, project, data_manager=None): - self.project = project - self.data_manager = data_manager - - def load_from_project(self): - pass - - def save_to_project(self): - pass - - def clear_project(self): - pass diff --git a/loopstructural/main/rasterFromModel.py b/loopstructural/main/rasterFromModel.py deleted file mode 100644 index 1731885..0000000 --- a/loopstructural/main/rasterFromModel.py +++ /dev/null @@ -1,66 +0,0 @@ -import os -import tempfile -import uuid - -import numpy as np -from osgeo import gdal, osr -from qgis.core import QgsRasterLayer - -from .geometry.mapGrid import createGrid - - -def callableToRaster(callable, dtm, bounding_box, crs, layer_name): - """Convert a feature to a raster and store it in QGIS as a temporary layer. - - :param feature: The object that has an `evaluate_value` method for computing values. - :param dtm: Digital terrain model (if needed for processing). - :param bounding_box: Object with `origin`, `maximum`, `step_vector`, and `nsteps`. - :param crs: Coordinate reference system (QGIS CRS object). - """ - # Create grid of points based on bounding_box - points = createGrid(bounding_box, dtm) # This function should return a list of coordinates - - # Evaluate feature at each point - values = callable(points) - # Reshape values into a 2D NumPy array (Fortran order) - values = np.array(values).reshape((bounding_box.nsteps[1], bounding_box.nsteps[0]), order='F') - - # Define raster metadata - rows, cols = values.shape - values = np.flipud(values) # Flip array vertically to match raster orientation - geotransform = [ - bounding_box.global_origin[0], # x_min - bounding_box.step_vector[0], # Pixel width (step in X) - 0, # No rotation (affine transform) - bounding_box.global_origin[1] - + bounding_box.step_vector[1] * bounding_box.nsteps[1], # y_min (origin at bottom-left) - 0, # No rotation - -bounding_box.step_vector[1], # Pixel height (negative so origin is bottom-left) - ] - - # Create an in-memory raster using `/vsimem/` - temp_dir = tempfile.gettempdir() - temp_raster_path = os.path.join(temp_dir, f'temp_raster_{uuid.uuid4().hex}.tif') - driver = gdal.GetDriverByName("GTiff") - ds = driver.Create(temp_raster_path, cols, rows, 1, gdal.GDT_Float32) - - # Set georeferencing - ds.SetGeoTransform(geotransform) - srs = osr.SpatialReference() - srs.ImportFromWkt(crs.toWkt()) # Convert QGIS CRS to GDAL WKT - ds.SetProjection(srs.ExportToWkt()) - - # Write data to raster band - band = ds.GetRasterBand(1) - band.WriteArray(values) - band.SetNoDataValue(-9999) # Optional: Set NoData value - band.FlushCache() - - # Close dataset - ds = None - - # Load raster into QGIS as a temporary layer - temp_layer = QgsRasterLayer(temp_raster_path, layer_name, "gdal") - temp_layer.setCustomProperty("temporary", True) - - return temp_layer From d775a0ebc6fbe8664f41d681a0c8562d291aa88b Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Thu, 7 Aug 2025 09:50:42 +1000 Subject: [PATCH 03/26] chore: remove strat column in favour of using loopstructural implementation --- loopstructural/main/stratigraphic_column.py | 304 -------------------- 1 file changed, 304 deletions(-) delete mode 100644 loopstructural/main/stratigraphic_column.py diff --git a/loopstructural/main/stratigraphic_column.py b/loopstructural/main/stratigraphic_column.py deleted file mode 100644 index 903c785..0000000 --- a/loopstructural/main/stratigraphic_column.py +++ /dev/null @@ -1,304 +0,0 @@ -import enum -from typing import Dict - - -class UnconformityType(enum.Enum): - """ - An enumeration for different types of unconformities in a stratigraphic column. - """ - - ERODE = 'erode' - ONLAP = 'onlap' - - -class StratigraphicColumnElementType(enum.Enum): - """ - An enumeration for different types of elements in a stratigraphic column. - """ - - UNIT = 'unit' - UNCONFORMITY = 'unconformity' - - -class StratigraphicColumnElement: - """ - A class to represent an element in a stratigraphic column, which can be a unit or a topological object - for example unconformity. - """ - - def __init__(self, uuid=None): - """ - Initializes the StratigraphicColumnElement with a name and an optional description. - """ - if uuid is None: - import uuid as uuid_module - - uuid = str(uuid_module.uuid4()) - self.uuid = uuid - - -class StratigraphicUnit(StratigraphicColumnElement): - """ - A class to represent a stratigraphic unit, which is a distinct layer of rock with specific characteristics. - """ - - def __init__(self, *, uuid=None, name=None, colour=None, thickness=None): - """ - Initializes the StratigraphicUnit with a name and an optional description. - """ - super().__init__(uuid) - self.name = name - self.colour = colour - self.thickness = thickness - self.element_type = StratigraphicColumnElementType.UNIT - - def to_dict(self): - """ - Converts the stratigraphic unit to a dictionary representation. - """ - return {"name": self.name, "colour": self.colour, "thickness": self.thickness} - - @classmethod - def from_dict(cls, data): - """ - Creates a StratigraphicUnit from a dictionary representation. - """ - if not isinstance(data, dict): - raise TypeError("Data must be a dictionary") - name = data.get("name") - colour = data.get("colour") - thickness = data.get("thickness", None) - uuid = data.get("uuid", None) - return cls(uuid=uuid, name=name, colour=colour, thickness=thickness) - - def __str__(self): - """ - Returns a string representation of the stratigraphic unit. - """ - return ( - f"StratigraphicUnit(name={self.name}, colour={self.colour}, thickness={self.thickness})" - ) - - -class StratigraphicUnconformity(StratigraphicColumnElement): - """ - A class to represent a stratigraphic unconformity, which is a surface of discontinuity in the stratigraphic record. - """ - - def __init__( - self, *, uuid=None, name=None, unconformity_type: UnconformityType = UnconformityType.ERODE - ): - """ - Initializes the StratigraphicUnconformity with a name and an optional description. - """ - super().__init__(uuid) - self.name = name - if unconformity_type not in [UnconformityType.ERODE, UnconformityType.ONLAP]: - raise ValueError("Invalid unconformity type") - self.unconformity_type = unconformity_type - self.element_type = StratigraphicColumnElementType.UNCONFORMITY - - def to_dict(self): - """ - Converts the stratigraphic unconformity to a dictionary representation. - """ - return { - "uuid": self.uuid, - "name": self.name, - "unconformity_type": self.unconformity_type.value, - } - - def __str__(self): - """ - Returns a string representation of the stratigraphic unconformity. - """ - return ( - f"StratigraphicUnconformity(name={self.name}, " - f"unconformity_type={self.unconformity_type.value})" - ) - - @classmethod - def from_dict(cls, data): - """ - Creates a StratigraphicUnconformity from a dictionary representation. - """ - if not isinstance(data, dict): - raise TypeError("Data must be a dictionary") - name = data.get("name") - unconformity_type = UnconformityType( - data.get("unconformity_type", UnconformityType.ERODE.value) - ) - uuid = data.get("uuid", None) - return cls(uuid=uuid, name=name, unconformity_type=unconformity_type) - - -class StratigraphicColumn: - """ - A class to represent a stratigraphic column, which is a vertical section of the Earth's crust - showing the sequence of rock layers and their relationships. - """ - - def __init__(self): - """ - Initializes the StratigraphicColumn with a name and a list of layers. - """ - self.order = [] - - def add_unit(self, name, colour, thickness=None): - unit = StratigraphicUnit(name=name, colour=colour, thickness=thickness) - - self.order.append(unit) - return unit - - def remove_unit(self, uuid): - """ - Removes a unit or unconformity from the stratigraphic column by its uuid. - """ - for i, element in enumerate(self.order): - if element.uuid == uuid: - del self.order[i] - return True - return False - - def add_unconformity(self, name, unconformity_type=UnconformityType.ERODE): - unconformity = StratigraphicUnconformity( - uuid=None, name=name, unconformity_type=unconformity_type - ) - - self.order.append(unconformity) - return unconformity - - def get_element_by_index(self, index): - """ - Retrieves an element by its index from the stratigraphic column. - """ - if index < 0 or index >= len(self.order): - raise IndexError("Index out of range") - return self.order[index] - - def get_unit_by_name(self, name): - """ - Retrieves a unit by its name from the stratigraphic column. - """ - for unit in self.order: - if isinstance(unit, StratigraphicUnit) and unit.name == name: - return unit - - return None - - def add_element(self, element): - """ - Adds a StratigraphicColumnElement to the stratigraphic column. - """ - if isinstance(element, StratigraphicColumnElement): - self.order.append(element) - else: - raise TypeError("Element must be an instance of StratigraphicColumnElement") - - def get_elements(self): - """ - Returns a list of all elements in the stratigraphic column. - """ - return self.order - - def get_groups(self): - groups = [] - group = [] - for e in self.order: - if isinstance(e, StratigraphicUnit): - group.append(e) - else: - if group: - groups.append(group) - group = [] - if group: - groups.append(group) - return groups - - def get_unitname_groups(self): - groups = [] - group = [] - for e in self.order: - if isinstance(e, StratigraphicUnit): - group.append(e.name) - else: - if group: - groups.append(group) - group = [] - if group: - groups.append(group) - return groups - - def __getitem__(self, uuid): - """ - Retrieves an element by its uuid from the stratigraphic column. - """ - for element in self.order: - if element.uuid == uuid: - return element - raise KeyError(f"No element found with uuid: {uuid}") - - def update_order(self, new_order): - """ - Updates the order of elements in the stratigraphic column based on a new order list. - """ - if not isinstance(new_order, list): - raise TypeError("New order must be a list") - self.order = [ - self.__getitem__(uuid) for uuid in new_order if self.__getitem__(uuid) is not None - ] - - def update_element(self, unit_data: Dict): - """ - Updates an existing element in the stratigraphic column with new data. - :param unit_data: A dictionary containing the updated data for the element. - """ - if not isinstance(unit_data, dict): - raise TypeError("unit_data must be a dictionary") - element = self.__getitem__(unit_data['uuid']) - if isinstance(element, StratigraphicUnit): - element.name = unit_data.get('name', element.name) - element.colour = unit_data.get('colour', element.colour) - element.thickness = unit_data.get('thickness', element.thickness) - elif isinstance(element, StratigraphicUnconformity): - element.name = unit_data.get('name', element.name) - element.unconformity_type = UnconformityType( - unit_data.get('unconformity_type', element.unconformity_type.value) - ) - - def clear(self): - """ - Clears the stratigraphic column, removing all elements. - """ - self.order.clear() - - def __str__(self): - """ - Returns a string representation of the stratigraphic column, listing all elements. - """ - return "\n".join([f"{i+1}. {element}" for i, element in enumerate(self.order)]) - - def to_dict(self): - """ - Converts the stratigraphic column to a dictionary representation. - """ - return { - "elements": [element.to_dict() for element in self.order], - } - - @classmethod - def from_dict(cls, data): - """ - Creates a StratigraphicColumn from a dictionary representation. - """ - if not isinstance(data, dict): - raise TypeError("Data must be a dictionary") - column = cls() - elements_data = data.get("elements", []) - for element_data in elements_data: - if "unconformity_type" in element_data: - element = StratigraphicUnconformity.from_dict(element_data) - else: - element = StratigraphicUnit.from_dict(element_data) - column.add_element(element) - return column From 367c252697fe80d8de388ba9446e8d1d94874e51 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Thu, 7 Aug 2025 14:52:51 +1000 Subject: [PATCH 04/26] fix: add AddFaultDialog and AddFoldFrameDialog for fault and fold frame creation --- .../gui/modelling/add_fault_dialog.py | 33 ++++ .../gui/modelling/add_fault_dialog.ui | 114 ++++++++++++++ .../gui/modelling/add_fold_frame_dialog.py | 149 ++++++++++++++++++ .../gui/modelling/add_fold_frame_dialog.ui | 64 ++++++++ .../gui/modelling/geological_model_tab.py | 51 ++++-- 5 files changed, 402 insertions(+), 9 deletions(-) create mode 100644 loopstructural/gui/modelling/add_fault_dialog.py create mode 100644 loopstructural/gui/modelling/add_fault_dialog.ui create mode 100644 loopstructural/gui/modelling/add_fold_frame_dialog.py create mode 100644 loopstructural/gui/modelling/add_fold_frame_dialog.ui diff --git a/loopstructural/gui/modelling/add_fault_dialog.py b/loopstructural/gui/modelling/add_fault_dialog.py new file mode 100644 index 0000000..4b0fc30 --- /dev/null +++ b/loopstructural/gui/modelling/add_fault_dialog.py @@ -0,0 +1,33 @@ +import os + +from PyQt5.QtWidgets import QDialog, QDialogButtonBox, QVBoxLayout +from PyQt5.uic import loadUi + + +class AddFaultDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + ui_path = os.path.join(os.path.dirname(__file__), 'add_fault_dialog.ui') + loadUi(ui_path, self) + self.setWindowTitle('Add Fault Feature') + # You can access widgets by their objectName from the .ui file + # Example: self.strike_input, self.dip_input, etc. + + def get_fault_data(self): + return { + 'strike': self.strike_input.value(), + 'dip': self.dip_input.value(), + 'centre': ( + self.centre_x_input.value(), + self.centre_y_input.value(), + self.centre_z_input.value(), + ), + 'ellipsoid_extents': ( + self.extent_x_input.value(), + self.extent_y_input.value(), + self.extent_z_input.value(), + ), + 'displacement': self.displacement_input.value(), + 'pitch': self.pitch_input.value(), + 'name': self.name_input.text(), + } diff --git a/loopstructural/gui/modelling/add_fault_dialog.ui b/loopstructural/gui/modelling/add_fault_dialog.ui new file mode 100644 index 0000000..7c6d8e3 --- /dev/null +++ b/loopstructural/gui/modelling/add_fault_dialog.ui @@ -0,0 +1,114 @@ + + + AddFaultDialog + + + Add Fault Feature + + + + + + + + Strike (°) + + + + + + + + + + Dip (°) + + + + + + + + + + Centre (X, Y, Z) + + + + + + + + + + + + + + + + + + + + Ellipsoid Extents (X, Y, Z) + + + + + + + + + + + + + + + + + + + + Displacement + + + + + + + + + + Pitch (°) + + + + + + + + + + Name + + + + + + + + + + + + QDialogButtonBox::Ok|QDialogButtonBox::Cancel + + + + + + + + diff --git a/loopstructural/gui/modelling/add_fold_frame_dialog.py b/loopstructural/gui/modelling/add_fold_frame_dialog.py new file mode 100644 index 0000000..41e3d16 --- /dev/null +++ b/loopstructural/gui/modelling/add_fold_frame_dialog.py @@ -0,0 +1,149 @@ +import os + +from PyQt5.QtWidgets import ( + QComboBox, + QDialog, + QDialogButtonBox, + QHBoxLayout, + QLabel, + QPushButton, + QVBoxLayout, +) +from PyQt5.uic import loadUi + + +class AddFoldFrameDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + ui_path = os.path.join(os.path.dirname(__file__), 'add_fold_frame_dialog.ui') + loadUi(ui_path, self) + self.setWindowTitle('Add Fold Frame') + # Setup table columns + self.items_table.setColumnCount(3) + self.items_table.setHorizontalHeaderLabels(["Type", "Select Layer", "Delete"]) + # Connect add button + self.add_item_button.clicked.connect(self.add_item_row) + + def add_item_row(self): + row = self.items_table.rowCount() + self.items_table.insertRow(row) + # Type dropdown + type_combo = self._create_type_combo() + self.items_table.setCellWidget(row, 0, type_combo) + # Select Layer button + select_layer_btn = self._create_select_layer_button(row, type_combo) + self.items_table.setCellWidget(row, 1, select_layer_btn) + # Delete button + del_btn = self._create_delete_button(row) + self.items_table.setCellWidget(row, 2, del_btn) + + def _create_select_layer_button(self, row, type_combo): + btn = QPushButton("Select Layer") + + def open_layer_dialog(): + dialog = QDialog(self) + dialog.setWindowTitle("Select Layer") + layout = QVBoxLayout(dialog) + # Layer combo box (replace with QgsLayerComboBox in QGIS environment) + layer_label = QLabel("Layer:") + layout.addWidget(layer_label) + layer_combo = QComboBox(dialog) + # TODO: Populate with actual layer names from QGIS + layer_combo.addItems(["Layer1", "Layer2", "Layer3"]) + layout.addWidget(layer_combo) + + strike_field_combo = None + dip_field_combo = None + field_layout = None + + def update_fields(): + nonlocal strike_field_combo, dip_field_combo, field_layout + if type_combo.currentText() == "Orientation": + if not field_layout: + field_layout = QHBoxLayout() + strike_field_combo = QComboBox(dialog) + dip_field_combo = QComboBox(dialog) + # TODO: Populate with actual field names from selected layer + strike_field_combo.addItems(["strike1", "strike2"]) + dip_field_combo.addItems(["dip1", "dip2"]) + field_layout.addWidget(QLabel("Strike:")) + field_layout.addWidget(strike_field_combo) + field_layout.addWidget(QLabel("Dip:")) + field_layout.addWidget(dip_field_combo) + layout.addLayout(field_layout) + else: + if field_layout: + # Remove field combo boxes if not orientation + for i in reversed(range(field_layout.count())): + widget = field_layout.itemAt(i).widget() + if widget: + widget.setParent(None) + layout.removeItem(field_layout) + strike_field_combo = None + dip_field_combo = None + field_layout = None + + type_combo.currentIndexChanged.connect(update_fields) + update_fields() + + button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + layout.addWidget(button_box) + button_box.accepted.connect(dialog.accept) + button_box.rejected.connect(dialog.reject) + + if dialog.exec_() == QDialog.Accepted: + selected_layer = layer_combo.currentText() + btn.setText(selected_layer) + # Optionally, store selected layer/fields in table for later retrieval + btn.selected_layer = selected_layer + if strike_field_combo and dip_field_combo: + btn.strike_field = strike_field_combo.currentText() + btn.dip_field = dip_field_combo.currentText() + else: + btn.strike_field = None + btn.dip_field = None + + btn.clicked.connect(open_layer_dialog) + return btn + + def _create_type_combo(self): + from PyQt5.QtWidgets import QComboBox + + combo = QComboBox() + combo.addItems(["Form Line", "Orientation"]) + return combo + + def _create_delete_button(self, row): + from PyQt5.QtWidgets import QPushButton + + btn = QPushButton("Delete") + btn.clicked.connect(lambda: self.delete_item_row(row)) + return btn + + def delete_item_row(self, row): + self.items_table.removeRow(row) + + def get_fold_frame_data(self): + # Collect feature name and all items from the table + feature_name = ( + self.feature_name_input.text() if hasattr(self, 'feature_name_input') else None + ) + items = [] + for row in range(self.items_table.rowCount()): + type_widget = self.items_table.cellWidget(row, 0) + select_layer_btn = self.items_table.cellWidget(row, 1) + item_type = type_widget.currentText() if type_widget else None + layer = getattr(select_layer_btn, 'selected_layer', None) if select_layer_btn else None + strike_field = ( + getattr(select_layer_btn, 'strike_field', None) if select_layer_btn else None + ) + dip_field = getattr(select_layer_btn, 'dip_field', None) if select_layer_btn else None + items.append( + { + 'type': item_type, + 'layer': layer, + 'strike_field': strike_field, + 'dip_field': dip_field, + } + ) + return {'feature_name': feature_name, 'items': items} diff --git a/loopstructural/gui/modelling/add_fold_frame_dialog.ui b/loopstructural/gui/modelling/add_fold_frame_dialog.ui new file mode 100644 index 0000000..7c2f767 --- /dev/null +++ b/loopstructural/gui/modelling/add_fold_frame_dialog.ui @@ -0,0 +1,64 @@ + + + AddFoldFrameDialog + + + Add Fold Frame + + + + + + Feature Name + + + + + + + + + + Items + + + + + + + 2 + + + 0 + + + + Type + + + + + Delete + + + + + + + + + + + + + + + + QDialogButtonBox::Ok|QDialogButtonBox::Cancel + + + + + + + + diff --git a/loopstructural/gui/modelling/geological_model_tab.py b/loopstructural/gui/modelling/geological_model_tab.py index c7121d4..84722b4 100644 --- a/loopstructural/gui/modelling/geological_model_tab.py +++ b/loopstructural/gui/modelling/geological_model_tab.py @@ -1,5 +1,6 @@ from PyQt5.QtCore import Qt from PyQt5.QtWidgets import ( + QMenu, QPushButton, QSplitter, QTreeWidget, @@ -8,6 +9,9 @@ QWidget, ) +# Import the AddFaultDialog +from loopstructural.gui.modelling.add_fault_dialog import AddFaultDialog +from loopstructural.gui.modelling.add_fold_frame_dialog import AddFoldFrameDialog from loopstructural.gui.modelling.feature_details_panel import ( FaultFeatureDetailsPanel, FoliationFeatureDetailsPanel, @@ -28,10 +32,21 @@ def __init__(self, parent=None, *, model_manager=None): mainLayout.addWidget(splitter) # Feature list panel + self.featureList = QTreeWidget() self.featureList.setHeaderLabel("Geological Features") - splitter.addWidget(self.featureList) - + side_panel = QVBoxLayout() + side_panel.addWidget(self.featureList) + add_feature_button = QPushButton("Add Feature") + + add_feature_button.setContextMenuPolicy(Qt.CustomContextMenu) + add_feature_button.customContextMenuRequested.connect(self.show_add_feature_menu) + add_feature_button.clicked.connect(self.show_add_feature_menu) + side_panel.addWidget(add_feature_button) + side_panel_widget = QWidget() + side_panel_widget.setLayout(side_panel) + splitter.addWidget(side_panel_widget) + # self.splitter.addWidget(QWidget()) # Placeholder for the feature list panel # Feature details panel self.featureDetailsPanel = QWidget() splitter.addWidget(self.featureDetailsPanel) @@ -52,13 +67,31 @@ def __init__(self, parent=None, *, model_manager=None): # Connect feature selection to update details panel self.featureList.itemClicked.connect(self.on_feature_selected) - def save_changes(self): - # Logic to save changes - pass - - def reset_parameters(self): - # Logic to reset parameters - pass + def show_add_feature_menu(self, *args): + menu = QMenu(self) + add_fault = menu.addAction("Add Fault") + add_fold_frame = menu.addAction("Add Fold Frame") + buttonPosition = self.sender().mapToGlobal(self.sender().rect().bottomLeft()) + action = menu.exec_(buttonPosition) + + if action == add_fault: + self.open_add_fault_dialog() + elif action == add_fold_frame: + self.open_add_fold_frame_dialog() + + def open_add_fault_dialog(self): + dialog = AddFaultDialog(self) + if dialog.exec_() == dialog.Accepted: + fault_data = dialog.get_fault_data() + # TODO: Add logic to use fault_data to add the fault to the model + print("Fault data:", fault_data) + + def open_add_fold_frame_dialog(self): + dialog = AddFoldFrameDialog(self) + if dialog.exec_() == dialog.Accepted: + fold_data = dialog.get_fold_data() + # TODO: Add logic to use fold_data to add the fold to the model + print("Fold data:", fold_data) def initialize_model(self): self.model_manager.update_model() From 0c03c26aa8723902156b1c034427862d0c806141 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Thu, 21 Aug 2025 14:36:23 +1000 Subject: [PATCH 05/26] add a dialog window for adding fold frame/folds to the model. Currently only strike/dip data connected --- .../gui/modelling/add_fold_frame_dialog.py | 156 +++++++++++------- .../gui/modelling/add_fold_frame_dialog.ui | 124 ++++++++------ 2 files changed, 166 insertions(+), 114 deletions(-) diff --git a/loopstructural/gui/modelling/add_fold_frame_dialog.py b/loopstructural/gui/modelling/add_fold_frame_dialog.py index 41e3d16..cfae7f2 100644 --- a/loopstructural/gui/modelling/add_fold_frame_dialog.py +++ b/loopstructural/gui/modelling/add_fold_frame_dialog.py @@ -10,11 +10,15 @@ QVBoxLayout, ) from PyQt5.uic import loadUi +from qgis.core import QgsMapLayerProxyModel +from qgis.gui import QgsFieldComboBox, QgsMapLayerComboBox class AddFoldFrameDialog(QDialog): - def __init__(self, parent=None): + def __init__(self, parent=None, *, data_manager=None, model_manager=None): super().__init__(parent) + self.data_manager = data_manager + self.model_manager = model_manager ui_path = os.path.join(os.path.dirname(__file__), 'add_fold_frame_dialog.ui') loadUi(ui_path, self) self.setWindowTitle('Add Fold Frame') @@ -23,6 +27,37 @@ def __init__(self, parent=None): self.items_table.setHorizontalHeaderLabels(["Type", "Select Layer", "Delete"]) # Connect add button self.add_item_button.clicked.connect(self.add_item_row) + self.buttonBox.accepted.connect(self.add_fold_frame) + self.buttonBox.rejected.connect(self.reject) + + self.modelFeatureComboBox.addItems( + [f.name for f in self.model_manager.features() if not f.name.startswith("__")] + ) + self.name_valid = False + self.name_error = "" + + def validate_name_field(text): + """Validate the feature name field.""" + valid = True + if not text.strip(): + valid = False + self.name_error = "Feature name cannot be empty." + if text.strip() in [f.name for f in self.model_manager.features()]: + valid = False + self.name_error = "Feature name must be unique." + + if not valid: + self.name_valid = False + self.feature_name_input.setStyleSheet("border: 1px solid red;") + else: + self.feature_name_input.setStyleSheet("") + self.name_valid = True + + self.feature_name_input.textChanged.connect(validate_name_field) + + @property + def name(self): + return self.feature_name_input.text().strip() def add_item_row(self): row = self.items_table.rowCount() @@ -37,63 +72,73 @@ def add_item_row(self): del_btn = self._create_delete_button(row) self.items_table.setCellWidget(row, 2, del_btn) + def add_layer_to_data_manager(self, layer_data: dict): + """Add selected layer data to the data manager.""" + if not isinstance(layer_data, dict): + raise ValueError("layer_data must be a dictionary.") + if self.data_manager: + self.data_manager.update_fold_frame_data(self.name, layer_data) + else: + raise RuntimeError("Data manager is not set.") + def _create_select_layer_button(self, row, type_combo): btn = QPushButton("Select Layer") def open_layer_dialog(): + if not self.name_valid: + self.data_manager.logger(f'Name is invalid: {self.name_error}', log_level=2) + return dialog = QDialog(self) dialog.setWindowTitle("Select Layer") layout = QVBoxLayout(dialog) # Layer combo box (replace with QgsLayerComboBox in QGIS environment) layer_label = QLabel("Layer:") layout.addWidget(layer_label) - layer_combo = QComboBox(dialog) - # TODO: Populate with actual layer names from QGIS - layer_combo.addItems(["Layer1", "Layer2", "Layer3"]) + layer_combo = QgsMapLayerComboBox() + layer_combo.setFilters( + QgsMapLayerProxyModel.LineLayer | QgsMapLayerProxyModel.PointLayer + ) layout.addWidget(layer_combo) - strike_field_combo = None dip_field_combo = None - field_layout = None - - def update_fields(): - nonlocal strike_field_combo, dip_field_combo, field_layout - if type_combo.currentText() == "Orientation": - if not field_layout: - field_layout = QHBoxLayout() - strike_field_combo = QComboBox(dialog) - dip_field_combo = QComboBox(dialog) - # TODO: Populate with actual field names from selected layer - strike_field_combo.addItems(["strike1", "strike2"]) - dip_field_combo.addItems(["dip1", "dip2"]) - field_layout.addWidget(QLabel("Strike:")) - field_layout.addWidget(strike_field_combo) - field_layout.addWidget(QLabel("Dip:")) - field_layout.addWidget(dip_field_combo) - layout.addLayout(field_layout) - else: - if field_layout: - # Remove field combo boxes if not orientation - for i in reversed(range(field_layout.count())): - widget = field_layout.itemAt(i).widget() - if widget: - widget.setParent(None) - layout.removeItem(field_layout) - strike_field_combo = None - dip_field_combo = None - field_layout = None - - type_combo.currentIndexChanged.connect(update_fields) - update_fields() - + if type_combo.currentText() == "Orientation": + field_layout = QHBoxLayout() + strike_field_label = QLabel("Strike:") + dip_field_label = QLabel("Dip:") + + strike_field_combo = QgsFieldComboBox() + dip_field_combo = QgsFieldComboBox() + field_layout.addWidget(strike_field_label) + field_layout.addWidget(strike_field_combo) + field_layout.addWidget(dip_field_label) + field_layout.addWidget(dip_field_combo) + layer_combo.layerChanged.connect(strike_field_combo.setLayer) + + layer_combo.layerChanged.connect(dip_field_combo.setLayer) + layout.addLayout(field_layout) button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) layout.addWidget(button_box) button_box.accepted.connect(dialog.accept) button_box.rejected.connect(dialog.reject) + def on_accepted(): + """Handle the accepted signal from the dialog.""" + data = {} + if layer_combo.currentLayer() is None: + return + if type_combo.currentText() == "Orientation": + if not strike_field_combo.currentField() or not dip_field_combo.currentField(): + return + data['strike_field'] = strike_field_combo.currentField() + data['dip_field'] = dip_field_combo.currentField() + data['layer'] = layer_combo.currentLayer() + data['type'] = type_combo.currentText() + self.add_layer_to_data_manager(data) + if dialog.exec_() == QDialog.Accepted: selected_layer = layer_combo.currentText() btn.setText(selected_layer) + on_accepted() # Optionally, store selected layer/fields in table for later retrieval btn.selected_layer = selected_layer if strike_field_combo and dip_field_combo: @@ -123,27 +168,16 @@ def _create_delete_button(self, row): def delete_item_row(self, row): self.items_table.removeRow(row) - def get_fold_frame_data(self): - # Collect feature name and all items from the table - feature_name = ( - self.feature_name_input.text() if hasattr(self, 'feature_name_input') else None - ) - items = [] - for row in range(self.items_table.rowCount()): - type_widget = self.items_table.cellWidget(row, 0) - select_layer_btn = self.items_table.cellWidget(row, 1) - item_type = type_widget.currentText() if type_widget else None - layer = getattr(select_layer_btn, 'selected_layer', None) if select_layer_btn else None - strike_field = ( - getattr(select_layer_btn, 'strike_field', None) if select_layer_btn else None - ) - dip_field = getattr(select_layer_btn, 'dip_field', None) if select_layer_btn else None - items.append( - { - 'type': item_type, - 'layer': layer, - 'strike_field': strike_field, - 'dip_field': dip_field, - } - ) - return {'feature_name': feature_name, 'items': items} + def add_fold_frame(self): + if not self.name_valid: + self.data_manager.logger(f'Name is invalid: {self.name_error}', log_level=2) + return + if len(self.data_manager.fold_data[self.name]) == 0: + self.data_manager.logger("No layers selected for the fold frame.", log_level=2) + return + folded_feature_name = None + if self.modelFeatureComboBox.currentText() != "": + folded_feature_name = self.modelFeatureComboBox.currentText() + + self.data_manager.add_fold_to_model(self.name, folded_feature_name=folded_feature_name) + self.accept() # Close the dialog diff --git a/loopstructural/gui/modelling/add_fold_frame_dialog.ui b/loopstructural/gui/modelling/add_fold_frame_dialog.ui index 7c2f767..f1dd080 100644 --- a/loopstructural/gui/modelling/add_fold_frame_dialog.ui +++ b/loopstructural/gui/modelling/add_fold_frame_dialog.ui @@ -2,62 +2,80 @@ AddFoldFrameDialog + + + 0 + 0 + 274 + 426 + + Add Fold Frame - - - - - Feature Name - - - - - - - - - - Items - - - - - - - 2 - - - 0 - - - - Type - - - - - Delete - - - - - - - - + - - - - - - - QDialogButtonBox::Ok|QDialogButtonBox::Cancel - - - - + + + + + Feature Name + + + + + + + + + + Items + + + + + + + 0 + + + 2 + + + + Type + + + + + Delete + + + + + + + + <html><head/><body><p>Add data to fold frame</p></body></html> + + + + + + + + + + + <html><head/><body><p>Link fold to feature</p></body></html> + + + + + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + From 976afeea2c227ed9207eecf32db42ce76bec7863 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Thu, 21 Aug 2025 14:36:49 +1000 Subject: [PATCH 06/26] pass data manager to the model tab widget --- loopstructural/gui/modelling/modelling_widget.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/loopstructural/gui/modelling/modelling_widget.py b/loopstructural/gui/modelling/modelling_widget.py index 242c6e7..bfd5380 100644 --- a/loopstructural/gui/modelling/modelling_widget.py +++ b/loopstructural/gui/modelling/modelling_widget.py @@ -31,7 +31,7 @@ def __init__( ) self.fault_adjacency_tab_widget = FaultAdjacencyTab(self, data_manager=self.data_manager) self.geological_model_tab_widget = GeologicalModelTab( - self, model_manager=self.model_manager + self, model_manager=self.model_manager, data_manager=self.data_manager ) mainLayout = QVBoxLayout(self) From 596b2c5e3401f32bc54c8249912aa1f3ffed766d Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Thu, 21 Aug 2025 14:37:08 +1000 Subject: [PATCH 07/26] add fold data to the data manager --- loopstructural/main/data_manager.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/loopstructural/main/data_manager.py b/loopstructural/main/data_manager.py index cdb98b1..ace5ba2 100644 --- a/loopstructural/main/data_manager.py +++ b/loopstructural/main/data_manager.py @@ -1,4 +1,5 @@ import json +from collections import defaultdict import numpy as np from qgis.core import QgsPointXY, QgsProject, QgsVectorLayer @@ -63,6 +64,7 @@ def __init__(self, *, project=None, mapCanvas=None, logger=None): self.dem_layer = None self.use_dem = True self.dem_callback = None + self.fold_data = defaultdict(list) def onSaveProject(self): """Save project data.""" @@ -557,3 +559,27 @@ def find_layer_by_name(self, layer_name): else: self.logger(message=f"Layer '{layer_name}' is not a vector layer.", log_level=2) return None + + def update_fold_frame_data(self, fold_frame_name: str, fold_frame_data: dict): + """Update the fold frame data in the data manager.""" + if not isinstance(fold_frame_data, dict): + raise ValueError("fold_frame_data must be a dictionary.") + self.fold_data[fold_frame_name].append(fold_frame_data) + self.logger(message=f"Updated fold frame data for '{fold_frame_name}'.") + + def add_fold_to_model(self, fold_frame_name: str, *, folded_feature_name=None): + """Add a fold frame to the model.""" + if fold_frame_name not in self.fold_data: + raise ValueError(f"Fold frame '{fold_frame_name}' does not exist in the data manager.") + fold_data = self.fold_data[fold_frame_name] + for layer in fold_data: + layer['df'] = qgsLayerToGeoDataFrame( + layer['layer'] + ) # Convert QgsVectorLayer to GeoDataFrame + if self._model_manager: + self._model_manager.add_fold_frame( + fold_frame_name, fold_data, folded_feature_name=folded_feature_name + ) + self.logger(message=f"Added fold frame '{fold_frame_name}' to the model.") + else: + raise RuntimeError("Model manager is not set.") From 67dc8d9e271508b7c4d84a5260bcfb61217008fa Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Thu, 21 Aug 2025 14:37:24 +1000 Subject: [PATCH 08/26] add fold frame and add fold to existing feature --- loopstructural/main/model_manager.py | 52 ++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/loopstructural/main/model_manager.py b/loopstructural/main/model_manager.py index 6d1e8da..707d876 100644 --- a/loopstructural/main/model_manager.py +++ b/loopstructural/main/model_manager.py @@ -9,6 +9,7 @@ from LoopStructural.datatypes import BoundingBox from LoopStructural.modelling.core.fault_topology import FaultRelationshipType from LoopStructural.modelling.core.stratigraphic_column import StratigraphicColumn +from LoopStructural.modelling.features import FeatureType from loopstructural.toolbelt.preferences import PlgSettingsStructure @@ -344,3 +345,54 @@ def update_model(self): def features(self): return self.model.features + + def add_fold_frame( + self, + name: str, + data: dict, + folded_feature_name=None, + sampler=AllSampler(), + use_z_coordinate=False, + ): + # for z + dfs = [] + for layer_data in data: + if layer_data['type'] == 'Orientation': + df = sampler(layer_data['df'], self.dem_function, use_z_coordinate) + df['strike'] = df[layer_data['strike_field']] + df['dip'] = df[layer_data['dip_field']] + df['feature_name'] = name + dfs.append(df[['X', 'Y', 'Z', 'strike', 'dip', 'feature_name']]) + elif layer_data['type'] == 'Formline': + pass + else: + pass + self.model.create_and_add_fold_frame( + name, fold_frame_data=pd.concat(dfs, ignore_index=True) + ) + # if folded_feature_name is not None: + # from LoopStructural.modelling.features._feature_converters import add_fold_to_feature + + # folded_feature = self.model.get_feature_by_name(folded_feature_name) + # folded_feature_name = add_fold_to_feature(frame, folded_feature) + # self.model[folded_feature_name] = folded_feature + for observer in self.observers: + observer() + + def add_fold_to_feature(self, feature_name: str, fold_frame_name: str, fold_weights={}): + + from LoopStructural.modelling.features._feature_converters import add_fold_to_feature + + fold_frame = self.model.get_feature_by_name(fold_frame_name) + if fold_frame is None: + raise ValueError(f"Fold frame '{fold_frame_name}' not found in the model.") + feature = self.model.get_feature_by_name(feature_name) + if feature is None: + raise ValueError(f"Feature '{feature_name}' not found in the model.") + folded_feature = add_fold_to_feature(feature, fold_frame) + self.model[feature_name] = folded_feature + + @property + def fold_frames(self): + """Return the fold frames in the model.""" + return [f for f in self.model.features if f.type == FeatureType.STRUCTURALFRAME] From e3f2d9ddd5f38ec52f5cc941e86dc0f5388c7298 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Thu, 21 Aug 2025 14:37:56 +1000 Subject: [PATCH 09/26] stratigraphic column was reversed in widget --- .../gui/modelling/stratigraphic_column/stratigraphic_column.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py index c7617a3..f7383a6 100644 --- a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py +++ b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py @@ -67,7 +67,7 @@ def update_display(self): """Update the widget display based on the data manager's stratigraphic column.""" self.unitList.clear() if self.data_manager and self.data_manager._stratigraphic_column: - for unit in self.data_manager._stratigraphic_column.order: + for unit in reversed(self.data_manager._stratigraphic_column.order): if unit.element_type == StratigraphicColumnElementType.UNIT: self.add_unit(unit_data=unit.to_dict(), create_new=False) elif unit.element_type == StratigraphicColumnElementType.UNCONFORMITY: From 1f347a7ed38c9542e2695e25754bd300de0c820b Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Thu, 21 Aug 2025 14:38:23 +1000 Subject: [PATCH 10/26] adding widget for folded foliation and structural frame details --- .../gui/modelling/feature_details_panel.py | 155 +++++++++++++++++- .../gui/modelling/geological_model_tab.py | 37 +++-- 2 files changed, 176 insertions(+), 16 deletions(-) diff --git a/loopstructural/gui/modelling/feature_details_panel.py b/loopstructural/gui/modelling/feature_details_panel.py index c369ae5..3d85512 100644 --- a/loopstructural/gui/modelling/feature_details_panel.py +++ b/loopstructural/gui/modelling/feature_details_panel.py @@ -1,22 +1,25 @@ from PyQt5.QtCore import Qt from PyQt5.QtWidgets import ( + QCheckBox, QComboBox, QDoubleSpinBox, QFormLayout, QLabel, + QPushButton, QScrollArea, QVBoxLayout, QWidget, ) from LoopStructural.modelling.features import StructuralFrame -from LoopStructural.utils import normal_vector_to_strike_and_dip +from LoopStructural.utils import normal_vector_to_strike_and_dip, plungeazimuth2vector class BaseFeatureDetailsPanel(QWidget): - def __init__(self, parent=None, *, feature=None): + def __init__(self, parent=None, *, feature=None, model_manager=None): super().__init__(parent) self.feature = feature + self.model_manager = model_manager # Create a scroll area for horizontal scrolling scroll = QScrollArea(self) scroll.setWidgetResizable(True) @@ -112,8 +115,9 @@ def getNelements(self, feature): class FaultFeatureDetailsPanel(BaseFeatureDetailsPanel): - def __init__(self, parent=None, *, fault=None): - super().__init__(parent, feature=fault) + + def __init__(self, parent=None, *, fault=None, model_manager=None): + super().__init__(parent, feature=fault, model_manager=model_manager) if fault is None: raise ValueError("Fault must be provided.") self.fault = fault @@ -194,8 +198,147 @@ def __init__(self, parent=None, *, fault=None): class FoliationFeatureDetailsPanel(BaseFeatureDetailsPanel): - def __init__(self, parent=None, *, feature=None): - super().__init__(parent, feature=feature) + def __init__(self, parent=None, *, feature=None, model_manager=None): + super().__init__(parent, feature=feature, model_manager=model_manager) + if feature is None: + raise ValueError("Feature must be provided.") + self.feature = feature + form_layout = QFormLayout() + fold_frame_combobox = QComboBox() + fold_frame_combobox.addItems([""] + [f.name for f in self.model_manager.fold_frames]) + fold_frame_combobox.currentTextChanged.connect(self.on_fold_frame_changed) + form_layout.addRow("Attach fold frame", fold_frame_combobox) + + QgsCollapsibleGroupBox = QWidget() + QgsCollapsibleGroupBox.setLayout(form_layout) + self.layout.addWidget(QgsCollapsibleGroupBox) + # Remove redundant layout setting + self.setLayout(self.layout) + + def on_fold_frame_changed(self, text): + self.model_manager.add_fold_to_feature(self.feature.name, fold_frame_name=text) + + +class StructuralFrameFeatureDetailsPanel(BaseFeatureDetailsPanel): + def __init__(self, parent=None, *, feature=None, model_manager=None): + super().__init__(parent, feature=feature, model_manager=model_manager) + + +class FoldedFeatureDetailsPanel(BaseFeatureDetailsPanel): + def __init__(self, parent=None, *, feature=None, model_manager=None): + super().__init__(parent, feature=feature, model_manager=model_manager) # Remove redundant layout setting # self.setLayout(self.layout) + form_layout = QFormLayout() + # remove_fold_frame_button = QPushButton("Remove Fold Frame") + # remove_fold_frame_button.clicked.connect(self.remove_fold_frame) + # form_layout.addRow(remove_fold_frame_button) + + norm_length = QDoubleSpinBox() + norm_length.setRange(0, 100000) + norm_length.setValue(1) # Set a default value + norm_length.valueChanged.connect( + lambda value: feature.builder.update_build_arguments( + { + 'fold_weights': { + **feature.builder.build_arguments.get('fold_weights', {}), + 'fold_norm': value, + } + } + ) + ) + form_layout.addRow("Normal Length", norm_length) + + norm_weight = QDoubleSpinBox() + norm_weight.setRange(0, 100000) + norm_weight.setValue(1) + norm_weight.valueChanged.connect( + lambda value: feature.builder.update_build_arguments( + { + 'fold_weights': { + **feature.builder.build_arguments.get('fold_weights', {}), + 'fold_normalisation': value, + } + } + ) + ) + form_layout.addRow("Normal Weight", norm_weight) + + fold_axis_weight = QDoubleSpinBox() + fold_axis_weight.setRange(0, 100000) + fold_axis_weight.setValue(1) + fold_axis_weight.valueChanged.connect( + lambda value: feature.builder.update_build_arguments( + { + 'fold_weights': { + **feature.builder.build_arguments.get('fold_weights', {}), + 'fold_axis_w': value, + } + } + ) + ) + form_layout.addRow("Fold Axis Weight", fold_axis_weight) + + fold_orientation_weight = QDoubleSpinBox() + fold_orientation_weight.setRange(0, 100000) + fold_orientation_weight.setValue(1) + fold_orientation_weight.valueChanged.connect( + lambda value: feature.builder.update_build_arguments( + { + 'fold_weights': { + **feature.builder.build_arguments.get('fold_weights', {}), + 'fold_orientation': value, + } + } + ) + ) + form_layout.addRow("Fold Orientation Weight", fold_orientation_weight) + + average_fold_axis_checkbox = QCheckBox("Average Fold Axis") + average_fold_axis_checkbox.setChecked(True) + average_fold_axis_checkbox.stateChanged.connect( + lambda state: feature.builder.update_build_arguments( + {'av_fold_axis': state != Qt.Checked} + ) + ) + average_fold_axis_checkbox.stateChanged.connect( + lambda state: fold_azimuth.setEnabled(state == Qt.Checked) + ) + average_fold_axis_checkbox.stateChanged.connect( + lambda state: fold_plunge.setEnabled(state == Qt.Checked) + ) + fold_plunge = QDoubleSpinBox() + fold_plunge.setRange(0, 90) + fold_plunge.setValue(0) + fold_azimuth = QDoubleSpinBox() + fold_azimuth.setRange(0, 360) + fold_azimuth.setValue(0) + fold_azimuth.setEnabled(False) + fold_plunge.setEnabled(False) + fold_plunge.valueChanged.connect(self.foldAxisFromPlungeAzimuth) + fold_azimuth.valueChanged.connect(self.foldAxisFromPlungeAzimuth) + form_layout.addRow(average_fold_axis_checkbox) + form_layout.addRow("Fold Plunge", fold_plunge) + form_layout.addRow("Fold Azimuth", fold_azimuth) + QgsCollapsibleGroupBox = QWidget() + QgsCollapsibleGroupBox.setLayout(form_layout) + self.layout.addWidget(QgsCollapsibleGroupBox) + # Remove redundant layout setting + self.setLayout(self.layout) + + def remove_fold_frame(self): + pass + + def foldAxisFromPlungeAzimuth(self): + """Calculate the fold axis from plunge and azimuth.""" + if self.feature: + plunge = ( + self.layout().itemAt(0).widget().findChild(QDoubleSpinBox, "fold_plunge").value() + ) + azimuth = ( + self.layout().itemAt(0).widget().findChild(QDoubleSpinBox, "fold_azimuth").value() + ) + vector = plungeazimuth2vector(plunge, azimuth)[0] + if plunge is not None and azimuth is not None: + self.feature.builder.update_build_arguments({'fold_axis': vector}) diff --git a/loopstructural/gui/modelling/geological_model_tab.py b/loopstructural/gui/modelling/geological_model_tab.py index 84722b4..437f703 100644 --- a/loopstructural/gui/modelling/geological_model_tab.py +++ b/loopstructural/gui/modelling/geological_model_tab.py @@ -14,16 +14,19 @@ from loopstructural.gui.modelling.add_fold_frame_dialog import AddFoldFrameDialog from loopstructural.gui.modelling.feature_details_panel import ( FaultFeatureDetailsPanel, + FoldedFeatureDetailsPanel, FoliationFeatureDetailsPanel, + StructuralFrameFeatureDetailsPanel, ) from LoopStructural.modelling.features import FeatureType class GeologicalModelTab(QWidget): - def __init__(self, parent=None, *, model_manager=None): + def __init__(self, parent=None, *, model_manager=None, data_manager=None): super().__init__(parent) self.model_manager = model_manager - + self.data_manager = data_manager + self.model_manager.observers.append(self.update_feature_list) # Main layout mainLayout = QVBoxLayout(self) @@ -87,14 +90,16 @@ def open_add_fault_dialog(self): print("Fault data:", fault_data) def open_add_fold_frame_dialog(self): - dialog = AddFoldFrameDialog(self) + dialog = AddFoldFrameDialog( + self, data_manager=self.data_manager, model_manager=self.model_manager + ) if dialog.exec_() == dialog.Accepted: - fold_data = dialog.get_fold_data() - # TODO: Add logic to use fold_data to add the fold to the model - print("Fold data:", fold_data) + pass def initialize_model(self): self.model_manager.update_model() + + def update_feature_list(self): self.featureList.clear() # Clear the feature list before populating it for feature in self.model_manager.features(): if feature.name.startswith("__"): @@ -110,12 +115,24 @@ def initialize_model(self): # self.featureList.itemClicked.connect(self.on_feature_selected) def on_feature_selected(self, item): - feature = item.data(0, 1) + feature_name = item.text(0) + feature = self.model_manager.model.get_feature_by_name(feature_name) if feature.type == FeatureType.FAULT: - print("Fault feature selected") - self.featureDetailsPanel = FaultFeatureDetailsPanel(fault=feature) + self.featureDetailsPanel = FaultFeatureDetailsPanel( + fault=feature, model_manager=self.model_manager + ) elif feature.type == FeatureType.INTERPOLATED: - self.featureDetailsPanel = FoliationFeatureDetailsPanel(feature=feature) + self.featureDetailsPanel = FoliationFeatureDetailsPanel( + feature=feature, model_manager=self.model_manager + ) + elif feature.type == FeatureType.STRUCTURALFRAME: + self.featureDetailsPanel = StructuralFrameFeatureDetailsPanel( + feature=feature, model_manager=self.model_manager + ) + elif feature.type == FeatureType.FOLDED: + self.featureDetailsPanel = FoldedFeatureDetailsPanel( + feature=feature, model_manager=self.model_manager + ) else: self.featureDetailsPanel = QWidget() # Default empty panel From 33a5276b1d4f69bcbd8fdcb840bcbedc4e207200 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Thu, 21 Aug 2025 14:38:36 +1000 Subject: [PATCH 11/26] remove model setup tab --- .../gui/modelling/model_setup_tab.py | 0 .../gui/modelling/model_setup_tab.ui | 136 ------------------ loopstructural/main/__init__.py | 1 - 3 files changed, 137 deletions(-) delete mode 100644 loopstructural/gui/modelling/model_setup_tab.py delete mode 100644 loopstructural/gui/modelling/model_setup_tab.ui diff --git a/loopstructural/gui/modelling/model_setup_tab.py b/loopstructural/gui/modelling/model_setup_tab.py deleted file mode 100644 index e69de29..0000000 diff --git a/loopstructural/gui/modelling/model_setup_tab.ui b/loopstructural/gui/modelling/model_setup_tab.ui deleted file mode 100644 index 978b1b9..0000000 --- a/loopstructural/gui/modelling/model_setup_tab.ui +++ /dev/null @@ -1,136 +0,0 @@ - - - Form - - - - 0 - 0 - 593 - 364 - - - - Form - - - - - 0 - 0 - 591 - 361 - - - - - - - - - Initialise Model - - - - - - - Run - - - - - - - Interpolation Parameters - - - - - -1 - 19 - 591 - 263 - - - - - - - - - - Qt::Vertical - - - - 20 - 40 - - - - - - - - - - Number of elements - - - - - - - 10000000 - - - - - - - Value point weight - - - - - - - Norm point weight - - - - - - - Regularisation - - - - - - - 50.000000000000000 - - - - - - - - - - - - - - - - - - - - - - - diff --git a/loopstructural/main/__init__.py b/loopstructural/main/__init__.py index 5ce165f..e69de29 100644 --- a/loopstructural/main/__init__.py +++ b/loopstructural/main/__init__.py @@ -1 +0,0 @@ -from .loopstructuralwrapper import QgsProcessInputData From ee7e8ccb2a976c7bb724e18e7f306cac4480c3ec Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Fri, 22 Aug 2025 17:58:06 +1000 Subject: [PATCH 12/26] fix: move model_setup to own submodule. Change from add fold frame to just foliation will add functionality to morph a foliation to a fold frame. added option for inequality and value data --- .../geological_model_tab/__init__.py | 1 + .../add_fault_dialog.py | 0 .../add_fault_dialog.ui | 0 .../add_foliation_dialog.py} | 65 ++++++++++++++++--- .../add_foliation_dialog.ui} | 6 +- .../geological_model_tab.py | 17 ++--- loopstructural/main/data_manager.py | 34 +++++----- loopstructural/main/model_manager.py | 21 ++++-- 8 files changed, 101 insertions(+), 43 deletions(-) create mode 100644 loopstructural/gui/modelling/geological_model_tab/__init__.py rename loopstructural/gui/modelling/{ => geological_model_tab}/add_fault_dialog.py (100%) rename loopstructural/gui/modelling/{ => geological_model_tab}/add_fault_dialog.ui (100%) rename loopstructural/gui/modelling/{add_fold_frame_dialog.py => geological_model_tab/add_foliation_dialog.py} (69%) rename loopstructural/gui/modelling/{add_fold_frame_dialog.ui => geological_model_tab/add_foliation_dialog.ui} (94%) rename loopstructural/gui/modelling/{ => geological_model_tab}/geological_model_tab.py (93%) diff --git a/loopstructural/gui/modelling/geological_model_tab/__init__.py b/loopstructural/gui/modelling/geological_model_tab/__init__.py new file mode 100644 index 0000000..78fa234 --- /dev/null +++ b/loopstructural/gui/modelling/geological_model_tab/__init__.py @@ -0,0 +1 @@ +from .geological_model_tab import GeologicalModelTab diff --git a/loopstructural/gui/modelling/add_fault_dialog.py b/loopstructural/gui/modelling/geological_model_tab/add_fault_dialog.py similarity index 100% rename from loopstructural/gui/modelling/add_fault_dialog.py rename to loopstructural/gui/modelling/geological_model_tab/add_fault_dialog.py diff --git a/loopstructural/gui/modelling/add_fault_dialog.ui b/loopstructural/gui/modelling/geological_model_tab/add_fault_dialog.ui similarity index 100% rename from loopstructural/gui/modelling/add_fault_dialog.ui rename to loopstructural/gui/modelling/geological_model_tab/add_fault_dialog.ui diff --git a/loopstructural/gui/modelling/add_fold_frame_dialog.py b/loopstructural/gui/modelling/geological_model_tab/add_foliation_dialog.py similarity index 69% rename from loopstructural/gui/modelling/add_fold_frame_dialog.py rename to loopstructural/gui/modelling/geological_model_tab/add_foliation_dialog.py index cfae7f2..6111ceb 100644 --- a/loopstructural/gui/modelling/add_fold_frame_dialog.py +++ b/loopstructural/gui/modelling/geological_model_tab/add_foliation_dialog.py @@ -14,20 +14,20 @@ from qgis.gui import QgsFieldComboBox, QgsMapLayerComboBox -class AddFoldFrameDialog(QDialog): +class AddFoliationDialog(QDialog): def __init__(self, parent=None, *, data_manager=None, model_manager=None): super().__init__(parent) self.data_manager = data_manager self.model_manager = model_manager - ui_path = os.path.join(os.path.dirname(__file__), 'add_fold_frame_dialog.ui') + ui_path = os.path.join(os.path.dirname(__file__), 'add_foliation_dialog.ui') loadUi(ui_path, self) - self.setWindowTitle('Add Fold Frame') + self.setWindowTitle('Add Foliation') # Setup table columns self.items_table.setColumnCount(3) self.items_table.setHorizontalHeaderLabels(["Type", "Select Layer", "Delete"]) # Connect add button self.add_item_button.clicked.connect(self.add_item_row) - self.buttonBox.accepted.connect(self.add_fold_frame) + self.buttonBox.accepted.connect(self.add_foliation) self.buttonBox.rejected.connect(self.reject) self.modelFeatureComboBox.addItems( @@ -77,7 +77,7 @@ def add_layer_to_data_manager(self, layer_data: dict): if not isinstance(layer_data, dict): raise ValueError("layer_data must be a dictionary.") if self.data_manager: - self.data_manager.update_fold_frame_data(self.name, layer_data) + self.data_manager.update_feature_data(self.name, layer_data) else: raise RuntimeError("Data manager is not set.") @@ -103,9 +103,20 @@ def open_layer_dialog(): dip_field_combo = None if type_combo.currentText() == "Orientation": field_layout = QHBoxLayout() + strike_field_label = QLabel("Strike:") dip_field_label = QLabel("Dip:") + format_combo = QComboBox() + format_combo.addItems(["Strike", "Dip Direction"]) + + def update_strike_label(text): + if text == "Dip Direction": + strike_field_label.setText("Dip Direction:") + else: + strike_field_label.setText("Strike:") + format_combo.currentTextChanged.connect(update_strike_label) + field_layout.addWidget(format_combo) strike_field_combo = QgsFieldComboBox() dip_field_combo = QgsFieldComboBox() field_layout.addWidget(strike_field_label) @@ -116,6 +127,30 @@ def open_layer_dialog(): layer_combo.layerChanged.connect(dip_field_combo.setLayer) layout.addLayout(field_layout) + if type_combo.currentText() == "Value": + field_layout = QHBoxLayout() + value_field_label = QLabel("Value Field:") + value_field_combo = QgsFieldComboBox() + value_field_combo.setLayer(layer_combo.currentLayer()) + field_layout.addWidget(value_field_label) + field_layout.addWidget(value_field_combo) + layout.addLayout(field_layout) + layer_combo.layerChanged.connect(value_field_combo.setLayer) + if type_combo.currentText() == "Inequality": + field_layout = QHBoxLayout() + lower_field_label = QLabel("Lower") + upper_field_label = QLabel("Upper") + lower_field_combo = QgsFieldComboBox() + upper_field_combo = QgsFieldComboBox() + lower_field_combo.setLayer(layer_combo.currentLayer()) + upper_field_combo.setLayer(layer_combo.currentLayer()) + field_layout.addWidget(lower_field_label) + field_layout.addWidget(lower_field_combo) + field_layout.addWidget(upper_field_label) + field_layout.addWidget(upper_field_combo) + layout.addLayout(field_layout) + layer_combo.layerChanged.connect(lower_field_combo.setLayer) + layer_combo.layerChanged.connect(upper_field_combo.setLayer) button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) layout.addWidget(button_box) button_box.accepted.connect(dialog.accept) @@ -131,6 +166,16 @@ def on_accepted(): return data['strike_field'] = strike_field_combo.currentField() data['dip_field'] = dip_field_combo.currentField() + elif type_combo.currentText() == "Value": + if not value_field_combo.currentField(): + return + data['value_field'] = value_field_combo.currentField() + elif type_combo.currentText() == "Inequality": + if not lower_field_combo.currentField() or not upper_field_combo.currentField(): + return + data['lower_field'] = lower_field_combo.currentField() + data['upper_field'] = upper_field_combo.currentField() + data['layer'] = layer_combo.currentLayer() data['type'] = type_combo.currentText() self.add_layer_to_data_manager(data) @@ -155,7 +200,7 @@ def _create_type_combo(self): from PyQt5.QtWidgets import QComboBox combo = QComboBox() - combo.addItems(["Form Line", "Orientation"]) + combo.addItems(["Value", "Form Line", "Orientation", "Inequality"]) return combo def _create_delete_button(self, row): @@ -168,16 +213,16 @@ def _create_delete_button(self, row): def delete_item_row(self, row): self.items_table.removeRow(row) - def add_fold_frame(self): + def add_foliation(self): if not self.name_valid: self.data_manager.logger(f'Name is invalid: {self.name_error}', log_level=2) return - if len(self.data_manager.fold_data[self.name]) == 0: - self.data_manager.logger("No layers selected for the fold frame.", log_level=2) + if len(self.data_manager.feature_data[self.name]) == 0: + self.data_manager.logger("No layers selected for the foliation.", log_level=2) return folded_feature_name = None if self.modelFeatureComboBox.currentText() != "": folded_feature_name = self.modelFeatureComboBox.currentText() - self.data_manager.add_fold_to_model(self.name, folded_feature_name=folded_feature_name) + self.data_manager.add_foliation_to_model(self.name, folded_feature_name=folded_feature_name) self.accept() # Close the dialog diff --git a/loopstructural/gui/modelling/add_fold_frame_dialog.ui b/loopstructural/gui/modelling/geological_model_tab/add_foliation_dialog.ui similarity index 94% rename from loopstructural/gui/modelling/add_fold_frame_dialog.ui rename to loopstructural/gui/modelling/geological_model_tab/add_foliation_dialog.ui index f1dd080..121443e 100644 --- a/loopstructural/gui/modelling/add_fold_frame_dialog.ui +++ b/loopstructural/gui/modelling/geological_model_tab/add_foliation_dialog.ui @@ -1,7 +1,7 @@ - AddFoldFrameDialog - + AddFoliationDialog + 0 @@ -11,7 +11,7 @@ - Add Fold Frame + Add Foliation diff --git a/loopstructural/gui/modelling/geological_model_tab.py b/loopstructural/gui/modelling/geological_model_tab/geological_model_tab.py similarity index 93% rename from loopstructural/gui/modelling/geological_model_tab.py rename to loopstructural/gui/modelling/geological_model_tab/geological_model_tab.py index 437f703..b76225c 100644 --- a/loopstructural/gui/modelling/geological_model_tab.py +++ b/loopstructural/gui/modelling/geological_model_tab/geological_model_tab.py @@ -9,9 +9,6 @@ QWidget, ) -# Import the AddFaultDialog -from loopstructural.gui.modelling.add_fault_dialog import AddFaultDialog -from loopstructural.gui.modelling.add_fold_frame_dialog import AddFoldFrameDialog from loopstructural.gui.modelling.feature_details_panel import ( FaultFeatureDetailsPanel, FoldedFeatureDetailsPanel, @@ -20,6 +17,10 @@ ) from LoopStructural.modelling.features import FeatureType +# Import the AddFaultDialog +from .add_fault_dialog import AddFaultDialog +from .add_foliation_dialog import AddFoliationDialog + class GeologicalModelTab(QWidget): def __init__(self, parent=None, *, model_manager=None, data_manager=None): @@ -73,14 +74,14 @@ def __init__(self, parent=None, *, model_manager=None, data_manager=None): def show_add_feature_menu(self, *args): menu = QMenu(self) add_fault = menu.addAction("Add Fault") - add_fold_frame = menu.addAction("Add Fold Frame") + add_foliaton = menu.addAction("Add Foliation") buttonPosition = self.sender().mapToGlobal(self.sender().rect().bottomLeft()) action = menu.exec_(buttonPosition) if action == add_fault: self.open_add_fault_dialog() - elif action == add_fold_frame: - self.open_add_fold_frame_dialog() + elif action == add_foliaton: + self.open_add_foliation_dialog() def open_add_fault_dialog(self): dialog = AddFaultDialog(self) @@ -89,8 +90,8 @@ def open_add_fault_dialog(self): # TODO: Add logic to use fault_data to add the fault to the model print("Fault data:", fault_data) - def open_add_fold_frame_dialog(self): - dialog = AddFoldFrameDialog( + def open_add_foliation_dialog(self): + dialog = AddFoliationDialog( self, data_manager=self.data_manager, model_manager=self.model_manager ) if dialog.exec_() == dialog.Accepted: diff --git a/loopstructural/main/data_manager.py b/loopstructural/main/data_manager.py index ace5ba2..3793ada 100644 --- a/loopstructural/main/data_manager.py +++ b/loopstructural/main/data_manager.py @@ -64,7 +64,7 @@ def __init__(self, *, project=None, mapCanvas=None, logger=None): self.dem_layer = None self.use_dem = True self.dem_callback = None - self.fold_data = defaultdict(list) + self.feature_data = defaultdict(list) def onSaveProject(self): """Save project data.""" @@ -560,26 +560,26 @@ def find_layer_by_name(self, layer_name): self.logger(message=f"Layer '{layer_name}' is not a vector layer.", log_level=2) return None - def update_fold_frame_data(self, fold_frame_name: str, fold_frame_data: dict): - """Update the fold frame data in the data manager.""" - if not isinstance(fold_frame_data, dict): - raise ValueError("fold_frame_data must be a dictionary.") - self.fold_data[fold_frame_name].append(fold_frame_data) - self.logger(message=f"Updated fold frame data for '{fold_frame_name}'.") - - def add_fold_to_model(self, fold_frame_name: str, *, folded_feature_name=None): - """Add a fold frame to the model.""" - if fold_frame_name not in self.fold_data: - raise ValueError(f"Fold frame '{fold_frame_name}' does not exist in the data manager.") - fold_data = self.fold_data[fold_frame_name] - for layer in fold_data: + def update_feature_data(self, feature_name: str, feature_data: dict): + """Update the feature data in the data manager.""" + if not isinstance(feature_data, dict): + raise ValueError("feature_data must be a dictionary.") + self.feature_data[feature_name].append(feature_data) + self.logger(message=f"Updated feature data for '{feature_name}'.") + + def add_foliation_to_model(self, foliation_name: str, *, folded_feature_name=None): + """Add a foliation to the model.""" + if foliation_name not in self.feature_data: + raise ValueError(f"Foliation '{foliation_name}' does not exist in the data manager.") + foliation_data = self.feature_data[foliation_name] + for layer in foliation_data: layer['df'] = qgsLayerToGeoDataFrame( layer['layer'] ) # Convert QgsVectorLayer to GeoDataFrame if self._model_manager: - self._model_manager.add_fold_frame( - fold_frame_name, fold_data, folded_feature_name=folded_feature_name + self._model_manager.add_foliation( + foliation_name, foliation_data, folded_feature_name=folded_feature_name ) - self.logger(message=f"Added fold frame '{fold_frame_name}' to the model.") + self.logger(message=f"Added foliation '{foliation_name}' to the model.") else: raise RuntimeError("Model manager is not set.") diff --git a/loopstructural/main/model_manager.py b/loopstructural/main/model_manager.py index 707d876..586297c 100644 --- a/loopstructural/main/model_manager.py +++ b/loopstructural/main/model_manager.py @@ -346,7 +346,7 @@ def update_model(self): def features(self): return self.model.features - def add_fold_frame( + def add_foliation( self, name: str, data: dict, @@ -365,11 +365,22 @@ def add_fold_frame( dfs.append(df[['X', 'Y', 'Z', 'strike', 'dip', 'feature_name']]) elif layer_data['type'] == 'Formline': pass + elif layer_data['type'] == 'Value': + df = sampler(layer_data['df'], self.dem_function, use_z_coordinate) + df['val'] = df[layer_data['value_field']] + df['feature_name'] = name + dfs.append(df[['X', 'Y', 'Z', 'val', 'feature_name']]) + + elif layer_data['type'] == 'Inequality': + df = sampler(layer_data['df'], self.dem_function, use_z_coordinate) + df['l'] = df[layer_data['lower_field']] + df['u'] = df[layer_data['upper_field']] + df['feature_name'] = name + dfs.append(df[['X', 'Y', 'Z', 'l', 'u', 'feature_name']]) + else: - pass - self.model.create_and_add_fold_frame( - name, fold_frame_data=pd.concat(dfs, ignore_index=True) - ) + raise ValueError(f"Unknown layer type: {layer_data['type']}") + self.model.create_and_add_foliation(name, data=pd.concat(dfs, ignore_index=True)) # if folded_feature_name is not None: # from LoopStructural.modelling.features._feature_converters import add_fold_to_feature From 136a6a5fef1bcb2d8e9122cb57d1a03d05185340 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Mon, 25 Aug 2025 13:55:30 +1000 Subject: [PATCH 13/26] fix: copy data to new feature name if name changes store data as dict of dicts --- .../add_foliation_dialog.py | 67 +++++++++++++++++-- loopstructural/main/data_manager.py | 6 +- loopstructural/main/model_manager.py | 9 ++- 3 files changed, 72 insertions(+), 10 deletions(-) diff --git a/loopstructural/gui/modelling/geological_model_tab/add_foliation_dialog.py b/loopstructural/gui/modelling/geological_model_tab/add_foliation_dialog.py index 6111ceb..b8b16e7 100644 --- a/loopstructural/gui/modelling/geological_model_tab/add_foliation_dialog.py +++ b/loopstructural/gui/modelling/geological_model_tab/add_foliation_dialog.py @@ -39,12 +39,16 @@ def __init__(self, parent=None, *, data_manager=None, model_manager=None): def validate_name_field(text): """Validate the feature name field.""" valid = True + old_name = self.name if not text.strip(): valid = False self.name_error = "Feature name cannot be empty." - if text.strip() in [f.name for f in self.model_manager.features()]: + elif text.strip() in [f.name for f in self.model_manager.features()]: valid = False self.name_error = "Feature name must be unique." + elif text.strip() in self.data_manager.feature_data: + valid = False + self.name_error = "Layer already exists in the data manager." if not valid: self.name_valid = False @@ -53,6 +57,15 @@ def validate_name_field(text): self.feature_name_input.setStyleSheet("") self.name_valid = True + # Enable/disable the OK button based on validation + self.buttonBox.button(QDialogButtonBox.Ok).setEnabled(self.name_valid) + + # if the name changes make sure the data manager updates the key + if old_name in self.data_manager.feature_data and old_name != self.name: + self.data_manager.feature_data[self.name] = self.data_manager.feature_data.pop( + old_name + ) + self.feature_name_input.textChanged.connect(validate_name_field) @property @@ -101,6 +114,23 @@ def open_layer_dialog(): layout.addWidget(layer_combo) strike_field_combo = None dip_field_combo = None + data = {} + button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + + def validate_layer_selection(): + if layer_combo.currentLayer().name() in self.data_manager.feature_data[self.name]: + self.data_manager.logger("Layer already selected.", log_level=2) + button_box.button(QDialogButtonBox.Ok).setEnabled(False) + return False + button_box.button(QDialogButtonBox.Ok).setEnabled(True) + return True + + layer_combo.layerChanged.connect(validate_layer_selection) + if btn.text() != "Select Layer" and hasattr(btn, 'selected_layer'): + data = self.data_manager.feature_data[self.name].get(btn.text(), {}) + if 'layer' in data: + layer_combo.setLayer(data['layer']) + if type_combo.currentText() == "Orientation": field_layout = QHBoxLayout() @@ -119,14 +149,23 @@ def update_strike_label(text): field_layout.addWidget(format_combo) strike_field_combo = QgsFieldComboBox() dip_field_combo = QgsFieldComboBox() + strike_field_combo.setLayer(layer_combo.currentLayer()) + dip_field_combo.setLayer(layer_combo.currentLayer()) field_layout.addWidget(strike_field_label) field_layout.addWidget(strike_field_combo) field_layout.addWidget(dip_field_label) field_layout.addWidget(dip_field_combo) layer_combo.layerChanged.connect(strike_field_combo.setLayer) - layer_combo.layerChanged.connect(dip_field_combo.setLayer) layout.addLayout(field_layout) + + # Populate fields with pre-existing data + if 'strike_field' in data: + strike_field_combo.setField(data['strike_field']) + if 'dip_field' in data: + dip_field_combo.setField(data['dip_field']) + if 'orientation_format' in data: + format_combo.setCurrentText(data['orientation_format']) if type_combo.currentText() == "Value": field_layout = QHBoxLayout() value_field_label = QLabel("Value Field:") @@ -136,6 +175,11 @@ def update_strike_label(text): field_layout.addWidget(value_field_combo) layout.addLayout(field_layout) layer_combo.layerChanged.connect(value_field_combo.setLayer) + + # Populate fields with pre-existing data + if 'value_field' in data: + value_field_combo.setField(data['value_field']) + if type_combo.currentText() == "Inequality": field_layout = QHBoxLayout() lower_field_label = QLabel("Lower") @@ -151,7 +195,13 @@ def update_strike_label(text): layout.addLayout(field_layout) layer_combo.layerChanged.connect(lower_field_combo.setLayer) layer_combo.layerChanged.connect(upper_field_combo.setLayer) - button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + + # Populate fields with pre-existing data + if 'lower_field' in data: + lower_field_combo.setField(data['lower_field']) + if 'upper_field' in data: + upper_field_combo.setField(data['upper_field']) + layout.addWidget(button_box) button_box.accepted.connect(dialog.accept) button_box.rejected.connect(dialog.reject) @@ -166,6 +216,7 @@ def on_accepted(): return data['strike_field'] = strike_field_combo.currentField() data['dip_field'] = dip_field_combo.currentField() + data['orientation_format'] = format_combo.currentText() elif type_combo.currentText() == "Value": if not value_field_combo.currentField(): return @@ -177,6 +228,7 @@ def on_accepted(): data['upper_field'] = upper_field_combo.currentField() data['layer'] = layer_combo.currentLayer() + data['layer_name'] = layer_combo.currentLayer().name() data['type'] = type_combo.currentText() self.add_layer_to_data_manager(data) @@ -203,15 +255,18 @@ def _create_type_combo(self): combo.addItems(["Value", "Form Line", "Orientation", "Inequality"]) return combo - def _create_delete_button(self, row): + def _create_delete_button(self, row, layer_name): from PyQt5.QtWidgets import QPushButton btn = QPushButton("Delete") - btn.clicked.connect(lambda: self.delete_item_row(row)) + btn.clicked.connect(lambda: self.delete_item_row(row, layer_name=btn.text())) return btn - def delete_item_row(self, row): + def delete_item_row(self, row, layer_name): self.items_table.removeRow(row) + print(f'removing layer: {layer_name} for foliation: {self.name}') + self.data_manager.feature_data[self.name].pop(layer_name, None) + print(self.data_manager.feature_data[self.name].keys()) def add_foliation(self): if not self.name_valid: diff --git a/loopstructural/main/data_manager.py b/loopstructural/main/data_manager.py index 3793ada..58949c4 100644 --- a/loopstructural/main/data_manager.py +++ b/loopstructural/main/data_manager.py @@ -64,7 +64,7 @@ def __init__(self, *, project=None, mapCanvas=None, logger=None): self.dem_layer = None self.use_dem = True self.dem_callback = None - self.feature_data = defaultdict(list) + self.feature_data = defaultdict(dict) def onSaveProject(self): """Save project data.""" @@ -564,7 +564,7 @@ def update_feature_data(self, feature_name: str, feature_data: dict): """Update the feature data in the data manager.""" if not isinstance(feature_data, dict): raise ValueError("feature_data must be a dictionary.") - self.feature_data[feature_name].append(feature_data) + self.feature_data[feature_name][feature_data['layer_name']] = feature_data self.logger(message=f"Updated feature data for '{feature_name}'.") def add_foliation_to_model(self, foliation_name: str, *, folded_feature_name=None): @@ -572,7 +572,7 @@ def add_foliation_to_model(self, foliation_name: str, *, folded_feature_name=Non if foliation_name not in self.feature_data: raise ValueError(f"Foliation '{foliation_name}' does not exist in the data manager.") foliation_data = self.feature_data[foliation_name] - for layer in foliation_data: + for layer in foliation_data.values(): layer['df'] = qgsLayerToGeoDataFrame( layer['layer'] ) # Convert QgsVectorLayer to GeoDataFrame diff --git a/loopstructural/main/model_manager.py b/loopstructural/main/model_manager.py index 586297c..ec2b657 100644 --- a/loopstructural/main/model_manager.py +++ b/loopstructural/main/model_manager.py @@ -356,7 +356,7 @@ def add_foliation( ): # for z dfs = [] - for layer_data in data: + for layer_data in data.values(): if layer_data['type'] == 'Orientation': df = sampler(layer_data['df'], self.dem_function, use_z_coordinate) df['strike'] = df[layer_data['strike_field']] @@ -403,6 +403,13 @@ def add_fold_to_feature(self, feature_name: str, fold_frame_name: str, fold_weig folded_feature = add_fold_to_feature(feature, fold_frame) self.model[feature_name] = folded_feature + def convert_feature_to_structural_frame(self, feature_name: str): + from LoopStructural.modelling.features.builders import StructuralFrameBuilder + + builder = self.model.get_feature_by_name(feature_name).builder + new_builder = StructuralFrameBuilder.from_feature_builder(builder) + self.model[feature_name] = new_builder.frame + @property def fold_frames(self): """Return the fold frames in the model.""" From a6d70ec0a2d912f3c2e6363f40d71cdf1f9a6594 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Mon, 25 Aug 2025 14:51:32 +1000 Subject: [PATCH 14/26] fix: use data arg, not specifc name --- loopstructural/main/model_manager.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/loopstructural/main/model_manager.py b/loopstructural/main/model_manager.py index ec2b657..cf69e3f 100644 --- a/loopstructural/main/model_manager.py +++ b/loopstructural/main/model_manager.py @@ -261,7 +261,7 @@ def update_foliation_features(self): data = pd.concat(data, ignore_index=True) foliation = self.model.create_and_add_foliation( groupname, - series_surface_data=data, + data=data, force_constrained=True, nelements=PlgSettingsStructure.interpolator_nelements, npw=PlgSettingsStructure.interpolator_npw, @@ -300,7 +300,7 @@ def update_fault_features(self): displacement=displacement, fault_dip=dip, fault_pitch=pitch, - fault_data=data, + data=data, nelements=PlgSettingsStructure.interpolator_nelements, npw=PlgSettingsStructure.interpolator_npw, cpw=PlgSettingsStructure.interpolator_cpw, From 7cfd1ade0e74c6bacc0e8b527d05175081dec274 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Mon, 25 Aug 2025 14:57:58 +1000 Subject: [PATCH 15/26] fix: add convert from feature to structural frame button --- loopstructural/gui/modelling/feature_details_panel.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/loopstructural/gui/modelling/feature_details_panel.py b/loopstructural/gui/modelling/feature_details_panel.py index 3d85512..a31b877 100644 --- a/loopstructural/gui/modelling/feature_details_panel.py +++ b/loopstructural/gui/modelling/feature_details_panel.py @@ -209,6 +209,11 @@ def __init__(self, parent=None, *, feature=None, model_manager=None): fold_frame_combobox.currentTextChanged.connect(self.on_fold_frame_changed) form_layout.addRow("Attach fold frame", fold_frame_combobox) + convert_to_frame_button = QPushButton("Convert to Structural Frame") + convert_to_frame_button.clicked.connect( + lambda: self.model_manager.convert_feature_to_structural_frame(self.feature.name) + ) + form_layout.addRow(convert_to_frame_button) QgsCollapsibleGroupBox = QWidget() QgsCollapsibleGroupBox.setLayout(form_layout) self.layout.addWidget(QgsCollapsibleGroupBox) From 4c71c6c9b36d117c81017047417b89269e145bb9 Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Tue, 26 Aug 2025 08:31:03 +1000 Subject: [PATCH 16/26] fix: ensure geoh5py try catch actually has import in the block --- loopstructural/gui/visualisation/object_list_widget.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/loopstructural/gui/visualisation/object_list_widget.py b/loopstructural/gui/visualisation/object_list_widget.py index 7b4eac9..49bf8e7 100644 --- a/loopstructural/gui/visualisation/object_list_widget.py +++ b/loopstructural/gui/visualisation/object_list_widget.py @@ -1,4 +1,3 @@ -import geoh5py import pyvista as pv from PyQt5.QtCore import Qt from PyQt5.QtWidgets import ( @@ -106,6 +105,7 @@ def export_selected_object(self): # Determine available formats based on object type and dependencies formats = [] try: + import geoh5py # noqa: F401 has_geoh5py = True except ImportError: has_geoh5py = False From 422bc1476f84916e55382aefe579194344f4a1aa Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Tue, 26 Aug 2025 08:32:02 +1000 Subject: [PATCH 17/26] fix: remove layer name from delete button the data tables need to be abstracted into classes of rows and the table --- .../modelling/geological_model_tab/add_foliation_dialog.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/loopstructural/gui/modelling/geological_model_tab/add_foliation_dialog.py b/loopstructural/gui/modelling/geological_model_tab/add_foliation_dialog.py index b8b16e7..17a2c61 100644 --- a/loopstructural/gui/modelling/geological_model_tab/add_foliation_dialog.py +++ b/loopstructural/gui/modelling/geological_model_tab/add_foliation_dialog.py @@ -255,11 +255,11 @@ def _create_type_combo(self): combo.addItems(["Value", "Form Line", "Orientation", "Inequality"]) return combo - def _create_delete_button(self, row, layer_name): + def _create_delete_button(self, row): from PyQt5.QtWidgets import QPushButton btn = QPushButton("Delete") - btn.clicked.connect(lambda: self.delete_item_row(row, layer_name=btn.text())) + btn.clicked.connect(lambda: self.delete_item_row(row)) return btn def delete_item_row(self, row, layer_name): From 3a253a6b4b7f9f2cf401ba0a682be727fc1aadce Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Tue, 26 Aug 2025 09:32:14 +1000 Subject: [PATCH 18/26] fix: abstract data table into separate class --- .../add_foliation_dialog.py | 266 ++-------- .../layer_selection_table.py | 486 ++++++++++++++++++ 2 files changed, 531 insertions(+), 221 deletions(-) create mode 100644 loopstructural/gui/modelling/geological_model_tab/layer_selection_table.py diff --git a/loopstructural/gui/modelling/geological_model_tab/add_foliation_dialog.py b/loopstructural/gui/modelling/geological_model_tab/add_foliation_dialog.py index 17a2c61..443a1d9 100644 --- a/loopstructural/gui/modelling/geological_model_tab/add_foliation_dialog.py +++ b/loopstructural/gui/modelling/geological_model_tab/add_foliation_dialog.py @@ -1,17 +1,9 @@ import os -from PyQt5.QtWidgets import ( - QComboBox, - QDialog, - QDialogButtonBox, - QHBoxLayout, - QLabel, - QPushButton, - QVBoxLayout, -) +from PyQt5.QtWidgets import QDialog, QDialogButtonBox from PyQt5.uic import loadUi -from qgis.core import QgsMapLayerProxyModel -from qgis.gui import QgsFieldComboBox, QgsMapLayerComboBox + +from .layer_selection_table import LayerSelectionTable class AddFoliationDialog(QDialog): @@ -22,13 +14,19 @@ def __init__(self, parent=None, *, data_manager=None, model_manager=None): ui_path = os.path.join(os.path.dirname(__file__), 'add_foliation_dialog.ui') loadUi(ui_path, self) self.setWindowTitle('Add Foliation') - # Setup table columns - self.items_table.setColumnCount(3) - self.items_table.setHorizontalHeaderLabels(["Type", "Select Layer", "Delete"]) + + # Initialize the abstracted table + self.layer_table = LayerSelectionTable( + table_widget=self.items_table, + data_manager=self.data_manager, + feature_name_provider=lambda: self.name, + name_validator=lambda: (self.name_valid, self.name_error) + ) + # Connect add button - self.add_item_button.clicked.connect(self.add_item_row) + self.add_item_button.clicked.connect(self.layer_table.add_item_row) self.buttonBox.accepted.connect(self.add_foliation) - self.buttonBox.rejected.connect(self.reject) + self.buttonBox.rejected.connect(self.cancel) self.modelFeatureComboBox.addItems( [f.name for f in self.model_manager.features() if not f.name.startswith("__")] @@ -40,13 +38,15 @@ def validate_name_field(text): """Validate the feature name field.""" valid = True old_name = self.name - if not text.strip(): + new_name = text.strip() + + if not new_name: valid = False self.name_error = "Feature name cannot be empty." - elif text.strip() in [f.name for f in self.model_manager.features()]: + elif new_name in [f.name for f in self.model_manager.features()]: valid = False self.name_error = "Feature name must be unique." - elif text.strip() in self.data_manager.feature_data: + elif new_name in self.data_manager.feature_data: valid = False self.name_error = "Layer already exists in the data manager." @@ -60,11 +60,19 @@ def validate_name_field(text): # Enable/disable the OK button based on validation self.buttonBox.button(QDialogButtonBox.Ok).setEnabled(self.name_valid) - # if the name changes make sure the data manager updates the key - if old_name in self.data_manager.feature_data and old_name != self.name: - self.data_manager.feature_data[self.name] = self.data_manager.feature_data.pop( - old_name - ) + # If the name changes, update the data manager key and reinitialize table + if old_name != new_name and old_name in self.data_manager.feature_data: + # Save current table data + old_data = self.layer_table.get_table_data() + + # Remove old key and set new key + self.data_manager.feature_data.pop(old_name, None) + if new_name and valid: + self.data_manager.feature_data[new_name] = old_data + + # Update table to reflect new feature name + self.layer_table.initialize_feature_data() + self.layer_table.restore_table_state() self.feature_name_input.textChanged.connect(validate_name_field) @@ -72,212 +80,28 @@ def validate_name_field(text): def name(self): return self.feature_name_input.text().strip() - def add_item_row(self): - row = self.items_table.rowCount() - self.items_table.insertRow(row) - # Type dropdown - type_combo = self._create_type_combo() - self.items_table.setCellWidget(row, 0, type_combo) - # Select Layer button - select_layer_btn = self._create_select_layer_button(row, type_combo) - self.items_table.setCellWidget(row, 1, select_layer_btn) - # Delete button - del_btn = self._create_delete_button(row) - self.items_table.setCellWidget(row, 2, del_btn) - - def add_layer_to_data_manager(self, layer_data: dict): - """Add selected layer data to the data manager.""" - if not isinstance(layer_data, dict): - raise ValueError("layer_data must be a dictionary.") - if self.data_manager: - self.data_manager.update_feature_data(self.name, layer_data) - else: - raise RuntimeError("Data manager is not set.") - - def _create_select_layer_button(self, row, type_combo): - btn = QPushButton("Select Layer") - - def open_layer_dialog(): - if not self.name_valid: - self.data_manager.logger(f'Name is invalid: {self.name_error}', log_level=2) - return - dialog = QDialog(self) - dialog.setWindowTitle("Select Layer") - layout = QVBoxLayout(dialog) - # Layer combo box (replace with QgsLayerComboBox in QGIS environment) - layer_label = QLabel("Layer:") - layout.addWidget(layer_label) - layer_combo = QgsMapLayerComboBox() - layer_combo.setFilters( - QgsMapLayerProxyModel.LineLayer | QgsMapLayerProxyModel.PointLayer - ) - layout.addWidget(layer_combo) - strike_field_combo = None - dip_field_combo = None - data = {} - button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) - - def validate_layer_selection(): - if layer_combo.currentLayer().name() in self.data_manager.feature_data[self.name]: - self.data_manager.logger("Layer already selected.", log_level=2) - button_box.button(QDialogButtonBox.Ok).setEnabled(False) - return False - button_box.button(QDialogButtonBox.Ok).setEnabled(True) - return True - - layer_combo.layerChanged.connect(validate_layer_selection) - if btn.text() != "Select Layer" and hasattr(btn, 'selected_layer'): - data = self.data_manager.feature_data[self.name].get(btn.text(), {}) - if 'layer' in data: - layer_combo.setLayer(data['layer']) - - if type_combo.currentText() == "Orientation": - field_layout = QHBoxLayout() - - strike_field_label = QLabel("Strike:") - dip_field_label = QLabel("Dip:") - format_combo = QComboBox() - format_combo.addItems(["Strike", "Dip Direction"]) - - def update_strike_label(text): - if text == "Dip Direction": - strike_field_label.setText("Dip Direction:") - else: - strike_field_label.setText("Strike:") - - format_combo.currentTextChanged.connect(update_strike_label) - field_layout.addWidget(format_combo) - strike_field_combo = QgsFieldComboBox() - dip_field_combo = QgsFieldComboBox() - strike_field_combo.setLayer(layer_combo.currentLayer()) - dip_field_combo.setLayer(layer_combo.currentLayer()) - field_layout.addWidget(strike_field_label) - field_layout.addWidget(strike_field_combo) - field_layout.addWidget(dip_field_label) - field_layout.addWidget(dip_field_combo) - layer_combo.layerChanged.connect(strike_field_combo.setLayer) - layer_combo.layerChanged.connect(dip_field_combo.setLayer) - layout.addLayout(field_layout) - - # Populate fields with pre-existing data - if 'strike_field' in data: - strike_field_combo.setField(data['strike_field']) - if 'dip_field' in data: - dip_field_combo.setField(data['dip_field']) - if 'orientation_format' in data: - format_combo.setCurrentText(data['orientation_format']) - if type_combo.currentText() == "Value": - field_layout = QHBoxLayout() - value_field_label = QLabel("Value Field:") - value_field_combo = QgsFieldComboBox() - value_field_combo.setLayer(layer_combo.currentLayer()) - field_layout.addWidget(value_field_label) - field_layout.addWidget(value_field_combo) - layout.addLayout(field_layout) - layer_combo.layerChanged.connect(value_field_combo.setLayer) - - # Populate fields with pre-existing data - if 'value_field' in data: - value_field_combo.setField(data['value_field']) - - if type_combo.currentText() == "Inequality": - field_layout = QHBoxLayout() - lower_field_label = QLabel("Lower") - upper_field_label = QLabel("Upper") - lower_field_combo = QgsFieldComboBox() - upper_field_combo = QgsFieldComboBox() - lower_field_combo.setLayer(layer_combo.currentLayer()) - upper_field_combo.setLayer(layer_combo.currentLayer()) - field_layout.addWidget(lower_field_label) - field_layout.addWidget(lower_field_combo) - field_layout.addWidget(upper_field_label) - field_layout.addWidget(upper_field_combo) - layout.addLayout(field_layout) - layer_combo.layerChanged.connect(lower_field_combo.setLayer) - layer_combo.layerChanged.connect(upper_field_combo.setLayer) - - # Populate fields with pre-existing data - if 'lower_field' in data: - lower_field_combo.setField(data['lower_field']) - if 'upper_field' in data: - upper_field_combo.setField(data['upper_field']) - - layout.addWidget(button_box) - button_box.accepted.connect(dialog.accept) - button_box.rejected.connect(dialog.reject) - - def on_accepted(): - """Handle the accepted signal from the dialog.""" - data = {} - if layer_combo.currentLayer() is None: - return - if type_combo.currentText() == "Orientation": - if not strike_field_combo.currentField() or not dip_field_combo.currentField(): - return - data['strike_field'] = strike_field_combo.currentField() - data['dip_field'] = dip_field_combo.currentField() - data['orientation_format'] = format_combo.currentText() - elif type_combo.currentText() == "Value": - if not value_field_combo.currentField(): - return - data['value_field'] = value_field_combo.currentField() - elif type_combo.currentText() == "Inequality": - if not lower_field_combo.currentField() or not upper_field_combo.currentField(): - return - data['lower_field'] = lower_field_combo.currentField() - data['upper_field'] = upper_field_combo.currentField() - - data['layer'] = layer_combo.currentLayer() - data['layer_name'] = layer_combo.currentLayer().name() - data['type'] = type_combo.currentText() - self.add_layer_to_data_manager(data) - - if dialog.exec_() == QDialog.Accepted: - selected_layer = layer_combo.currentText() - btn.setText(selected_layer) - on_accepted() - # Optionally, store selected layer/fields in table for later retrieval - btn.selected_layer = selected_layer - if strike_field_combo and dip_field_combo: - btn.strike_field = strike_field_combo.currentText() - btn.dip_field = dip_field_combo.currentText() - else: - btn.strike_field = None - btn.dip_field = None - - btn.clicked.connect(open_layer_dialog) - return btn - - def _create_type_combo(self): - from PyQt5.QtWidgets import QComboBox - - combo = QComboBox() - combo.addItems(["Value", "Form Line", "Orientation", "Inequality"]) - return combo - - def _create_delete_button(self, row): - from PyQt5.QtWidgets import QPushButton - - btn = QPushButton("Delete") - btn.clicked.connect(lambda: self.delete_item_row(row)) - return btn - - def delete_item_row(self, row, layer_name): - self.items_table.removeRow(row) - print(f'removing layer: {layer_name} for foliation: {self.name}') - self.data_manager.feature_data[self.name].pop(layer_name, None) - print(self.data_manager.feature_data[self.name].keys()) - def add_foliation(self): if not self.name_valid: self.data_manager.logger(f'Name is invalid: {self.name_error}', log_level=2) return - if len(self.data_manager.feature_data[self.name]) == 0: + + # Ensure table state is synchronized with data manager + self.layer_table.sync_table_with_data() + + # Check if we have any layers selected + if not self.layer_table.has_layers(): self.data_manager.logger("No layers selected for the foliation.", log_level=2) return + folded_feature_name = None if self.modelFeatureComboBox.currentText() != "": folded_feature_name = self.modelFeatureComboBox.currentText() self.data_manager.add_foliation_to_model(self.name, folded_feature_name=folded_feature_name) self.accept() # Close the dialog + + def cancel(self): + # Clean up any temporary data if necessary + if self.name in self.data_manager.feature_data: + self.data_manager.feature_data.pop(self.name, None) + self.reject() \ No newline at end of file diff --git a/loopstructural/gui/modelling/geological_model_tab/layer_selection_table.py b/loopstructural/gui/modelling/geological_model_tab/layer_selection_table.py new file mode 100644 index 0000000..1bdeb94 --- /dev/null +++ b/loopstructural/gui/modelling/geological_model_tab/layer_selection_table.py @@ -0,0 +1,486 @@ +from PyQt5.QtWidgets import ( + QComboBox, + QDialog, + QDialogButtonBox, + QHBoxLayout, + QLabel, + QPushButton, + QTableWidget, + QVBoxLayout, +) +from qgis.core import QgsMapLayerProxyModel +from qgis.gui import QgsFieldComboBox, QgsMapLayerComboBox + + +class LayerSelectionTable: + """Handles layer selection table functionality for geological features.""" + + def __init__(self, table_widget, data_manager, feature_name_provider, name_validator): + """ + Initialize the layer selection table. + + Args: + table_widget: QTableWidget instance + data_manager: Data manager instance + feature_name_provider: Callable that returns the current feature name + name_validator: Callable that returns (is_valid, error_message) + """ + self.table = table_widget + self.data_manager = data_manager + self.get_feature_name = feature_name_provider + self.validate_name = name_validator + + self._setup_table() + self.initialize_feature_data() + self.restore_table_state() + + def _setup_table(self): + """Setup table columns and headers.""" + self.table.setColumnCount(3) + self.table.setHorizontalHeaderLabels(["Type", "Select Layer", "Delete"]) + + def initialize_feature_data(self): + """Initialize feature data in the data manager if it doesn't exist.""" + feature_name = self.get_feature_name() + if feature_name and feature_name not in self.data_manager.feature_data: + self.data_manager.feature_data[feature_name] = {} + + def restore_table_state(self): + """Restore table state from data manager.""" + feature_name = self.get_feature_name() + if not feature_name or feature_name not in self.data_manager.feature_data: + return + + # Clear existing table rows + self.table.setRowCount(0) + + # Restore rows from data + feature_data = self.data_manager.feature_data[feature_name] + for layer_name, layer_data in feature_data.items(): + self._add_row_from_data(layer_data) + + def _add_row_from_data(self, layer_data): + """Add a row to the table from existing data.""" + row = self.table.rowCount() + self.table.insertRow(row) + + # Type dropdown + type_combo = self._create_type_combo() + type_combo.setCurrentText(layer_data.get('type', 'Value')) + self.table.setCellWidget(row, 0, type_combo) + + # Select Layer button + select_layer_btn = self._create_select_layer_button(row, type_combo) + self._update_button_with_selection(select_layer_btn, layer_data) + self.table.setCellWidget(row, 1, select_layer_btn) + + # Delete button + del_btn = self._create_delete_button(row) + self.table.setCellWidget(row, 2, del_btn) + + def add_item_row(self): + """Add a new row to the table.""" + self.initialize_feature_data() # Ensure feature data exists + + row = self.table.rowCount() + self.table.insertRow(row) + + # Type dropdown + type_combo = self._create_type_combo() + self.table.setCellWidget(row, 0, type_combo) + + # Select Layer button + select_layer_btn = self._create_select_layer_button(row, type_combo) + self.table.setCellWidget(row, 1, select_layer_btn) + + # Delete button + del_btn = self._create_delete_button(row) + self.table.setCellWidget(row, 2, del_btn) + + def _create_type_combo(self): + """Create type selection combo box.""" + combo = QComboBox() + combo.addItems(["Value", "Form Line", "Orientation", "Inequality"]) + return combo + + def _create_select_layer_button(self, row, type_combo): + """Create select layer button.""" + btn = QPushButton("Select Layer") + + def open_layer_dialog(): + name_valid, name_error = self.validate_name() + if not name_valid: + self.data_manager.logger(f'Name is invalid: {name_error}', log_level=2) + return + + dialog = LayerSelectionDialog( + parent=self.table, + data_manager=self.data_manager, + feature_name=self.get_feature_name(), + layer_type=type_combo.currentText(), + existing_data=self._get_existing_data_for_button(btn) + ) + + if dialog.exec_() == QDialog.Accepted: + layer_data = dialog.get_layer_data() + if layer_data: + self._update_button_with_selection(btn, layer_data) + self._add_layer_to_data_manager(layer_data) + + btn.clicked.connect(open_layer_dialog) + return btn + + def _create_delete_button(self, row): + """Create delete button for a row.""" + btn = QPushButton("Delete") + btn.clicked.connect(lambda: self._delete_item_row(row)) + return btn + + def _delete_item_row(self, row): + """Delete a row from the table and update data manager.""" + # Find the select layer button in the same row to get layer name + select_btn = self.table.cellWidget(row, 1) + if hasattr(select_btn, 'selected_layer'): + layer_name = select_btn.selected_layer + feature_name = self.get_feature_name() + if feature_name in self.data_manager.feature_data: + self.data_manager.feature_data[feature_name].pop(layer_name, None) + print(f'Removing layer: {layer_name} for feature: {feature_name}') + + # Remove the row from table + self.table.removeRow(row) + + # Update delete button connections for remaining rows + self._update_delete_button_connections() + + def _update_delete_button_connections(self): + """Update delete button connections after row deletion to maintain correct row indices.""" + for row in range(self.table.rowCount()): + delete_btn = self.table.cellWidget(row, 2) + if delete_btn: + # Disconnect old connections + delete_btn.clicked.disconnect() + # Reconnect with correct row index + delete_btn.clicked.connect(lambda checked, r=row: self._delete_item_row(r)) + + def _get_existing_data_for_button(self, btn): + """Get existing data for a button if it has been configured.""" + if btn.text() != "Select Layer" and hasattr(btn, 'selected_layer'): + feature_name = self.get_feature_name() + if feature_name and feature_name in self.data_manager.feature_data: + return self.data_manager.feature_data[feature_name].get(btn.selected_layer, {}) + return {} + + def _update_button_with_selection(self, btn, layer_data): + """Update button text and store selection data.""" + layer_name = layer_data.get('layer_name', 'Unknown') + btn.setText(layer_name) + btn.selected_layer = layer_name + + # Store field information for different types + btn.strike_field = layer_data.get('strike_field') + btn.dip_field = layer_data.get('dip_field') + btn.value_field = layer_data.get('value_field') + btn.lower_field = layer_data.get('lower_field') + btn.upper_field = layer_data.get('upper_field') + + def _add_layer_to_data_manager(self, layer_data): + """Add selected layer data to the data manager.""" + if not isinstance(layer_data, dict): + raise ValueError("layer_data must be a dictionary.") + if self.data_manager: + feature_name = self.get_feature_name() + self.data_manager.update_feature_data(feature_name, layer_data) + else: + raise RuntimeError("Data manager is not set.") + + def clear_table(self): + """Clear all rows from the table and reset feature data.""" + feature_name = self.get_feature_name() + if feature_name and feature_name in self.data_manager.feature_data: + self.data_manager.feature_data[feature_name].clear() + + # Clear all table rows + self.table.setRowCount(0) + + def get_table_data(self): + """Get all table data as a dictionary.""" + feature_name = self.get_feature_name() + if feature_name and feature_name in self.data_manager.feature_data: + return self.data_manager.feature_data[feature_name].copy() + return {} + + def set_table_data(self, data): + """Set table data and restore table state.""" + feature_name = self.get_feature_name() + if not feature_name: + return + + # Clear existing table + self.clear_table() + + # Update data manager + self.initialize_feature_data() + self.data_manager.feature_data[feature_name] = data.copy() + + # Restore table state + self.restore_table_state() + + def validate_table_state(self): + """Validate that table state matches data manager state.""" + feature_name = self.get_feature_name() + if not feature_name or feature_name not in self.data_manager.feature_data: + return True + + feature_data = self.data_manager.feature_data[feature_name] + table_layers = [] + + # Collect layers from table + for row in range(self.table.rowCount()): + select_btn = self.table.cellWidget(row, 1) + if hasattr(select_btn, 'selected_layer'): + table_layers.append(select_btn.selected_layer) + + # Compare with data manager + data_layers = list(feature_data.keys()) + + if set(table_layers) != set(data_layers): + print(f"Table state inconsistency detected for feature '{feature_name}':") + print(f" Table layers: {table_layers}") + print(f" Data layers: {data_layers}") + return False + + return True + + def sync_table_with_data(self): + """Synchronize table state with data manager state.""" + if not self.validate_table_state(): + print("Syncing table with data manager...") + self.restore_table_state() + + def get_layer_count(self): + """Get the number of layers currently in the table.""" + feature_name = self.get_feature_name() + if feature_name and feature_name in self.data_manager.feature_data: + return len(self.data_manager.feature_data[feature_name]) + return 0 + + def has_layers(self): + """Check if there are any layers in the table.""" + return self.get_layer_count() > 0 + + def get_layer_names(self): + """Get a list of all layer names in the table.""" + feature_name = self.get_feature_name() + if feature_name and feature_name in self.data_manager.feature_data: + return list(self.data_manager.feature_data[feature_name].keys()) + return [] + + +class LayerSelectionDialog(QDialog): + """Dialog for selecting layers and configuring their fields.""" + + def __init__(self, parent=None, data_manager=None, feature_name="", layer_type="", existing_data=None): + super().__init__(parent) + self.data_manager = data_manager + self.feature_name = feature_name + self.layer_type = layer_type + self.existing_data = existing_data or {} + self.layer_data = {} + + self.setWindowTitle("Select Layer") + self._setup_ui() + + def _setup_ui(self): + """Setup the dialog UI.""" + layout = QVBoxLayout(self) + + # Layer selection + layer_label = QLabel("Layer:") + layout.addWidget(layer_label) + + self.layer_combo = QgsMapLayerComboBox() + self.layer_combo.setFilters( + QgsMapLayerProxyModel.LineLayer | QgsMapLayerProxyModel.PointLayer + ) + layout.addWidget(self.layer_combo) + + # Set existing layer if available + if 'layer' in self.existing_data: + self.layer_combo.setLayer(self.existing_data['layer']) + + # Type-specific field selection + self._setup_type_specific_fields(layout) + + # Dialog buttons + self.button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + layout.addWidget(self.button_box) + + self.button_box.accepted.connect(self._on_accepted) + self.button_box.rejected.connect(self.reject) + + # Validation + self.layer_combo.layerChanged.connect(self._validate_layer_selection) + self._validate_layer_selection() + + def _setup_type_specific_fields(self, layout): + """Setup fields specific to the layer type.""" + self.field_combos = {} + + if self.layer_type == "Orientation": + self._setup_orientation_fields(layout) + elif self.layer_type == "Value": + self._setup_value_fields(layout) + elif self.layer_type == "Inequality": + self._setup_inequality_fields(layout) + + def _setup_orientation_fields(self, layout): + """Setup fields for orientation data type.""" + field_layout = QHBoxLayout() + + # Format selection + self.format_combo = QComboBox() + self.format_combo.addItems(["Strike", "Dip Direction"]) + if 'orientation_format' in self.existing_data: + self.format_combo.setCurrentText(self.existing_data['orientation_format']) + field_layout.addWidget(self.format_combo) + + # Strike/Dip Direction field + self.strike_field_label = QLabel("Strike:") + self.strike_field_combo = QgsFieldComboBox() + self.strike_field_combo.setLayer(self.layer_combo.currentLayer()) + if 'strike_field' in self.existing_data: + self.strike_field_combo.setField(self.existing_data['strike_field']) + + field_layout.addWidget(self.strike_field_label) + field_layout.addWidget(self.strike_field_combo) + + # Dip field + dip_field_label = QLabel("Dip:") + self.dip_field_combo = QgsFieldComboBox() + self.dip_field_combo.setLayer(self.layer_combo.currentLayer()) + if 'dip_field' in self.existing_data: + self.dip_field_combo.setField(self.existing_data['dip_field']) + + field_layout.addWidget(dip_field_label) + field_layout.addWidget(self.dip_field_combo) + + layout.addLayout(field_layout) + + # Update strike label based on format + def update_strike_label(text): + if text == "Dip Direction": + self.strike_field_label.setText("Dip Direction:") + else: + self.strike_field_label.setText("Strike:") + + self.format_combo.currentTextChanged.connect(update_strike_label) + self.layer_combo.layerChanged.connect(self.strike_field_combo.setLayer) + self.layer_combo.layerChanged.connect(self.dip_field_combo.setLayer) + + self.field_combos = { + 'strike_field': self.strike_field_combo, + 'dip_field': self.dip_field_combo, + 'format': self.format_combo + } + + def _setup_value_fields(self, layout): + """Setup fields for value data type.""" + field_layout = QHBoxLayout() + + value_field_label = QLabel("Value Field:") + self.value_field_combo = QgsFieldComboBox() + self.value_field_combo.setLayer(self.layer_combo.currentLayer()) + if 'value_field' in self.existing_data: + self.value_field_combo.setField(self.existing_data['value_field']) + + field_layout.addWidget(value_field_label) + field_layout.addWidget(self.value_field_combo) + layout.addLayout(field_layout) + + self.layer_combo.layerChanged.connect(self.value_field_combo.setLayer) + + self.field_combos = { + 'value_field': self.value_field_combo + } + + def _setup_inequality_fields(self, layout): + """Setup fields for inequality data type.""" + field_layout = QHBoxLayout() + + lower_field_label = QLabel("Lower:") + self.lower_field_combo = QgsFieldComboBox() + self.lower_field_combo.setLayer(self.layer_combo.currentLayer()) + if 'lower_field' in self.existing_data: + self.lower_field_combo.setField(self.existing_data['lower_field']) + + upper_field_label = QLabel("Upper:") + self.upper_field_combo = QgsFieldComboBox() + self.upper_field_combo.setLayer(self.layer_combo.currentLayer()) + if 'upper_field' in self.existing_data: + self.upper_field_combo.setField(self.existing_data['upper_field']) + + field_layout.addWidget(lower_field_label) + field_layout.addWidget(self.lower_field_combo) + field_layout.addWidget(upper_field_label) + field_layout.addWidget(self.upper_field_combo) + layout.addLayout(field_layout) + + self.layer_combo.layerChanged.connect(self.lower_field_combo.setLayer) + self.layer_combo.layerChanged.connect(self.upper_field_combo.setLayer) + + self.field_combos = { + 'lower_field': self.lower_field_combo, + 'upper_field': self.upper_field_combo + } + + def _validate_layer_selection(self): + """Validate the current layer selection.""" + if self.layer_combo.currentLayer() is None: + self.button_box.button(QDialogButtonBox.Ok).setEnabled(False) + return False + + layer_name = self.layer_combo.currentLayer().name() + if layer_name in self.data_manager.feature_data.get(self.feature_name, {}): + self.data_manager.logger("Layer already selected.", log_level=2) + self.button_box.button(QDialogButtonBox.Ok).setEnabled(False) + return False + + self.button_box.button(QDialogButtonBox.Ok).setEnabled(True) + return True + + def _on_accepted(self): + """Handle dialog acceptance.""" + if self.layer_combo.currentLayer() is None: + return + + self.layer_data = { + 'layer': self.layer_combo.currentLayer(), + 'layer_name': self.layer_combo.currentLayer().name(), + 'type': self.layer_type + } + + # Add type-specific data + if self.layer_type == "Orientation": + if not self.field_combos['strike_field'].currentField() or not self.field_combos['dip_field'].currentField(): + return + self.layer_data['strike_field'] = self.field_combos['strike_field'].currentField() + self.layer_data['dip_field'] = self.field_combos['dip_field'].currentField() + self.layer_data['orientation_format'] = self.field_combos['format'].currentText() + + elif self.layer_type == "Value": + if not self.field_combos['value_field'].currentField(): + return + self.layer_data['value_field'] = self.field_combos['value_field'].currentField() + + elif self.layer_type == "Inequality": + if not self.field_combos['lower_field'].currentField() or not self.field_combos['upper_field'].currentField(): + return + self.layer_data['lower_field'] = self.field_combos['lower_field'].currentField() + self.layer_data['upper_field'] = self.field_combos['upper_field'].currentField() + + self.accept() + + def get_layer_data(self): + """Get the configured layer data.""" + return self.layer_data From 8948cded69bb1eae51882e7a7605b703ba29eaef Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Tue, 26 Aug 2025 09:32:49 +1000 Subject: [PATCH 19/26] fix: add data table to feature details panel --- .../feature_details_panel.py | 30 ++++++++++++------- .../geological_model_tab.py | 10 +++---- 2 files changed, 25 insertions(+), 15 deletions(-) rename loopstructural/gui/modelling/{ => geological_model_tab}/feature_details_panel.py (94%) diff --git a/loopstructural/gui/modelling/feature_details_panel.py b/loopstructural/gui/modelling/geological_model_tab/feature_details_panel.py similarity index 94% rename from loopstructural/gui/modelling/feature_details_panel.py rename to loopstructural/gui/modelling/geological_model_tab/feature_details_panel.py index a31b877..82f412e 100644 --- a/loopstructural/gui/modelling/feature_details_panel.py +++ b/loopstructural/gui/modelling/geological_model_tab/feature_details_panel.py @@ -9,17 +9,20 @@ QScrollArea, QVBoxLayout, QWidget, + QTableWidget, ) +from .layer_selection_table import LayerSelectionTable from LoopStructural.modelling.features import StructuralFrame from LoopStructural.utils import normal_vector_to_strike_and_dip, plungeazimuth2vector class BaseFeatureDetailsPanel(QWidget): - def __init__(self, parent=None, *, feature=None, model_manager=None): + def __init__(self, parent=None, *, feature=None, model_manager=None, data_manager=None): super().__init__(parent) self.feature = feature self.model_manager = model_manager + self.data_manager = data_manager # Create a scroll area for horizontal scrolling scroll = QScrollArea(self) scroll.setWidgetResizable(True) @@ -73,6 +76,13 @@ def __init__(self, parent=None, *, feature=None, model_manager=None): self.n_elements_spinbox.valueChanged.connect(self.updateNelements) + self.items_table = QTableWidget() + self.layer_table = LayerSelectionTable( + table_widget=self.items_table, + data_manager=self.data_manager, + feature_name_provider=lambda: self.feature.name, + name_validator=lambda: True, # Always valid in this context + ) # Form layout for better organization form_layout = QFormLayout() form_layout.addRow(self.interpolator_type_label, self.interpolator_type_combo) @@ -80,7 +90,7 @@ def __init__(self, parent=None, *, feature=None, model_manager=None): form_layout.addRow('Regularisation', self.regularisation_spin_box) form_layout.addRow('Contact points weight', self.cpw_spin_box) form_layout.addRow('Orientation point weight', self.npw_spin_box) - + form_layout.addRow(QLabel("Data Layers:"), self.items_table) QgsCollapsibleGroupBox = QWidget() QgsCollapsibleGroupBox.setLayout(form_layout) self.layout.addWidget(QgsCollapsibleGroupBox) @@ -116,8 +126,8 @@ def getNelements(self, feature): class FaultFeatureDetailsPanel(BaseFeatureDetailsPanel): - def __init__(self, parent=None, *, fault=None, model_manager=None): - super().__init__(parent, feature=fault, model_manager=model_manager) + def __init__(self, parent=None, *, fault=None, model_manager=None, data_manager=None): + super().__init__(parent, feature=fault, model_manager=model_manager, data_manager=data_manager) if fault is None: raise ValueError("Fault must be provided.") self.fault = fault @@ -198,8 +208,8 @@ def __init__(self, parent=None, *, fault=None, model_manager=None): class FoliationFeatureDetailsPanel(BaseFeatureDetailsPanel): - def __init__(self, parent=None, *, feature=None, model_manager=None): - super().__init__(parent, feature=feature, model_manager=model_manager) + def __init__(self, parent=None, *, feature=None, model_manager=None, data_manager=None): + super().__init__(parent, feature=feature, model_manager=model_manager, data_manager=data_manager) if feature is None: raise ValueError("Feature must be provided.") self.feature = feature @@ -226,13 +236,13 @@ def on_fold_frame_changed(self, text): class StructuralFrameFeatureDetailsPanel(BaseFeatureDetailsPanel): - def __init__(self, parent=None, *, feature=None, model_manager=None): - super().__init__(parent, feature=feature, model_manager=model_manager) + def __init__(self, parent=None, *, feature=None, model_manager=None, data_manager=None): + super().__init__(parent, feature=feature, model_manager=model_manager, data_manager=data_manager) class FoldedFeatureDetailsPanel(BaseFeatureDetailsPanel): - def __init__(self, parent=None, *, feature=None, model_manager=None): - super().__init__(parent, feature=feature, model_manager=model_manager) + def __init__(self, parent=None, *, feature=None, model_manager=None, data_manager=None): + super().__init__(parent, feature=feature, model_manager=model_manager, data_manager=data_manager) # Remove redundant layout setting # self.setLayout(self.layout) form_layout = QFormLayout() diff --git a/loopstructural/gui/modelling/geological_model_tab/geological_model_tab.py b/loopstructural/gui/modelling/geological_model_tab/geological_model_tab.py index b76225c..a9e3c9b 100644 --- a/loopstructural/gui/modelling/geological_model_tab/geological_model_tab.py +++ b/loopstructural/gui/modelling/geological_model_tab/geological_model_tab.py @@ -9,7 +9,7 @@ QWidget, ) -from loopstructural.gui.modelling.feature_details_panel import ( +from .feature_details_panel import ( FaultFeatureDetailsPanel, FoldedFeatureDetailsPanel, FoliationFeatureDetailsPanel, @@ -120,19 +120,19 @@ def on_feature_selected(self, item): feature = self.model_manager.model.get_feature_by_name(feature_name) if feature.type == FeatureType.FAULT: self.featureDetailsPanel = FaultFeatureDetailsPanel( - fault=feature, model_manager=self.model_manager + fault=feature, model_manager=self.model_manager, data_manager=self.data_manager ) elif feature.type == FeatureType.INTERPOLATED: self.featureDetailsPanel = FoliationFeatureDetailsPanel( - feature=feature, model_manager=self.model_manager + feature=feature, model_manager=self.model_manager, data_manager=self.data_manager ) elif feature.type == FeatureType.STRUCTURALFRAME: self.featureDetailsPanel = StructuralFrameFeatureDetailsPanel( - feature=feature, model_manager=self.model_manager + feature=feature, model_manager=self.model_manager, data_manager=self.data_manager ) elif feature.type == FeatureType.FOLDED: self.featureDetailsPanel = FoldedFeatureDetailsPanel( - feature=feature, model_manager=self.model_manager + feature=feature, model_manager=self.model_manager, data_manager=self.data_manager ) else: self.featureDetailsPanel = QWidget() # Default empty panel From 0bcc4dacb1e6ebb1882dd2370f0b2ca52ecca8cf Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Tue, 26 Aug 2025 13:38:14 +1000 Subject: [PATCH 20/26] fix: integrate layer selection table into add foliation dialog --- .../add_foliation_dialog.py | 53 +++++++++++-- .../feature_details_panel.py | 1 - .../layer_selection_table.py | 78 +++++++++++++++++-- 3 files changed, 118 insertions(+), 14 deletions(-) diff --git a/loopstructural/gui/modelling/geological_model_tab/add_foliation_dialog.py b/loopstructural/gui/modelling/geological_model_tab/add_foliation_dialog.py index 443a1d9..93088d7 100644 --- a/loopstructural/gui/modelling/geological_model_tab/add_foliation_dialog.py +++ b/loopstructural/gui/modelling/geological_model_tab/add_foliation_dialog.py @@ -15,16 +15,16 @@ def __init__(self, parent=None, *, data_manager=None, model_manager=None): loadUi(ui_path, self) self.setWindowTitle('Add Foliation') - # Initialize the abstracted table + # Create the layer selection table widget self.layer_table = LayerSelectionTable( - table_widget=self.items_table, data_manager=self.data_manager, feature_name_provider=lambda: self.name, name_validator=lambda: (self.name_valid, self.name_error) ) - # Connect add button - self.add_item_button.clicked.connect(self.layer_table.add_item_row) + # Replace or integrate with existing UI + self._integrate_layer_table() + self.buttonBox.accepted.connect(self.add_foliation) self.buttonBox.rejected.connect(self.cancel) @@ -99,9 +99,50 @@ def add_foliation(self): self.data_manager.add_foliation_to_model(self.name, folded_feature_name=folded_feature_name) self.accept() # Close the dialog - + def cancel(self): # Clean up any temporary data if necessary if self.name in self.data_manager.feature_data: self.data_manager.feature_data.pop(self.name, None) - self.reject() \ No newline at end of file + self.reject() + + def _integrate_layer_table(self): + """Integrate the layer table widget with the existing UI.""" + # Try to replace existing table widget if it exists + if hasattr(self, 'items_table'): + table_parent = self.items_table.parent() + + # Get the position of the original table + if hasattr(table_parent, 'layout') and table_parent.layout(): + layout = table_parent.layout() + + # Find the index of the original table in the layout + table_index = -1 + for i in range(layout.count()): + item = layout.itemAt(i) + if item and item.widget() == self.items_table: + table_index = i + break + + # Remove original widgets + if hasattr(self, 'items_table'): + self.items_table.setParent(None) + if hasattr(self, 'add_item_button'): + self.add_item_button.setParent(None) + + # Insert new widget at the same position + if table_index >= 0: + layout.insertWidget(table_index, self.layer_table) + else: + layout.addWidget(self.layer_table) + else: + # Fallback: add to parent widget directly + if hasattr(table_parent, 'layout') and not table_parent.layout(): + from PyQt5.QtWidgets import QVBoxLayout + layout = QVBoxLayout(table_parent) + table_parent.setLayout(layout) + layout.addWidget(self.layer_table) + + # If no existing table found, try to add to main layout + elif hasattr(self, 'layout') and self.layout(): + self.layout().addWidget(self.layer_table) \ No newline at end of file diff --git a/loopstructural/gui/modelling/geological_model_tab/feature_details_panel.py b/loopstructural/gui/modelling/geological_model_tab/feature_details_panel.py index 82f412e..ee6d99c 100644 --- a/loopstructural/gui/modelling/geological_model_tab/feature_details_panel.py +++ b/loopstructural/gui/modelling/geological_model_tab/feature_details_panel.py @@ -78,7 +78,6 @@ def __init__(self, parent=None, *, feature=None, model_manager=None, data_manage self.items_table = QTableWidget() self.layer_table = LayerSelectionTable( - table_widget=self.items_table, data_manager=self.data_manager, feature_name_provider=lambda: self.feature.name, name_validator=lambda: True, # Always valid in this context diff --git a/loopstructural/gui/modelling/geological_model_tab/layer_selection_table.py b/loopstructural/gui/modelling/geological_model_tab/layer_selection_table.py index 1bdeb94..4648f87 100644 --- a/loopstructural/gui/modelling/geological_model_tab/layer_selection_table.py +++ b/loopstructural/gui/modelling/geological_model_tab/layer_selection_table.py @@ -7,33 +7,70 @@ QPushButton, QTableWidget, QVBoxLayout, + QWidget, ) from qgis.core import QgsMapLayerProxyModel from qgis.gui import QgsFieldComboBox, QgsMapLayerComboBox -class LayerSelectionTable: - """Handles layer selection table functionality for geological features.""" +class LayerSelectionTable(QWidget): + """ + Self-contained widget for layer selection table functionality for geological features. - def __init__(self, table_widget, data_manager, feature_name_provider, name_validator): + This widget includes: + - A table for displaying selected layers with Type, Layer, and Delete columns + - An "Add Data" button at the bottom for adding new layers + - Complete data management integration with data_manager.feature_data + + Usage example: + # Create the widget + layer_table = LayerSelectionTable( + data_manager=my_data_manager, + feature_name_provider=lambda: "my_feature_name", + name_validator=lambda: (True, "") # or your validation logic + ) + + # Add to your layout + layout.addWidget(layer_table) + + # Access data + if layer_table.has_layers(): + data = layer_table.get_table_data() + """ + + def __init__(self, data_manager, feature_name_provider, name_validator, parent=None): """ - Initialize the layer selection table. + Initialize the layer selection table widget. Args: - table_widget: QTableWidget instance data_manager: Data manager instance feature_name_provider: Callable that returns the current feature name name_validator: Callable that returns (is_valid, error_message) + parent: Parent widget (optional) """ - self.table = table_widget + super().__init__(parent) self.data_manager = data_manager self.get_feature_name = feature_name_provider self.validate_name = name_validator - self._setup_table() + self._setup_ui() self.initialize_feature_data() self.restore_table_state() + def _setup_ui(self): + """Setup the widget UI with table and add button.""" + layout = QVBoxLayout(self) + + # Create the table widget + self.table = QTableWidget() + self._setup_table() + layout.addWidget(self.table) + + # Create add button + self.add_button = QPushButton("Add Data") + self.add_button.clicked.connect(self.add_item_row) + layout.addWidget(self.add_button) + def _setup_table(self): """Setup table columns and headers.""" self.table.setColumnCount(3) @@ -275,6 +312,33 @@ def get_layer_names(self): if feature_name and feature_name in self.data_manager.feature_data: return list(self.data_manager.feature_data[feature_name].keys()) return [] + + def get_table_widget(self): + """Get the internal table widget for direct access if needed.""" + return self.table + + def get_add_button(self): + """Get the add button widget for customization if needed.""" + return self.add_button + + def set_add_button_text(self, text): + """Set the text of the add button.""" + self.add_button.setText(text) + + def set_table_headers(self, headers): + """Set custom table headers.""" + if len(headers) == 3: + self.table.setHorizontalHeaderLabels(headers) + else: + raise ValueError("Headers list must contain exactly 3 items") + + def set_add_button_enabled(self, enabled): + """Enable or disable the add button.""" + self.add_button.setEnabled(enabled) + + def is_add_button_enabled(self): + """Check if the add button is enabled.""" + return self.add_button.isEnabled() class LayerSelectionDialog(QDialog): From 5016afbde1a63ca997aa7085d6fa05731280cac0 Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Tue, 26 Aug 2025 14:06:07 +1000 Subject: [PATCH 21/26] fix: upgrade LS --- loopstructural/requirements.txt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/loopstructural/requirements.txt b/loopstructural/requirements.txt index 1c14ece..ba0cec3 100644 --- a/loopstructural/requirements.txt +++ b/loopstructural/requirements.txt @@ -1,5 +1,3 @@ pyvistaqt pyvista -LoopStructural==1.6.17 -geoh5py -meshio +LoopStructural==1.6.20 From c4b649c76a48855f4c371146ffa59edeab30ecb3 Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Tue, 26 Aug 2025 14:09:28 +1000 Subject: [PATCH 22/26] fix: replace items table with layer selection table in base feature details panel --- .../modelling/geological_model_tab/feature_details_panel.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/loopstructural/gui/modelling/geological_model_tab/feature_details_panel.py b/loopstructural/gui/modelling/geological_model_tab/feature_details_panel.py index ee6d99c..3bbe7e7 100644 --- a/loopstructural/gui/modelling/geological_model_tab/feature_details_panel.py +++ b/loopstructural/gui/modelling/geological_model_tab/feature_details_panel.py @@ -76,11 +76,10 @@ def __init__(self, parent=None, *, feature=None, model_manager=None, data_manage self.n_elements_spinbox.valueChanged.connect(self.updateNelements) - self.items_table = QTableWidget() self.layer_table = LayerSelectionTable( data_manager=self.data_manager, feature_name_provider=lambda: self.feature.name, - name_validator=lambda: True, # Always valid in this context + name_validator=lambda: (True, ''), # Always valid in this context ) # Form layout for better organization form_layout = QFormLayout() @@ -89,10 +88,10 @@ def __init__(self, parent=None, *, feature=None, model_manager=None, data_manage form_layout.addRow('Regularisation', self.regularisation_spin_box) form_layout.addRow('Contact points weight', self.cpw_spin_box) form_layout.addRow('Orientation point weight', self.npw_spin_box) - form_layout.addRow(QLabel("Data Layers:"), self.items_table) QgsCollapsibleGroupBox = QWidget() QgsCollapsibleGroupBox.setLayout(form_layout) self.layout.addWidget(QgsCollapsibleGroupBox) + self.layout.addWidget(self.layer_table) # self.layout.addLayout(form_layout) From 7b7850366e5032a001b7231f6d445d5c1e4380de Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Tue, 26 Aug 2025 14:39:14 +1000 Subject: [PATCH 23/26] fix: add unconformity button --- .../add_unconformity_dialog.py | 68 +++++++++++++++++++ .../geological_model_tab.py | 12 +++- loopstructural/main/model_manager.py | 10 ++- 3 files changed, 87 insertions(+), 3 deletions(-) create mode 100644 loopstructural/gui/modelling/geological_model_tab/add_unconformity_dialog.py diff --git a/loopstructural/gui/modelling/geological_model_tab/add_unconformity_dialog.py b/loopstructural/gui/modelling/geological_model_tab/add_unconformity_dialog.py new file mode 100644 index 0000000..978095f --- /dev/null +++ b/loopstructural/gui/modelling/geological_model_tab/add_unconformity_dialog.py @@ -0,0 +1,68 @@ +from PyQt5.QtWidgets import ( + QComboBox, + QDialog, + QDialogButtonBox, + QDoubleSpinBox, + QFormLayout, + QVBoxLayout, + QWidget, +) +from LoopStructural.modelling.features import FeatureType +class AddUnconformityDialog(QDialog): + def __init__(self, parent=None, data_manager=None, model_manager=None): + super().__init__(parent) + self.data_manager = data_manager + self.model_manager = model_manager + self.setWindowTitle("Add Unconformity") + self.setMinimumWidth(300) + + layout = QVBoxLayout() + + form_layout = QFormLayout() + + self.foliation_combo = QComboBox() + foliations = [ + feature.name + for feature in self.model_manager.features() + if feature.type == FeatureType.INTERPOLATED + ] + self.foliation_combo.addItems(foliations) + form_layout.addRow("Foliation:", self.foliation_combo) + + self.value_spinbox = QDoubleSpinBox() + self.value_spinbox.setRange(-1e6, 1e6) + self.value_spinbox.setDecimals(3) + self.value_spinbox.setValue(0.0) + form_layout.addRow("Value:", self.value_spinbox) + + self.type_combo = QComboBox() + self.type_combo.addItems(["Unconformity", "Onlap Unconformity"]) + form_layout.addRow("Type:", self.type_combo) + + layout.addLayout(form_layout) + + button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + button_box.accepted.connect(self.accept) + button_box.rejected.connect(self.reject) + layout.addWidget(button_box) + + self.setLayout(layout) + + def accept(self): + foliation_name = self.foliation_combo.currentText() + value = self.value_spinbox.value() + type_text = self.type_combo.currentText() + if type_text == "Unconformity": + feature_type = FeatureType.UNCONFORMITY + else: + feature_type = FeatureType.ONLAPUNCONFORMITY + + try: + self.model_manager.add_unconformity(foliation_name, value, feature_type) + super().accept() + except ValueError as e: + from PyQt5.QtWidgets import QMessageBox + + QMessageBox.critical(self, "Error", str(e)) + def reject(self): + super().reject() \ No newline at end of file diff --git a/loopstructural/gui/modelling/geological_model_tab/geological_model_tab.py b/loopstructural/gui/modelling/geological_model_tab/geological_model_tab.py index a9e3c9b..139ae06 100644 --- a/loopstructural/gui/modelling/geological_model_tab/geological_model_tab.py +++ b/loopstructural/gui/modelling/geological_model_tab/geological_model_tab.py @@ -20,6 +20,7 @@ # Import the AddFaultDialog from .add_fault_dialog import AddFaultDialog from .add_foliation_dialog import AddFoliationDialog +from .add_unconformity_dialog import AddUnconformityDialog class GeologicalModelTab(QWidget): @@ -75,6 +76,7 @@ def show_add_feature_menu(self, *args): menu = QMenu(self) add_fault = menu.addAction("Add Fault") add_foliaton = menu.addAction("Add Foliation") + add_unconformity = menu.addAction("Add Unconformity") buttonPosition = self.sender().mapToGlobal(self.sender().rect().bottomLeft()) action = menu.exec_(buttonPosition) @@ -82,7 +84,8 @@ def show_add_feature_menu(self, *args): self.open_add_fault_dialog() elif action == add_foliaton: self.open_add_foliation_dialog() - + elif action == add_unconformity: + self.open_add_unconformity_dialog() def open_add_fault_dialog(self): dialog = AddFaultDialog(self) if dialog.exec_() == dialog.Accepted: @@ -96,7 +99,12 @@ def open_add_foliation_dialog(self): ) if dialog.exec_() == dialog.Accepted: pass - + def open_add_unconformity_dialog(self): + dialog = AddUnconformityDialog( + self, data_manager=self.data_manager, model_manager=self.model_manager + ) + if dialog.exec_() == dialog.Accepted: + pass def initialize_model(self): self.model_manager.update_model() diff --git a/loopstructural/main/model_manager.py b/loopstructural/main/model_manager.py index cf69e3f..f59db4b 100644 --- a/loopstructural/main/model_manager.py +++ b/loopstructural/main/model_manager.py @@ -389,7 +389,15 @@ def add_foliation( # self.model[folded_feature_name] = folded_feature for observer in self.observers: observer() - + def add_unconformity(self, foliation_name: str, value: float, type: FeatureType = FeatureType.UNCONFORMITY): + foliation = self.model.get_feature_by_name(foliation_name) + if foliation is None: + raise ValueError(f"Foliation '{foliation_name}' not found in the model.") + if type == FeatureType.UNCONFORMITY: + self.model.add_unconformity(foliation, value) + elif type == FeatureType.ONLAPUNCONFORMITY: + self.model.add_onlap_unconformity(foliation, value) + def add_fold_to_feature(self, feature_name: str, fold_frame_name: str, fold_weights={}): from LoopStructural.modelling.features._feature_converters import add_fold_to_feature From a0279101cdd44c522a419e07d19526ad46f2771b Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Tue, 26 Aug 2025 15:58:28 +1000 Subject: [PATCH 24/26] fix: adding splot dialog --- .../feature_details_panel.py | 11 ++++- .../modelling/geological_model_tab/splot.py | 49 +++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 loopstructural/gui/modelling/geological_model_tab/splot.py diff --git a/loopstructural/gui/modelling/geological_model_tab/feature_details_panel.py b/loopstructural/gui/modelling/geological_model_tab/feature_details_panel.py index 3bbe7e7..359d622 100644 --- a/loopstructural/gui/modelling/geological_model_tab/feature_details_panel.py +++ b/loopstructural/gui/modelling/geological_model_tab/feature_details_panel.py @@ -13,6 +13,7 @@ ) from .layer_selection_table import LayerSelectionTable +from .splot import SPlotDialog from LoopStructural.modelling.features import StructuralFrame from LoopStructural.utils import normal_vector_to_strike_and_dip, plungeazimuth2vector @@ -334,12 +335,20 @@ def __init__(self, parent=None, *, feature=None, model_manager=None, data_manage form_layout.addRow(average_fold_axis_checkbox) form_layout.addRow("Fold Plunge", fold_plunge) form_layout.addRow("Fold Azimuth", fold_azimuth) + # splot_button = QPushButton("S-Plot") + # splot_button.clicked.connect( + # lambda: self.open_splot_dialog() + # ) + form_layout.addRow(splot_button) QgsCollapsibleGroupBox = QWidget() QgsCollapsibleGroupBox.setLayout(form_layout) self.layout.addWidget(QgsCollapsibleGroupBox) # Remove redundant layout setting self.setLayout(self.layout) - + def open_splot_dialog(self): + dialog = SPlotDialog(self, data_manager=self.data_manager, model_manager=self.model_manager, feature_name=self.feature.name) + if dialog.exec_() == dialog.Accepted: + pass def remove_fold_frame(self): pass diff --git a/loopstructural/gui/modelling/geological_model_tab/splot.py b/loopstructural/gui/modelling/geological_model_tab/splot.py new file mode 100644 index 0000000..710bd3f --- /dev/null +++ b/loopstructural/gui/modelling/geological_model_tab/splot.py @@ -0,0 +1,49 @@ +import numpy as np +import pyqtgraph as pg +from qgis.PyQt.QtWidgets import ( + QComboBox, + QDialog, + QDialogButtonBox, + QDoubleSpinBox, + QFormLayout, + QVBoxLayout, + QWidget, +) +from LoopStructural.modelling.features import FeatureType + + +class SPlotDialog(QDialog): + def __init__(self, parent=None, *, data_manager=None, model_manager=None, feature_name=None): + super().__init__(parent) + self.data_manager = data_manager + self.model_manager = model_manager + self.feature_name = feature_name + self.setWindowTitle('S-Plot') + self.setMinimumWidth(300) + feature = self.model_manager.model.get_feature_by_name(self.feature_name) + layout = QVBoxLayout() + + + fold_frame = feature.fold.fold_limb_rotation.fold_frame_coordinate + rotation = feature.fold.fold_limb_rotation.rotation_angle + # Placeholder scatter plot using pyqtgraph + self.plot_widget = pg.PlotWidget() + + self.plot_widget.plot( + fold_frame, + rotation, + pen=None, + symbol='o', + symbolSize=6, + symbolBrush=(100, 150, 255, 200), + ) + self.plot_widget.setLabel('left', 'Fold limb rotation angle (°)') + self.plot_widget.setLabel('bottom', 'Fold frame coordinate') + layout.addWidget(self.plot_widget) + + button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + button_box.accepted.connect(self.accept) + button_box.rejected.connect(self.reject) + layout.addWidget(button_box) + + self.setLayout(layout) From 5c9dfe74480962d68bc48e503a453ef8ba43e59c Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Tue, 26 Aug 2025 15:58:38 +1000 Subject: [PATCH 25/26] fix: convert from structural frame to fold frame --- loopstructural/main/model_manager.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/loopstructural/main/model_manager.py b/loopstructural/main/model_manager.py index f59db4b..71eeb3f 100644 --- a/loopstructural/main/model_manager.py +++ b/loopstructural/main/model_manager.py @@ -9,7 +9,8 @@ from LoopStructural.datatypes import BoundingBox from LoopStructural.modelling.core.fault_topology import FaultRelationshipType from LoopStructural.modelling.core.stratigraphic_column import StratigraphicColumn -from LoopStructural.modelling.features import FeatureType +from LoopStructural.modelling.features import FeatureType, StructuralFrame +from LoopStructural.modelling.features.fold import FoldFrame from loopstructural.toolbelt.preferences import PlgSettingsStructure @@ -397,12 +398,14 @@ def add_unconformity(self, foliation_name: str, value: float, type: FeatureType self.model.add_unconformity(foliation, value) elif type == FeatureType.ONLAPUNCONFORMITY: self.model.add_onlap_unconformity(foliation, value) - + def add_fold_to_feature(self, feature_name: str, fold_frame_name: str, fold_weights={}): from LoopStructural.modelling.features._feature_converters import add_fold_to_feature fold_frame = self.model.get_feature_by_name(fold_frame_name) + if isinstance(fold_frame,StructuralFrame): + fold_frame = FoldFrame(fold_frame.name,fold_frame.features, None, fold_frame.model) if fold_frame is None: raise ValueError(f"Fold frame '{fold_frame_name}' not found in the model.") feature = self.model.get_feature_by_name(feature_name) From 67b06ba3a5cd29f409b2c15b956f83f95c7bdddb Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Tue, 26 Aug 2025 15:59:30 +1000 Subject: [PATCH 26/26] fix: add pyqtgraph --- loopstructural/requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/loopstructural/requirements.txt b/loopstructural/requirements.txt index ba0cec3..550f000 100644 --- a/loopstructural/requirements.txt +++ b/loopstructural/requirements.txt @@ -1,3 +1,4 @@ pyvistaqt pyvista LoopStructural==1.6.20 +pyqtgraph \ No newline at end of file