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..aa02c8e --- /dev/null +++ b/loopstructural/gui/modelling/geological_model_tab/bounding_box_widget.py @@ -0,0 +1,216 @@ +import numpy as np +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import ( + QGridLayout, + 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..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,16 +10,21 @@ QVBoxLayout, QWidget, ) +from qgis.gui import QgsMapLayerComboBox, QgsCollapsibleGroupBox +from qgis.utils import plugins from .layer_selection_table import LayerSelectionTable from .splot import SPlotDialog +from .bounding_box_widget import BoundingBoxWidget from LoopStructural.modelling.features import StructuralFrame from LoopStructural.utils import normal_vector_to_strike_and_dip, plungeazimuth2vector - - +from LoopStructural import getLogger +logger = getLogger(__name__) class BaseFeatureDetailsPanel(QWidget): def __init__(self, parent=None, *, feature=None, model_manager=None, data_manager=None): super().__init__(parent) + self.plugin = plugins.get('loopstructural') + self.feature = feature self.model_manager = model_manager self.data_manager = data_manager @@ -42,6 +47,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 +56,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() @@ -76,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) @@ -88,12 +98,180 @@ 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): + """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 + except Exception: + # imports may fail outside QGIS environment; we'll handle at runtime + pass - # self.layout.addLayout(form_layout) + export_widget = QgsCollapsibleGroupBox('Export Feature') + 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","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 = 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) + 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) + + + + + + + + # 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) + 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 _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 +298,201 @@ 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 + 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, QgsPoint, 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') + + + + + 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') + elif self.evaluate_target_combo.currentIndex() == 1: + # 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 + 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') + gdf = self.model_manager.export_feature_values_to_geodataframe( + self.feature.name, + pts, + scalar_type=scalar_type, + attributes=attributes_df, + crs=crs, + ) + except Exception: + 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 +583,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]) @@ -222,13 +595,14 @@ def __init__(self, parent=None, *, feature=None, model_manager=None, data_manage 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) + def on_fold_frame_changed(self, text): self.model_manager.add_fold_to_feature(self.feature.name, fold_frame_name=text) @@ -241,6 +615,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 +627,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 +642,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 +657,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 +672,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, } } @@ -309,39 +684,39 @@ def __init__(self, parent=None, *, feature=None, model_manager=None, data_manage 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: feature.builder.update_build_arguments( + 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() # ) # 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): @@ -355,11 +730,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()}) + + 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) 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/gui/visualisation/feature_list_widget.py b/loopstructural/gui/visualisation/feature_list_widget.py index ad235c6..b8363ba 100644 --- a/loopstructural/gui/visualisation/feature_list_widget.py +++ b/loopstructural/gui/visualisation/feature_list_widget.py @@ -92,32 +92,30 @@ 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') - print(f"Adding scalar field to feature: {feature_name}") + self.viewer.add_mesh_object(scalar_field.vtk(), name=f'{feature_name}_scalar_field') 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') - print(f"Adding surface to feature: {feature_name}") + self.viewer.add_mesh_object(surface.vtk(), name=f'{feature_name}_surface') 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') - print(f"Adding vector field to feature: {feature_name}") + self.viewer.add_mesh_object(vector_field.vtk(scale=scale), name=f'{feature_name}_vector_field') 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 - 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 +123,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 +133,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 +142,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..5005e3c 100644 --- a/loopstructural/gui/visualisation/loop_pyvistaqt_wrapper.py +++ b/loopstructural/gui/visualisation/loop_pyvistaqt_wrapper.py @@ -1,7 +1,6 @@ -import re - from PyQt5.QtCore import pyqtSignal from pyvistaqt import QtInteractor +from typing import Optional, Any, Dict, Tuple class LoopPyVistaQTPlotter(QtInteractor): @@ -11,6 +10,9 @@ def __init__(self, parent): super().__init__(parent=parent) self.objects = {} self.add_axes() + # maps name -> dict(mesh=..., actor=..., kwargs={...}) + self.meshes = {} + # maintain an internal pyvista plotter def increment_name(self, name): parts = name.split('_') @@ -25,52 +27,97 @@ def increment_name(self, name): name = '_'.join(parts) return name - def add_mesh(self, *args, **kwargs): - """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] - 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.actors: - self.remove_actor(self.actors[name]) - self.update() - else: - raise ValueError(f"Object '{name}' not found in the plotter.") + 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 + scalars = scalars if scalars is not None else mesh.active_scalars_name + use_scalar = color is None and scalars is not None - 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 + # 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 '{old_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.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..e3f9d99 100644 --- a/loopstructural/gui/visualisation/object_list_widget.py +++ b/loopstructural/gui/visualisation/object_list_widget.py @@ -15,36 +15,186 @@ 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) + 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: + # 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) + + 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: + 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) - 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) + # 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: + 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 @@ -98,10 +248,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: @@ -146,28 +298,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) + mesh.save(file_path) if hasattr(mesh, "save") else 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}") @@ -193,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) @@ -201,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 {"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( @@ -216,12 +481,9 @@ 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(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..10bed1b --- /dev/null +++ b/loopstructural/gui/visualisation/object_properties_widget.py @@ -0,0 +1,857 @@ +from PyQt5.QtCore import Qt + +from PyQt5.QtWidgets import ( + 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) + 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 selection + layout.addWidget(QLabel("Active Scalar:")) + self.scalar_combo = QComboBox() + self.scalar_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.scalar_combo.currentTextChanged.connect(self._on_scalar_changed) + layout.addWidget(self.scalar_combo) + + # 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) + 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) + 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) + # apply colormap changes when user selects a different cmap + self.colormap_combo.currentTextChanged.connect(self._on_colormap_changed) + 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) + # 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) + 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) + + # 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) + 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) + + # Initialize UI state + self.color_with_scalar_checkbox.setChecked(False) + self._on_color_with_scalar_toggled(False) + + def choose_color(self): + color = QColorDialog.getColor() + 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'): + try: + actor.prop.color = (color.redF(), color.greenF(), color.blueF()) + except Exception: + pass + elif hasattr(actor, 'GetProperty'): + 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]['color'] = (color.redF(), color.greenF(), color.blueF()) + except Exception: + pass + + def set_opacity(self, value: float): + 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'): + try: + actor.prop.opacity = value + except Exception: + pass + elif hasattr(actor, 'GetProperty'): + 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 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) + if mesh_entry is None: + self.current_mesh = None + self.title_label.setText(f"Object: {object_name} (not found)") + self._update_histogram(None) + return + self.current_mesh = mesh_entry.get('mesh', None) + self.title_label.setText(f"Object: {object_name}") + + # reset small things + self.scalar_bar_checkbox.setChecked(False) + self.range_min.clear() + self.range_max.clear() + + # populate scalar combo + self.scalar_combo.blockSignals(True) + self.scalar_combo.clear() + + 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()): + self.scalar_combo.addItem(f"cell:{k}") + except Exception: + pass + + # 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: + sel_name = prev_scalars + 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}" + idx = self.scalar_combo.findText(sel_name) + if idx >= 0: + self.scalar_combo.setCurrentIndex(idx) + else: + idx = self.scalar_combo.findText("") + if idx >= 0: + self.scalar_combo.setCurrentIndex(idx) + except Exception: + pass + self.scalar_combo.blockSignals(False) + + # infer scalar range for display + try: + if self.current_mesh is not None: + pdata = getattr(self.current_mesh, 'point_data', None) or {} + cdata = getattr(self.current_mesh, 'cell_data', None) or {} + vals = None + if len(pdata.keys()) > 0: + vals = next(iter(pdata.values())) + elif len(cdata.keys()) > 0: + vals = next(iter(cdata.values())) + 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: + self.range_min.clear() + self.range_max.clear() + except Exception: + pass + + # detect scalar bar visibility + try: + 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: + 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 {} + default_color_with_scalar = bool(kwargs.get('scalars') or kwargs.get('cmap')) + self.color_with_scalar_checkbox.blockSignals(True) + self.color_with_scalar_checkbox.setChecked(default_color_with_scalar) + self.color_with_scalar_checkbox.blockSignals(False) + self._on_color_with_scalar_toggled(default_color_with_scalar) + except Exception: + pass + + # update histogram display + 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): + # update histogram preview immediately + try: + vals = self._get_scalar_values(scalar_name) + self._update_histogram(vals if self.color_with_scalar_checkbox.isChecked() else None) + except Exception: + self._update_histogram(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 {} + + scalars = None + if scalar_name and scalar_name != "": + if scalar_name.startswith('cell:'): + scalars = scalar_name.split(':', 1)[1] + else: + scalars = scalar_name + + 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() + + 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, 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 + + def _on_color_with_scalar_toggled(self, checked: bool): + try: + 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) + self.color_button.setEnabled(not checked) + 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 + + 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 + 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): + try: + self.hist_ax.clear() + if values is None: + 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: + 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") + 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 + scalars = scalar_name + if scalar_name.startswith('cell:'): + scalars = scalar_name.split(':', 1)[1] + + 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 we didn't use plotter.update_scalars, use the centralized mapper update helper + if not applied: + try: + 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 + self._update_actor_mapper(mesh_entry, scalars, cmap, clim, values, actor, plotter) + return + except Exception: + pass 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 diff --git a/loopstructural/main/data_manager.py b/loopstructural/main/data_manager.py index f7a6a67..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): @@ -587,7 +585,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: diff --git a/loopstructural/main/model_manager.py b/loopstructural/main/model_manager.py index bf9916d..e896734 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 @@ -52,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 @@ -162,6 +176,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 +380,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 +398,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 +459,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 +487,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 +520,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 @@ -426,3 +541,129 @@ 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 + + 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 + + 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 + return mesh \ No newline at end of file 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