From 037d50f980dfa18d818f4d42e829f6ac50b308dd Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Sat, 6 Sep 2025 07:39:23 +0400 Subject: [PATCH 01/22] fix: adding export of scalar field value to qgis vector pointset --- .../bounding_box_widget.py | 217 ++++++++++ .../feature_details_panel.py | 393 +++++++++++++++++- loopstructural/main/model_manager.py | 104 +++++ 3 files changed, 698 insertions(+), 16 deletions(-) create mode 100644 loopstructural/gui/modelling/geological_model_tab/bounding_box_widget.py diff --git a/loopstructural/gui/modelling/geological_model_tab/bounding_box_widget.py b/loopstructural/gui/modelling/geological_model_tab/bounding_box_widget.py new file mode 100644 index 0000000..591a319 --- /dev/null +++ b/loopstructural/gui/modelling/geological_model_tab/bounding_box_widget.py @@ -0,0 +1,217 @@ +import numpy as np +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import ( + QGridLayout, + QHBoxLayout, + QLabel, + QDoubleSpinBox, + QVBoxLayout, + QWidget, +) +from qgis.gui import QgsCollapsibleGroupBox + +from LoopStructural import getLogger +logger = getLogger(__name__) + + +class BoundingBoxWidget(QWidget): + """Standalone bounding-box widget used in the export/evaluation panel. + + Shows a compact 3-column layout for X/Y/Z nsteps and a single-row + control for the overall element count (nelements). The widget keeps + itself in sync with the authoritative bounding_box available from a + provided data_manager or model_manager and will call back to update + the model when the user changes values. + """ + + def __init__(self, parent=None, *, model_manager=None, data_manager=None): + super().__init__(parent) + self.model_manager = model_manager + self.data_manager = data_manager + + # Create the inner layout that will be placed inside a collapsible group widget + inner_layout = QVBoxLayout() + inner_layout.setContentsMargins(0, 0, 0, 0) + inner_layout.setSpacing(6) + + grid = QGridLayout() + grid.setSpacing(6) + + # header row: blank, X, Y, Z + grid.addWidget(QLabel(""), 0, 0) + grid.addWidget(QLabel("X"), 0, 1, alignment=Qt.AlignCenter) + grid.addWidget(QLabel("Y"), 0, 2, alignment=Qt.AlignCenter) + grid.addWidget(QLabel("Z"), 0, 3, alignment=Qt.AlignCenter) + + # Nsteps row + grid.addWidget(QLabel("Nsteps:"), 1, 0) + self.nsteps_x = QDoubleSpinBox() + self.nsteps_y = QDoubleSpinBox() + self.nsteps_z = QDoubleSpinBox() + for sb in (self.nsteps_x, self.nsteps_y, self.nsteps_z): + sb.setRange(1, 1_000_000) + sb.setDecimals(0) + sb.setSingleStep(1) + sb.setAlignment(Qt.AlignRight) + grid.addWidget(self.nsteps_x, 1, 1) + grid.addWidget(self.nsteps_y, 1, 2) + grid.addWidget(self.nsteps_z, 1, 3) + + # Elements row (span columns) + grid.addWidget(QLabel("Elements:"), 2, 0) + self.nelements = QDoubleSpinBox() + self.nelements.setRange(1, 1_000_000_000) + self.nelements.setDecimals(0) + self.nelements.setSingleStep(100) + self.nelements.setAlignment(Qt.AlignRight) + grid.addWidget(self.nelements, 2, 1, 1, 3) + + inner_layout.addLayout(grid) + + # Place the inner layout into a QGIS collapsible group box so it matches other sections + group = QgsCollapsibleGroupBox() + group.setTitle("Bounding Box") + group.setLayout(inner_layout) + + # Outer layout for this widget contains the group box (so it can be treated as a single section) + outer_layout = QVBoxLayout(self) + outer_layout.setContentsMargins(0, 0, 0, 0) + outer_layout.setSpacing(0) + outer_layout.addWidget(group) + + # initialise values from bounding box if available + bb = self._get_bounding_box() + if bb is not None: + try: + if getattr(bb, 'nsteps', None) is not None: + self.nsteps_x.setValue(int(bb.nsteps[0])) + self.nsteps_y.setValue(int(bb.nsteps[1])) + self.nsteps_z.setValue(int(bb.nsteps[2])) + except Exception: + self.nsteps_x.setValue(100) + self.nsteps_y.setValue(100) + self.nsteps_z.setValue(1) + try: + if getattr(bb, 'nelements', None) is not None: + self.nelements.setValue(int(getattr(bb, 'nelements'))) + except Exception: + self.nelements.setValue(getattr(bb, 'nelements', 1000) if bb is not None else 1000) + else: + self.nsteps_x.setValue(100) + self.nsteps_y.setValue(100) + self.nsteps_z.setValue(1) + self.nelements.setValue(1000) + + # connect signals + self.nelements.valueChanged.connect(self._on_nelements_changed) + self.nsteps_x.valueChanged.connect(self._on_nsteps_changed) + self.nsteps_y.valueChanged.connect(self._on_nsteps_changed) + self.nsteps_z.valueChanged.connect(self._on_nsteps_changed) + + # register update callback so this widget stays in sync + if self.data_manager is not None and hasattr(self.data_manager, 'set_bounding_box_update_callback'): + try: + self.data_manager.set_bounding_box_update_callback(self._on_bounding_box_updated) + except Exception: + pass + + def _get_bounding_box(self): + bounding_box = None + if self.data_manager is not None: + try: + if hasattr(self.data_manager, 'get_bounding_box'): + bounding_box = self.data_manager.get_bounding_box() + elif hasattr(self.data_manager, 'bounding_box'): + bounding_box = getattr(self.data_manager, 'bounding_box') + except Exception: + logger.debug('Failed to get bounding box from data_manager', exc_info=True) + bounding_box = None + if bounding_box is None and self.model_manager is not None and getattr(self.model_manager, 'model', None) is not None: + try: + bounding_box = getattr(self.model_manager.model, 'bounding_box', None) + except Exception: + logger.debug('Failed to get bounding box from model_manager', exc_info=True) + bounding_box = None + return bounding_box + + def _on_nelements_changed(self, val): + bb = self._get_bounding_box() + if bb is None: + return + try: + bb.nelements = int(val) + except Exception: + bb.nelements = val + if self.model_manager is not None: + try: + self.model_manager.update_bounding_box(bb) + except Exception: + logger.debug('Failed to update bounding_box on model_manager', exc_info=True) + # refresh from authoritative source + self._refresh_bb_ui() + + def _on_nsteps_changed(self, _): + bb = self._get_bounding_box() + if bb is None: + return + try: + bb.nsteps = np.array([int(self.nsteps_x.value()), int(self.nsteps_y.value()), int(self.nsteps_z.value())]) + except Exception: + try: + bb.nsteps = [int(self.nsteps_x.value()), int(self.nsteps_y.value()), int(self.nsteps_z.value())] + except Exception: + pass + if self.model_manager is not None: + try: + self.model_manager.update_bounding_box(bb) + except Exception: + logger.debug('Failed to update bounding_box on model_manager', exc_info=True) + # refresh from authoritative source + self._refresh_bb_ui() + + def _refresh_bb_ui(self): + bb = self._get_bounding_box() + if bb is not None: + try: + self._on_bounding_box_updated(bb) + except Exception: + pass + + def _on_bounding_box_updated(self, bounding_box): + # collect spinboxes + spinboxes = [self.nelements, self.nsteps_x, self.nsteps_y, self.nsteps_z] + for sb in spinboxes: + try: + sb.blockSignals(True) + except Exception: + pass + try: + if getattr(bounding_box, 'nelements', None) is not None: + try: + self.nelements.setValue(int(getattr(bounding_box, 'nelements'))) + except Exception: + try: + self.nelements.setValue(getattr(bounding_box, 'nelements')) + except Exception: + logger.debug('Could not set nelements', 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])] + except Exception: + nsteps = None + if nsteps is not None: + try: + self.nsteps_x.setValue(int(nsteps[0])) + self.nsteps_y.setValue(int(nsteps[1])) + self.nsteps_z.setValue(int(nsteps[2])) + except Exception: + logger.debug('Could not set nsteps', exc_info=True) + finally: + for sb in spinboxes: + try: + sb.blockSignals(False) + except Exception: + pass 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 5554f8a..07ebbc8 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,5 @@ -from PyQt5.QtCore import Qt +import numpy as np +from PyQt5.QtCore import Qt, QVariant from PyQt5.QtWidgets import ( QCheckBox, QComboBox, @@ -13,9 +14,11 @@ 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): @@ -42,6 +45,7 @@ def __init__(self, parent=None, *, feature=None, model_manager=None, data_manage # Set the main layout self.setLayout(mainLayout) + ## define interpolator parameters # Regularisation spin box self.regularisation_spin_box = QDoubleSpinBox() @@ -50,20 +54,20 @@ def __init__(self, parent=None, *, feature=None, model_manager=None, data_manage feature.builder.build_arguments.get('regularisation', 1.0) ) self.regularisation_spin_box.valueChanged.connect( - lambda value: feature.builder.update_build_arguments({'regularisation': value}) + lambda value: self.feature.builder.update_build_arguments({'regularisation': value}) ) 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: feature.builder.update_build_arguments({'cpw': value}) + lambda value: self.feature.builder.update_build_arguments({'cpw': value}) ) 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: feature.builder.update_build_arguments({'npw': value}) + lambda value: self.feature.builder.update_build_arguments({'npw': value}) ) self.interpolator_type_label = QLabel("Interpolator Type:") self.interpolator_type_combo = QComboBox() @@ -92,8 +96,176 @@ def __init__(self, parent=None, *, feature=None, model_manager=None, data_manage QgsCollapsibleGroupBox.setLayout(form_layout) self.layout.addWidget(QgsCollapsibleGroupBox) self.layout.addWidget(self.layer_table) + 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 + export controls. Subclasses should override this to add feature-specific + mid-panel controls. + """ + return + + def addExportBlock(self): + # Export/Evaluation blocks container + self.export_eval_container = QWidget() + self.export_eval_layout = QVBoxLayout(self.export_eval_container) + self.export_eval_layout.setContentsMargins(0, 0, 0, 0) + 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) + # keep reference so export handlers can use it + self.bounding_box_widget = bb_widget + self.export_eval_layout.addWidget(bb_widget) + + # --- Per-feature export controls (for this panel's feature) --- + try: + from PyQt5.QtWidgets import QFormLayout, QHBoxLayout + from PyQt5.QtWidgets import QGroupBox + from qgis.core import QgsVectorLayer, QgsFeature, QgsFields, QgsField, QgsProject, QgsGeometry, QgsPointXYZ + except Exception: + # imports may fail outside QGIS environment; we'll handle at runtime + pass - # self.layout.addLayout(form_layout) + export_widget = QWidget() + export_layout = QFormLayout(export_widget) + + # Scalar selector (support scalar and gradient) + self.scalar_field_combo = QComboBox() + self.scalar_field_combo.addItems(["scalar", "gradient"]) + export_layout.addRow("Scalar:", self.scalar_field_combo) + + # 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"]) + export_layout.addRow("Evaluate on:", self.evaluate_target_combo) + + # Project layer selector (populated with point vector layers from project) + self.project_layer_combo = QComboBox() + self.project_layer_combo.setEnabled(False) + export_layout.addRow("Project point layer:", self.project_layer_combo) + + # Connect evaluate target change to enable/disable project layer combo + def _on_evaluate_target_changed(index): + use_project = (index == 1) + self.project_layer_combo.setEnabled(use_project) + if use_project: + try: + self.populate_project_point_layers() + except Exception: + pass + + self.evaluate_target_combo.currentIndexChanged.connect(_on_evaluate_target_changed) + + + + + + + + # Export button + self.export_points_button = QPushButton("Export to QGIS points") + export_layout.addRow(self.export_points_button) + self.export_points_button.clicked.connect(self._export_scalar_points) + + self.export_eval_layout.addWidget(export_widget) + + # Dictionary to hold per-feature export/eval blocks for later population + self.export_blocks = {} + + # Create a placeholder block for each feature known to the model_manager. + # 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(): + block = QWidget() + block.setObjectName(f"export_block_{getattr(feat, 'name', 'feature')}") + block_layout = QVBoxLayout(block) + block_layout.setContentsMargins(0, 0, 0, 0) + # Placeholder label — disabled until controls are added + lbl = QLabel(getattr(feat, 'name', '')) + lbl.setEnabled(False) + block_layout.addWidget(lbl) + self.export_eval_layout.addWidget(block) + self.export_blocks[getattr(feat, 'name', f"feature_{len(self.export_blocks)}")] = block + + self.layout.addWidget(self.export_eval_container) + + def populate_project_point_layers(self): + """Populate self.project_layer_combo with point vector layers from the QGIS project. + + Guarded so the module can be imported outside QGIS. + """ + try: + from qgis.core import QgsProject, QgsWkbTypes, QgsMapLayer + except Exception: + return + + self.project_layer_combo.clear() + for lyr in QgsProject.instance().mapLayers().values(): + try: + if lyr.type() == QgsMapLayer.VectorLayer and lyr.geometryType() == QgsWkbTypes.PointGeometry: + self.project_layer_combo.addItem(lyr.name(), lyr.id()) + except Exception: + continue + + def _on_bounding_box_updated(self, bounding_box): + """Callback to update UI widgets when bounding box object changes externally. + + Blocks spinbox signals to avoid feedback loops, updates nelements, nsteps, + and then restores signals. + """ + # Collect spinboxes if they exist on this instance + spinboxes = [] + for name in ('bb_nelements_spinbox', 'bb_nsteps_x', 'bb_nsteps_y', 'bb_nsteps_z'): + sb = getattr(self, name, None) + if sb is not None: + spinboxes.append(sb) + + # Block signals + for sb in spinboxes: + try: + sb.blockSignals(True) + except Exception: + pass + + try: + 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) + + 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])] + except Exception: + nsteps = None + if nsteps is not None: + try: + if hasattr(self, 'bb_nsteps_x'): + self.bb_nsteps_x.setValue(int(nsteps[0])) + if hasattr(self, 'bb_nsteps_y'): + self.bb_nsteps_y.setValue(int(nsteps[1])) + 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) + + finally: + # Unblock signals + for sb in spinboxes: + try: + sb.blockSignals(False) + except Exception: + pass def updateNelements(self, value): """Update the number of elements in the feature's interpolator.""" @@ -120,7 +292,191 @@ 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 + QGIS project. Imports and QGIS calls are guarded so the module can be imported + outside of QGIS. + """ + # determine scalar type + print('HERE') + logger.info('Exporting scalar points') + scalar_type = self.scalar_field_combo.currentText() if hasattr(self, 'scalar_field_combo') else 'scalar' + + # gather points + pts = None + attributes_df = None + crs = self.data_manager.project.crs().authid() + try: + # QGIS imports (guarded) + from qgis.core import QgsProject, QgsVectorLayer, QgsFeature, QgsGeometry, QgsPoint, QgsFields, QgsField + from qgis.PyQt.QtCore import QVariant + except Exception as e: + # Not running inside QGIS — nothing to do + logger.info('Not running inside QGIS, cannot export points') + print(e) + return + + # Evaluate on bounding box grid + if self.evaluate_target_combo.currentIndex() == 0: + # use bounding-box resolution or custom nsteps + logger.info('Using bounding box cell centres for evaluation') + bb = None + try: + bb = getattr(self.model_manager.model, 'bounding_box', None) + except Exception: + bb = None + + + + + pts = self.model_manager.model.bounding_box.cell_centres() + # no extra attributes for grid + attributes_df = None + logger.info(f'Got {len(pts)} points from bounding box cell centres') + else: + # Evaluate on an existing project point layer + layer_id = None + try: + layer_id = self.project_layer_combo.currentData() + except Exception: + layer_id = None + if layer_id is None: + return + layer = QgsProject.instance().mapLayer(layer_id) + if layer is None: + return + # build points array and attributes + pts_list = [] + attrs = [] + fields = [f.name() for f in layer.fields()] + for feat in layer.getFeatures(): + try: + geom = feat.geometry() + if geom is None or geom.isEmpty(): + continue + # handle point geometries + if geom.type() == 0: # QgsWkbTypes.PointGeometry -> numeric value 0 + try: + p = geom.asPoint() + x, y = p.x(), p.y() + # some QgsPoint has z attribute + try: + z = p.z() + except Exception: + 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 + except Exception: + continue + pts_list.append((x, y, z)) + # collect attributes + row = {k: feat[k] for k in fields} + attrs.append(row) + else: + # skip non-point geometries + continue + except Exception: + continue + if len(pts_list) == 0: + return + import pandas as _pd + pts = _pd.DataFrame(pts_list).values + try: + attributes_df = _pd.DataFrame(attrs) + except Exception: + attributes_df = None + try: + crs = layer.crs().authid() + except Exception: + crs = None + + # call model_manager to produce GeoDataFrame + try: + logger.info('Exporting feature values to GeoDataFrame') + gdf = self.model_manager.export_feature_values_to_geodataframe( + self.feature.name, + pts, + scalar_type=scalar_type, + attributes=attributes_df, + crs=crs, + ) + except Exception as e: + logger.debug('Failed to export feature values', exc_info=True) + return + + # convert returned GeoDataFrame to a QGIS memory layer and add to project + if gdf is None or len(gdf) == 0: + return + + # create memory layer + # derive CRS string if available + layer_uri = 'Point' + if hasattr(gdf, 'crs') and gdf.crs is not None: + try: + crs_str = gdf.crs.to_string() + if crs_str: + layer_uri = f"Point?crs={crs_str}" + except Exception: + pass + mem_layer = QgsVectorLayer(layer_uri, f"model_{self.feature.name}", 'memory') + prov = mem_layer.dataProvider() + + # add fields + cols = [c for c in gdf.columns if c != 'geometry'] + qfields = [] + for c in cols: + sample = gdf[c].dropna() + qtype = QVariant.String + if not sample.empty: + v = sample.iloc[0] + if isinstance(v, (int,)): + qtype = QVariant.Int + elif isinstance(v, (float,)): + qtype = QVariant.Double + else: + qtype = QVariant.String + prov.addAttributes([QgsField(c, qtype)]) + qfields.append(c) + mem_layer.updateFields() + + # add features + feats = [] + for _, row in gdf.reset_index(drop=True).iterrows(): + f = QgsFeature() + # set attributes in provider order + attr_vals = [row.get(c) for c in qfields] + try: + f.setAttributes(attr_vals) + except Exception: + pass + # geometry + try: + geom = row.get('geometry') + if geom is not None: + # try to extract x,y,z from shapely Point + try: + x, y = geom.x, geom.y + z = geom.z if hasattr(geom, 'z') else None + if z is None: + qgsp = QgsPoint(x, y, 0) + else: + qgsp = QgsPoint(x, y, z) + f.setGeometry(qgsp) + except Exception: + # fallback: skip geometry + pass + except Exception: + pass + feats.append(f) + if feats: + prov.addFeatures(feats) + mem_layer.updateExtents() + QgsProject.instance().addMapLayer(mem_layer) class FaultFeatureDetailsPanel(BaseFeatureDetailsPanel): @@ -211,6 +567,7 @@ def __init__(self, parent=None, *, feature=None, model_manager=None, data_manage if feature is None: raise ValueError("Feature must be provided.") self.feature = feature + def addMidBlock(self): form_layout = QFormLayout() fold_frame_combobox = QComboBox() fold_frame_combobox.addItems([""] + [f.name for f in self.model_manager.fold_frames]) @@ -229,6 +586,7 @@ def __init__(self, parent=None, *, feature=None, model_manager=None, data_manage # 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) @@ -241,6 +599,7 @@ def __init__(self, parent=None, *, feature=None, model_manager=None, data_manage 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): # Remove redundant layout setting # self.setLayout(self.layout) form_layout = QFormLayout() @@ -252,10 +611,10 @@ def __init__(self, parent=None, *, feature=None, model_manager=None, data_manage norm_length.setRange(0, 100000) norm_length.setValue(1) # Set a default value norm_length.valueChanged.connect( - lambda value: feature.builder.update_build_arguments( + lambda value: self.feature.builder.update_build_arguments( { 'fold_weights': { - **feature.builder.build_arguments.get('fold_weights', {}), + **self.feature.builder.build_arguments.get('fold_weights', {}), 'fold_norm': value, } } @@ -267,10 +626,10 @@ def __init__(self, parent=None, *, feature=None, model_manager=None, data_manage norm_weight.setRange(0, 100000) norm_weight.setValue(1) norm_weight.valueChanged.connect( - lambda value: feature.builder.update_build_arguments( + lambda value: self.feature.builder.update_build_arguments( { 'fold_weights': { - **feature.builder.build_arguments.get('fold_weights', {}), + **self.feature.builder.build_arguments.get('fold_weights', {}), 'fold_normalisation': value, } } @@ -282,10 +641,10 @@ def __init__(self, parent=None, *, feature=None, model_manager=None, data_manage fold_axis_weight.setRange(0, 100000) fold_axis_weight.setValue(1) fold_axis_weight.valueChanged.connect( - lambda value: feature.builder.update_build_arguments( + lambda value: self.feature.builder.update_build_arguments( { 'fold_weights': { - **feature.builder.build_arguments.get('fold_weights', {}), + **self.feature.builder.build_arguments.get('fold_weights', {}), 'fold_axis_w': value, } } @@ -297,10 +656,10 @@ def __init__(self, parent=None, *, feature=None, model_manager=None, data_manage fold_orientation_weight.setRange(0, 100000) fold_orientation_weight.setValue(1) fold_orientation_weight.valueChanged.connect( - lambda value: feature.builder.update_build_arguments( + lambda value: self.feature.builder.update_build_arguments( { 'fold_weights': { - **feature.builder.build_arguments.get('fold_weights', {}), + **self.feature.builder.build_arguments.get('fold_weights', {}), 'fold_orientation': value, } } @@ -311,7 +670,7 @@ def __init__(self, parent=None, *, feature=None, model_manager=None, data_manage 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( + lambda state: self.feature.builder.update_build_arguments( {'av_fold_axis': state != Qt.Checked} ) ) @@ -363,3 +722,5 @@ def foldAxisFromPlungeAzimuth(self): 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/main/model_manager.py b/loopstructural/main/model_manager.py index bf9916d..c43a7a0 100644 --- a/loopstructural/main/model_manager.py +++ b/loopstructural/main/model_manager.py @@ -426,3 +426,107 @@ def convert_feature_to_structural_frame(self, feature_name: str): 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: + """Evaluate a model feature at the provided points. + + :param feature_name: Name of the feature to evaluate. + :param points: Nx3 numpy array of points [x, y, z]. + :param scalar_type: 'scalar' or 'gradient'. + :returns: numpy array of evaluated values. For 'scalar' an (N,) array is returned. For 'gradient' an (N,3) array is returned if supported. + """ + if self.model is None: + raise RuntimeError('No model available for evaluation') + pts = np.asarray(points) + if pts.ndim != 2 or pts.shape[1] < 3: + raise ValueError('points must be an Nx3 array') + + try: + if scalar_type == 'gradient': + # Prefer a dedicated gradient evaluation method if available + if hasattr(self.model, 'evaluate_feature_gradient'): + vals = self.model.evaluate_feature_gradient(feature_name, pts) + else: + # Some models may support a gradient flag on the value evaluator + try: + vals = self.model.evaluate_feature_value(feature_name, pts, gradient=True) + except TypeError: + # Not supported by the model + raise RuntimeError('Model does not support gradient evaluation') + else: + vals = self.model.evaluate_feature_value(feature_name, pts) + return np.asarray(vals) + except Exception: + # Re-raise with context preserved for the caller/UI to handle + raise + + def export_feature_values_to_geodataframe( + self, + feature_name: str, + points: np.ndarray, + scalar_type: str = 'scalar', + attributes: 'pd.DataFrame' = None, + crs: str | None = None, + value_field_name: str | None = None, + ) -> 'gpd.GeoDataFrame': + """Evaluate a feature on points and return a GeoDataFrame with results. + + :param feature_name: Feature name to evaluate. + :param points: Nx3 array-like of points (x,y,z). + :param scalar_type: 'scalar' or 'gradient'. + :param attributes: Optional pandas DataFrame with attributes to attach (must have same length as points). + :param crs: Optional CRS for the returned GeoDataFrame (e.g. 'EPSG:4326'). If None, GeoDataFrame will be created without CRS. + :param value_field_name: Optional name for the value field; defaults to '{feature_name}_value' or '{feature_name}_gradient'. + :returns: GeoDataFrame with geometry and value columns (and any provided attributes). + """ + import pandas as _pd + import geopandas as _gpd + try: + from shapely.geometry import Point as _Point + except Exception: + print("Shapely not available; geometry column will be omitted." ) + _Point = None + if crs is None: + crs = self.project. + pts = np.asarray(points) + if pts.ndim != 2 or pts.shape[1] < 3: + raise ValueError('points must be an Nx3 array') + + values = self.evaluate_feature_on_points(feature_name, pts, scalar_type=scalar_type) + + # Build a DataFrame + df = _pd.DataFrame({'x': pts[:, 0], 'y': pts[:, 1], 'z': pts[:, 2]}) + + if scalar_type == 'gradient': + vals = np.asarray(values) + if vals.ndim == 2 and vals.shape[1] == 3: + df['gx'] = vals[:, 0] + df['gy'] = vals[:, 1] + df['gz'] = vals[:, 2] + # also provide magnitude + df[f'{feature_name}_gmag'] = np.linalg.norm(vals, axis=1) + else: + # Unexpected shape; attempt to flatten + df[f'{feature_name}_gradient'] = list(vals) + else: + df[value_field_name or f"{feature_name}_value"] = np.asarray(values) + + # Attach attributes if provided + 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) + except Exception: + # ignore attributes if they cannot be combined + pass + + # Create geometry column + geoms = None + if _Point is not None: + geoms = [_Point(x, y, z) for x, y, z in pts] + gdf = _gpd.GeoDataFrame(df, geometry=geoms, crs=crs) + else: + # shapely not available; return a regular DataFrame inside a GeoDataFrame placeholder + gdf = _gpd.GeoDataFrame(df) + + return gdf From 3ce70a17bcf740c30c6e4001e4896e179cdce8ff Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Sat, 6 Sep 2025 11:42:27 +0400 Subject: [PATCH 02/22] fix: remove feature data from data manager when its deleted --- .../gui/modelling/geological_model_tab/geological_model_tab.py | 1 + 1 file changed, 1 insertion(+) 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 ce14d6d..8f3832b 100644 --- a/loopstructural/gui/modelling/geological_model_tab/geological_model_tab.py +++ b/loopstructural/gui/modelling/geological_model_tab/geological_model_tab.py @@ -171,6 +171,7 @@ def delete_feature(self, item): # Try model's __delitem__ if supported try: del self.model_manager.model[feature_name] + del self.data_manager.feature_data[feature_name] except Exception: # Fallback: remove object from features list and feature index if present feature = self.model_manager.model.get_feature_by_name(feature_name) From ccc03b99238dbaf6968ca56957bb7052dd37a398 Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Sat, 6 Sep 2025 11:44:23 +0400 Subject: [PATCH 03/22] fix: make fold plunge/azi attributes of panel for easy access --- .../feature_details_panel.py | 38 +++++++++---------- 1 file changed, 18 insertions(+), 20 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 07ebbc8..a452271 100644 --- a/loopstructural/gui/modelling/geological_model_tab/feature_details_panel.py +++ b/loopstructural/gui/modelling/geological_model_tab/feature_details_panel.py @@ -299,7 +299,6 @@ def _export_scalar_points(self): outside of QGIS. """ # determine scalar type - print('HERE') logger.info('Exporting scalar points') scalar_type = self.scalar_field_combo.currentText() if hasattr(self, 'scalar_field_combo') else 'scalar' @@ -668,31 +667,31 @@ def addMidBlock(self): 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.setChecked(False) average_fold_axis_checkbox.stateChanged.connect( lambda state: self.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) + lambda state: self.fold_azimuth.setEnabled(state != Qt.Checked) ) average_fold_axis_checkbox.stateChanged.connect( - lambda state: fold_plunge.setEnabled(state == Qt.Checked) + lambda state: self.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) + self.fold_plunge = QDoubleSpinBox() + self.fold_plunge.setRange(0, 90) + self.fold_plunge.setValue(0) + self.fold_azimuth = QDoubleSpinBox() + self.fold_azimuth.setRange(0, 360) + self.fold_azimuth.setValue(0) + self.fold_azimuth.setEnabled(False) + self.fold_plunge.setEnabled(False) + self.fold_plunge.valueChanged.connect(self.foldAxisFromPlungeAzimuth) + self.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) + form_layout.addRow("Fold Plunge", self.fold_plunge) + form_layout.addRow("Fold Azimuth", self.fold_azimuth) # splot_button = QPushButton("S-Plot") # splot_button.clicked.connect( # lambda: self.open_splot_dialog() @@ -714,13 +713,12 @@ 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() + self.fold_plunge.value() ) azimuth = ( - self.layout().itemAt(0).widget().findChild(QDoubleSpinBox, "fold_azimuth").value() - ) + 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}) + self.feature.builder.update_build_arguments({'fold_axis': vector.tolist()}) From 1dc4b2f65fa3cdb3547feee743839fe16a7fa8ff Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Sun, 7 Sep 2025 16:11:28 +0200 Subject: [PATCH 04/22] fix: update viewer to store meshes and a reference to actors. Add some options to change properties of objects in 3D --- .../gui/visualisation/feature_list_widget.py | 16 +- .../visualisation/loop_pyvistaqt_wrapper.py | 50 ++--- .../gui/visualisation/object_list_widget.py | 177 ++++++++++++++++-- .../visualisation/object_properties_widget.py | 165 ++++++++++++++++ .../gui/visualisation/visualisation_widget.py | 71 ++++++- 5 files changed, 420 insertions(+), 59 deletions(-) create mode 100644 loopstructural/gui/visualisation/object_properties_widget.py diff --git a/loopstructural/gui/visualisation/feature_list_widget.py b/loopstructural/gui/visualisation/feature_list_widget.py index ad235c6..b5d624a 100644 --- a/loopstructural/gui/visualisation/feature_list_widget.py +++ b/loopstructural/gui/visualisation/feature_list_widget.py @@ -92,19 +92,19 @@ 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(scalar_field.vtk(), name=f'{feature_name}_scalar_field') + self.viewer.add_mesh_object(scalar_field.vtk(), name=f'{feature_name}_scalar_field') print(f"Adding scalar field to feature: {feature_name}") def add_surface(self, feature_name): surfaces = self.model_manager.model[feature_name].surfaces() for surface in surfaces: - self.viewer.add_mesh(surface.vtk(), name=f'{feature_name}_surface') + self.viewer.add_mesh_object(surface.vtk(), name=f'{feature_name}_surface') print(f"Adding surface to feature: {feature_name}") 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(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') print(f"Adding vector field to feature: {feature_name}") def add_data(self, feature_name): @@ -113,11 +113,11 @@ def add_data(self, feature_name): if issubclass(type(d), VectorPoints): scale = self._get_vector_scale() # tolerance is None means all points are shown - self.viewer.add_mesh( + self.viewer.add_mesh_object( d.vtk(scale=scale, tolerance=None), name=f'{feature_name}_{d.name}_points' ) else: - self.viewer.add_mesh(d.vtk(), name=f'{feature_name}_{d.name}') + self.viewer.add_mesh_object(d.vtk(), name=f'{feature_name}_{d.name}') print(f"Adding data to feature: {feature_name}") def add_model_bounding_box(self): @@ -125,7 +125,7 @@ def add_model_bounding_box(self): print("Model manager is not set.") return bb = self.model_manager.model.bounding_box.vtk().outline() - self.viewer.add_mesh(bb, name='model_bounding_box') + self.viewer.add_mesh_object(bb, name='model_bounding_box') # Logic for adding model bounding box print("Adding model bounding box...") @@ -135,7 +135,7 @@ def add_fault_surfaces(self): return fault_surfaces = self.model_manager.model.get_fault_surfaces() for surface in fault_surfaces: - self.viewer.add_mesh(surface.vtk(), name=f'fault_surface_{surface.name}') + self.viewer.add_mesh_object(surface.vtk(), name=f'fault_surface_{surface.name}') print("Adding fault surfaces...") def add_stratigraphic_surfaces(self): @@ -144,5 +144,5 @@ def add_stratigraphic_surfaces(self): return stratigraphic_surfaces = self.model_manager.model.get_stratigraphic_surfaces() for surface in stratigraphic_surfaces: - self.viewer.add_mesh(surface.vtk(), name=surface.name) + self.viewer.add_mesh_object(surface.vtk(), name=surface.name) print("Adding stratigraphic surfaces...") diff --git a/loopstructural/gui/visualisation/loop_pyvistaqt_wrapper.py b/loopstructural/gui/visualisation/loop_pyvistaqt_wrapper.py index 6c9bca4..f45455b 100644 --- a/loopstructural/gui/visualisation/loop_pyvistaqt_wrapper.py +++ b/loopstructural/gui/visualisation/loop_pyvistaqt_wrapper.py @@ -1,5 +1,5 @@ import re - +from collections import defaultdict from PyQt5.QtCore import pyqtSignal from pyvistaqt import QtInteractor @@ -11,7 +11,8 @@ def __init__(self, parent): super().__init__(parent=parent) self.objects = {} self.add_axes() - + self.meshes = {} + self.mesh_actors = defaultdict(list) def increment_name(self, name): parts = name.split('_') if len(parts) == 1: @@ -25,52 +26,31 @@ def increment_name(self, name): name = '_'.join(parts) return name - def add_mesh(self, *args, **kwargs): + def add_mesh_object(self, mesh, name:str): """Add a mesh to the plotter.""" - if 'name' not in kwargs or not kwargs['name']: - name = 'unnnamed_object' - kwargs['name'] = name - kwargs['name'] = kwargs['name'].replace(' ', '_') - kwargs['name'] = re.sub(r'[^a-zA-Z0-9_$]', '_', kwargs['name']) - if kwargs['name'][0].isdigit(): - kwargs['name'] = 'ls_' + kwargs['name'] - if kwargs['name'][0] == '_': - kwargs['name'] = 'ls' + kwargs['name'] - kwargs['name'] = self.increment_name(kwargs['name']) - if '__opacity' in kwargs['name']: - raise ValueError('Cannot use __opacity in name') - if '__visibility' in kwargs['name']: - raise ValueError('Cannot use __visibility in name') - if '__control_visibility' in kwargs['name']: - raise ValueError('Cannot use __control_visibility in name') - actor = super().add_mesh(*args, **kwargs) - self.objects[kwargs['name']] = args[0] + if name in self.meshes: + name = self.increment_name(name) + + self.meshes[name] = {'mesh':mesh} + actor = self.add_mesh(mesh) + self.meshes[name]['actor'] = actor self.objectAdded.emit(self) return actor def remove_object(self, name): """Remove an object by name.""" - if name in self.actors: - self.remove_actor(self.actors[name]) + if name in self.meshes: + self.remove_actor(self.meshes[name]['actor']) self.update() else: raise ValueError(f"Object '{name}' not found in the plotter.") - def change_object_name(self, old_name, new_name): - """Change the name of an object.""" - if old_name in self.actors: - if new_name in self.objects: - raise ValueError(f"Object '{new_name}' already exists.") - self.actors[new_name] = self.actors.pop(old_name) - self.actors[new_name].name = new_name - else: - raise ValueError(f"Object '{old_name}' not found in the plotter.") + def change_object_visibility(self, name, visibility): """Change the visibility of an object.""" - if name in self.actors: - self.actors[name].visibility = visibility - self.actors[name].actor.visibility = visibility + if name in self.meshes: + self.meshes[name]['actor'].visibility = visibility self.update() else: raise ValueError(f"Object '{name}' not found in the plotter.") diff --git a/loopstructural/gui/visualisation/object_list_widget.py b/loopstructural/gui/visualisation/object_list_widget.py index 2a47897..460fd3e 100644 --- a/loopstructural/gui/visualisation/object_list_widget.py +++ b/loopstructural/gui/visualisation/object_list_widget.py @@ -15,36 +15,183 @@ class ObjectListWidget(QWidget): - def __init__(self, parent=None, *, viewer=None): + def __init__(self, parent=None, *, viewer=None, properties_widget=None): super().__init__(parent) self.mainLayout = QVBoxLayout(self) self.treeWidget = QTreeWidget(self) self.treeWidget.setHeaderHidden(True) # Hide the header - self.treeWidget.setSelectionMode(QTreeWidget.MultiSelection) # Enable multi-selection self.mainLayout.addWidget(self.treeWidget) addButton = QPushButton("Add Object", self) addButton.setContextMenuPolicy(Qt.CustomContextMenu) addButton.clicked.connect(self.show_add_object_menu) self.mainLayout.addWidget(addButton) - + self.properties_widget = properties_widget self.setLayout(self.mainLayout) self.viewer = viewer self.viewer.objectAdded.connect(self.update_object_list) self.treeWidget.installEventFilter(self) + self.treeWidget.itemSelectionChanged.connect(self.on_object_selected) + def on_object_selected(self): + selected_items = self.treeWidget.selectedItems() + if not selected_items: + # if nothing selected keep the previous selection. + # Need to select a new object to change its properties + return + + # For simplicity, just handle the first selected item + item = selected_items[0] + item_widget = self.treeWidget.itemWidget(item, 0) + object_label = item_widget.findChild(QLabel).text() + if hasattr(self, 'properties_widget') and self.properties_widget: + + self.properties_widget.setCurrentObject(object_label) def update_object_list(self, new_object): + """Rebuild the tree so top-level items are the entries in + `viewer.meshes`. Each mesh gets a visibility checkbox and child + items listing its point and cell data arrays. + """ + if not self.viewer: + return + + # Clear and rebuild the tree to reflect current meshes + self.treeWidget.clear() + + meshes = getattr(self.viewer, 'meshes', {}) or {} + for mesh_name in sorted(meshes.keys()): + mesh = meshes[mesh_name] + self.add_mesh_item(mesh_name, mesh) - for object_name in self.viewer.actors: - # Check if object already exists in tree - exists = False - for i in range(self.treeWidget.topLevelItemCount()): - item = self.treeWidget.topLevelItem(i) - widget = self.treeWidget.itemWidget(item, 0) - if widget and widget.findChild(QLabel).text() == object_name: - exists = True - break - if not exists: - self.add_actor(object_name) + def add_mesh_item(self, mesh_name, mesh): + """Add a top-level tree item for a mesh and populate children for + point/cell data arrays. + """ + top = QTreeWidgetItem(self.treeWidget) + + # Determine initial visibility. Prefer viewer.actors entry if available. + initial_visibility = True + try: + if hasattr(self.viewer, 'actors') and mesh_name in getattr(self.viewer, 'actors', {}): + initial_visibility = bool(self.viewer.actors[mesh_name].visibility) + elif hasattr(mesh, 'visibility'): + initial_visibility = bool(getattr(mesh, 'visibility')) + except Exception: + initial_visibility = True + + visibilityCheckbox = QCheckBox() + visibilityCheckbox.setChecked(initial_visibility) + + # Connect checkbox: prefer viewer APIs, fallback to mesh attribute + def _on_vis(state, name=mesh_name, m=mesh): + checked = state == Qt.Checked + if hasattr(self.viewer, 'actors') and name in getattr(self.viewer, 'actors', {}): + self.set_object_visibility(name, checked) + return + if hasattr(self.viewer, 'set_object_visibility'): + try: + self.viewer.set_object_visibility(name, checked) + return + except Exception: + pass + # Fallback: set on mesh if possible + if hasattr(m, 'visibility'): + try: + setattr(m, 'visibility', checked) + except Exception: + pass + + visibilityCheckbox.stateChanged.connect(_on_vis) + + # Compose widget (checkbox + label) + itemWidget = QWidget() + itemLayout = QHBoxLayout(itemWidget) + itemLayout.setContentsMargins(0, 0, 0, 0) + itemLayout.addWidget(visibilityCheckbox) + itemLayout.addWidget(QLabel(mesh_name)) + itemWidget.setLayout(itemLayout) + + self.treeWidget.setItemWidget(top, 0, itemWidget) + top.setExpanded(False) + + # Add children: Point Data and Cell Data groups + try: + point_data = getattr(mesh, 'point_data', None) + cell_data = getattr(mesh, 'cell_data', None) + + if point_data is not None and len(point_data.keys()) > 0: + pd_group = QTreeWidgetItem(top) + pd_group.setText(0, 'Point Data') + for array_name in sorted(point_data.keys()): + arr_item = QTreeWidgetItem(pd_group) + # show name and length/type if available + try: + vals = point_data[array_name] + meta = f" ({len(vals)})" if hasattr(vals, '__len__') else '' + except Exception: + meta = '' + arr_item.setText(0, f"{array_name}{meta}") + + if cell_data is not None and len(cell_data.keys()) > 0: + cd_group = QTreeWidgetItem(top) + cd_group.setText(0, 'Cell Data') + for array_name in sorted(cell_data.keys()): + arr_item = QTreeWidgetItem(cd_group) + try: + vals = cell_data[array_name] + meta = f" ({len(vals)})" if hasattr(vals, '__len__') else '' + except Exception: + meta = '' + arr_item.setText(0, f"{array_name}{meta}") + except Exception: + # If mesh lacks expected attributes, silently continue + pass + + def add_object_item(self, object_name, instance=None): + """Add a generic object entry to the tree. This mirrors add_actor but works + for objects/meshes that are not present in viewer.actors.""" + objectItem = QTreeWidgetItem(self.treeWidget) + + # Determine initial visibility + visibility = False + if instance is not None and hasattr(instance, 'visibility'): + visibility = bool(getattr(instance, 'visibility')) + + visibilityCheckbox = QCheckBox() + visibilityCheckbox.setChecked(visibility) + + # Connect checkbox to toggle visibility. Prefer using viewer APIs if available. + def _on_visibility_change(state, name=object_name, inst=instance): + checked = state == Qt.Checked + # If there's an actor for this name, delegate to set_object_visibility + if hasattr(self.viewer, 'actors') and name in getattr(self.viewer, 'actors', {}): + self.set_object_visibility(name, checked) + return + # If viewer exposes a generic setter use it + if hasattr(self.viewer, 'set_object_visibility'): + try: + self.viewer.set_object_visibility(name, checked) + return + except Exception: + pass + # Fallback: set attribute on the instance if possible + if inst is not None and hasattr(inst, 'visibility'): + try: + setattr(inst, 'visibility', checked) + except Exception: + pass + + visibilityCheckbox.stateChanged.connect(_on_visibility_change) + + # Create a widget to hold the checkbox and name on a single line + itemWidget = QWidget() + itemLayout = QHBoxLayout(itemWidget) + itemLayout.setContentsMargins(0, 0, 0, 0) + itemLayout.addWidget(visibilityCheckbox) + itemLayout.addWidget(QLabel(object_name)) + itemWidget.setLayout(itemLayout) + + self.treeWidget.setItemWidget(objectItem, 0, itemWidget) + objectItem.setExpanded(False) # Initially collapsed def add_actor(self, actor_name): # Create a tree item for the object @@ -221,7 +368,7 @@ def load_feature_from_file(self): # Add the mesh to the viewer if self.viewer and hasattr(self.viewer, 'add_mesh'): - self.viewer.add_mesh(mesh, name=file_name) + self.viewer.add_mesh_object(mesh, name=file_name) else: print("Error: Viewer is not initialized or does not support adding meshes.") diff --git a/loopstructural/gui/visualisation/object_properties_widget.py b/loopstructural/gui/visualisation/object_properties_widget.py new file mode 100644 index 0000000..a6835f3 --- /dev/null +++ b/loopstructural/gui/visualisation/object_properties_widget.py @@ -0,0 +1,165 @@ +from PyQt5.QtCore import Qt + +from PyQt5.QtWidgets import ( + QWidget, QVBoxLayout, QLabel, QComboBox, QSlider, QCheckBox, QColorDialog, QPushButton, QHBoxLayout, QLineEdit, QSizePolicy +) + +class ObjectPropertiesWidget(QWidget): + def __init__(self, parent=None, *, viewer=None): + super().__init__(parent) + layout = QVBoxLayout() + # Keep widgets close together and provide padding at the bottom + layout.setSpacing(8) + layout.setContentsMargins(8, 8, 8, 20) + + # Title / currently selected object + self.title_label = QLabel("No object selected") + self.title_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + layout.addWidget(self.title_label) + + # Scalar Bar + self.scalar_bar_checkbox = QCheckBox("Show Scalar Bar") + self.scalar_bar_checkbox.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) + layout.addWidget(self.scalar_bar_checkbox) + + # Colormap + layout.addWidget(QLabel("Colormap:")) + self.colormap_combo = QComboBox() + self.colormap_combo.addItems(["Viridis", "Plasma", "Inferno", "Magma", "Greys"]) + self.colormap_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + layout.addWidget(self.colormap_combo) + + # Opacity + layout.addWidget(QLabel("Opacity:")) + self.opacity_slider = QSlider(Qt.Horizontal) + self.opacity_slider.setRange(0, 100) + self.opacity_slider.setValue(100) + self.opacity_slider.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.opacity_slider.valueChanged.connect(lambda val: self.set_opacity(val / 100.0)) + layout.addWidget(self.opacity_slider) + + # Show Edges + self.show_edges_checkbox = QCheckBox("Show Edges") + self.show_edges_checkbox.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) + layout.addWidget(self.show_edges_checkbox) + + # Colormap Range + range_layout = QHBoxLayout() + range_layout.setSpacing(6) + range_layout.addWidget(QLabel("Colormap Range:")) + self.range_min = QLineEdit() + self.range_min.setPlaceholderText("Min") + self.range_min.setMaximumWidth(120) + self.range_min.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.range_max = QLineEdit() + self.range_max.setPlaceholderText("Max") + self.range_max.setMaximumWidth(120) + self.range_max.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + range_layout.addWidget(self.range_min) + range_layout.addWidget(self.range_max) + layout.addLayout(range_layout) + + # Surface Color + surface_color_layout = QHBoxLayout() + surface_color_layout.setSpacing(6) + surface_color_layout.addWidget(QLabel("Surface Color:")) + self.color_button = QPushButton("Choose Color") + self.color_button.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + surface_color_layout.addWidget(self.color_button) + layout.addLayout(surface_color_layout) + + # Add stretch at end so widgets stay grouped at top and bottom has padding + layout.addStretch(1) + + self.setLayout(layout) + + # Internal state + self.current_object_name = None + self.current_mesh = None + self.viewer = viewer + + # Connect color button to color dialog + self.color_button.clicked.connect(self.choose_color) + + def choose_color(self): + color = QColorDialog.getColor() + if color.isValid(): + self.color_button.setStyleSheet(f"background-color: {color.name()}") + actor = self.viewer.meshes[self.current_object_name]['actor'] + actor.prop.color = (color.redF(), color.greenF(), color.blueF()) + # Placeholder: store or use the selected color + self.meshes[self.current_object_name]['color'] = (color.redF(), color.greenF(), color.blueF()) + def set_opacity(self, value: float): + """Set the opacity of the current object. + + :param value: Opacity value between 0.0 (transparent) and 1.0 (opaque) + """ + if self.current_object_name is None or self.viewer is None: + return + actor = self.viewer.meshes[self.current_object_name]['actor'] + actor.prop.opacity = value + self.meshes[self.current_object_name]['opacity'] = value + + + def setCurrentObject(self, object_name: str): + """Populate the properties widget for the given object. + + :param object_name: name of the selected object + :param mesh: optional mesh instance (pyvista.PolyData or similar) + :param viewer: optional viewer instance for deeper control + """ + self.current_object_name = object_name + self.current_mesh = self.viewer.meshes[object_name] + self.title_label.setText(f"Object: {object_name}") + + # Default values + self.scalar_bar_checkbox.setChecked(False) + self.range_min.clear() + self.range_max.clear() + + # Try to infer a scalar range from mesh data (point or cell data) + try: + if self.current_meshmesh is not None: + # Prefer point_data, fall back to cell_data + pdata = getattr(self.current_mesh, 'point_data', None) or {} + cdata = getattr(self.current_mesh, 'cell_data', None) or {} + first = None + if len(pdata.keys()) > 0: + first = next(iter(pdata.keys())) + vals = pdata[first] + elif len(cdata.keys()) > 0: + first = next(iter(cdata.keys())) + vals = cdata[first] + else: + first = None + vals = None + + if vals is not None: + try: + mn = float(getattr(vals, 'min', lambda: min(vals))()) if hasattr(vals, 'min') else float(min(vals)) + mx = float(getattr(vals, 'max', lambda: max(vals))()) if hasattr(vals, 'max') else float(max(vals)) + self.range_min.setText(str(mn)) + self.range_max.setText(str(mx)) + except Exception: + # best-effort only + self.range_min.clear() + self.range_max.clear() + + except Exception: + # Keep defaults on error + pass + + # Try to detect scalar bar visibility via viewer/actor if provided + try: + if self.viewer is not None and hasattr(self.viewer, 'actors') and object_name in getattr(self.viewer, 'actors', {}): + actor = self.viewer.actors[object_name] + # some actor wrappers expose mapper.scalar_visibility or similar + mapper = getattr(actor, 'mapper', None) + vis = False + if mapper is not None and hasattr(mapper, 'scalar_visibility'): + vis = bool(getattr(mapper, 'scalar_visibility')) + # update checkbox (best-effort) + self.scalar_bar_checkbox.setChecked(vis) + except Exception: + # ignore failures + pass \ No newline at end of file diff --git a/loopstructural/gui/visualisation/visualisation_widget.py b/loopstructural/gui/visualisation/visualisation_widget.py index 88f9dde..9a6e488 100644 --- a/loopstructural/gui/visualisation/visualisation_widget.py +++ b/loopstructural/gui/visualisation/visualisation_widget.py @@ -8,6 +8,7 @@ from .loop_pyvistaqt_wrapper import LoopPyVistaQTPlotter from .object_list_widget import ObjectListWidget from .feature_list_widget import FeatureListWidget +from .object_properties_widget import ObjectPropertiesWidget class VisualisationWidget(QWidget): @@ -31,8 +32,9 @@ def __init__(self, parent: QWidget = None, mapCanvas=None, logger=None, model_ma # Create the viewer self.plotter = LoopPyVistaQTPlotter(parent) # self.plotter.add_axes() + self.objectPropertiesWidget = ObjectPropertiesWidget(viewer=self.plotter) - self.objectList = ObjectListWidget(viewer=self.plotter) + self.objectList = ObjectListWidget(viewer=self.plotter,properties_widget=self.objectPropertiesWidget) # Modify layout to stack object list and feature list vertically sidebarSplitter = QSplitter(Qt.Vertical, self) @@ -43,3 +45,70 @@ def __init__(self, parent: QWidget = None, mapCanvas=None, logger=None, model_ma sidebarSplitter.addWidget(self.featureList) splitter.addWidget(sidebarSplitter) splitter.addWidget(self.plotter) + # Add properties panel but start it collapsed (size 0) + splitter.addWidget(self.objectPropertiesWidget) + self._main_splitter = splitter + # initial sizes: sidebar, main, properties (collapsed) + splitter.setSizes([200, 600, 0]) + # remember previous sizes so we can restore when blocking expansion + self._previous_splitter_sizes = splitter.sizes() + # Intercept user splitter moves to prevent expanding properties panel when not allowed + try: + splitter.splitterMoved.connect(self._on_splitter_moved) + except Exception: + pass + + def show_properties_panel(self, show: bool): + """Expand or collapse the properties panel in the splitter. + + When collapsed we set its size to 0 so it can't be opened accidentally. + """ + if not hasattr(self, '_main_splitter'): + return + sizes = self._main_splitter.sizes() + # sizes: [sidebar, main, properties] + if show: + # restore a modest width for properties panel, clamp values + sidebar = max(150, sizes[0]) + main = max(300, sizes[1]) + prop = 250 + self._main_splitter.setSizes([sidebar, main, prop]) + self._previous_splitter_sizes = [sidebar, main, prop] + else: + # collapse properties to 0 width + self._main_splitter.setSizes([sizes[0], sizes[1], 0]) + self._previous_splitter_sizes = [sizes[0], sizes[1], 0] + + def is_properties_panel_visible(self) -> bool: + if not hasattr(self, '_main_splitter'): + return False + return self._main_splitter.sizes()[2] > 0 + + def _can_show_properties(self) -> bool: + """Return True when properties panel may be shown (e.g. an object selected).""" + try: + w = self.objectPropertiesWidget + return getattr(w, 'current_object_name', None) is not None + except Exception: + return False + + def _on_splitter_moved(self, pos: int, index: int): + """Handler called after the user moves the splitter handle. + + If the user tries to open the properties panel (third pane) but + _can_show_properties() is False, silently restore the previous sizes + to block expansion. + """ + try: + sizes = self._main_splitter.sizes() + # if properties width > 0 and we are not allowed to show it, restore + if sizes[2] > 0 and not self._can_show_properties(): + # restore previous sizes + self._main_splitter.blockSignals(True) + self._main_splitter.setSizes(self._previous_splitter_sizes) + self._main_splitter.blockSignals(False) + else: + # update remembered sizes for later + self._previous_splitter_sizes = sizes + except Exception: + pass From 437817c3415dafd02b11a5ca6c99f4fa9775881b Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Sun, 7 Sep 2025 16:52:42 +0200 Subject: [PATCH 05/22] fix: uses the meshes dictionary for export --- .../gui/visualisation/object_list_widget.py | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/loopstructural/gui/visualisation/object_list_widget.py b/loopstructural/gui/visualisation/object_list_widget.py index 460fd3e..d10b334 100644 --- a/loopstructural/gui/visualisation/object_list_widget.py +++ b/loopstructural/gui/visualisation/object_list_widget.py @@ -245,10 +245,12 @@ def export_selected_object(self): item_widget = self.treeWidget.itemWidget(selected_items[0], 0) object_label = item_widget.findChild(QLabel).text() - object = self.viewer.objects.get(object_label, None) - if object is None: + mesh_dict = self.viewer.meshes.get(object_label, None) + if mesh_dict is None: + return + mesh = mesh_dict.get('mesh', None) + if mesh is None: return - # Determine available formats based on object type and dependencies formats = [] try: @@ -293,28 +295,28 @@ def export_selected_object(self): try: if selected_format == "obj": ( - object.save(file_path) - if hasattr(object, "save") - else pv.save_meshio(file_path, object) + mesh.save(file_path) + if hasattr(mesh, "save") + else pv.save_meshio(file_path, mesh) ) elif selected_format == "vtk": - pv.save_meshio(file_path, object) + pv.save_meshio(file_path, mesh ) elif selected_format == "ply": - pv.save_meshio(file_path, object) + pv.save_meshio(file_path, mesh) elif selected_format == "vtp": ( - object.save(file_path) - if hasattr(object, "save") - else pv.save_meshio(file_path, object) + mesh.save(file_path) + if hasattr(mesh, "save") + else pv.save_meshio(file_path, mesh) ) elif selected_format == "geoh5": with geoh5py.Geoh5(file_path, overwrite=True) as geoh5: - if hasattr(object, "faces"): + if hasattr(mesh, "faces"): geoh5.add_surface( - name=object_label, vertices=object.points, faces=object.faces + name=object_label, vertices=mesh.points, faces=mesh.faces ) else: - geoh5.add_points(name=object_label, vertices=object.points) + geoh5.add_points(name=object_label, vertices=mesh.points) print(f"Exported {object_label} to {file_path} as {selected_format}") except Exception as e: print(f"Failed to export object: {e}") From 6073e7f01cc25053694db02169ad5a6befcd5e18 Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Sun, 7 Sep 2025 17:10:24 +0200 Subject: [PATCH 06/22] remove typo --- loopstructural/main/model_manager.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/loopstructural/main/model_manager.py b/loopstructural/main/model_manager.py index c43a7a0..dbb85e8 100644 --- a/loopstructural/main/model_manager.py +++ b/loopstructural/main/model_manager.py @@ -486,8 +486,7 @@ def export_feature_values_to_geodataframe( except Exception: print("Shapely not available; geometry column will be omitted." ) _Point = None - if crs is None: - crs = self.project. + pts = np.asarray(points) if pts.ndim != 2 or pts.shape[1] < 3: raise ValueError('points must be an Nx3 array') From ea0aefc6b67e200bb2e4e4f157a25624c161c392 Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Sun, 7 Sep 2025 17:15:18 +0200 Subject: [PATCH 07/22] fix: add histogram for scalar value, allow selecting scalar, choose to colour with scalar or with solid colour --- .../visualisation/loop_pyvistaqt_wrapper.py | 106 ++++- .../visualisation/object_properties_widget.py | 449 +++++++++++++++++- 2 files changed, 519 insertions(+), 36 deletions(-) diff --git a/loopstructural/gui/visualisation/loop_pyvistaqt_wrapper.py b/loopstructural/gui/visualisation/loop_pyvistaqt_wrapper.py index f45455b..a6d6ae6 100644 --- a/loopstructural/gui/visualisation/loop_pyvistaqt_wrapper.py +++ b/loopstructural/gui/visualisation/loop_pyvistaqt_wrapper.py @@ -2,6 +2,8 @@ from collections import defaultdict from PyQt5.QtCore import pyqtSignal from pyvistaqt import QtInteractor +from typing import Optional, Any, Dict, Tuple +import pyvista as pv class LoopPyVistaQTPlotter(QtInteractor): @@ -11,8 +13,10 @@ def __init__(self, parent): super().__init__(parent=parent) self.objects = {} self.add_axes() + # maps name -> dict(mesh=..., actor=..., kwargs={...}) self.meshes = {} - self.mesh_actors = defaultdict(list) + # maintain an internal pyvista plotter + def increment_name(self, name): parts = name.split('_') if len(parts) == 1: @@ -26,28 +30,92 @@ def increment_name(self, name): name = '_'.join(parts) return name - def add_mesh_object(self, mesh, name:str): - """Add a mesh to the plotter.""" - if name in self.meshes: - name = self.increment_name(name) - - self.meshes[name] = {'mesh':mesh} - actor = self.add_mesh(mesh) - self.meshes[name]['actor'] = actor - self.objectAdded.emit(self) - return actor + 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: + """Add a mesh to the plotter. - def remove_object(self, name): - """Remove an object by name.""" - if name in self.meshes: - self.remove_actor(self.meshes[name]['actor']) - self.update() + This wrapper stores metadata to allow robust re-adding and + updating of visualization parameters. + + :param mesh: a pyvista mesh-like object + :param name: unique name for the mesh + :param scalars: name of scalar array or scalar values to map + :param cmap: colormap name + :param clim: tuple (min, max) for colormap + :param opacity: float 0-1 for surface opacity + :param show_scalar_bar: whether to show scalar bar + :param color: tuple of 3 floats (r,g,b) in 0..1 for solid color; if provided, overrides scalars + """ + # 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 + # pass + + # Decide rendering mode: color (solid) if color provided else scalar mapping + use_scalar = color is None and scalars is not None + + # Build add_mesh kwargs + add_kwargs: Dict[str, Any] = {} + if use_scalar: + add_kwargs['scalars'] = scalars + add_kwargs['cmap'] = cmap + if clim is not None: + add_kwargs['clim'] = clim + add_kwargs['show_scalar_bar'] = show_scalar_bar else: - raise ValueError(f"Object '{name}' not found in the plotter.") + # solid color + if color is not None: + add_kwargs['color'] = color + # ensure scalar bar is disabled if color is used + add_kwargs['show_scalar_bar'] = False + + if opacity is not None: + add_kwargs['opacity'] = opacity + + # merge any extra kwargs (allow caller to override default choices) + add_kwargs.update(kwargs) + + # attempt to add to the underlying pyvista plotter + 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}} + self.objectAdded.emit(self) + + def remove_object(self, name: str) -> None: + """Remove an object by name and clean up stored metadata. - + This ensures names can be re-used and re-adding works predictably. + """ + if name not in self.meshes: + return + entry = self.meshes[name] + actor = entry.get('actor', None) + try: + if actor is not None: + # pyvista.Plotter has remove_actor or remove_mesh depending on version + if hasattr(self, 'remove_actor'): + try: + self.remove_actor(actor) + except Exception: + # fallback to remove_mesh by name + if hasattr(self, 'remove_mesh'): + self.remove_mesh(name) + elif hasattr(self, 'remove_mesh'): + self.remove_mesh(name) + except Exception: + # ignore errors during actor removal + pass + # finally delete metadata + try: + del self.meshes[name] + except Exception: + pass - def change_object_visibility(self, name, visibility): + def set_object_visibility(self, name: str, visibility): """Change the visibility of an object.""" if name in self.meshes: self.meshes[name]['actor'].visibility = visibility diff --git a/loopstructural/gui/visualisation/object_properties_widget.py b/loopstructural/gui/visualisation/object_properties_widget.py index a6835f3..5fe4ce3 100644 --- a/loopstructural/gui/visualisation/object_properties_widget.py +++ b/loopstructural/gui/visualisation/object_properties_widget.py @@ -4,6 +4,11 @@ QWidget, QVBoxLayout, QLabel, QComboBox, QSlider, QCheckBox, QColorDialog, QPushButton, QHBoxLayout, QLineEdit, QSizePolicy ) +# Add plotting imports for scalar histogram +from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas +import matplotlib.pyplot as plt +import numpy as np + class ObjectPropertiesWidget(QWidget): def __init__(self, parent=None, *, viewer=None): super().__init__(parent) @@ -17,6 +22,20 @@ def __init__(self, parent=None, *, viewer=None): self.title_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) layout.addWidget(self.title_label) + # Scalar selection + layout.addWidget(QLabel("Active Scalar:")) + self.scalar_combo = QComboBox() + self.scalar_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.scalar_combo.addItem("") + self.scalar_combo.currentTextChanged.connect(self._on_scalar_changed) + layout.addWidget(self.scalar_combo) + + # Color with Scalar checkbox (new) + self.color_with_scalar_checkbox = QCheckBox("Color with Scalar") + self.color_with_scalar_checkbox.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) + self.color_with_scalar_checkbox.toggled.connect(self._on_color_with_scalar_toggled) + layout.addWidget(self.color_with_scalar_checkbox) + # Scalar Bar self.scalar_bar_checkbox = QCheckBox("Show Scalar Bar") self.scalar_bar_checkbox.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) @@ -25,7 +44,7 @@ def __init__(self, parent=None, *, viewer=None): # Colormap layout.addWidget(QLabel("Colormap:")) self.colormap_combo = QComboBox() - self.colormap_combo.addItems(["Viridis", "Plasma", "Inferno", "Magma", "Greys"]) + self.colormap_combo.addItems(["viridis", "plasma", "inferno", "magma", "greys"]) self.colormap_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) layout.addWidget(self.colormap_combo) @@ -59,6 +78,14 @@ def __init__(self, parent=None, *, viewer=None): range_layout.addWidget(self.range_max) layout.addLayout(range_layout) + # Scalar Histogram (matplotlib canvas) + layout.addWidget(QLabel("Scalar Histogram:")) + self.hist_fig = plt.Figure(figsize=(4, 2)) + self.hist_canvas = FigureCanvas(self.hist_fig) + self.hist_ax = self.hist_fig.subplots() + self.hist_canvas.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + layout.addWidget(self.hist_canvas) + # Surface Color surface_color_layout = QHBoxLayout() surface_color_layout.setSpacing(6) @@ -81,14 +108,197 @@ def __init__(self, parent=None, *, viewer=None): # Connect color button to color dialog self.color_button.clicked.connect(self.choose_color) + # Initialize UI state: default to not coloring by scalar until an object is selected + self.color_with_scalar_checkbox.setChecked(False) + # Ensure controls reflect the initial state + self._on_color_with_scalar_toggled(False) + def choose_color(self): color = QColorDialog.getColor() if color.isValid(): self.color_button.setStyleSheet(f"background-color: {color.name()}") + try: + actor = self.viewer.meshes[self.current_object_name]['actor'] + # pyvista/VTK actor property access may vary; try common attributes + if hasattr(actor, 'prop'): + actor.prop.color = (color.redF(), color.greenF(), color.blueF()) + elif hasattr(actor, 'GetProperty'): + prop = actor.GetProperty() + prop.SetColor(color.redF(), color.greenF(), color.blueF()) + # store color in metadata if present + if self.current_object_name in getattr(self.viewer, 'meshes', {}): + self.viewer.meshes[self.current_object_name].set('color', (color.redF(), color.greenF(), color.blueF())) + except Exception: + pass + + def set_opacity(self, value: float): + """Set the opacity of the current object. + + :param value: Opacity value between 0.0 (transparent) and 1.0 (opaque) + """ + if self.current_object_name is None or self.viewer is None: + return + try: actor = self.viewer.meshes[self.current_object_name]['actor'] - actor.prop.color = (color.redF(), color.greenF(), color.blueF()) - # Placeholder: store or use the selected color - self.meshes[self.current_object_name]['color'] = (color.redF(), color.greenF(), color.blueF()) + if hasattr(actor, 'prop'): + actor.prop.opacity = value + elif hasattr(actor, 'GetProperty'): + prop = actor.GetProperty() + prop.SetOpacity(value) + # store in metadata + if self.current_object_name in getattr(self.viewer, 'meshes', {}): + self.viewer.meshes[self.current_object_name].set('kwargs', {**self.viewer.meshes[self.current_object_name].get('kwargs', {}), 'opacity': value}) + except Exception: + pass + + + def setCurrentObject(self, object_name: str): + """Populate the properties widget for the given object. + + :param object_name: name of the selected object + """ + self.current_object_name = object_name + # viewer.meshes stores a dict with keys 'mesh', 'actor', 'kwargs' + mesh_entry = self.viewer.meshes.get(object_name, None) + if mesh_entry is None: + self.current_mesh = None + self.title_label.setText(f"Object: {object_name} (not found)") + # clear histogram + self._update_histogram(None) + return + self.current_mesh = mesh_entry.get('mesh', None) + self.title_label.setText(f"Object: {object_name}") + + # Default values + self.scalar_bar_checkbox.setChecked(False) + self.range_min.clear() + self.range_max.clear() + + # Populate scalar combo with available arrays + self.scalar_combo.blockSignals(True) + self.scalar_combo.clear() + self.scalar_combo.addItem("") + try: + pdata = getattr(self.current_mesh, 'point_data', None) or {} + cdata = getattr(self.current_mesh, 'cell_data', None) or {} + for k in sorted(pdata.keys()): + self.scalar_combo.addItem(k) + for k in sorted(cdata.keys()): + # prefix cell_ to help user know where it comes from + self.scalar_combo.addItem(f"cell:{k}") + except Exception: + pass + + # If kwargs indicate a previously-used scalar, select it + try: + kwargs = mesh_entry.get('kwargs', {}) if isinstance(mesh_entry, dict) else {} + prev_scalars = kwargs.get('scalars') + if prev_scalars: + # if scalar came from cell data, it may be stored as 'cell:NAME' or just NAME + sel_name = prev_scalars + # prefer 'cell:...' if that exists in the combo + if f"cell:{prev_scalars}" in [self.scalar_combo.itemText(i) for i in range(self.scalar_combo.count())]: + sel_name = f"cell:{prev_scalars}" + # set selection without emitting signals + idx = self.scalar_combo.findText(sel_name) + if idx >= 0: + self.scalar_combo.setCurrentIndex(idx) + else: + # ensure default selection is + idx = self.scalar_combo.findText("") + if idx >= 0: + self.scalar_combo.setCurrentIndex(idx) + except Exception: + pass + + self.scalar_combo.blockSignals(False) + + # Try to infer a scalar range from mesh data (point or cell data) + try: + if self.current_mesh is not None: + # Prefer point_data, fall back to cell_data + pdata = getattr(self.current_mesh, 'point_data', None) or {} + cdata = getattr(self.current_mesh, 'cell_data', None) or {} + first = None + vals = None + if len(pdata.keys()) > 0: + first = next(iter(pdata.keys())) + vals = pdata[first] + elif len(cdata.keys()) > 0: + first = next(iter(cdata.keys())) + vals = cdata[first] + + if vals is not None: + try: + # attempt numpy-like min/max or python min/max + mn = float(getattr(vals, 'min', lambda: min(vals))()) if hasattr(vals, 'min') else float(min(vals)) + mx = float(getattr(vals, 'max', lambda: max(vals))()) if hasattr(vals, 'max') else float(max(vals)) + self.range_min.setText(str(mn)) + self.range_max.setText(str(mx)) + except Exception: + self.range_min.clear() + self.range_max.clear() + + except Exception: + # Keep defaults on error + pass + + # Try to detect scalar bar visibility via viewer/actor if provided + try: + if self.viewer is not None and hasattr(self.viewer, 'mesh' or 'meshes') and object_name in getattr(self.viewer, 'meshes', {}): + actor = self.viewer.meshes[object_name].get('actor', None) + # some actor wrappers expose mapper.scalar_visibility or similar + mapper = getattr(actor, 'mapper', None) + vis = False + if mapper is not None and hasattr(mapper, 'scalar_visibility'): + vis = bool(getattr(mapper, 'scalar_visibility')) + # update checkbox (best-effort) + self.scalar_bar_checkbox.setChecked(vis) + except Exception: + # ignore failures + pass + + # Determine initial 'color with scalar' state from stored kwargs if available + try: + kwargs = mesh_entry.get('kwargs', {}) if isinstance(mesh_entry, dict) else {} + # If scalars or cmap were provided when the mesh was added, default to coloring with scalar + default_color_with_scalar = bool(kwargs.get('scalars') or kwargs.get('cmap')) + # set without emitting signal to avoid immediate re-add + self.color_with_scalar_checkbox.blockSignals(True) + self.color_with_scalar_checkbox.setChecked(default_color_with_scalar) + self.color_with_scalar_checkbox.blockSignals(False) + # update controls to reflect this choice + self._on_color_with_scalar_toggled(default_color_with_scalar) + except Exception: + # ignore any failure and leave the previously set state + pass + + # Update histogram for the currently selected scalar (if any and if enabled) + try: + current_scalar = self.scalar_combo.currentText() + vals = self._get_scalar_values(current_scalar) + self._update_histogram(vals if self.color_with_scalar_checkbox.isChecked() else None) + except Exception: + self._update_histogram(None) + + def choose_color(self): + color = QColorDialog.getColor() + if color.isValid(): + self.color_button.setStyleSheet(f"background-color: {color.name()}") + try: + actor = self.viewer.meshes[self.current_object_name]['actor'] + # pyvista/VTK actor property access may vary; try common attributes + if hasattr(actor, 'prop'): + actor.prop.color = (color.redF(), color.greenF(), color.blueF()) + elif hasattr(actor, 'GetProperty'): + prop = actor.GetProperty() + prop.SetColor(color.redF(), color.greenF(), color.blueF()) + # store color in metadata if present + if self.current_object_name in getattr(self.viewer, 'meshes', {}): + self.viewer.meshes[self.current_object_name].set('color', (color.redF(), color.greenF(), color.blueF())) + except Exception: + pass + def set_opacity(self, value: float): """Set the opacity of the current object. @@ -96,20 +306,35 @@ def set_opacity(self, value: float): """ if self.current_object_name is None or self.viewer is None: return - actor = self.viewer.meshes[self.current_object_name]['actor'] - actor.prop.opacity = value - self.meshes[self.current_object_name]['opacity'] = value + try: + actor = self.viewer.meshes[self.current_object_name]['actor'] + if hasattr(actor, 'prop'): + actor.prop.opacity = value + elif hasattr(actor, 'GetProperty'): + prop = actor.GetProperty() + prop.SetOpacity(value) + # store in metadata + if self.current_object_name in getattr(self.viewer, 'meshes', {}): + self.viewer.meshes[self.current_object_name].set('kwargs', {**self.viewer.meshes[self.current_object_name].get('kwargs', {}), 'opacity': value}) + except Exception: + pass def setCurrentObject(self, object_name: str): """Populate the properties widget for the given object. :param object_name: name of the selected object - :param mesh: optional mesh instance (pyvista.PolyData or similar) - :param viewer: optional viewer instance for deeper control """ self.current_object_name = object_name - self.current_mesh = self.viewer.meshes[object_name] + # viewer.meshes stores a dict with keys 'mesh', 'actor', 'kwargs' + mesh_entry = self.viewer.meshes.get(object_name, None) + if mesh_entry is None: + self.current_mesh = None + self.title_label.setText(f"Object: {object_name} (not found)") + # clear histogram + self._update_histogram(None) + return + self.current_mesh = mesh_entry.get('mesh', None) self.title_label.setText(f"Object: {object_name}") # Default values @@ -117,31 +342,68 @@ def setCurrentObject(self, object_name: str): self.range_min.clear() self.range_max.clear() + # Populate scalar combo with available arrays + self.scalar_combo.blockSignals(True) + self.scalar_combo.clear() + self.scalar_combo.addItem("") + try: + pdata = getattr(self.current_mesh, 'point_data', None) or {} + cdata = getattr(self.current_mesh, 'cell_data', None) or {} + for k in sorted(pdata.keys()): + self.scalar_combo.addItem(k) + for k in sorted(cdata.keys()): + # prefix cell_ to help user know where it comes from + self.scalar_combo.addItem(f"cell:{k}") + except Exception: + pass + + # If kwargs indicate a previously-used scalar, select it + try: + kwargs = mesh_entry.get('kwargs', {}) if isinstance(mesh_entry, dict) else {} + prev_scalars = kwargs.get('scalars') + if prev_scalars: + # if scalar came from cell data, it may be stored as 'cell:NAME' or just NAME + sel_name = prev_scalars + # prefer 'cell:...' if that exists in the combo + if f"cell:{prev_scalars}" in [self.scalar_combo.itemText(i) for i in range(self.scalar_combo.count())]: + sel_name = f"cell:{prev_scalars}" + # set selection without emitting signals + idx = self.scalar_combo.findText(sel_name) + if idx >= 0: + self.scalar_combo.setCurrentIndex(idx) + else: + # ensure default selection is + idx = self.scalar_combo.findText("") + if idx >= 0: + self.scalar_combo.setCurrentIndex(idx) + except Exception: + pass + + self.scalar_combo.blockSignals(False) + # Try to infer a scalar range from mesh data (point or cell data) try: - if self.current_meshmesh is not None: + if self.current_mesh is not None: # Prefer point_data, fall back to cell_data pdata = getattr(self.current_mesh, 'point_data', None) or {} cdata = getattr(self.current_mesh, 'cell_data', None) or {} first = None + vals = None if len(pdata.keys()) > 0: first = next(iter(pdata.keys())) vals = pdata[first] elif len(cdata.keys()) > 0: first = next(iter(cdata.keys())) vals = cdata[first] - else: - first = None - vals = None if vals is not None: try: + # attempt numpy-like min/max or python min/max mn = float(getattr(vals, 'min', lambda: min(vals))()) if hasattr(vals, 'min') else float(min(vals)) mx = float(getattr(vals, 'max', lambda: max(vals))()) if hasattr(vals, 'max') else float(max(vals)) self.range_min.setText(str(mn)) self.range_max.setText(str(mx)) except Exception: - # best-effort only self.range_min.clear() self.range_max.clear() @@ -151,8 +413,8 @@ def setCurrentObject(self, object_name: str): # Try to detect scalar bar visibility via viewer/actor if provided try: - if self.viewer is not None and hasattr(self.viewer, 'actors') and object_name in getattr(self.viewer, 'actors', {}): - actor = self.viewer.actors[object_name] + if self.viewer is not None and hasattr(self.viewer, 'mesh' or 'meshes') and object_name in getattr(self.viewer, 'meshes', {}): + actor = self.viewer.meshes[object_name].get('actor', None) # some actor wrappers expose mapper.scalar_visibility or similar mapper = getattr(actor, 'mapper', None) vis = False @@ -162,4 +424,157 @@ def setCurrentObject(self, object_name: str): self.scalar_bar_checkbox.setChecked(vis) except Exception: # ignore failures + pass + + # Determine initial 'color with scalar' state from stored kwargs if available + try: + kwargs = mesh_entry.get('kwargs', {}) if isinstance(mesh_entry, dict) else {} + # If scalars or cmap were provided when the mesh was added, default to coloring with scalar + default_color_with_scalar = bool(kwargs.get('scalars') or kwargs.get('cmap')) + # set without emitting signal to avoid immediate re-add + self.color_with_scalar_checkbox.blockSignals(True) + self.color_with_scalar_checkbox.setChecked(default_color_with_scalar) + self.color_with_scalar_checkbox.blockSignals(False) + # update controls to reflect this choice + self._on_color_with_scalar_toggled(default_color_with_scalar) + except Exception: + # ignore any failure and leave the previously set state + pass + + # Update histogram for the currently selected scalar (if any and if enabled) + try: + current_scalar = self.scalar_combo.currentText() + vals = self._get_scalar_values(current_scalar) + self._update_histogram(vals if self.color_with_scalar_checkbox.isChecked() else None) + except Exception: + self._update_histogram(None) + + def _on_scalar_changed(self, scalar_name: str): + """Handler when the user selects a different active scalar. + + Approach: remove the existing actor/object and re-add it passing the + chosen scalar name to the viewer. This keeps the viewer in control of + actor creation and colormap handling. + """ + # update histogram preview first (don't require re-add to preview) + try: + vals = self._get_scalar_values(scalar_name) + self._update_histogram(vals) + except Exception: + self._update_histogram(None) + + if not self.current_object_name or self.viewer is None: + return + + mesh_entry = self.viewer.meshes.get(self.current_object_name, None) + if mesh_entry is None: + return + mesh = mesh_entry.get('mesh') + old_kwargs = mesh_entry.get('kwargs', {}) if isinstance(mesh_entry, dict) else {} + + # Determine scalars parameter and whether it's point or cell data + scalars = None + if scalar_name and scalar_name != "": + if scalar_name.startswith('cell:'): + scalars = scalar_name.split(':', 1)[1] + else: + scalars = scalar_name + + # Determine cmap and clim from widgets + cmap = self.colormap_combo.currentText() or None + clim = None + try: + if self.range_min.text() and self.range_max.text(): + clim = (float(self.range_min.text()), float(self.range_max.text())) + except Exception: + clim = None + + opacity = old_kwargs.get('opacity', None) + show_scalar_bar = self.scalar_bar_checkbox.isChecked() + + # Remove existing object and re-add with new scalars + try: + # preserve name: remove_object removes metadata so re-adding keeps same name + self.viewer.remove_object(self.current_object_name) + except Exception: + # if remove fails, continue and try to add anyway (may create duplicate names) + pass + + try: + self.viewer.add_mesh_object(mesh, name=self.current_object_name, scalars=scalars, cmap=cmap, clim=clim, opacity=opacity, show_scalar_bar=show_scalar_bar) + # refresh local pointers + self.current_mesh = self.viewer.meshes.get(self.current_object_name, {}).get('mesh') + except Exception: + # on failure, try to add without scalars + try: + self.viewer.add_mesh_object(mesh, name=self.current_object_name) + self.current_mesh = self.viewer.meshes.get(self.current_object_name, {}).get('mesh') + except Exception: + pass + + def _on_color_with_scalar_toggled(self, checked: bool): + """Enable/disable scalar/colormap controls vs solid color selector. + + When checked: enable scalar selection, colormap and colormap range and scalar bar. + When unchecked: enable surface color chooser and disable scalar-related controls. + """ + try: + # scalar-related controls + self.scalar_combo.setEnabled(checked) + self.colormap_combo.setEnabled(checked) + self.range_min.setEnabled(checked) + self.range_max.setEnabled(checked) + self.scalar_bar_checkbox.setEnabled(checked) + # color chooser is enabled only when not coloring by scalar + self.color_button.setEnabled(not checked) + # histogram visibility follows scalar-mode + self.hist_canvas.setVisible(checked) + except Exception: + pass + + # New helper methods for histogram + def _get_scalar_values(self, scalar_name: str): + """Return a numpy array of scalar values for the current mesh and scalar_name. + + scalar_name can be '', 'name' (point data) or 'cell:name'. + Returns None if values cannot be retrieved. + """ + if not scalar_name or scalar_name == "" or self.current_mesh is None: + return None + try: + if scalar_name.startswith('cell:'): + name = scalar_name.split(':', 1)[1] + cdata = getattr(self.current_mesh, 'cell_data', None) or {} + vals = cdata.get(name, None) + else: + name = scalar_name + pdata = getattr(self.current_mesh, 'point_data', None) or {} + vals = pdata.get(name, None) + if vals is None: + return None + arr = np.asarray(vals) + if arr.size == 0: + return None + return arr + except Exception: + return None + + def _update_histogram(self, values): + """Draw the histogram of the provided scalar values. If values is None, + clear the axes and show a placeholder message. + """ + try: + self.hist_ax.clear() + if values is None: + # show placeholder + self.hist_ax.text(0.5, 0.5, 'No scalar selected', ha='center', va='center', transform=self.hist_ax.transAxes) + self.hist_ax.set_xticks([]) + self.hist_ax.set_yticks([]) + else: + self.hist_ax.hist(values.flatten(), bins=40, color='C0', alpha=0.8) + self.hist_ax.set_xlabel('Value') + self.hist_ax.set_ylabel('Count') + self.hist_canvas.draw_idle() + except Exception: + # swallow plotting errors pass \ No newline at end of file From 165f25fcebf6a605bdb1fb89ce94ac8fa9b0e250 Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Sun, 7 Sep 2025 17:23:51 +0200 Subject: [PATCH 08/22] fix: streamline color handling and scalar updates in ObjectPropertiesWidget --- .../visualisation/object_properties_widget.py | 469 ++++++++---------- 1 file changed, 204 insertions(+), 265 deletions(-) diff --git a/loopstructural/gui/visualisation/object_properties_widget.py b/loopstructural/gui/visualisation/object_properties_widget.py index 5fe4ce3..25e0180 100644 --- a/loopstructural/gui/visualisation/object_properties_widget.py +++ b/loopstructural/gui/visualisation/object_properties_widget.py @@ -30,7 +30,7 @@ def __init__(self, parent=None, *, viewer=None): self.scalar_combo.currentTextChanged.connect(self._on_scalar_changed) layout.addWidget(self.scalar_combo) - # Color with Scalar checkbox (new) + # Color with Scalar checkbox self.color_with_scalar_checkbox = QCheckBox("Color with Scalar") self.color_with_scalar_checkbox.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) self.color_with_scalar_checkbox.toggled.connect(self._on_color_with_scalar_toggled) @@ -108,241 +108,75 @@ def __init__(self, parent=None, *, viewer=None): # Connect color button to color dialog self.color_button.clicked.connect(self.choose_color) - # Initialize UI state: default to not coloring by scalar until an object is selected + # Initialize UI state self.color_with_scalar_checkbox.setChecked(False) - # Ensure controls reflect the initial state self._on_color_with_scalar_toggled(False) def choose_color(self): color = QColorDialog.getColor() - if color.isValid(): - self.color_button.setStyleSheet(f"background-color: {color.name()}") - try: - actor = self.viewer.meshes[self.current_object_name]['actor'] - # pyvista/VTK actor property access may vary; try common attributes - if hasattr(actor, 'prop'): - actor.prop.color = (color.redF(), color.greenF(), color.blueF()) - elif hasattr(actor, 'GetProperty'): - prop = actor.GetProperty() - prop.SetColor(color.redF(), color.greenF(), color.blueF()) - # store color in metadata if present - if self.current_object_name in getattr(self.viewer, 'meshes', {}): - self.viewer.meshes[self.current_object_name].set('color', (color.redF(), color.greenF(), color.blueF())) - except Exception: - pass - - def set_opacity(self, value: float): - """Set the opacity of the current object. - - :param value: Opacity value between 0.0 (transparent) and 1.0 (opaque) - """ - if self.current_object_name is None or self.viewer is None: + if not color.isValid(): return + self.color_button.setStyleSheet(f"background-color: {color.name()}") try: + if not self.current_object_name: + return actor = self.viewer.meshes[self.current_object_name]['actor'] if hasattr(actor, 'prop'): - actor.prop.opacity = value + try: + actor.prop.color = (color.redF(), color.greenF(), color.blueF()) + except Exception: + pass elif hasattr(actor, 'GetProperty'): - prop = actor.GetProperty() - prop.SetOpacity(value) - # store in metadata + try: + prop = actor.GetProperty() + prop.SetColor(color.redF(), color.greenF(), color.blueF()) + except Exception: + pass + # store color in metadata if self.current_object_name in getattr(self.viewer, 'meshes', {}): - self.viewer.meshes[self.current_object_name].set('kwargs', {**self.viewer.meshes[self.current_object_name].get('kwargs', {}), 'opacity': value}) - except Exception: - pass - - - def setCurrentObject(self, object_name: str): - """Populate the properties widget for the given object. - - :param object_name: name of the selected object - """ - self.current_object_name = object_name - # viewer.meshes stores a dict with keys 'mesh', 'actor', 'kwargs' - mesh_entry = self.viewer.meshes.get(object_name, None) - if mesh_entry is None: - self.current_mesh = None - self.title_label.setText(f"Object: {object_name} (not found)") - # clear histogram - self._update_histogram(None) - return - self.current_mesh = mesh_entry.get('mesh', None) - self.title_label.setText(f"Object: {object_name}") - - # Default values - self.scalar_bar_checkbox.setChecked(False) - self.range_min.clear() - self.range_max.clear() - - # Populate scalar combo with available arrays - self.scalar_combo.blockSignals(True) - self.scalar_combo.clear() - self.scalar_combo.addItem("") - try: - pdata = getattr(self.current_mesh, 'point_data', None) or {} - cdata = getattr(self.current_mesh, 'cell_data', None) or {} - for k in sorted(pdata.keys()): - self.scalar_combo.addItem(k) - for k in sorted(cdata.keys()): - # prefix cell_ to help user know where it comes from - self.scalar_combo.addItem(f"cell:{k}") - except Exception: - pass - - # If kwargs indicate a previously-used scalar, select it - try: - kwargs = mesh_entry.get('kwargs', {}) if isinstance(mesh_entry, dict) else {} - prev_scalars = kwargs.get('scalars') - if prev_scalars: - # if scalar came from cell data, it may be stored as 'cell:NAME' or just NAME - sel_name = prev_scalars - # prefer 'cell:...' if that exists in the combo - if f"cell:{prev_scalars}" in [self.scalar_combo.itemText(i) for i in range(self.scalar_combo.count())]: - sel_name = f"cell:{prev_scalars}" - # set selection without emitting signals - idx = self.scalar_combo.findText(sel_name) - if idx >= 0: - self.scalar_combo.setCurrentIndex(idx) - else: - # ensure default selection is - idx = self.scalar_combo.findText("") - if idx >= 0: - self.scalar_combo.setCurrentIndex(idx) - except Exception: - pass - - self.scalar_combo.blockSignals(False) - - # Try to infer a scalar range from mesh data (point or cell data) - try: - if self.current_mesh is not None: - # Prefer point_data, fall back to cell_data - pdata = getattr(self.current_mesh, 'point_data', None) or {} - cdata = getattr(self.current_mesh, 'cell_data', None) or {} - first = None - vals = None - if len(pdata.keys()) > 0: - first = next(iter(pdata.keys())) - vals = pdata[first] - elif len(cdata.keys()) > 0: - first = next(iter(cdata.keys())) - vals = cdata[first] - - if vals is not None: - try: - # attempt numpy-like min/max or python min/max - mn = float(getattr(vals, 'min', lambda: min(vals))()) if hasattr(vals, 'min') else float(min(vals)) - mx = float(getattr(vals, 'max', lambda: max(vals))()) if hasattr(vals, 'max') else float(max(vals)) - self.range_min.setText(str(mn)) - self.range_max.setText(str(mx)) - except Exception: - self.range_min.clear() - self.range_max.clear() - + self.viewer.meshes[self.current_object_name]['color'] = (color.redF(), color.greenF(), color.blueF()) except Exception: - # Keep defaults on error pass - # Try to detect scalar bar visibility via viewer/actor if provided - try: - if self.viewer is not None and hasattr(self.viewer, 'mesh' or 'meshes') and object_name in getattr(self.viewer, 'meshes', {}): - actor = self.viewer.meshes[object_name].get('actor', None) - # some actor wrappers expose mapper.scalar_visibility or similar - mapper = getattr(actor, 'mapper', None) - vis = False - if mapper is not None and hasattr(mapper, 'scalar_visibility'): - vis = bool(getattr(mapper, 'scalar_visibility')) - # update checkbox (best-effort) - self.scalar_bar_checkbox.setChecked(vis) - except Exception: - # ignore failures - pass - - # Determine initial 'color with scalar' state from stored kwargs if available - try: - kwargs = mesh_entry.get('kwargs', {}) if isinstance(mesh_entry, dict) else {} - # If scalars or cmap were provided when the mesh was added, default to coloring with scalar - default_color_with_scalar = bool(kwargs.get('scalars') or kwargs.get('cmap')) - # set without emitting signal to avoid immediate re-add - self.color_with_scalar_checkbox.blockSignals(True) - self.color_with_scalar_checkbox.setChecked(default_color_with_scalar) - self.color_with_scalar_checkbox.blockSignals(False) - # update controls to reflect this choice - self._on_color_with_scalar_toggled(default_color_with_scalar) - except Exception: - # ignore any failure and leave the previously set state - pass - - # Update histogram for the currently selected scalar (if any and if enabled) - try: - current_scalar = self.scalar_combo.currentText() - vals = self._get_scalar_values(current_scalar) - self._update_histogram(vals if self.color_with_scalar_checkbox.isChecked() else None) - except Exception: - self._update_histogram(None) - - def choose_color(self): - color = QColorDialog.getColor() - if color.isValid(): - self.color_button.setStyleSheet(f"background-color: {color.name()}") - try: - actor = self.viewer.meshes[self.current_object_name]['actor'] - # pyvista/VTK actor property access may vary; try common attributes - if hasattr(actor, 'prop'): - actor.prop.color = (color.redF(), color.greenF(), color.blueF()) - elif hasattr(actor, 'GetProperty'): - prop = actor.GetProperty() - prop.SetColor(color.redF(), color.greenF(), color.blueF()) - # store color in metadata if present - if self.current_object_name in getattr(self.viewer, 'meshes', {}): - self.viewer.meshes[self.current_object_name].set('color', (color.redF(), color.greenF(), color.blueF())) - except Exception: - pass - def set_opacity(self, value: float): - """Set the opacity of the current object. - - :param value: Opacity value between 0.0 (transparent) and 1.0 (opaque) - """ if self.current_object_name is None or self.viewer is None: return try: actor = self.viewer.meshes[self.current_object_name]['actor'] if hasattr(actor, 'prop'): - actor.prop.opacity = value + try: + actor.prop.opacity = value + except Exception: + pass elif hasattr(actor, 'GetProperty'): - prop = actor.GetProperty() - prop.SetOpacity(value) + try: + prop = actor.GetProperty() + prop.SetOpacity(value) + except Exception: + pass # store in metadata if self.current_object_name in getattr(self.viewer, 'meshes', {}): self.viewer.meshes[self.current_object_name].set('kwargs', {**self.viewer.meshes[self.current_object_name].get('kwargs', {}), 'opacity': value}) except Exception: pass - def setCurrentObject(self, object_name: str): - """Populate the properties widget for the given object. - - :param object_name: name of the selected object - """ self.current_object_name = object_name - # viewer.meshes stores a dict with keys 'mesh', 'actor', 'kwargs' mesh_entry = self.viewer.meshes.get(object_name, None) if mesh_entry is None: self.current_mesh = None self.title_label.setText(f"Object: {object_name} (not found)") - # clear histogram self._update_histogram(None) return self.current_mesh = mesh_entry.get('mesh', None) self.title_label.setText(f"Object: {object_name}") - # Default values + # reset small things self.scalar_bar_checkbox.setChecked(False) self.range_min.clear() self.range_max.clear() - # Populate scalar combo with available arrays + # populate scalar combo self.scalar_combo.blockSignals(True) self.scalar_combo.clear() self.scalar_combo.addItem("") @@ -352,53 +186,41 @@ def setCurrentObject(self, object_name: str): for k in sorted(pdata.keys()): self.scalar_combo.addItem(k) for k in sorted(cdata.keys()): - # prefix cell_ to help user know where it comes from self.scalar_combo.addItem(f"cell:{k}") except Exception: pass - # If kwargs indicate a previously-used scalar, select it + # restore previous scalar selection if available try: kwargs = mesh_entry.get('kwargs', {}) if isinstance(mesh_entry, dict) else {} prev_scalars = kwargs.get('scalars') if prev_scalars: - # if scalar came from cell data, it may be stored as 'cell:NAME' or just NAME sel_name = prev_scalars - # prefer 'cell:...' if that exists in the combo if f"cell:{prev_scalars}" in [self.scalar_combo.itemText(i) for i in range(self.scalar_combo.count())]: sel_name = f"cell:{prev_scalars}" - # set selection without emitting signals idx = self.scalar_combo.findText(sel_name) if idx >= 0: self.scalar_combo.setCurrentIndex(idx) else: - # ensure default selection is idx = self.scalar_combo.findText("") if idx >= 0: self.scalar_combo.setCurrentIndex(idx) except Exception: pass - self.scalar_combo.blockSignals(False) - # Try to infer a scalar range from mesh data (point or cell data) + # infer scalar range for display try: if self.current_mesh is not None: - # Prefer point_data, fall back to cell_data pdata = getattr(self.current_mesh, 'point_data', None) or {} cdata = getattr(self.current_mesh, 'cell_data', None) or {} - first = None vals = None if len(pdata.keys()) > 0: - first = next(iter(pdata.keys())) - vals = pdata[first] + vals = next(iter(pdata.values())) elif len(cdata.keys()) > 0: - first = next(iter(cdata.keys())) - vals = cdata[first] - + vals = next(iter(cdata.values())) if vals is not None: try: - # attempt numpy-like min/max or python min/max mn = float(getattr(vals, 'min', lambda: min(vals))()) if hasattr(vals, 'min') else float(min(vals)) mx = float(getattr(vals, 'max', lambda: max(vals))()) if hasattr(vals, 'max') else float(max(vals)) self.range_min.setText(str(mn)) @@ -406,42 +228,32 @@ def setCurrentObject(self, object_name: str): except Exception: self.range_min.clear() self.range_max.clear() - except Exception: - # Keep defaults on error pass - # Try to detect scalar bar visibility via viewer/actor if provided + # detect scalar bar visibility try: - if self.viewer is not None and hasattr(self.viewer, 'mesh' or 'meshes') and object_name in getattr(self.viewer, 'meshes', {}): - actor = self.viewer.meshes[object_name].get('actor', None) - # some actor wrappers expose mapper.scalar_visibility or similar - mapper = getattr(actor, 'mapper', None) - vis = False - if mapper is not None and hasattr(mapper, 'scalar_visibility'): - vis = bool(getattr(mapper, 'scalar_visibility')) - # update checkbox (best-effort) - self.scalar_bar_checkbox.setChecked(vis) + actor = mesh_entry.get('actor', None) + mapper = getattr(actor, 'mapper', None) + vis = False + if mapper is not None and hasattr(mapper, 'scalar_visibility'): + vis = bool(getattr(mapper, 'scalar_visibility')) + self.scalar_bar_checkbox.setChecked(vis) except Exception: - # ignore failures pass - # Determine initial 'color with scalar' state from stored kwargs if available + # determine initial color-with-scalar try: kwargs = mesh_entry.get('kwargs', {}) if isinstance(mesh_entry, dict) else {} - # If scalars or cmap were provided when the mesh was added, default to coloring with scalar default_color_with_scalar = bool(kwargs.get('scalars') or kwargs.get('cmap')) - # set without emitting signal to avoid immediate re-add self.color_with_scalar_checkbox.blockSignals(True) self.color_with_scalar_checkbox.setChecked(default_color_with_scalar) self.color_with_scalar_checkbox.blockSignals(False) - # update controls to reflect this choice self._on_color_with_scalar_toggled(default_color_with_scalar) except Exception: - # ignore any failure and leave the previously set state pass - # Update histogram for the currently selected scalar (if any and if enabled) + # update histogram display try: current_scalar = self.scalar_combo.currentText() vals = self._get_scalar_values(current_scalar) @@ -450,29 +262,38 @@ def setCurrentObject(self, object_name: str): self._update_histogram(None) def _on_scalar_changed(self, scalar_name: str): - """Handler when the user selects a different active scalar. - - Approach: remove the existing actor/object and re-add it passing the - chosen scalar name to the viewer. This keeps the viewer in control of - actor creation and colormap handling. - """ - # update histogram preview first (don't require re-add to preview) + # update histogram preview immediately try: vals = self._get_scalar_values(scalar_name) - self._update_histogram(vals) + self._update_histogram(vals if self.color_with_scalar_checkbox.isChecked() else None) except Exception: self._update_histogram(None) - if not self.current_object_name or self.viewer is None: + # if not coloring by scalar, only update metadata + if not self.color_with_scalar_checkbox.isChecked(): + try: + if self.current_object_name in getattr(self.viewer, 'meshes', {}): + self.viewer.meshes[self.current_object_name].set('kwargs', {**self.viewer.meshes[self.current_object_name].get('kwargs', {}), 'scalars': None}) + except Exception: + pass return + # try in-place update + try: + self._apply_scalar_to_actor(self.current_object_name, scalar_name) + return + except Exception: + pass + + # fallback to remove/add + if not self.current_object_name or self.viewer is None: + return mesh_entry = self.viewer.meshes.get(self.current_object_name, None) if mesh_entry is None: return mesh = mesh_entry.get('mesh') old_kwargs = mesh_entry.get('kwargs', {}) if isinstance(mesh_entry, dict) else {} - # Determine scalars parameter and whether it's point or cell data scalars = None if scalar_name and scalar_name != "": if scalar_name.startswith('cell:'): @@ -480,7 +301,6 @@ def _on_scalar_changed(self, scalar_name: str): else: scalars = scalar_name - # Determine cmap and clim from widgets cmap = self.colormap_combo.currentText() or None clim = None try: @@ -492,20 +312,15 @@ def _on_scalar_changed(self, scalar_name: str): opacity = old_kwargs.get('opacity', None) show_scalar_bar = self.scalar_bar_checkbox.isChecked() - # Remove existing object and re-add with new scalars try: - # preserve name: remove_object removes metadata so re-adding keeps same name self.viewer.remove_object(self.current_object_name) except Exception: - # if remove fails, continue and try to add anyway (may create duplicate names) pass try: self.viewer.add_mesh_object(mesh, name=self.current_object_name, scalars=scalars, cmap=cmap, clim=clim, opacity=opacity, show_scalar_bar=show_scalar_bar) - # refresh local pointers self.current_mesh = self.viewer.meshes.get(self.current_object_name, {}).get('mesh') except Exception: - # on failure, try to add without scalars try: self.viewer.add_mesh_object(mesh, name=self.current_object_name) self.current_mesh = self.viewer.meshes.get(self.current_object_name, {}).get('mesh') @@ -513,32 +328,50 @@ def _on_scalar_changed(self, scalar_name: str): pass def _on_color_with_scalar_toggled(self, checked: bool): - """Enable/disable scalar/colormap controls vs solid color selector. - - When checked: enable scalar selection, colormap and colormap range and scalar bar. - When unchecked: enable surface color chooser and disable scalar-related controls. - """ try: - # scalar-related controls self.scalar_combo.setEnabled(checked) self.colormap_combo.setEnabled(checked) self.range_min.setEnabled(checked) self.range_max.setEnabled(checked) self.scalar_bar_checkbox.setEnabled(checked) - # color chooser is enabled only when not coloring by scalar self.color_button.setEnabled(not checked) - # histogram visibility follows scalar-mode self.hist_canvas.setVisible(checked) + + if self.current_object_name and self.current_object_name in getattr(self.viewer, 'meshes', {}): + current_scalar = self.scalar_combo.currentText() + if checked: + try: + self._apply_scalar_to_actor(self.current_object_name, current_scalar) + except Exception: + pass + else: + try: + actor = self.viewer.meshes[self.current_object_name].get('actor') + mapper = getattr(actor, 'mapper', None) + if mapper is not None: + try: + if hasattr(mapper, 'scalar_visibility'): + mapper.scalar_visibility = False + except Exception: + pass + try: + if hasattr(mapper, 'ScalarVisibilityOff'): + mapper.ScalarVisibilityOff() + except Exception: + pass + stored = self.viewer.meshes[self.current_object_name].get('kwargs', {}) + color = self.viewer.meshes[self.current_object_name].get('color') or stored.get('color') + if color is not None and hasattr(actor, 'prop'): + try: + actor.prop.color = color + except Exception: + pass + except Exception: + pass except Exception: pass - # New helper methods for histogram def _get_scalar_values(self, scalar_name: str): - """Return a numpy array of scalar values for the current mesh and scalar_name. - - scalar_name can be '', 'name' (point data) or 'cell:name'. - Returns None if values cannot be retrieved. - """ if not scalar_name or scalar_name == "" or self.current_mesh is None: return None try: @@ -560,13 +393,9 @@ def _get_scalar_values(self, scalar_name: str): return None def _update_histogram(self, values): - """Draw the histogram of the provided scalar values. If values is None, - clear the axes and show a placeholder message. - """ try: self.hist_ax.clear() if values is None: - # show placeholder self.hist_ax.text(0.5, 0.5, 'No scalar selected', ha='center', va='center', transform=self.hist_ax.transAxes) self.hist_ax.set_xticks([]) self.hist_ax.set_yticks([]) @@ -576,5 +405,115 @@ def _update_histogram(self, values): self.hist_ax.set_ylabel('Count') self.hist_canvas.draw_idle() except Exception: - # swallow plotting errors - pass \ No newline at end of file + pass + + def _apply_scalar_to_actor(self, object_name: str, scalar_name: str): + if not object_name or self.viewer is None: + raise RuntimeError("No viewer or object specified") + mesh_entry = self.viewer.meshes.get(object_name) + if mesh_entry is None: + raise RuntimeError("Object not found in viewer.meshes") + mesh = mesh_entry.get('mesh') + actor = mesh_entry.get('actor') + + # disable mapping if requested + if not scalar_name or scalar_name == "": + mapper = getattr(actor, 'mapper', None) + if mapper is not None: + if hasattr(mapper, 'scalar_visibility'): + mapper.scalar_visibility = False + if hasattr(mapper, 'ScalarVisibilityOff'): + mapper.ScalarVisibilityOff() + mesh_entry.setdefault('kwargs', {})['scalars'] = None + return + + # resolve scalar name + use_cell = False + scalars = scalar_name + if scalar_name.startswith('cell:'): + scalars = scalar_name.split(':', 1)[1] + use_cell = True + + values = self._get_scalar_values(scalar_name) + if values is None: + raise RuntimeError('Failed to retrieve scalar values') + + plotter = getattr(self.viewer, 'plotter', None) + applied = False + if plotter is not None and hasattr(plotter, 'update_scalars'): + try: + plotter.update_scalars(values, mesh=mesh, render=True, name=object_name) + applied = True + except Exception: + applied = False + + if not applied: + mapper = getattr(actor, 'mapper', None) + if mapper is None: + raise RuntimeError('Actor has no mapper to update') + # try to select color array + try: + if hasattr(mapper, 'SelectColorArray'): + mapper.SelectColorArray(scalars) + except Exception: + pass + try: + if hasattr(mapper, 'SetArrayName'): + mapper.SetArrayName(scalars) + except Exception: + pass + try: + if hasattr(mapper, 'scalar_visibility'): + mapper.scalar_visibility = True + except Exception: + pass + try: + if hasattr(mapper, 'ScalarVisibilityOn'): + mapper.ScalarVisibilityOn() + except Exception: + pass + # set scalar range + try: + if self.range_min.text() and self.range_max.text(): + mn = float(self.range_min.text()) + mx = float(self.range_max.text()) + if hasattr(mapper, 'SetScalarRange'): + mapper.SetScalarRange(mn, mx) + elif hasattr(mapper, 'scalar_range'): + mapper.scalar_range = (mn, mx) + except Exception: + pass + + # best-effort to set colormap via plotter's actor/lookup table + try: + cmap = self.colormap_combo.currentText() or None + if cmap and hasattr(plotter, 'add_mesh'): + # Changing lookup-table programmatically is backend/version dependent; try to trigger a re-render + try: + if hasattr(plotter, 'render'): + plotter.render() + except Exception: + pass + except Exception: + pass + + # update metadata + try: + kwargs = mesh_entry.get('kwargs', {}) if isinstance(mesh_entry, dict) else {} + kwargs['scalars'] = scalars + kwargs['cmap'] = self.colormap_combo.currentText() or None + if self.range_min.text() and self.range_max.text(): + try: + kwargs['clim'] = (float(self.range_min.text()), float(self.range_max.text())) + except Exception: + kwargs['clim'] = None + mesh_entry['kwargs'] = kwargs + except Exception: + pass + + # request render + try: + if plotter is not None and hasattr(plotter, 'render'): + plotter.render() + except Exception: + pass From 8bf43e1311d5c967c6113239d84fd36b34fd21b3 Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Sun, 7 Sep 2025 17:30:16 +0200 Subject: [PATCH 09/22] fix: add edge visibility toggle and line width control in ObjectPropertiesWidget --- .../visualisation/object_properties_widget.py | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) diff --git a/loopstructural/gui/visualisation/object_properties_widget.py b/loopstructural/gui/visualisation/object_properties_widget.py index 25e0180..7dad8de 100644 --- a/loopstructural/gui/visualisation/object_properties_widget.py +++ b/loopstructural/gui/visualisation/object_properties_widget.py @@ -60,8 +60,20 @@ def __init__(self, parent=None, *, viewer=None): # Show Edges self.show_edges_checkbox = QCheckBox("Show Edges") self.show_edges_checkbox.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) + # call set_show_edges when toggled + self.show_edges_checkbox.toggled.connect(self.set_show_edges) layout.addWidget(self.show_edges_checkbox) + # Line Width + layout.addWidget(QLabel("Line Width:")) + self.line_width_slider = QSlider(Qt.Horizontal) + # allow 0..20, interpreted as float line width + self.line_width_slider.setRange(0, 20) + self.line_width_slider.setValue(1) + self.line_width_slider.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.line_width_slider.valueChanged.connect(lambda val: self.set_line_width(val)) + layout.addWidget(self.line_width_slider) + # Colormap Range range_layout = QHBoxLayout() range_layout.setSpacing(6) @@ -160,6 +172,110 @@ def set_opacity(self, value: float): except Exception: pass + def set_show_edges(self, show: bool): + """Enable or disable edge display for the current object. + Best-effort support for both pyvista actor wrappers and raw VTK actors. + """ + if self.current_object_name is None or self.viewer is None: + return + try: + mesh_entry = self.viewer.meshes.get(self.current_object_name, {}) + actor = mesh_entry.get('actor') + if actor is None: + return + # pyvista-style + if hasattr(actor, 'prop'): + try: + actor.prop.edge_visibility = bool(show) + except Exception: + pass + try: + # some wrappers expose setter methods on prop + actor.prop.SetEdgeVisibility(bool(show)) + except Exception: + pass + # raw VTK actor + elif hasattr(actor, 'GetProperty'): + try: + prop = actor.GetProperty() + if hasattr(prop, 'SetEdgeVisibility'): + prop.SetEdgeVisibility(bool(show)) + else: + # fall back to On/Off style methods + if bool(show) and hasattr(prop, 'EdgeVisibilityOn'): + prop.EdgeVisibilityOn() + elif not bool(show) and hasattr(prop, 'EdgeVisibilityOff'): + prop.EdgeVisibilityOff() + except Exception: + pass + # update metadata + try: + kwargs = mesh_entry.get('kwargs', {}) if isinstance(mesh_entry, dict) else {} + kwargs['show_edges'] = bool(show) + mesh_entry['kwargs'] = kwargs + # persist back to viewer.meshes + if hasattr(self.viewer, 'meshes') and self.current_object_name in self.viewer.meshes: + self.viewer.meshes[self.current_object_name] = mesh_entry + except Exception: + pass + # request render + plotter = getattr(self.viewer, 'plotter', None) + if plotter is not None and hasattr(plotter, 'render'): + try: + plotter.render() + except Exception: + pass + except Exception: + pass + + def set_line_width(self, value): + """Set the line width for edge rendering. Value is an integer slider value; interpreted as float width.""" + try: + width = float(value) + except Exception: + return + if self.current_object_name is None or self.viewer is None: + return + try: + mesh_entry = self.viewer.meshes.get(self.current_object_name, {}) + actor = mesh_entry.get('actor') + if actor is None: + return + if hasattr(actor, 'prop'): + try: + actor.prop.line_width = width + except Exception: + pass + try: + actor.prop.SetLineWidth(width) + except Exception: + pass + elif hasattr(actor, 'GetProperty'): + try: + prop = actor.GetProperty() + if hasattr(prop, 'SetLineWidth'): + prop.SetLineWidth(width) + except Exception: + pass + # update metadata + try: + kwargs = mesh_entry.get('kwargs', {}) if isinstance(mesh_entry, dict) else {} + kwargs['line_width'] = width + mesh_entry['kwargs'] = kwargs + if hasattr(self.viewer, 'meshes') and self.current_object_name in self.viewer.meshes: + self.viewer.meshes[self.current_object_name] = mesh_entry + except Exception: + pass + # request render + plotter = getattr(self.viewer, 'plotter', None) + if plotter is not None and hasattr(plotter, 'render'): + try: + plotter.render() + except Exception: + pass + except Exception: + pass + def setCurrentObject(self, object_name: str): self.current_object_name = object_name mesh_entry = self.viewer.meshes.get(object_name, None) @@ -242,6 +358,50 @@ def setCurrentObject(self, object_name: str): except Exception: pass + # restore edge and line width from metadata or actor + try: + kwargs = mesh_entry.get('kwargs', {}) if isinstance(mesh_entry, dict) else {} + show_edges = kwargs.get('show_edges', None) + line_width = kwargs.get('line_width', None) + actor = mesh_entry.get('actor', None) + if show_edges is None and actor is not None: + try: + prop = getattr(actor, 'prop', None) + if prop is not None and hasattr(prop, 'edge_visibility'): + show_edges = bool(getattr(prop, 'edge_visibility')) + elif hasattr(actor, 'GetProperty'): + p = actor.GetProperty() + if hasattr(p, 'GetEdgeVisibility'): + show_edges = bool(p.GetEdgeVisibility()) + except Exception: + show_edges = False + if line_width is None and actor is not None: + try: + prop = getattr(actor, 'prop', None) + if prop is not None and hasattr(prop, 'line_width'): + line_width = float(getattr(prop, 'line_width')) + elif hasattr(actor, 'GetProperty'): + p = actor.GetProperty() + if hasattr(p, 'GetLineWidth'): + line_width = float(p.GetLineWidth()) + except Exception: + line_width = 1.0 + if show_edges is None: + show_edges = False + if line_width is None: + line_width = 1.0 + self.show_edges_checkbox.blockSignals(True) + self.show_edges_checkbox.setChecked(bool(show_edges)) + self.show_edges_checkbox.blockSignals(False) + self.line_width_slider.blockSignals(True) + try: + self.line_width_slider.setValue(int(round(float(line_width)))) + except Exception: + self.line_width_slider.setValue(1) + self.line_width_slider.blockSignals(False) + except Exception: + pass + # determine initial color-with-scalar try: kwargs = mesh_entry.get('kwargs', {}) if isinstance(mesh_entry, dict) else {} From 742508e63adabd9f9620595ded28a68ef8ee2c24 Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Sun, 7 Sep 2025 18:06:51 +0200 Subject: [PATCH 10/22] fix: allow all pv objects to be loaded double click to reset camera --- loopstructural/gui/visualisation/object_list_widget.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/loopstructural/gui/visualisation/object_list_widget.py b/loopstructural/gui/visualisation/object_list_widget.py index d10b334..f185313 100644 --- a/loopstructural/gui/visualisation/object_list_widget.py +++ b/loopstructural/gui/visualisation/object_list_widget.py @@ -31,6 +31,9 @@ def __init__(self, parent=None, *, viewer=None, properties_widget=None): self.viewer.objectAdded.connect(self.update_object_list) self.treeWidget.installEventFilter(self) self.treeWidget.itemSelectionChanged.connect(self.on_object_selected) + self.treeWidget.itemDoubleClicked.connect(self.onDoubleClick) + def onDoubleClick(self, item, column): + self.viewer.reset_camera() def on_object_selected(self): selected_items = self.treeWidget.selectedItems() if not selected_items: @@ -300,7 +303,7 @@ def export_selected_object(self): else pv.save_meshio(file_path, mesh) ) elif selected_format == "vtk": - pv.save_meshio(file_path, mesh ) + mesh.save(file_path) if hasattr(mesh, "save") else pv.save_meshio(file_path, mesh) elif selected_format == "ply": pv.save_meshio(file_path, mesh) elif selected_format == "vtp": @@ -365,9 +368,6 @@ def load_feature_from_file(self): try: mesh = pv.read(file_path) - if not isinstance(mesh, pv.PolyData): - raise ValueError("The file does not contain a valid mesh.") - # Add the mesh to the viewer if self.viewer and hasattr(self.viewer, 'add_mesh'): self.viewer.add_mesh_object(mesh, name=file_name) From 8124f36adab8eff9aa596b529dfe7e3af94b7e9b Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Sun, 7 Sep 2025 18:07:15 +0200 Subject: [PATCH 11/22] fix: use active_scalars_name to prefile scalars kwarg --- loopstructural/gui/visualisation/loop_pyvistaqt_wrapper.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/loopstructural/gui/visualisation/loop_pyvistaqt_wrapper.py b/loopstructural/gui/visualisation/loop_pyvistaqt_wrapper.py index a6d6ae6..db6e714 100644 --- a/loopstructural/gui/visualisation/loop_pyvistaqt_wrapper.py +++ b/loopstructural/gui/visualisation/loop_pyvistaqt_wrapper.py @@ -55,10 +55,12 @@ def add_mesh_object(self, mesh, name: str, *, scalars: Optional[Any] = None, cma # pass # Decide rendering mode: color (solid) if color provided else scalar mapping + scalars = scalars if scalars is not None else mesh.active_scalars_name use_scalar = color is None and scalars is not None # Build add_mesh kwargs add_kwargs: Dict[str, Any] = {} + if use_scalar: add_kwargs['scalars'] = scalars add_kwargs['cmap'] = cmap From 352f5b1b2fc50e1f3717f58ea737bde2d9b40b07 Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Sun, 7 Sep 2025 18:23:03 +0200 Subject: [PATCH 12/22] fix: apply colourmap change and refactored actor/mapper update into single function --- .../visualisation/object_properties_widget.py | 316 ++++++++++++++---- 1 file changed, 248 insertions(+), 68 deletions(-) diff --git a/loopstructural/gui/visualisation/object_properties_widget.py b/loopstructural/gui/visualisation/object_properties_widget.py index 7dad8de..fcda2a7 100644 --- a/loopstructural/gui/visualisation/object_properties_widget.py +++ b/loopstructural/gui/visualisation/object_properties_widget.py @@ -26,7 +26,6 @@ def __init__(self, parent=None, *, viewer=None): layout.addWidget(QLabel("Active Scalar:")) self.scalar_combo = QComboBox() self.scalar_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) - self.scalar_combo.addItem("") self.scalar_combo.currentTextChanged.connect(self._on_scalar_changed) layout.addWidget(self.scalar_combo) @@ -46,6 +45,8 @@ def __init__(self, parent=None, *, viewer=None): self.colormap_combo = QComboBox() self.colormap_combo.addItems(["viridis", "plasma", "inferno", "magma", "greys"]) self.colormap_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + # apply colormap changes when user selects a different cmap + self.colormap_combo.currentTextChanged.connect(self._on_colormap_changed) layout.addWidget(self.colormap_combo) # Opacity @@ -295,7 +296,7 @@ def setCurrentObject(self, object_name: str): # populate scalar combo self.scalar_combo.blockSignals(True) self.scalar_combo.clear() - self.scalar_combo.addItem("") + try: pdata = getattr(self.current_mesh, 'point_data', None) or {} cdata = getattr(self.current_mesh, 'cell_data', None) or {} @@ -531,6 +532,82 @@ def _on_color_with_scalar_toggled(self, checked: bool): except Exception: pass + def _on_colormap_changed(self, cmap: str): + """Apply or persist selected colormap for the current object. + Best-effort: try in-place application via _apply_scalar_to_actor, otherwise remove and re-add the mesh with the new cmap.""" + try: + if not self.current_object_name or self.viewer is None: + return + + # persist cmap in metadata even when not coloring by scalar + try: + if self.current_object_name in getattr(self.viewer, 'meshes', {}): + mesh_entry = self.viewer.meshes[self.current_object_name] + kwargs = mesh_entry.get('kwargs', {}) if isinstance(mesh_entry, dict) else {} + kwargs['cmap'] = cmap or None + mesh_entry['kwargs'] = kwargs + # write back + if hasattr(self.viewer, 'meshes'): + self.viewer.meshes[self.current_object_name] = mesh_entry + except Exception: + pass + + # only need to change rendering if we're coloring by scalar + if not self.color_with_scalar_checkbox.isChecked(): + return + + scalar_name = self.scalar_combo.currentText() + if not scalar_name or scalar_name == "": + return + + # try in-place update first + try: + self._apply_scalar_to_actor(self.current_object_name, scalar_name) + return + except Exception: + pass + + # fallback: remove and re-add mesh with new cmap + mesh_entry = self.viewer.meshes.get(self.current_object_name, None) + if mesh_entry is None: + return + mesh = mesh_entry.get('mesh') + old_kwargs = mesh_entry.get('kwargs', {}) if isinstance(mesh_entry, dict) else {} + + scalars = None + if scalar_name and scalar_name != "": + if scalar_name.startswith('cell:'): + scalars = scalar_name.split(':', 1)[1] + else: + scalars = scalar_name + + clim = None + try: + if self.range_min.text() and self.range_max.text(): + clim = (float(self.range_min.text()), float(self.range_max.text())) + except Exception: + clim = None + + opacity = old_kwargs.get('opacity', None) + show_scalar_bar = self.scalar_bar_checkbox.isChecked() + + try: + self.viewer.remove_object(self.current_object_name) + except Exception: + pass + + try: + self.viewer.add_mesh_object(mesh, name=self.current_object_name, scalars=scalars, cmap=cmap or None, clim=clim, opacity=opacity, show_scalar_bar=show_scalar_bar) + self.current_mesh = self.viewer.meshes.get(self.current_object_name, {}).get('mesh') + except Exception: + try: + self.viewer.add_mesh_object(mesh, name=self.current_object_name) + self.current_mesh = self.viewer.meshes.get(self.current_object_name, {}).get('mesh') + except Exception: + pass + except Exception: + pass + def _get_scalar_values(self, scalar_name: str): if not scalar_name or scalar_name == "" or self.current_mesh is None: return None @@ -567,6 +644,165 @@ def _update_histogram(self, values): except Exception: pass + def _update_actor_mapper(self, mesh_entry, scalars, cmap, clim, values, actor, plotter): + """Centralized actor/mapper update: + - select/enable scalar array + - set scalar range + - build and assign a LUT from matplotlib cmap when possible + - persist kwargs and trigger render + """ + try: + mapper = getattr(actor, 'mapper', None) + # if plotter can update scalars more directly, prefer that + if plotter is not None and hasattr(plotter, 'update_scalars') and values is not None: + try: + plotter.update_scalars(values, mesh=mesh_entry.get('mesh'), render=False, name=self.current_object_name) + except Exception: + pass + + if mapper is None: + return + + # select color array + try: + if scalars and hasattr(mapper, 'SelectColorArray'): + try: + mapper.SelectColorArray(scalars) + except Exception: + pass + except Exception: + pass + try: + if scalars and hasattr(mapper, 'SetArrayName'): + try: + mapper.SetArrayName(scalars) + except Exception: + pass + except Exception: + pass + + # enable scalar visibility + try: + if hasattr(mapper, 'scalar_visibility'): + try: + mapper.scalar_visibility = True + except Exception: + pass + except Exception: + pass + try: + if hasattr(mapper, 'ScalarVisibilityOn'): + try: + mapper.ScalarVisibilityOn() + except Exception: + pass + except Exception: + pass + + # set scalar range + try: + mn = mx = None + if clim: + mn, mx = float(clim[0]), float(clim[1]) + else: + try: + import numpy as _np + arr = _np.asarray(values) if values is not None else None + if arr is not None and arr.size > 0: + mn = float(_np.nanmin(arr)) + mx = float(_np.nanmax(arr)) + except Exception: + pass + if mn is not None and mx is not None: + try: + if hasattr(mapper, 'SetScalarRange'): + mapper.SetScalarRange(mn, mx) + except Exception: + pass + except Exception: + pass + + # build and assign LUT from matplotlib cmap + try: + if cmap: + vtkLookupTable = None + try: + from vtk import vtkLookupTable as _vtkLookupTable # type: ignore + vtkLookupTable = _vtkLookupTable + except Exception: + try: + from vtkmodules.vtkCommonCore import vtkLookupTable as _vtkLookupTable # type: ignore + vtkLookupTable = _vtkLookupTable + except Exception: + vtkLookupTable = None + if vtkLookupTable is not None: + lut = vtkLookupTable() + lut.SetNumberOfTableValues(256) + lut.Build() + try: + import matplotlib.cm as mcm + cm = mcm.get_cmap(cmap) + for i in range(256): + r, g, b, a = cm(i / 255.0) + try: + lut.SetTableValue(i, float(r), float(g), float(b), float(a)) + except Exception: + try: + lut.SetTableValue(i, r, g, b, a) + except Exception: + pass + except Exception: + pass + + # set LUT range if we know clim + try: + if clim is not None and len(clim) == 2: + try: + lut.SetRange(float(clim[0]), float(clim[1])) + except Exception: + pass + except Exception: + pass + + # assign to mapper + try: + if hasattr(mapper, 'SetLookupTable'): + try: + mapper.SetLookupTable(lut) + except Exception: + pass + if hasattr(mapper, 'SetUseLookupTableScalarRange'): + try: + mapper.SetUseLookupTableScalarRange(True) + except Exception: + pass + except Exception: + pass + except Exception: + pass + + # persist kwargs + try: + kwargs = mesh_entry.get('kwargs', {}) if isinstance(mesh_entry, dict) else {} + kwargs['scalars'] = scalars + kwargs['cmap'] = cmap or None + if clim is not None: + kwargs['clim'] = (float(clim[0]), float(clim[1])) + mesh_entry['kwargs'] = kwargs + if hasattr(self.viewer, 'meshes') and self.current_object_name in self.viewer.meshes: + self.viewer.meshes[self.current_object_name] = mesh_entry + except Exception: + pass + + # request render + try: + if plotter is not None and hasattr(plotter, 'render'): + plotter.render() + except Exception: + pass + except Exception: + pass + def _apply_scalar_to_actor(self, object_name: str, scalar_name: str): if not object_name or self.viewer is None: raise RuntimeError("No viewer or object specified") @@ -607,73 +843,17 @@ def _apply_scalar_to_actor(self, object_name: str, scalar_name: str): except Exception: applied = False + # If we didn't use plotter.update_scalars, use the centralized mapper update helper if not applied: - mapper = getattr(actor, 'mapper', None) - if mapper is None: - raise RuntimeError('Actor has no mapper to update') - # try to select color array - try: - if hasattr(mapper, 'SelectColorArray'): - mapper.SelectColorArray(scalars) - except Exception: - pass - try: - if hasattr(mapper, 'SetArrayName'): - mapper.SetArrayName(scalars) - except Exception: - pass try: - if hasattr(mapper, 'scalar_visibility'): - mapper.scalar_visibility = True - except Exception: - pass - try: - if hasattr(mapper, 'ScalarVisibilityOn'): - mapper.ScalarVisibilityOn() - except Exception: - pass - # set scalar range - try: - if self.range_min.text() and self.range_max.text(): - mn = float(self.range_min.text()) - mx = float(self.range_max.text()) - if hasattr(mapper, 'SetScalarRange'): - mapper.SetScalarRange(mn, mx) - elif hasattr(mapper, 'scalar_range'): - mapper.scalar_range = (mn, mx) - except Exception: - pass - - # best-effort to set colormap via plotter's actor/lookup table - try: - cmap = self.colormap_combo.currentText() or None - if cmap and hasattr(plotter, 'add_mesh'): - # Changing lookup-table programmatically is backend/version dependent; try to trigger a re-render + cmap = self.colormap_combo.currentText() or None + clim = None try: - if hasattr(plotter, 'render'): - plotter.render() + if self.range_min.text() and self.range_max.text(): + clim = (float(self.range_min.text()), float(self.range_max.text())) except Exception: - pass - except Exception: - pass - - # update metadata - try: - kwargs = mesh_entry.get('kwargs', {}) if isinstance(mesh_entry, dict) else {} - kwargs['scalars'] = scalars - kwargs['cmap'] = self.colormap_combo.currentText() or None - if self.range_min.text() and self.range_max.text(): - try: - kwargs['clim'] = (float(self.range_min.text()), float(self.range_max.text())) - except Exception: - kwargs['clim'] = None - mesh_entry['kwargs'] = kwargs - except Exception: - pass - - # request render - try: - if plotter is not None and hasattr(plotter, 'render'): - plotter.render() - except Exception: - pass + clim = None + self._update_actor_mapper(mesh_entry, scalars, cmap, clim, values, actor, plotter) + return + except Exception: + pass From 33b29670aa31d92bbfb8bd65cb2ec070522fd8dc Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Sun, 7 Sep 2025 20:33:29 +0200 Subject: [PATCH 13/22] fix: evalute feature onto vtk grid --- .../feature_details_panel.py | 84 ++++++++++++------- loopstructural/main/model_manager.py | 6 ++ 2 files changed, 60 insertions(+), 30 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 a452271..3d63f23 100644 --- a/loopstructural/gui/modelling/geological_model_tab/feature_details_panel.py +++ b/loopstructural/gui/modelling/geological_model_tab/feature_details_panel.py @@ -11,7 +11,8 @@ QVBoxLayout, QWidget, ) - +from qgis.gui import QgsMapLayerComboBox +from qgis.utils import plugins from .layer_selection_table import LayerSelectionTable from .splot import SPlotDialog from .bounding_box_widget import BoundingBoxWidget @@ -19,10 +20,11 @@ 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) + self.plugin = plugins.get('loopstructural') + self.feature = feature self.model_manager = model_manager self.data_manager = data_manager @@ -138,24 +140,46 @@ 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"]) + 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) - self.project_layer_combo = QComboBox() + self.project_layer_combo = QgsMapLayerComboBox() self.project_layer_combo.setEnabled(False) + # self.project_layer_combo.setFilters(QgsMapLayerComboBox.PointLayer) + self.project_layer_combo.setVisible(False) # initially hidden + self.meshObjectCombo = QComboBox() export_layout.addRow("Project point layer:", self.project_layer_combo) - + export_layout.addRow("Viewer object:", self.meshObjectCombo) + # hide the labels for these rows initially (keep layout spacing until used) + lbl = export_layout.labelForField(self.project_layer_combo) + if lbl is not None: + lbl.setVisible(False) + 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) + self.project_layer_combo.setVisible(use_project) self.project_layer_combo.setEnabled(use_project) - if use_project: - try: - self.populate_project_point_layers() - except Exception: - pass - + self.meshObjectCombo.setVisible(use_vtk) + self.meshObjectCombo.setEnabled(use_vtk) + # also hide/show the labels associated with those fields + lbl = export_layout.labelForField(self.project_layer_combo) + if lbl is not None: + lbl.setVisible(use_project) + lbl = export_layout.labelForField(self.meshObjectCombo) + if lbl is not None: + lbl.setVisible(use_vtk) + if use_vtk: + # populate with pyvista objects from viewer + self.meshObjectCombo.clear() + if self.plugin.loop_widget.visualisation_widget.plotter is not None: + 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) @@ -192,23 +216,7 @@ def _on_evaluate_target_changed(index): self.layout.addWidget(self.export_eval_container) - def populate_project_point_layers(self): - """Populate self.project_layer_combo with point vector layers from the QGIS project. - - Guarded so the module can be imported outside QGIS. - """ - try: - from qgis.core import QgsProject, QgsWkbTypes, QgsMapLayer - except Exception: - return - - self.project_layer_combo.clear() - for lyr in QgsProject.instance().mapLayers().values(): - try: - if lyr.type() == QgsMapLayer.VectorLayer and lyr.geometryType() == QgsWkbTypes.PointGeometry: - self.project_layer_combo.addItem(lyr.name(), lyr.id()) - except Exception: - continue + def _on_bounding_box_updated(self, bounding_box): """Callback to update UI widgets when bounding box object changes externally. @@ -333,7 +341,7 @@ def _export_scalar_points(self): # no extra attributes for grid attributes_df = None logger.info(f'Got {len(pts)} points from bounding box cell centres') - else: + elif self.evaluate_target_combo.currentIndex() == 1: # Evaluate on an existing project point layer layer_id = None try: @@ -393,7 +401,23 @@ def _export_scalar_points(self): crs = layer.crs().authid() except Exception: 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 + # the scalar as a new field to the objects + viewer = self.plugin.loop_widget.visualisation_widget.plotter + if viewer is None: + return + mesh = self.meshObjectCombo.currentText() + if not mesh: + 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 + ) # call model_manager to produce GeoDataFrame try: logger.info('Exporting feature values to GeoDataFrame') @@ -721,4 +745,4 @@ def foldAxisFromPlungeAzimuth(self): if plunge is not None and azimuth is not None: self.feature.builder.update_build_arguments({'fold_axis': vector.tolist()}) - + diff --git a/loopstructural/main/model_manager.py b/loopstructural/main/model_manager.py index dbb85e8..8ab3970 100644 --- a/loopstructural/main/model_manager.py +++ b/loopstructural/main/model_manager.py @@ -529,3 +529,9 @@ def export_feature_values_to_geodataframe( gdf = _gpd.GeoDataFrame(df) return gdf + + 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 From a26fc498d4a60d98b4df074b078842dd4294a884 Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Sun, 7 Sep 2025 20:44:48 +0200 Subject: [PATCH 14/22] add docs --- loopstructural/main/model_manager.py | 129 +++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/loopstructural/main/model_manager.py b/loopstructural/main/model_manager.py index 8ab3970..1b8a36c 100644 --- a/loopstructural/main/model_manager.py +++ b/loopstructural/main/model_manager.py @@ -1,3 +1,14 @@ +"""Geological model manager utilities used by the LoopStructural plugin. + +This module exposes the `GeologicalModelManager` which wraps a LoopStructural +`GeologicalModel` and provides helpers to ingest GeoDataFrames, update +stratigraphy and faults, evaluate features on point clouds or meshes, and +export results to GeoDataFrames or VTK meshes. + +The goal of the manager is to isolate data transformation, sampling and +interaction with the LoopStructural model from the GUI code. +""" + from collections import defaultdict from typing import Callable @@ -162,6 +173,27 @@ def update_contact_traces( unit_name_field=None, use_z_coordinate=False, ): + """Ingest basal contact traces and populate internal stratigraphy. + + Parameters + ---------- + basal_contacts : geopandas.GeoDataFrame + GeoDataFrame containing basal contact geometries and attributes. + sampler : callable, optional + Callable used to sample geometries to point rows (default: AllSampler()). + unit_name_field : str or None + Field name in `basal_contacts` giving the stratigraphic unit name. If + None the function returns early. + use_z_coordinate : bool + If True, use Z values from geometries when available; otherwise use + the manager's DEM function. + + Notes + ----- + This method clears existing stratigraphy and replaces contact entries + keyed by unit name. It does not notify observers; callers should call + `update_model` or trigger observers as required. + """ self.stratigraphy.clear() # Clear existing stratigraphy unit_points = sampler(basal_contacts, self.dem_function, use_z_coordinate) if len(unit_points) == 0 or unit_points.empty: @@ -345,6 +377,14 @@ def update_model(self): observer() def features(self): + """Return the list of features currently held by the internal model. + + Returns + ------- + list + List-like collection of feature objects contained in the wrapped + LoopStructural `GeologicalModel`. + """ return self.model.features def add_foliation( @@ -355,6 +395,31 @@ def add_foliation( sampler=AllSampler(), use_z_coordinate=False, ): + """Create and add a foliation feature from grouped input layers. + + Parameters + ---------- + name : str + Name for the new foliation feature. + data : dict + Mapping of layer identifiers to dicts describing each layer. Each + layer dict must include a 'type' key (one of 'Orientation', + 'Formline', 'Value', 'Inequality') and the fields required by that + type (e.g. 'strike_field', 'dip_field', 'value_field', ...). + folded_feature_name : str or None + Optional name of a feature to which the foliation should be + associated/converted (currently unused in this helper). + sampler : callable, optional + Callable used to sample provided GeoDataFrames into plain pandas + rows (default: AllSampler()). + use_z_coordinate : bool + Whether to use Z coordinates from input geometries when present. + + Raises + ------ + ValueError + If a layer uses an unknown 'type' value. + """ # for z dfs = [] kwargs={} @@ -391,7 +456,25 @@ 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): + """Add an unconformity (or onlap unconformity) to a named foliation. + + Parameters + ---------- + foliation_name : str + Name of an existing foliation feature in the model. + value : float + Value (level) at which the unconformity should be inserted. + type : FeatureType + Type of unconformity (default: FeatureType.UNCONFORMITY). Use + FeatureType.ONLAPUNCONFORMITY for onlap-type behaviour. + + Raises + ------ + ValueError + If the foliation named by `foliation_name` cannot be found in the model. + """ foliation = self.model.get_feature_by_name(foliation_name) if foliation is None: raise ValueError(f"Foliation '{foliation_name}' not found in the model.") @@ -401,6 +484,24 @@ def add_unconformity(self, foliation_name: str, value: float, type: FeatureType self.model.add_onlap_unconformity(foliation, value) 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. + + Parameters + ---------- + feature_name : str + Name of the feature to fold. + fold_frame_name : str + Name of an existing fold frame feature in the model to use for + folding. + fold_weights : dict + Optional weights passed to the fold conversion; currently forwarded + to the converter implementation. + + Raises + ------ + ValueError + If either the fold frame or the target feature cannot be found. + """ from LoopStructural.modelling.features._feature_converters import add_fold_to_feature @@ -416,6 +517,17 @@ def add_fold_to_feature(self, feature_name: str, fold_frame_name: str, fold_weig self.model[feature_name] = folded_feature def convert_feature_to_structural_frame(self, feature_name: str): + """Convert an interpolated feature into a StructuralFrame. + + This helper constructs a StructuralFrameBuilder from the existing + feature's builder and replaces the feature in the model with the new + frame instance. + + Parameters + ---------- + feature_name : str + Name of the feature to convert. + """ from LoopStructural.modelling.features.builders import StructuralFrameBuilder builder = self.model.get_feature_by_name(feature_name).builder @@ -531,6 +643,23 @@ def export_feature_values_to_geodataframe( return gdf 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 + ---------- + name : str + Feature name to evaluate. + mesh : pyvista.PolyData or similar + Mesh-like object exposing a `points` array and supporting item + assignment for point data (e.g. mesh[name] = values). + scalar_type : str + 'scalar' or 'gradient' to control what is computed and attached. + + Returns + ------- + mesh + The same mesh instance with added/updated point data named `name`. + """ pts = mesh.points values = self.evaluate_feature_on_points(name, pts, scalar_type=scalar_type) mesh[name] = values From dc7d8f24c2ec55a1de780cdb4f4bec9b8e7be9a6 Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Mon, 8 Sep 2025 08:28:57 +0200 Subject: [PATCH 15/22] fix: linting --- .../bounding_box_widget.py | 1 - .../feature_details_panel.py | 18 +++++------------- .../visualisation/loop_pyvistaqt_wrapper.py | 3 --- .../gui/visualisation/object_list_widget.py | 4 ++-- .../visualisation/object_properties_widget.py | 2 -- 5 files changed, 7 insertions(+), 21 deletions(-) diff --git a/loopstructural/gui/modelling/geological_model_tab/bounding_box_widget.py b/loopstructural/gui/modelling/geological_model_tab/bounding_box_widget.py index 591a319..aa02c8e 100644 --- a/loopstructural/gui/modelling/geological_model_tab/bounding_box_widget.py +++ b/loopstructural/gui/modelling/geological_model_tab/bounding_box_widget.py @@ -2,7 +2,6 @@ from PyQt5.QtCore import Qt from PyQt5.QtWidgets import ( QGridLayout, - QHBoxLayout, QLabel, QDoubleSpinBox, QVBoxLayout, 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 3d63f23..87866cf 100644 --- a/loopstructural/gui/modelling/geological_model_tab/feature_details_panel.py +++ b/loopstructural/gui/modelling/geological_model_tab/feature_details_panel.py @@ -1,5 +1,4 @@ -import numpy as np -from PyQt5.QtCore import Qt, QVariant +from PyQt5.QtCore import Qt from PyQt5.QtWidgets import ( QCheckBox, QComboBox, @@ -123,9 +122,7 @@ def addExportBlock(self): # --- Per-feature export controls (for this panel's feature) --- try: - from PyQt5.QtWidgets import QFormLayout, QHBoxLayout - from PyQt5.QtWidgets import QGroupBox - from qgis.core import QgsVectorLayer, QgsFeature, QgsFields, QgsField, QgsProject, QgsGeometry, QgsPointXYZ + from PyQt5.QtWidgets import QFormLayout except Exception: # imports may fail outside QGIS environment; we'll handle at runtime pass @@ -316,7 +313,7 @@ def _export_scalar_points(self): crs = self.data_manager.project.crs().authid() try: # QGIS imports (guarded) - from qgis.core import QgsProject, QgsVectorLayer, QgsFeature, QgsGeometry, QgsPoint, QgsFields, QgsField + from qgis.core import QgsProject, QgsVectorLayer, QgsFeature, QgsPoint, QgsField from qgis.PyQt.QtCore import QVariant except Exception as e: # Not running inside QGIS — nothing to do @@ -328,12 +325,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') - bb = None - try: - bb = getattr(self.model_manager.model, 'bounding_box', None) - except Exception: - bb = None - + @@ -428,7 +420,7 @@ def _export_scalar_points(self): attributes=attributes_df, crs=crs, ) - except Exception as e: + except Exception: logger.debug('Failed to export feature values', exc_info=True) return diff --git a/loopstructural/gui/visualisation/loop_pyvistaqt_wrapper.py b/loopstructural/gui/visualisation/loop_pyvistaqt_wrapper.py index db6e714..5005e3c 100644 --- a/loopstructural/gui/visualisation/loop_pyvistaqt_wrapper.py +++ b/loopstructural/gui/visualisation/loop_pyvistaqt_wrapper.py @@ -1,9 +1,6 @@ -import re -from collections import defaultdict from PyQt5.QtCore import pyqtSignal from pyvistaqt import QtInteractor from typing import Optional, Any, Dict, Tuple -import pyvista as pv class LoopPyVistaQTPlotter(QtInteractor): diff --git a/loopstructural/gui/visualisation/object_list_widget.py b/loopstructural/gui/visualisation/object_list_widget.py index f185313..f2f17d4 100644 --- a/loopstructural/gui/visualisation/object_list_widget.py +++ b/loopstructural/gui/visualisation/object_list_widget.py @@ -99,7 +99,7 @@ def _on_vis(state, name=mesh_name, m=mesh): # Fallback: set on mesh if possible if hasattr(m, 'visibility'): try: - setattr(m, 'visibility', checked) + m.visibility = checked except Exception: pass @@ -179,7 +179,7 @@ def _on_visibility_change(state, name=object_name, inst=instance): # Fallback: set attribute on the instance if possible if inst is not None and hasattr(inst, 'visibility'): try: - setattr(inst, 'visibility', checked) + inst.visibility = checked except Exception: pass diff --git a/loopstructural/gui/visualisation/object_properties_widget.py b/loopstructural/gui/visualisation/object_properties_widget.py index fcda2a7..10bed1b 100644 --- a/loopstructural/gui/visualisation/object_properties_widget.py +++ b/loopstructural/gui/visualisation/object_properties_widget.py @@ -824,11 +824,9 @@ def _apply_scalar_to_actor(self, object_name: str, scalar_name: str): return # resolve scalar name - use_cell = False scalars = scalar_name if scalar_name.startswith('cell:'): scalars = scalar_name.split(':', 1)[1] - use_cell = True values = self._get_scalar_values(scalar_name) if values is None: From 5561ded0cc3772e16180c35c1d8275f17b7a09fe Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Mon, 8 Sep 2025 08:52:13 +0200 Subject: [PATCH 16/22] fix: use z coordinate if it exists for all manually added features --- loopstructural/main/data_manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/loopstructural/main/data_manager.py b/loopstructural/main/data_manager.py index f7a6a67..ba8c606 100644 --- a/loopstructural/main/data_manager.py +++ b/loopstructural/main/data_manager.py @@ -587,7 +587,7 @@ def add_foliation_to_model(self, foliation_name: str, *, folded_feature_name=Non ) # Convert QgsVectorLayer to GeoDataFrame if self._model_manager: self._model_manager.add_foliation( - foliation_name, foliation_data, folded_feature_name=folded_feature_name + foliation_name, foliation_data, folded_feature_name=folded_feature_name,use_z_coordinate=True ) self.logger(message=f"Added foliation '{foliation_name}' to the model.") else: From 29abb3ebd11cd74e105a6440f52032fea88eb69b Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Mon, 8 Sep 2025 08:52:39 +0200 Subject: [PATCH 17/22] fix: remove print statements, rescale z for data to be in real coordinates --- loopstructural/gui/visualisation/feature_list_widget.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/loopstructural/gui/visualisation/feature_list_widget.py b/loopstructural/gui/visualisation/feature_list_widget.py index b5d624a..b8363ba 100644 --- a/loopstructural/gui/visualisation/feature_list_widget.py +++ b/loopstructural/gui/visualisation/feature_list_widget.py @@ -93,23 +93,21 @@ 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') - print(f"Adding scalar field to feature: {feature_name}") 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') - print(f"Adding surface to feature: {feature_name}") 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') - print(f"Adding vector field to feature: {feature_name}") def add_data(self, feature_name): data = self.model_manager.model[feature_name].get_data() for d in data: + d.locations = self.model_manager.model.rescale(d.locations) if issubclass(type(d), VectorPoints): scale = self._get_vector_scale() # tolerance is None means all points are shown From 08a50db9b8d37157462817779045e4be83993bf9 Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Mon, 8 Sep 2025 08:53:12 +0200 Subject: [PATCH 18/22] fix: put all feature options in QgsCollapsibleGroupBox --- .../feature_details_panel.py | 33 ++++++++++--------- 1 file changed, 17 insertions(+), 16 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 87866cf..bdc3bfa 100644 --- a/loopstructural/gui/modelling/geological_model_tab/feature_details_panel.py +++ b/loopstructural/gui/modelling/geological_model_tab/feature_details_panel.py @@ -10,7 +10,8 @@ QVBoxLayout, QWidget, ) -from qgis.gui import QgsMapLayerComboBox +from qgis.gui import QgsMapLayerComboBox, QgsCollapsibleGroupBox + from qgis.utils import plugins from .layer_selection_table import LayerSelectionTable from .splot import SPlotDialog @@ -81,11 +82,15 @@ def __init__(self, parent=None, *, feature=None, model_manager=None, data_manage self.n_elements_spinbox.valueChanged.connect(self.updateNelements) + table_group_box = QgsCollapsibleGroupBox('Data Layers') 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 ) + table_layout = QVBoxLayout() + table_layout.addWidget(self.layer_table) + table_group_box.setLayout(table_layout) # Form layout for better organization form_layout = QFormLayout() form_layout.addRow(self.interpolator_type_label, self.interpolator_type_combo) @@ -93,10 +98,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) - QgsCollapsibleGroupBox = QWidget() - QgsCollapsibleGroupBox.setLayout(form_layout) - self.layout.addWidget(QgsCollapsibleGroupBox) - self.layout.addWidget(self.layer_table) + group_box = QgsCollapsibleGroupBox('Interpolator Settings') + group_box.setLayout(form_layout) + self.layout.addWidget(group_box) + self.layout.addWidget(table_group_box) self.addMidBlock() self.addExportBlock() def addMidBlock(self): @@ -127,7 +132,7 @@ def addExportBlock(self): # imports may fail outside QGIS environment; we'll handle at runtime pass - export_widget = QWidget() + export_widget = QgsCollapsibleGroupBox('Export Feature') export_layout = QFormLayout(export_widget) # Scalar selector (support scalar and gradient) @@ -204,10 +209,6 @@ def _on_evaluate_target_changed(index): block.setObjectName(f"export_block_{getattr(feat, 'name', 'feature')}") block_layout = QVBoxLayout(block) block_layout.setContentsMargins(0, 0, 0, 0) - # Placeholder label — disabled until controls are added - lbl = QLabel(getattr(feat, 'name', '')) - lbl.setEnabled(False) - block_layout.addWidget(lbl) self.export_eval_layout.addWidget(block) self.export_blocks[getattr(feat, 'name', f"feature_{len(self.export_blocks)}")] = block @@ -594,9 +595,9 @@ def addMidBlock(self): 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) + group_box = QgsCollapsibleGroupBox('Fold Settings') + group_box.setLayout(form_layout) + self.layout.addWidget(group_box) # Remove redundant layout setting self.setLayout(self.layout) @@ -713,9 +714,9 @@ def addMidBlock(self): # lambda: self.open_splot_dialog() # ) # form_layout.addRow(splot_button) - QgsCollapsibleGroupBox = QWidget() - QgsCollapsibleGroupBox.setLayout(form_layout) - self.layout.addWidget(QgsCollapsibleGroupBox) + group_box = QgsCollapsibleGroupBox() + group_box.setLayout(form_layout) + self.layout.addWidget(group_box) # Remove redundant layout setting self.setLayout(self.layout) def open_splot_dialog(self): From 50cec1bd9772499c5484f700d6bf8d569eb9a818 Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Mon, 8 Sep 2025 14:21:57 +0200 Subject: [PATCH 19/22] fix: add layer from qgis to visualisation --- .../gui/visualisation/object_list_widget.py | 115 +++++++++++++++++- 1 file changed, 114 insertions(+), 1 deletion(-) diff --git a/loopstructural/gui/visualisation/object_list_widget.py b/loopstructural/gui/visualisation/object_list_widget.py index f2f17d4..93e210a 100644 --- a/loopstructural/gui/visualisation/object_list_widget.py +++ b/loopstructural/gui/visualisation/object_list_widget.py @@ -345,6 +345,7 @@ def show_add_object_menu(self): addFeatureAction = menu.addAction("Surface from model") loadFeatureAction = menu.addAction("Load from file") + addQgsLayerAction = menu.addAction("Add from QGIS layer") buttonPosition = self.sender().mapToGlobal(self.sender().rect().bottomLeft()) action = menu.exec_(buttonPosition) @@ -353,10 +354,122 @@ def show_add_object_menu(self): self.add_feature_from_geological_model() elif action == loadFeatureAction: self.load_feature_from_file() - + elif action == addQgsLayerAction: + self.add_object_from_qgis_layer() def add_feature_from_geological_model(self): # Logic to add a feature from the geological model print("Adding feature from geological model") + def add_object_from_qgis_layer(self): + """Show a dialog to pick a QGIS point vector layer, convert it to a VTK/PyVista + point cloud and copy numeric attributes as point scalars. + """ + # Local imports so the module can still be imported when QGIS GUI isn't available + try: + from qgis.gui import QgsMapLayerComboBox + from qgis.core import QgsMapLayerProxyModel, QgsWkbTypes + except Exception as e: + print("QGIS GUI components are not available:", e) + return + + try: + from loopstructural.main.vectorLayerWrapper import qgsLayerToGeoDataFrame + except Exception as e: + print("Could not import qgsLayerToGeoDataFrame:", e) + return + from loopstructural.main.model_manager import AllSampler + + from PyQt5.QtWidgets import QDialog, QDialogButtonBox, QVBoxLayout, QMessageBox + import numpy as np + import pandas as pd + + dialog = QDialog(self) + dialog.setWindowTitle("Add from QGIS layer") + layout = QVBoxLayout(dialog) + + layout.addWidget(QLabel("Select point layer:")) + layer_combo = QgsMapLayerComboBox(dialog) + # Restrict to point layers only + layer_combo.setFilters(QgsMapLayerProxyModel.PointLayer) + layout.addWidget(layer_combo) + + buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + buttons.accepted.connect(dialog.accept) + buttons.rejected.connect(dialog.reject) + layout.addWidget(buttons) + + if dialog.exec_() != QDialog.Accepted: + return + + layer = layer_combo.currentLayer() + if layer is None or not layer.isValid(): + QMessageBox.warning(self, "Invalid layer", "No valid layer selected.") + return + + # Basic geometry check - ensure the layer contains point geometry + try: + if layer.wkbType() != QgsWkbTypes.Point and QgsWkbTypes.geometryType(layer.wkbType()) != QgsWkbTypes.PointGeometry: + # Some QGIS versions use different enums; allow via proxy filter primarily + # If the check fails, continue but warn + print("Selected layer does not appear to be a point layer. Proceeding anyway.") + except Exception: + # ignore strict checks - rely on conversion result + pass + + # Convert layer to a DataFrame (no DTM) + gdf = qgsLayerToGeoDataFrame(layer) + sampler = AllSampler() + # sample the points from the gdf with no DTM and include Z if present + df = sampler(gdf,None,True) + if df is None or df.empty: + QMessageBox.warning(self, "No data", "Selected layer contains no points.") + return + + # Ensure X,Y,Z columns present + if not set(["X", "Y", "Z"]).issubset(df.columns): + QMessageBox.warning(self, "Invalid data", "Layer conversion did not produce X/Y/Z columns.") + return + + # Build points array + try: + pts = np.vstack([df["X"].to_numpy(), df["Y"].to_numpy(), df["Z"].to_numpy()]).T.astype(float) + except Exception as e: + QMessageBox.warning(self, "Error", f"Failed to build point coordinates: {e}") + return + + # Create PyVista point cloud / PolyData + try: + mesh = pv.PolyData(pts) + except Exception as e: + QMessageBox.warning(self, "Error", f"Failed to create mesh: {e}") + return + + # Add numeric attributes as point scalars + for col in df.columns: + if col in ("X", "Y", "Z"): + continue + try: + ser = pd.to_numeric(df[col], errors='coerce') + if ser.isnull().all(): + # no numeric values present + continue + arr = ser.to_numpy().astype(float) + # Ensure length matches points + if len(arr) != mesh.n_points: + # skip columns that don't match + continue + mesh.point_data[col] = arr + except Exception: + # skip non-numeric or problematic fields + continue + + # Add to viewer + if self.viewer and hasattr(self.viewer, 'add_mesh_object'): + try: + self.viewer.add_mesh_object(mesh, name=layer.name()) + except Exception as e: + print("Failed to add mesh to viewer:", e) + else: + print("Error: Viewer is not initialized or does not support adding meshes.") def load_feature_from_file(self): file_path, _ = QFileDialog.getOpenFileName( From 2572749f858158503292f1b7b5c520053ecf5788 Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Mon, 8 Sep 2025 14:22:36 +0200 Subject: [PATCH 20/22] fix: updating stratigraphic column, need update loopstructural. Reading from dict was reversing column. --- .../stratigraphic_column/stratigraphic_column.py | 2 +- loopstructural/main/data_manager.py | 4 +--- loopstructural/main/model_manager.py | 13 ++++++++----- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py index f7383a6..c7617a3 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 reversed(self.data_manager._stratigraphic_column.order): + for unit in 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: diff --git a/loopstructural/main/data_manager.py b/loopstructural/main/data_manager.py index ba8c606..1799d47 100644 --- a/loopstructural/main/data_manager.py +++ b/loopstructural/main/data_manager.py @@ -466,7 +466,6 @@ def from_dict(self, data): self._structural_orientations = data['structural_orientations'] if 'stratigraphic_column' in data: self._stratigraphic_column = StratigraphicColumn.from_dict(data['stratigraphic_column']) - print([o.name for o in self._stratigraphic_column.order]) self.stratigraphic_column_callback() def update_from_dict(self, data): @@ -537,11 +536,10 @@ def update_from_dict(self, data): if 'stratigraphic_column' in data: self._stratigraphic_column.update_from_dict(data['stratigraphic_column']) else: - self._stratigraphic_column = StratigraphicColumn() + self._stratigraphic_column.clear() if self.stratigraphic_column_callback: self.stratigraphic_column_callback() - print([o.name for o in self._stratigraphic_column.order]) def find_layer_by_name(self, layer_name): diff --git a/loopstructural/main/model_manager.py b/loopstructural/main/model_manager.py index 1b8a36c..e896734 100644 --- a/loopstructural/main/model_manager.py +++ b/loopstructural/main/model_manager.py @@ -63,13 +63,16 @@ def __call__(self, line: gpd.GeoDataFrame, dem: Callable, use_z: bool) -> pd.Dat {'X': x, 'Y': y, 'Z': z, 'feature_id': feature_id, **attributes} ) elif geom.geom_type == 'Point': - x, y = geom.x, geom.y + + coords = list(geom.coords[0]) # Use Z from geometry if available, otherwise use DEM - if use_z and hasattr(geom, 'z'): - z = geom.z + if use_z and len(coords) > 2: + z = coords[2] + elif dem is not None: + z = dem(coords[0], coords[1]) else: - z = dem(x, y) - points.append({'X': x, 'Y': y, 'Z': z, 'feature_id': feature_id, **attributes}) + z = 0 + points.append({'X': coords[0], 'Y': coords[1], 'Z': z, 'feature_id': feature_id, **attributes}) feature_id += 1 df = pd.DataFrame(points) return df From efec3ab55774dbd0bf41798457b2662162b636e8 Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Mon, 8 Sep 2025 14:24:49 +0200 Subject: [PATCH 21/22] fix: lint --- 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 93e210a..e3f9d99 100644 --- a/loopstructural/gui/visualisation/object_list_widget.py +++ b/loopstructural/gui/visualisation/object_list_widget.py @@ -425,7 +425,7 @@ def add_object_from_qgis_layer(self): return # Ensure X,Y,Z columns present - if not set(["X", "Y", "Z"]).issubset(df.columns): + if not {"X", "Y", "Z"}.issubset(df.columns): QMessageBox.warning(self, "Invalid data", "Layer conversion did not produce X/Y/Z columns.") return From c905dda4448183fc0ad68bae7bfe5fac84635b02 Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Mon, 8 Sep 2025 14:30:27 +0200 Subject: [PATCH 22/22] fix: upgrade loopstructural requirement --- loopstructural/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/loopstructural/requirements.txt b/loopstructural/requirements.txt index f75b140..b3904c8 100644 --- a/loopstructural/requirements.txt +++ b/loopstructural/requirements.txt @@ -1,6 +1,6 @@ pyvistaqt pyvista -LoopStructural==1.6.21 +LoopStructural==1.6.22 pyqtgraph loopsolver geopandas \ No newline at end of file