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 bdc3bfa..7056445 100644 --- a/loopstructural/gui/modelling/geological_model_tab/feature_details_panel.py +++ b/loopstructural/gui/modelling/geological_model_tab/feature_details_panel.py @@ -1,4 +1,4 @@ -from PyQt5.QtCore import Qt +from PyQt5.QtCore import Qt, QTimer from PyQt5.QtWidgets import ( QCheckBox, QComboBox, @@ -10,16 +10,24 @@ QVBoxLayout, QWidget, ) -from qgis.gui import QgsMapLayerComboBox, QgsCollapsibleGroupBox - +from qgis.gui import QgsCollapsibleGroupBox, QgsMapLayerComboBox from qgis.utils import plugins + +from LoopStructural import getLogger +from LoopStructural.modelling.features import StructuralFrame +from LoopStructural.utils import ( + normal_vector_to_strike_and_dip, + plungeazimuth2vector, + strikedip2vector, +) + +from .bounding_box_widget import BoundingBoxWidget from .layer_selection_table import LayerSelectionTable from .splot import SPlotDialog -from .bounding_box_widget import BoundingBoxWidget -from LoopStructural.modelling.features import StructuralFrame -from LoopStructural.utils import normal_vector_to_strike_and_dip, plungeazimuth2vector -from LoopStructural import getLogger + logger = getLogger(__name__) + + class BaseFeatureDetailsPanel(QWidget): def __init__(self, parent=None, *, feature=None, model_manager=None, data_manager=None): super().__init__(parent) @@ -47,7 +55,13 @@ def __init__(self, parent=None, *, feature=None, model_manager=None, data_manage # Set the main layout self.setLayout(mainLayout) - + # Debounce timer for rebuilds: schedule a single rebuild after user stops + # interacting for a short interval to avoid repeated expensive builds. + self._rebuild_timer = QTimer(self) + self._rebuild_timer.setSingleShot(True) + self._rebuild_timer.setInterval(500) # milliseconds; adjust as desired + self._rebuild_timer.timeout.connect(self._perform_rebuild) + ## define interpolator parameters # Regularisation spin box self.regularisation_spin_box = QDoubleSpinBox() @@ -55,21 +69,31 @@ def __init__(self, parent=None, *, feature=None, model_manager=None, data_manage self.regularisation_spin_box.setValue( feature.builder.build_arguments.get('regularisation', 1.0) ) + # Update build arguments and schedule a debounced rebuild self.regularisation_spin_box.valueChanged.connect( - lambda value: self.feature.builder.update_build_arguments({'regularisation': value}) + lambda value: ( + self.feature.builder.update_build_arguments({'regularisation': value}), + self.schedule_rebuild(), + ) ) self.cpw_spin_box = QDoubleSpinBox() self.cpw_spin_box.setRange(0, 100) self.cpw_spin_box.setValue(feature.builder.build_arguments.get('cpw', 1.0)) self.cpw_spin_box.valueChanged.connect( - lambda value: self.feature.builder.update_build_arguments({'cpw': value}) + lambda value: ( + self.feature.builder.update_build_arguments({'cpw': value}), + self.schedule_rebuild(), + ) ) self.npw_spin_box = QDoubleSpinBox() self.npw_spin_box.setRange(0, 100) self.npw_spin_box.setValue(feature.builder.build_arguments.get('npw', 1.0)) self.npw_spin_box.valueChanged.connect( - lambda value: self.feature.builder.update_build_arguments({'npw': value}) + lambda value: ( + self.feature.builder.update_build_arguments({'npw': value}), + self.schedule_rebuild(), + ) ) self.interpolator_type_label = QLabel("Interpolator Type:") self.interpolator_type_combo = QComboBox() @@ -104,6 +128,7 @@ def __init__(self, parent=None, *, feature=None, model_manager=None, data_manage self.layout.addWidget(table_group_box) self.addMidBlock() self.addExportBlock() + def addMidBlock(self): """Base mid block is intentionally empty now — bounding-box controls were moved into the export/evaluation section so they appear alongside @@ -120,7 +145,9 @@ def addExportBlock(self): self.export_eval_layout.setSpacing(6) # --- Bounding box controls (moved here into dedicated widget) --- - bb_widget = BoundingBoxWidget(parent=self, model_manager=self.model_manager, data_manager=self.data_manager) + bb_widget = BoundingBoxWidget( + parent=self, model_manager=self.model_manager, data_manager=self.data_manager + ) # keep reference so export handlers can use it self.bounding_box_widget = bb_widget self.export_eval_layout.addWidget(bb_widget) @@ -142,7 +169,9 @@ def addExportBlock(self): # Evaluate target: bounding-box centres or project point layer self.evaluate_target_combo = QComboBox() - self.evaluate_target_combo.addItems(["Bounding box cell centres", "Project point layer","Viewer Object"]) + self.evaluate_target_combo.addItems( + ["Bounding box cell centres", "Project point layer", "Viewer Object"] + ) export_layout.addRow("Evaluate on:", self.evaluate_target_combo) # Project layer selector (populated with point vector layers from project) @@ -160,10 +189,11 @@ def addExportBlock(self): lbl = export_layout.labelForField(self.meshObjectCombo) if lbl is not None: lbl.setVisible(False) + # Connect evaluate target change to enable/disable project layer combo def _on_evaluate_target_changed(index): - use_project = (index == 1) - use_vtk = (index == 2) + use_project = index == 1 + use_vtk = index == 2 self.project_layer_combo.setVisible(use_project) self.project_layer_combo.setEnabled(use_project) self.meshObjectCombo.setVisible(use_vtk) @@ -182,13 +212,8 @@ def _on_evaluate_target_changed(index): viewer = self.plugin.loop_widget.visualisation_widget.plotter mesh_names = list(viewer.meshes.keys()) self.meshObjectCombo.addItems(mesh_names) - self.evaluate_target_combo.currentIndexChanged.connect(_on_evaluate_target_changed) - - - - - + self.evaluate_target_combo.currentIndexChanged.connect(_on_evaluate_target_changed) # Export button self.export_points_button = QPushButton("Export to QGIS points") @@ -204,18 +229,18 @@ def _on_evaluate_target_changed(index): # These blocks are intentionally minimal now (only a disabled label) and # will be populated with export/evaluate controls later. if self.model_manager is not None: - for feat in self.model_manager.features(): + for feat in self.model_manager.features(): block = QWidget() block.setObjectName(f"export_block_{getattr(feat, 'name', 'feature')}") block_layout = QVBoxLayout(block) block_layout.setContentsMargins(0, 0, 0, 0) self.export_eval_layout.addWidget(block) - self.export_blocks[getattr(feat, 'name', f"feature_{len(self.export_blocks)}")] = block + self.export_blocks[getattr(feat, 'name', f"feature_{len(self.export_blocks)}")] = ( + block + ) self.layout.addWidget(self.export_eval_container) - - def _on_bounding_box_updated(self, bounding_box): """Callback to update UI widgets when bounding box object changes externally. @@ -237,21 +262,29 @@ def _on_bounding_box_updated(self, bounding_box): pass try: - if getattr(bounding_box, 'nelements', None) is not None and hasattr(self, 'bb_nelements_spinbox'): + if getattr(bounding_box, 'nelements', None) is not None and hasattr( + self, 'bb_nelements_spinbox' + ): try: self.bb_nelements_spinbox.setValue(int(getattr(bounding_box, 'nelements'))) except Exception: try: self.bb_nelements_spinbox.setValue(getattr(bounding_box, 'nelements')) except Exception: - logger.debug('Could not set nelements spinbox from bounding_box', exc_info=True) + logger.debug( + 'Could not set nelements spinbox from bounding_box', exc_info=True + ) if getattr(bounding_box, 'nsteps', None) is not None: try: nsteps = list(bounding_box.nsteps) except Exception: try: - nsteps = [int(bounding_box.nsteps[0]), int(bounding_box.nsteps[1]), int(bounding_box.nsteps[2])] + nsteps = [ + int(bounding_box.nsteps[0]), + int(bounding_box.nsteps[1]), + int(bounding_box.nsteps[2]), + ] except Exception: nsteps = None if nsteps is not None: @@ -263,7 +296,9 @@ def _on_bounding_box_updated(self, bounding_box): if hasattr(self, 'bb_nsteps_z'): self.bb_nsteps_z.setValue(int(nsteps[2])) except Exception: - logger.debug('Could not set nsteps spinboxes from bounding_box', exc_info=True) + logger.debug( + 'Could not set nsteps spinboxes from bounding_box', exc_info=True + ) finally: # Unblock signals @@ -281,12 +316,14 @@ def updateNelements(self, value): if self.feature[i].interpolator is not None: self.feature[i].interpolator.nelements = value self.feature[i].builder.update_build_arguments({'nelements': value}) - self.feature[i].builder.build() + # schedule a single debounced rebuild after user stops changing value + self.schedule_rebuild() elif self.feature.interpolator is not None: self.feature.interpolator.nelements = value self.feature.builder.update_build_arguments({'nelements': value}) - self.feature.builder.build() + # schedule a debounced rebuild instead of building immediately + self.schedule_rebuild() else: print("Error: Feature is not initialized.") @@ -298,6 +335,7 @@ def getNelements(self, feature): elif feature.interpolator is not None: return feature.interpolator.n_elements return 1000 + def _export_scalar_points(self): """Gather points (bounding-box centres or project point layer), evaluate feature values using the model_manager and add the resulting GeoDataFrame as a memory layer to the @@ -306,7 +344,11 @@ def _export_scalar_points(self): """ # determine scalar type logger.info('Exporting scalar points') - scalar_type = self.scalar_field_combo.currentText() if hasattr(self, 'scalar_field_combo') else 'scalar' + scalar_type = ( + self.scalar_field_combo.currentText() + if hasattr(self, 'scalar_field_combo') + else 'scalar' + ) # gather points pts = None @@ -314,7 +356,7 @@ def _export_scalar_points(self): crs = self.data_manager.project.crs().authid() try: # QGIS imports (guarded) - from qgis.core import QgsProject, QgsVectorLayer, QgsFeature, QgsPoint, QgsField + from qgis.core import QgsFeature, QgsField, QgsPoint, QgsProject, QgsVectorLayer from qgis.PyQt.QtCore import QVariant except Exception as e: # Not running inside QGIS — nothing to do @@ -326,10 +368,7 @@ def _export_scalar_points(self): if self.evaluate_target_combo.currentIndex() == 0: # use bounding-box resolution or custom nsteps logger.info('Using bounding box cell centres for evaluation') - - - pts = self.model_manager.model.bounding_box.cell_centres() # no extra attributes for grid attributes_df = None @@ -364,13 +403,21 @@ def _export_scalar_points(self): try: z = p.z() except Exception: - z = self.model_manager.dem_function(x, y) if hasattr(self.model_manager, 'dem_function') else 0 + z = ( + self.model_manager.dem_function(x, y) + if hasattr(self.model_manager, 'dem_function') + else 0 + ) except Exception: # fallback to centroid try: c = geom.centroid().asPoint() x, y = c.x(), c.y() - z = self.model_manager.dem_function(x, y) if hasattr(self.model_manager, 'dem_function') else 0 + z = ( + self.model_manager.dem_function(x, y) + if hasattr(self.model_manager, 'dem_function') + else 0 + ) except Exception: continue pts_list.append((x, y, z)) @@ -385,6 +432,7 @@ def _export_scalar_points(self): if len(pts_list) == 0: return import pandas as _pd + pts = _pd.DataFrame(pts_list).values try: attributes_df = _pd.DataFrame(attrs) @@ -396,7 +444,7 @@ def _export_scalar_points(self): crs = None elif self.evaluate_target_combo.currentIndex() == 2: # Evaluate on an object from the viewer - # These are all pyvista objects and we want to add + # These are all pyvista objects and we want to add # the scalar as a new field to the objects viewer = self.plugin.loop_widget.visualisation_widget.plotter @@ -407,9 +455,7 @@ def _export_scalar_points(self): return vtk_mesh = viewer.meshes[mesh]['mesh'] self.model_manager.export_feature_values_to_vtk_mesh( - self.feature.name, - vtk_mesh, - scalar_type=scalar_type + self.feature.name, vtk_mesh, scalar_type=scalar_type ) # call model_manager to produce GeoDataFrame try: @@ -494,14 +540,45 @@ def _export_scalar_points(self): mem_layer.updateExtents() QgsProject.instance().addMapLayer(mem_layer) + def schedule_rebuild(self, delay_ms: int = 500): + """Schedule a debounced rebuild of the current feature. + + Multiple calls will reset the timer so only a single rebuild occurs + after user activity has settled. + """ + try: + if self._rebuild_timer is None: + return + self._rebuild_timer.stop() + self._rebuild_timer.setInterval(delay_ms) + self._rebuild_timer.start() + except Exception: + logger.debug('Failed to schedule debounced rebuild', exc_info=True) + pass + + def _perform_rebuild(self): + """Perform the actual build operation when the debounce timer fires.""" + try: + if not hasattr(self, 'feature') or self.feature is None: + return + # StructuralFrame consists of three sub-features + self.model_manager.update_feature(self.feature.name) + + except Exception: + logger.debug('Debounced rebuild failed', exc_info=True) + + class FaultFeatureDetailsPanel(BaseFeatureDetailsPanel): 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) + 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 - dip = normal_vector_to_strike_and_dip(fault.fault_normal_vector)[0, 0] + dip = normal_vector_to_strike_and_dip(fault.fault_normal_vector)[0, 1] + pitch = 0 self.fault_parameters = { 'displacement': fault.displacement, @@ -513,76 +590,90 @@ def __init__(self, parent=None, *, fault=None, model_manager=None, data_manager= # 'enabled': fault.fault_enabled } - # # Fault displacement slider - # self.displacement_spinbox = QDoubleSpinBox() - # self.displacement_spinbox.setRange(0, 1000000) # Example range - # self.displacement_spinbox.setValue(self.fault.displacement) - # self.displacement_spinbox.valueChanged.connect( - # lambda value: self.fault_parameters.__setitem__('displacement', value) - # ) + def update_displacement(value): + self.fault.displacement = value + + def update_major_axis(value): + self.fault.fault_major_axis = value + # schedule a debounced rebuild so multiple rapid edits are coalesced + self.schedule_rebuild() + + def update_minor_axis(value): + self.fault.fault_minor_axis = value + self.schedule_rebuild() + + def update_intermediate_axis(value): + self.fault.fault_intermediate_axis = value + self.schedule_rebuild() + + def update_dip(value): + strike = normal_vector_to_strike_and_dip(self.fault.fault_normal_vector)[0, 0] + self.fault.builder.fault_normal_vector = strikedip2vector([strike], [value])[0] + self.schedule_rebuild() + + # Fault displacement slider + self.displacement_spinbox = QDoubleSpinBox() + self.displacement_spinbox.setRange(0, 1000000) # Example range + self.displacement_spinbox.setValue(self.fault.displacement) + self.displacement_spinbox.valueChanged.connect(update_displacement) + + # Fault axis lengths + self.major_axis_spinbox = QDoubleSpinBox() + self.major_axis_spinbox.setRange(0, float('inf')) + self.major_axis_spinbox.setValue(self.fault.fault_major_axis) + # self.major_axis_spinbox.setPrefix("Major Axis Length: ") + self.major_axis_spinbox.valueChanged.connect(update_major_axis) + self.minor_axis_spinbox = QDoubleSpinBox() + self.minor_axis_spinbox.setRange(0, float('inf')) + self.minor_axis_spinbox.setValue(self.fault.fault_minor_axis) + # self.minor_axis_spinbox.setPrefix("Minor Axis Length: ") + self.minor_axis_spinbox.valueChanged.connect(update_minor_axis) + self.intermediate_axis_spinbox = QDoubleSpinBox() + self.intermediate_axis_spinbox.setRange(0, float('inf')) + self.intermediate_axis_spinbox.setValue(fault.fault_intermediate_axis) + self.intermediate_axis_spinbox.valueChanged.connect(update_intermediate_axis) + # self.intermediate_axis_spinbox.setPrefix("Intermediate Axis Length: ") + + # Fault dip field + self.dip_spinbox = QDoubleSpinBox() + self.dip_spinbox.setRange(0, 90) # Dip angle range + self.dip_spinbox.setValue(dip) + # self.dip_spinbox.setPrefix("Fault Dip: ") + self.dip_spinbox.valueChanged.connect(update_dip) + self.pitch_spinbox = QDoubleSpinBox() + self.pitch_spinbox.setRange(0, 180) + self.pitch_spinbox.setValue(self.fault_parameters['pitch']) + self.pitch_spinbox.valueChanged.connect( + lambda value: self.fault_parameters.__setitem__('pitch', value) + ) + # self.dip_spinbox.valueChanged.connect( - # # Fault axis lengths - # self.major_axis_spinbox = QDoubleSpinBox() - # self.major_axis_spinbox.setRange(0, float('inf')) - # self.major_axis_spinbox.setValue(self.fault_parameters['major_axis_length']) - # # self.major_axis_spinbox.setPrefix("Major Axis Length: ") - # self.major_axis_spinbox.valueChanged.connect( - # lambda value: self.fault_parameters.__setitem__('major_axis_length', value) - # ) - # self.minor_axis_spinbox = QDoubleSpinBox() - # self.minor_axis_spinbox.setRange(0, float('inf')) - # self.minor_axis_spinbox.setValue(self.fault_parameters['minor_axis_length']) - # # self.minor_axis_spinbox.setPrefix("Minor Axis Length: ") - # self.minor_axis_spinbox.valueChanged.connect( - # lambda value: self.fault_parameters.__setitem__('minor_axis_length', value) - # ) - # self.intermediate_axis_spinbox = QDoubleSpinBox() - # self.intermediate_axis_spinbox.setRange(0, float('inf')) - # self.intermediate_axis_spinbox.setValue(self.fault_parameters['intermediate_axis_length']) - # self.intermediate_axis_spinbox.valueChanged.connect( - # lambda value: self.fault_parameters.__setitem__('intermediate_axis_length', value) - # ) - # # self.intermediate_axis_spinbox.setPrefix("Intermediate Axis Length: ") + # Enabled field + # self.enabled_checkbox = QCheckBox("Enabled") + # self.enabled_checkbox.setChecked(False) - # # Fault dip field - # self.dip_spinbox = QDoubleSpinBox() - # self.dip_spinbox.setRange(0, 90) # Dip angle range - # self.dip_spinbox.setValue(self.fault_parameters['dip']) - # # self.dip_spinbox.setPrefix("Fault Dip: ") - # self.dip_spinbox.valueChanged.connect( - # lambda value: self.fault_parameters.__setitem__('dip', value) - # ) - # self.pitch_spinbox = QDoubleSpinBox() - # self.pitch_spinbox.setRange(0, 180) - # self.pitch_spinbox.setValue(self.fault_parameters['pitch']) - # self.pitch_spinbox.valueChanged.connect( - # lambda value: self.fault_parameters.__setitem__('pitch', value) - # ) - # # self.dip_spinbox.valueChanged.connect( - - # # Enabled field - # # self.enabled_checkbox = QCheckBox("Enabled") - # # self.enabled_checkbox.setChecked(False) - - # # Form layout for better organization - # form_layout = QFormLayout() - # form_layout.addRow("Fault displacement", self.displacement_spinbox) - # form_layout.addRow("Major Axis Length", self.major_axis_spinbox) - # form_layout.addRow("Minor Axis Length", self.minor_axis_spinbox) - # form_layout.addRow("Intermediate Axis Length", self.intermediate_axis_spinbox) - # form_layout.addRow("Fault Dip", self.dip_spinbox) - # # form_layout.addRow("Enabled:", self.enabled_checkbox) - - # self.layout.addLayout(form_layout) - # self.setLayout(self.layout) + # Form layout for better organization + form_layout = QFormLayout() + form_layout.addRow("Fault displacement", self.displacement_spinbox) + form_layout.addRow("Major Axis Length", self.major_axis_spinbox) + form_layout.addRow("Minor Axis Length", self.minor_axis_spinbox) + form_layout.addRow("Intermediate Axis Length", self.intermediate_axis_spinbox) + form_layout.addRow("Fault Dip", self.dip_spinbox) + # form_layout.addRow("Enabled:", self.enabled_checkbox) + + self.layout.addLayout(form_layout) + self.setLayout(self.layout) class FoliationFeatureDetailsPanel(BaseFeatureDetailsPanel): 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) + 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 + def addMidBlock(self): form_layout = QFormLayout() fold_frame_combobox = QComboBox() @@ -602,20 +693,24 @@ def addMidBlock(self): # 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, data_manager=None): - super().__init__(parent, feature=feature, model_manager=model_manager, data_manager=data_manager) + 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, data_manager=None): - super().__init__(parent, feature=feature, model_manager=model_manager, data_manager=data_manager) - def addMidBlock(self): + super().__init__( + parent, feature=feature, model_manager=model_manager, data_manager=data_manager + ) + + def addMidBlock(self): # Remove redundant layout setting # self.setLayout(self.layout) form_layout = QFormLayout() @@ -636,6 +731,7 @@ def addMidBlock(self): } ) ) + norm_length.valueChanged.connect(lambda value: self.schedule_rebuild()) form_layout.addRow("Normal Length", norm_length) norm_weight = QDoubleSpinBox() @@ -651,6 +747,7 @@ def addMidBlock(self): } ) ) + norm_weight.valueChanged.connect(lambda value: self.schedule_rebuild()) form_layout.addRow("Normal Weight", norm_weight) fold_axis_weight = QDoubleSpinBox() @@ -666,6 +763,7 @@ def addMidBlock(self): } ) ) + fold_axis_weight.valueChanged.connect(lambda value: self.schedule_rebuild()) form_layout.addRow("Fold Axis Weight", fold_axis_weight) fold_orientation_weight = QDoubleSpinBox() @@ -681,6 +779,7 @@ def addMidBlock(self): } ) ) + fold_orientation_weight.valueChanged.connect(lambda value: self.schedule_rebuild()) form_layout.addRow("Fold Orientation Weight", fold_orientation_weight) average_fold_axis_checkbox = QCheckBox("Average Fold Axis") @@ -719,23 +818,27 @@ def addMidBlock(self): self.layout.addWidget(group_box) # 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) + 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 def foldAxisFromPlungeAzimuth(self): """Calculate the fold axis from plunge and azimuth.""" if self.feature: - plunge = ( - self.fold_plunge.value() - ) - azimuth = ( - self.fold_azimuth.value()) + plunge = self.fold_plunge.value() + azimuth = self.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.tolist()}) - - + # schedule rebuild after updating builder arguments + self.schedule_rebuild() 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 8f3832b..533bd8f 100644 --- a/loopstructural/gui/modelling/geological_model_tab/geological_model_tab.py +++ b/loopstructural/gui/modelling/geological_model_tab/geological_model_tab.py @@ -1,6 +1,8 @@ -from PyQt5.QtCore import Qt +from PyQt5.QtCore import QObject, Qt, QThread, pyqtSignal, pyqtSlot from PyQt5.QtWidgets import ( QMenu, + QMessageBox, + QProgressDialog, QPushButton, QSplitter, QTreeWidget, @@ -9,18 +11,18 @@ QWidget, ) -from .feature_details_panel import ( - FaultFeatureDetailsPanel, - FoldedFeatureDetailsPanel, - FoliationFeatureDetailsPanel, - StructuralFrameFeatureDetailsPanel, -) from LoopStructural.modelling.features import FeatureType # Import the AddFaultDialog from .add_fault_dialog import AddFaultDialog from .add_foliation_dialog import AddFoliationDialog from .add_unconformity_dialog import AddUnconformityDialog +from .feature_details_panel import ( + FaultFeatureDetailsPanel, + FoldedFeatureDetailsPanel, + FoliationFeatureDetailsPanel, + StructuralFrameFeatureDetailsPanel, +) class GeologicalModelTab(QWidget): @@ -28,7 +30,28 @@ 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) + # Register update observer using Observable API if available + if self.model_manager is not None: + try: + # listen for model-level updates + self._disp_model = self.model_manager.attach( + self.update_feature_list, 'model_updated' + ) + # show progress when model updates start/finish (covers indirect calls) + self._disp_update_start = self.model_manager.attach( + lambda _obs, _ev, *a, **k: self._on_model_update_started(), + 'model_update_started', + ) + self._disp_update_finish = self.model_manager.attach( + lambda _obs, _ev, *a, **k: self._on_model_update_finished(), + 'model_update_finished', + ) + except Exception: + # fallback to legacy list + try: + self.model_manager.observers.append(self.update_feature_list) + except Exception: + pass # Main layout mainLayout = QVBoxLayout(self) @@ -75,6 +98,10 @@ def __init__(self, parent=None, *, model_manager=None, data_manager=None): # Connect feature selection to update details panel self.featureList.itemClicked.connect(self.on_feature_selected) + # thread handle to keep worker alive while running + self._model_update_thread = None + self._model_update_worker = None + def show_add_feature_menu(self, *args): menu = QMenu(self) add_fault = menu.addAction("Add Fault") @@ -89,6 +116,7 @@ def show_add_feature_menu(self, *args): 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: @@ -102,16 +130,95 @@ 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() + # Run update_model in a background thread to avoid blocking the UI. + if not self.model_manager: + return + + # create progress dialog (indeterminate) + progress = QProgressDialog("Updating geological model...", "Cancel", 0, 0, self) + progress.setWindowModality(Qt.ApplicationModal) + progress.setWindowTitle("Updating Model") + progress.setCancelButton(None) + progress.setMinimumDuration(0) + progress.show() + + # worker and thread + thread = QThread(self) + worker = _ModelUpdateWorker(self.model_manager) + worker.moveToThread(thread) + + # When thread starts run worker.run + thread.started.connect(worker.run) + + # on worker finished, notify observers on main thread and cleanup + def _on_finished(): + try: + # notify observers now on main thread + try: + self.model_manager.notify('model_updated') + except Exception: + for obs in getattr(self.model_manager, 'observers', []): + try: + obs() + except Exception: + pass + finally: + try: + progress.close() + except Exception: + pass + # cleanup worker/thread + try: + worker.deleteLater() + except Exception: + pass + try: + thread.quit() + thread.wait(2000) + except Exception: + pass + + def _on_error(tb): + try: + progress.close() + except Exception: + pass + try: + QMessageBox.critical( + self, + "Model update failed", + f"An error occurred while updating the model:\n{tb}", + ) + except Exception: + pass + # ensure thread cleanup + try: + worker.deleteLater() + except Exception: + pass + try: + thread.quit() + thread.wait(2000) + except Exception: + pass + + worker.finished.connect(_on_finished) + worker.error.connect(_on_error) + thread.finished.connect(thread.deleteLater) + self._model_update_thread = thread + self._model_update_worker = worker + thread.start() - def update_feature_list(self): + def update_feature_list(self, *args, **kwargs): self.featureList.clear() # Clear the feature list before populating it for feature in self.model_manager.features(): if feature.name.startswith("__"): @@ -153,6 +260,43 @@ def on_feature_selected(self, item): splitter.widget(1).deleteLater() # Remove the existing widget splitter.addWidget(self.featureDetailsPanel) # Add the new widget + def _on_model_update_started(self): + """Show a non-blocking indeterminate progress dialog for model updates. + + This method is invoked via the Observable notifications and ensures the + user sees that a background or foreground update is in progress. + """ + print("Model update started - showing progress dialog") + try: + if getattr(self, '_progress_dialog', None) is None: + self._progress_dialog = QProgressDialog( + "Updating geological model...", None, 0, 0, self + ) + self._progress_dialog.setWindowTitle("Updating Model") + self._progress_dialog.setWindowModality(Qt.ApplicationModal) + self._progress_dialog.setCancelButton(None) + self._progress_dialog.setMinimumDuration(0) + self._progress_dialog.show() + except Exception: + pass + + def _on_model_update_finished(self): + """Close the progress dialog shown for model updates.""" + print("Model update finished - closing progress dialog") + try: + if getattr(self, '_progress_dialog', None) is not None: + try: + self._progress_dialog.close() + except Exception: + pass + try: + self._progress_dialog.deleteLater() + except Exception: + pass + self._progress_dialog = None + except Exception: + pass + def show_feature_context_menu(self, pos): # Show context menu only for items item = self.featureList.itemAt(pos) @@ -197,10 +341,50 @@ def delete_feature(self, item): # Notify observers to refresh UI try: - for obs in getattr(self.model_manager, 'observers', []): - try: - obs() - except Exception: - pass + # Prefer notify API + try: + self.model_manager.notify('model_updated') + except Exception: + # fallback to legacy observers list + for obs in getattr(self.model_manager, 'observers', []): + try: + obs() + except Exception: + pass except Exception: pass + + +class _ModelUpdateWorker(QObject): + """Worker that runs model_manager.update_model in a background thread. + + Emits finished when done and error with a string if an exception occurs. + """ + + finished = pyqtSignal() + error = pyqtSignal(str) + + def __init__(self, model_manager): + super().__init__() + self.model_manager = model_manager + + @pyqtSlot() + def run(self): + try: + # perform the expensive update + # run update without notifying observers from the background thread + try: + self.model_manager.update_model(notify_observers=False) + except TypeError: + # fallback if update_model signature not available + self.model_manager.update_model() + except Exception as e: + try: + import traceback + + tb = traceback.format_exc() + except Exception: + tb = str(e) + self.error.emit(tb) + finally: + self.finished.emit() diff --git a/loopstructural/gui/visualisation/feature_list_widget.py b/loopstructural/gui/visualisation/feature_list_widget.py index 1159eed..7173c4e 100644 --- a/loopstructural/gui/visualisation/feature_list_widget.py +++ b/loopstructural/gui/visualisation/feature_list_widget.py @@ -1,9 +1,12 @@ +import logging from typing import Optional, Union from PyQt5.QtWidgets import QMenu, QPushButton, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget from LoopStructural.datatypes import VectorPoints +logger = logging.getLogger(__name__) + class FeatureListWidget(QWidget): def __init__(self, parent=None, *, model_manager=None, viewer=None): @@ -33,7 +36,31 @@ def __init__(self, parent=None, *, model_manager=None, viewer=None): # Populate the feature list self.update_feature_list() - self.model_manager.observers.append(self.update_feature_list) + # register observer to refresh list and viewer when model changes + if self.model_manager is not None: + # Attach to specific model events using the Observable framework + try: + # listeners will receive (observable, event, *args) + # attach wrappers that match the Observable callback signature + self._disp_update = self.model_manager.attach( + lambda _obs, _event, *a, **k: self.update_feature_list(), 'model_updated' + ) + # also listen for model and feature updates so visualisation can refresh + # forward event and args into the handler so it can act on specific surfaces + self._disp_feature = self.model_manager.attach( + lambda _obs, _event, *a, **k: self._on_model_update(_event, *a), 'model_updated' + ) + self._disp_feature2 = self.model_manager.attach( + lambda _obs, _event, *a, **k: self._on_model_update(_event, *a), + 'feature_updated', + ) + except Exception: + # Fall back to legacy observers list if available + try: + self.model_manager.observers.append(self.update_feature_list) + self.model_manager.observers.append(self._on_model_update) + except Exception: + pass def update_feature_list(self): if not self.model_manager: @@ -94,17 +121,50 @@ def contextMenuEvent(self, event): def add_scalar_field(self, feature_name): scalar_field = self.model_manager.model[feature_name].scalar_field() - self.viewer.add_mesh_object(scalar_field.vtk(), name=f'{feature_name}_scalar_field') + self.viewer.add_mesh_object( + scalar_field.vtk(), + name=f'{feature_name}_scalar_field', + source_feature=feature_name, + source_type='feature_scalar', + ) def add_surface(self, feature_name): surfaces = self.model_manager.model[feature_name].surfaces() - for surface in surfaces: - self.viewer.add_mesh_object(surface.vtk(), name=f'{feature_name}_surface') + for i, surface in enumerate(surfaces): + # ensure unique names for multiple surfaces per feature + mesh_name = f'{feature_name}_surface' if i == 0 else f'{feature_name}_surface_{i+1}' + # try to determine an isovalue for this surface (may be an attribute or encoded in name) + isovalue = None + try: + isovalue = getattr(surface, 'isovalue', None) + except Exception: + isovalue = None + if isovalue is None: + # attempt to parse trailing numeric suffix in the surface name + try: + parts = str(surface.name).rsplit('_', 1) + if len(parts) == 2: + isovalue = float(parts[1]) + except Exception: + isovalue = None + + self.viewer.add_mesh_object( + surface.vtk(), + name=mesh_name, + source_feature=feature_name, + source_type='feature_surface', + isovalue=isovalue, + ) def add_vector_field(self, feature_name): vector_field = self.model_manager.model[feature_name].vector_field() scale = self._get_vector_scale() - self.viewer.add_mesh_object(vector_field.vtk(scale=scale), name=f'{feature_name}_vector_field') + self.viewer.add_mesh_object( + vector_field.vtk(scale=scale), + name=f'{feature_name}_vector_field', + source_feature=feature_name, + source_type='feature_vector', + ) def add_data(self, feature_name): data = self.model_manager.model[feature_name].get_data() @@ -114,35 +174,241 @@ def add_data(self, feature_name): scale = self._get_vector_scale() # tolerance is None means all points are shown self.viewer.add_mesh_object( - d.vtk(scale=scale, tolerance=None), name=f'{feature_name}_{d.name}_points' + d.vtk(scale=scale, tolerance=None), + name=f'{feature_name}_{d.name}_points', + source_feature=feature_name, + source_type='feature_points', ) else: - self.viewer.add_mesh_object(d.vtk(), name=f'{feature_name}_{d.name}') - print(f"Adding data to feature: {feature_name}") + self.viewer.add_mesh_object( + d.vtk(), + name=f'{feature_name}_{d.name}', + source_feature=feature_name, + source_type='feature_data', + ) + logger.debug(f"Adding data to feature: {feature_name}") def add_model_bounding_box(self): if not self.model_manager: - print("Model manager is not set.") + logger.debug("Model manager is not set.") return bb = self.model_manager.model.bounding_box.vtk().outline() - self.viewer.add_mesh_object(bb, name='model_bounding_box') + self.viewer.add_mesh_object( + bb, name='model_bounding_box', source_feature='__model__', source_type='bounding_box' + ) # Logic for adding model bounding box - print("Adding model bounding box...") + logger.debug("Adding model bounding box...") def add_fault_surfaces(self): if not self.model_manager: - print("Model manager is not set.") + logger.debug("Model manager is not set.") return + self.model_manager.update_all_features(subset='faults') fault_surfaces = self.model_manager.model.get_fault_surfaces() for surface in fault_surfaces: - self.viewer.add_mesh_object(surface.vtk(), name=f'fault_surface_{surface.name}') - print("Adding fault surfaces...") + self.viewer.add_mesh_object( + surface.vtk(), + name=f'fault_surface_{surface.name}', + source_feature=surface.name, + source_type='fault_surface', + isovalue=0.0, + ) + logger.debug("Adding fault surfaces...") def add_stratigraphic_surfaces(self): if not self.model_manager: - print("Model manager is not set.") + logger.debug("Model manager is not set.") return stratigraphic_surfaces = self.model_manager.model.get_stratigraphic_surfaces() for surface in stratigraphic_surfaces: - self.viewer.add_mesh_object(surface.vtk(), name=surface.name,color=surface.colour) - print("Adding stratigraphic surfaces...") + self.viewer.add_mesh_object( + surface.vtk(), + name=surface.name, + color=surface.colour, + source_feature=surface.name, + source_type='stratigraphic_surface', + ) + + def _on_model_update(self, event: str, *args): + """Called when the underlying model_manager notifies observers. + + We remove any meshes that were created from model features and re-add + them from the current model so visualisation follows model changes. + + If the notification is for a specific feature (event == 'feature_updated') + and an isovalue is provided (either as second arg or stored in viewer + metadata), only the matching surface will be re-added. For generic + 'model_updated' notifications the previous behaviour (re-add all + affected feature representations) is preserved. + """ + logger.debug(f"Model update event received: {event} with args: {args}") + logger.debug([f"Mesh: {name}, Meta: {meta}" for name, meta in self.viewer.meshes.items()]) + if not self.model_manager or not self.viewer: + return + if event not in ('model_updated', 'feature_updated'): + return + feature_name = None + if event == 'feature_updated' and len(args) >= 1: + feature_name = args[0] + # Build a set of features that currently have viewer meshes + affected_features = set() + for _, meta in list(self.viewer.meshes.items()): + if feature_name is not None: + if meta.get('source_feature', None) == feature_name: + affected_features.add(feature_name) + logger.debug(f"Updating visualisation for feature: {feature_name}") + continue + + sf = meta.get('source_feature', None) + + if sf is not None: + affected_features.add(sf) + logger.debug(f"Affected features to update: {affected_features}") + # For each affected feature, only update existing meshes tied to that feature + for feature_name in affected_features: + # collect mesh names that belong to this feature (snapshot to avoid mutation while iterating) + meshes_for_feature = [ + name + for name, meta in list(self.viewer.meshes.items()) + if meta.get('source_feature') == feature_name + ] + logger.debug(f"Re-adding meshes for feature: {feature_name}: {meshes_for_feature}") + + for mesh_name in meshes_for_feature: + meta = self.viewer.meshes.get(mesh_name, {}) + source_type = meta.get('source_type') + kwargs = meta.get('kwargs', {}) or {} + isovalue = meta.get('isovalue', None) + + # remove existing actor/entry so add_mesh_object can recreate with same name + try: + self.viewer.remove_object(mesh_name) + logger.debug(f"Removed existing mesh: {mesh_name}") + except Exception: + logger.debug(f"Failed to remove existing mesh: {mesh_name}") + pass + + try: + # Surfaces associated with individual features + if source_type == 'feature_surface': + surfaces = [] + try: + if isovalue is not None: + surfaces = self.model_manager.model[feature_name].surfaces(isovalue) + else: + surfaces = self.model_manager.model[feature_name].surfaces() + + if surfaces: + add_name = mesh_name + logger.debug( + f"Re-adding surface for feature: {feature_name} with isovalue: {isovalue}" + ) + self.viewer.add_mesh_object( + surfaces[0].vtk(), + name=add_name, + source_feature=feature_name, + source_type='feature_surface', + isovalue=isovalue, + **kwargs, + ) + continue + except Exception as e: + logger.debug( + f"Failed to find matching surface for feature: {feature_name} with isovalue: {isovalue}, trying all surfaces. Error: {e}" + ) + + # Fault surfaces (added via add_fault_surfaces) + if source_type == 'fault_surface': + try: + fault_surfaces = self.model_manager.model.get_fault_surfaces() + match = next( + (s for s in fault_surfaces if str(s.name) == str(feature_name)), + None, + ) + if match is not None: + logger.debug(f"Re-adding fault surface for: {feature_name}") + self.viewer.add_mesh_object( + match.vtk(), + name=mesh_name, + source_feature=feature_name, + source_type='fault_surface', + isovalue=meta.get('isovalue', 0.0), + **kwargs, + ) + continue + except Exception as e: + logger.debug(f"Failed to re-add fault surface for {feature_name}: {e}") + + # Stratigraphic surfaces (added via add_stratigraphic_surfaces) + if source_type == 'stratigraphic_surface': + try: + strat_surfaces = self.model_manager.model.get_stratigraphic_surfaces() + match = next( + (s for s in strat_surfaces if str(s.name) == str(feature_name)), + None, + ) + if match is not None: + logger.debug(f"Re-adding stratigraphic surface for: {feature_name}") + self.viewer.add_mesh_object( + match.vtk(), + name=mesh_name, + color=getattr(match, 'colour', None), + source_feature=feature_name, + source_type='stratigraphic_surface', + **kwargs, + ) + continue + except Exception as e: + logger.debug( + f"Failed to re-add stratigraphic surface for {feature_name}: {e}" + ) + + # Vectors, points, scalar fields and other feature related objects + if source_type == 'feature_vector' or source_type == 'feature_vectors': + try: + self.add_vector_field(feature_name) + continue + except Exception as e: + logger.debug(f"Failed to re-add vector field for {feature_name}: {e}") + + if source_type in ('feature_points', 'feature_data'): + try: + self.add_data(feature_name) + continue + except Exception as e: + logger.debug(f"Failed to re-add data for {feature_name}: {e}") + + if source_type == 'feature_scalar': + try: + self.add_scalar_field(feature_name) + continue + except Exception as e: + logger.debug(f"Failed to re-add scalar field for {feature_name}: {e}") + + if source_type == 'bounding_box' or mesh_name == 'model_bounding_box': + try: + self.add_model_bounding_box() + continue + except Exception as e: + logger.debug(f"Failed to re-add bounding box: {e}") + + # Fallback: if nothing matched, attempt to re-add by using viewer metadata + # Many viewer entries store the vtk source under meta['vtk'] or similar; try best-effort + try: + vtk_src = meta.get('vtk') + if vtk_src is not None: + logger.debug(f"Fallback re-add for mesh {mesh_name}") + self.viewer.add_mesh_object(vtk_src, name=mesh_name, **kwargs) + except Exception: + pass + + except Exception as e: + logger.debug( + f"Failed to update visualisation for feature: {feature_name}. Error: {e}" + ) + + # Refresh the viewer + try: + self.viewer.update() + except Exception: + pass diff --git a/loopstructural/gui/visualisation/loop_pyvistaqt_wrapper.py b/loopstructural/gui/visualisation/loop_pyvistaqt_wrapper.py index c08d904..9f48c15 100644 --- a/loopstructural/gui/visualisation/loop_pyvistaqt_wrapper.py +++ b/loopstructural/gui/visualisation/loop_pyvistaqt_wrapper.py @@ -1,6 +1,7 @@ +from typing import Any, Dict, Optional, Tuple + from PyQt5.QtCore import pyqtSignal from pyvistaqt import QtInteractor -from typing import Optional, Any, Dict, Tuple class LoopPyVistaQTPlotter(QtInteractor): @@ -27,7 +28,22 @@ def increment_name(self, name): name = '_'.join(parts) return name - def add_mesh_object(self, mesh, name: str, *, scalars: Optional[Any] = None, cmap: Optional[str] = None, clim: Optional[Tuple[float, float]] = None, opacity: Optional[float] = None, show_scalar_bar: bool = False, color: Optional[Tuple[float, float, float]] = None, **kwargs) -> None: + def add_mesh_object( + self, + mesh, + name: str, + *, + scalars: Optional[Any] = None, + cmap: Optional[str] = None, + clim: Optional[Tuple[float, float]] = None, + opacity: Optional[float] = None, + show_scalar_bar: bool = False, + color: Optional[Tuple[float, float, float]] = None, + source_feature: Optional[str] = None, + source_type: Optional[str] = None, + isovalue: Optional[float] = None, + **kwargs, + ) -> None: """Add a mesh to the plotter. This wrapper stores metadata to allow robust re-adding and @@ -51,8 +67,12 @@ def add_mesh_object(self, mesh, name: str, *, scalars: Optional[Any] = None, cma Whether to show a scalar bar for mapped scalars. color : Optional[tuple(float, float, float)] Solid color as (r, g, b) in 0..1; if provided, overrides scalars. - **kwargs : dict - Additional keyword arguments forwarded to the underlying pyvista add_mesh call. + source_feature : Optional[str] + Name of the geological feature (or other source identifier) that + generated this mesh. Stored for later updates. + source_type : Optional[str] + A short tag describing the kind of source (e.g. 'feature_surface', + 'fault_surface', 'bounding_box'). Returns ------- @@ -61,7 +81,7 @@ def add_mesh_object(self, mesh, name: str, *, scalars: Optional[Any] = None, cma # Remove any previous entry with the same name (to keep metadata consistent) # if name in self.meshes: # try: - + # # self.remove_object(name) # except Exception: # # ignore removal errors and proceed to add @@ -73,7 +93,7 @@ def add_mesh_object(self, mesh, name: str, *, scalars: Optional[Any] = None, cma # Build add_mesh kwargs add_kwargs: Dict[str, Any] = {} - + if use_scalar: add_kwargs['scalars'] = scalars add_kwargs['cmap'] = cmap @@ -97,7 +117,15 @@ def add_mesh_object(self, mesh, name: str, *, scalars: Optional[Any] = None, cma actor = self.add_mesh(mesh, name=name, **add_kwargs) # store the mesh, actor and kwargs for future re-adds - self.meshes[name] = {'mesh': mesh, 'actor': actor, 'kwargs': {**add_kwargs}} + # persist source metadata so callers can find meshes created from model features + self.meshes[name] = { + 'mesh': mesh, + 'actor': actor, + 'kwargs': {**add_kwargs}, + 'source_feature': source_feature, + 'source_type': source_type, + 'isovalue': isovalue, + } self.objectAdded.emit(self) def remove_object(self, name: str) -> None: diff --git a/loopstructural/main/debug/export.py b/loopstructural/main/debug/export.py index e292b98..006fd7e 100644 --- a/loopstructural/main/debug/export.py +++ b/loopstructural/main/debug/export.py @@ -1,8 +1,6 @@ -import json import pickle -from doctest import debug from pathlib import Path -from typing import Any, Dict, Optional +from typing import Any, Dict def export_debug_package( @@ -30,12 +28,10 @@ def export_debug_package( exported[name] = pkl_name except Exception as e: debug_manager.logger(f"Failed to save debug file '{pkl_name}': {e}") + with open(Path(__file__).parent / 'template.txt', 'r') as f: + template = f.read() + template = template.format(runner_name=runner_script_name.replace('.py', '')) - script = ( - open(Path(__file__).parent / 'template.txt') - .read() - .format(runner_name=runner_script_name.replace('.py', '')) - ) - debug_manager.save_debug_file(runner_script_name, script.encode("utf-8")) + debug_manager.save_debug_file(runner_script_name, template.encode("utf-8")) debug_manager.logger(f"Exported debug package with runner script '{runner_script_name}'") return exported diff --git a/loopstructural/main/m2l_api.py b/loopstructural/main/m2l_api.py index 46a906e..53a15d5 100644 --- a/loopstructural/main/m2l_api.py +++ b/loopstructural/main/m2l_api.py @@ -16,6 +16,8 @@ from loopstructural.main.debug import export +from loopstructural.main.debug import export + from ..main.vectorLayerWrapper import qgsLayerToDataFrame, qgsLayerToGeoDataFrame from .debug.export import export_debug_package @@ -106,7 +108,7 @@ def extract_basal_contacts( layers = {"geology": geology, "faults": faults} pickles = {"contact_extractor": contact_extractor} # export layers and pickles first to get the actual filenames used - exported = export_debug_package( + _exported = export_debug_package( debug_manager, runner_script_name="run_extract_basal_contacts.py", m2l_object=contact_extractor, diff --git a/loopstructural/main/model_manager.py b/loopstructural/main/model_manager.py index f08daa5..9ea9578 100644 --- a/loopstructural/main/model_manager.py +++ b/loopstructural/main/model_manager.py @@ -10,7 +10,8 @@ """ from collections import defaultdict -from typing import Callable, Optional +from contextlib import contextmanager +from typing import Callable, Optional, Union import geopandas as gpd import numpy as np @@ -23,6 +24,7 @@ from LoopStructural.modelling.features import FeatureType, StructuralFrame from LoopStructural.modelling.features.fold import FoldFrame from loopstructural.toolbelt.preferences import PlgSettingsStructure +from LoopStructural.utils.observer import Observable class AllSampler: @@ -72,19 +74,24 @@ def __call__(self, line: gpd.GeoDataFrame, dem: Callable, use_z: bool) -> pd.Dat z = dem(coords[0], coords[1]) else: z = 0 - points.append({'X': coords[0], 'Y': coords[1], 'Z': z, 'feature_id': feature_id, **attributes}) + points.append( + {'X': coords[0], 'Y': coords[1], 'Z': z, 'feature_id': feature_id, **attributes} + ) feature_id += 1 df = pd.DataFrame(points) return df -class GeologicalModelManager: +class GeologicalModelManager(Observable): """This class manages the geological model and assembles it from the data provided by the data manager. It is responsible for updating the model with faults, stratigraphy, and other geological features. """ def __init__(self): """Initialize the geological model manager.""" + # Initialize Observable state + super().__init__() + self.model = GeologicalModel([0, 0, 0], [1, 1, 1]) self.stratigraphy = {} self.groups = [] @@ -92,12 +99,41 @@ def __init__(self): self.stratigraphy = defaultdict(dict) self.stratigraphic_column = None self.fault_topology = None - self.observers = [] + # Observers managed by Observable base class self.dem_function = lambda x, y: 0 + # internal flag to temporarily suppress notifications (used when + # updates are performed in background threads) + self._suppress_notifications = False + + @contextmanager + def suspend_notifications(self): + prev = getattr(self, '_suppress_notifications', False) + self._suppress_notifications = True + try: + yield + finally: + self._suppress_notifications = prev + + def _emit(self, *args, **kwargs): + """Emit an observer notification unless notifications are suppressed. + + This wrapper should be used instead of calling self.notify directly from + the manager so callers can suppress notifications when running updates + on background threads. + """ + if getattr(self, '_suppress_notifications', False): + return + try: + self.notify(*args, **kwargs) + except Exception: + # be tolerant of observer errors + pass def set_stratigraphic_column(self, stratigraphic_column: StratigraphicColumn): """Set the stratigraphic column for the geological model manager.""" self.stratigraphic_column = stratigraphic_column + # changing the stratigraphic column changes model geometry + self._emit('stratigraphic_column_changed') def set_fault_topology(self, fault_topology): """Set the fault topology for the geological model manager.""" @@ -191,6 +227,8 @@ def update_fault_points( for fault_name in existing_faults: self.fault_topology.remove_fault(fault_name) + # signal that fault point input changed + self._emit('data_changed', 'fault_points') def update_contact_traces( self, @@ -235,6 +273,9 @@ def update_contact_traces( unit_points['unit_name'] == unit_name, ['X', 'Y', 'Z'] ] + # signal that input data changed — consumers may choose to rebuild the model + self._emit('data_changed', 'contact_traces') + def update_structural_data( self, structural_orientations: gpd.GeoDataFrame, @@ -277,6 +318,9 @@ def update_structural_data( ] self.stratigraphy[unit_name]['orientations'] = orientations + # signal structural orientation data changed + self._emit('data_changed', 'structural_orientations') + def update_stratigraphic_column(self, stratigraphic_column: StratigraphicColumn): """Update the stratigraphic column with a new stratigraphic column""" self.stratigraphic_column = stratigraphic_column @@ -330,6 +374,8 @@ def update_foliation_features(self): ) self.model.add_unconformity(foliation, 0) self.model.stratigraphic_column = self.stratigraphic_column + # foliation features were rebuilt; let observers know + self._emit('foliation_features_updated') def update_fault_features(self): """Update the fault features in the geological model.""" @@ -348,7 +394,6 @@ def update_fault_features(self): dip = fault_data['data']['dip'].mean() else: dip = 90 - print(f"Fault {fault_name} dip: {dip}") if 'pitch' in fault_data['data']: pitch = fault_data['data']['pitch'].mean() @@ -390,18 +435,76 @@ def valid(self): valid = False return valid - def update_model(self): - """Update the geological model with the current stratigraphy and faults.""" + def update_model(self, notify_observers: bool = True): + """Update the geological model with the current stratigraphy and faults. + + Parameters + ---------- + notify_observers : bool + If True (default) observers will be notified after the model update + completes. If False, the caller is responsible for notifying + observers from the main thread (useful when performing the update + in a background thread). + """ self.model.features = [] self.model.feature_name_index = {} + # Notify start (if requested) so UI can react + if notify_observers: + self._emit('model_update_started') + # Update the model with stratigraphy self.update_fault_features() self.update_foliation_features() - for observer in self.observers: - observer() + # Notify observers using the Observable framework if requested + if notify_observers: + self._emit('model_updated') + self._emit('model_update_finished') + + def update_feature(self, feature_name: str): + """Update a specific feature in the geological model. + + Parameters + ---------- + feature_name : str + Name of the feature to update. + """ + feature = self.model.get_feature_by_name(feature_name) + if feature is None: + raise ValueError(f"Feature '{feature_name}' not found in the model.") + # Allow UI to react to a feature update + self._emit('model_update_started') + feature.builder.update() + # Notify observers and include feature name for interested listeners + self._emit('feature_updated', feature_name) + self._emit('model_update_finished') + + def update_all_features(self, subset: Optional[Union[list, str]] = None): + """Update all features in the geological model.""" + # Allow UI to react to a feature update + self._emit('model_update_started') + if subset is not None: + + if isinstance(subset, str): + if subset == 'faults': + subset = [f.name for f in self.model.features if f.type == FeatureType.FAULT] + elif subset == 'stratigraphy' or subset == 'foliations': + subset = [ + f.name for f in self.model.features if f.type == FeatureType.FOLIATION + ] + else: + subset = [subset] + for feature_name in subset: + feature = self.model.get_feature_by_name(feature_name) + if feature is not None: + feature.builder.update() + else: + self.model.update() + # Notify observers and include feature name for interested listeners + self._emit('all_features_updated') + self._emit('model_update_finished') def features(self): """Return the list of features currently held by the internal model. @@ -449,7 +552,7 @@ def add_foliation( """ # for z dfs = [] - kwargs={} + kwargs = {} for layer_data in data.values(): if layer_data['type'] == 'Orientation': df = sampler(layer_data['df'], self.dem_function, use_z_coordinate) @@ -471,20 +574,16 @@ def add_foliation( df['u'] = df[layer_data['upper_field']] df['feature_name'] = name dfs.append(df[['X', 'Y', 'Z', 'l', 'u', 'feature_name']]) - kwargs['solver']='admm' + kwargs['solver'] = 'admm' else: raise ValueError(f"Unknown layer type: {layer_data['type']}") - self.model.create_and_add_foliation(name, data=pd.concat(dfs, ignore_index=True), **kwargs) - # 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() + self.model.create_and_add_foliation(name, data=pd.concat(dfs, ignore_index=True), **kwargs) + # inform listeners that a new foliation/feature was added + self._emit('model_updated') - def add_unconformity(self, foliation_name: str, value: float, type: FeatureType = FeatureType.UNCONFORMITY): + def add_unconformity( + self, foliation_name: str, value: float, type: FeatureType = FeatureType.UNCONFORMITY + ): """Add an unconformity (or onlap unconformity) to a named foliation. Parameters @@ -509,6 +608,8 @@ 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) + # model geometry changed + self._emit('model_updated') def add_fold_to_feature(self, feature_name: str, fold_frame_name: str, fold_weights={}): """Apply a FoldFrame to an existing feature, producing a folded feature. @@ -533,8 +634,8 @@ def add_fold_to_feature(self, feature_name: str, fold_frame_name: str, fold_weig 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 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) @@ -542,6 +643,8 @@ def add_fold_to_feature(self, feature_name: str, fold_frame_name: str, fold_weig 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 + # feature replaced/modified + self._emit('model_updated') def convert_feature_to_structural_frame(self, feature_name: str): """Convert an interpolated feature into a StructuralFrame. @@ -560,13 +663,17 @@ def convert_feature_to_structural_frame(self, feature_name: str): builder = self.model.get_feature_by_name(feature_name).builder new_builder = StructuralFrameBuilder.from_feature_builder(builder) self.model[feature_name] = new_builder.frame + # feature converted + self._emit('model_updated') @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] - def evaluate_feature_on_points(self, feature_name: str, points: np.ndarray, scalar_type: str = 'scalar') -> np.ndarray: + def evaluate_feature_on_points( + self, feature_name: str, points: np.ndarray, scalar_type: str = 'scalar' + ) -> np.ndarray: """Evaluate a model feature at the provided points. Parameters @@ -641,14 +748,15 @@ def export_feature_values_to_geodataframe( GeoDataFrame containing point geometries and computed value columns (and any provided attributes). """ - import pandas as _pd import geopandas as _gpd + import pandas as _pd + try: from shapely.geometry import Point as _Point except Exception: - print("Shapely not available; geometry column will be omitted." ) + print("Shapely not available; geometry column will be omitted.") _Point = None - + pts = np.asarray(points) if pts.ndim != 2 or pts.shape[1] < 3: raise ValueError('points must be an Nx3 array') @@ -676,7 +784,9 @@ def export_feature_values_to_geodataframe( if attributes is not None: try: attributes = _pd.DataFrame(attributes).reset_index(drop=True) - df = _pd.concat([df.reset_index(drop=True), attributes.reset_index(drop=True)], axis=1) + df = _pd.concat( + [df.reset_index(drop=True), attributes.reset_index(drop=True)], axis=1 + ) except Exception: # ignore attributes if they cannot be combined pass @@ -692,7 +802,7 @@ def export_feature_values_to_geodataframe( return gdf - def export_feature_values_to_vtk_mesh(self, name, mesh, scalar_type='scalar'): + def export_feature_values_to_vtk_mesh(self, name, mesh, scalar_type='scalar'): """Evaluate a feature on a mesh's points and attach the values as a field. Parameters @@ -713,4 +823,4 @@ def export_feature_values_to_vtk_mesh(self, name, mesh, scalar_type='scalar'): pts = mesh.points values = self.evaluate_feature_on_points(name, pts, scalar_type=scalar_type) mesh[name] = values - return mesh \ No newline at end of file + return mesh diff --git a/loopstructural/toolbelt/preferences.py b/loopstructural/toolbelt/preferences.py index b14c9b2..1214d2e 100644 --- a/loopstructural/toolbelt/preferences.py +++ b/loopstructural/toolbelt/preferences.py @@ -7,6 +7,7 @@ """ # standard +import logging from dataclasses import asdict, dataclass, fields # PyQGIS @@ -44,6 +45,21 @@ class PlgOptionsManager: plugin to persist user preferences such as debug mode and UI options. """ + @staticmethod + def _configure_logging(debug_mode: bool): + """Configure Python logging level according to plugin debug setting. + + When debug_mode is True the root logger level is set to DEBUG so that + any logger.debug(...) calls in the plugin will be emitted. When False + the level is set to INFO to reduce verbosity. + """ + try: + root = logging.getLogger() + root.setLevel(logging.DEBUG if bool(debug_mode) else logging.INFO) + except Exception: + # Best-effort: do not raise from logging configuration issues + pass + @staticmethod def get_plg_settings() -> PlgSettingsStructure: """Load and return plugin settings as a PlgSettingsStructure instance. @@ -74,6 +90,9 @@ def get_plg_settings() -> PlgSettingsStructure: settings.endGroup() + # Ensure logging level matches the loaded debug_mode preference + PlgOptionsManager._configure_logging(options.debug_mode) + return options @staticmethod @@ -171,6 +190,13 @@ def set_value_from_key(cls, key: str, value) -> bool: try: settings.setValue(key, value) out_value = True + + # If debug mode was toggled, immediately apply logging configuration + if key == "debug_mode": + try: + PlgOptionsManager._configure_logging(value) + except Exception: + pass except Exception as err: log_hdlr.PlgLogger.log( message="Error occurred trying to set settings: {}.Trace: {}".format(key, err)