diff --git a/docs/development/usability-plan.md b/docs/development/usability-plan.md index 3f5cd6ae..7510d3c8 100644 --- a/docs/development/usability-plan.md +++ b/docs/development/usability-plan.md @@ -711,30 +711,30 @@ Rules: Tasks: -- [ ] Read the three files and write a short list of what each class does and +- [x] Read the three files and write a short list of what each class does and which methods are duplicated or not used. Remove dead code first. -- [ ] Add an object registry class (no Qt) for the meshes and their source +- [x] Add an object registry class (no Qt) for the meshes and their source information. Move the reads and writes of `viewer.meshes` to it. -- [ ] Split `feature_list_widget.py`: move the mesh builders to a module without +- [x] Split `feature_list_widget.py`: move the mesh builders to a module without Qt code (`mesh_builders.py`), and move the cross-section, block model and topography actions out of the tree widget. -- [ ] Simplify `object_properties_widget.py`: one handler for the selected +- [x] Simplify `object_properties_widget.py`: one handler for the selected object, and the controls shown for each source type, not a check for the object name in each method. -- [ ] Simplify the object/model view (`object_list_widget.py`): group the +- [x] Simplify the object/model view (`object_list_widget.py`): group the objects by source feature, and show the type and the isovalue of each object. -- [ ] Add "Add isosurface..." to the menu of a model feature. The user enters +- [x] Add "Add isosurface..." to the menu of a model feature. The user enters one or more values (a list, or a start, an end and a count). The default values come from the range of the scalar field. A pure function gives the values from the input. -- [ ] Build the isosurfaces with the scalar field of the feature +- [x] Build the isosurfaces with the scalar field of the feature (`feature.surfaces(value)`). Give each object a name with its value, for example `Fault_1_iso_0.50`. A value outside the range of the field gives a message in the message bar, not an error dialog. -- [ ] Let the user change the value of an existing isosurface in the object +- [x] Let the user change the value of an existing isosurface in the object properties. The object is built again. -- [ ] Update the docs for the viewer. +- [x] Update the docs for the viewer. Acceptance: the user adds five isosurfaces from one feature in one action. They show in the object list under that feature, each with its value. The user diff --git a/docs/usage/interface.md b/docs/usage/interface.md index a9709a3e..00194c15 100644 --- a/docs/usage/interface.md +++ b/docs/usage/interface.md @@ -20,6 +20,20 @@ The **Export** panel in step 5 is open when the model is solved. It has three ta The exports run in the background and show a message when they finish. +### The 3D view +The 3D view has three parts: the object list, the feature list with the action buttons, and the properties of the selected object. + +- **Object list**: the objects in the view are in groups, one group for each source feature. Each row has a check box (show or hide), the name of the object, its type and, for a surface from a scalar value, its value. Select an object to see its properties. The right-click menu can zoom to an object, export it, show or hide it, or remove it. +- **Feature list**: right-click a feature to add its scalar field, its surfaces, its vector field or its data. The action buttons under the list add the model bounding box, the fault and stratigraphic surfaces, the topography, cross sections and a block model. +- **Out-of-date objects**: when the model changes, the objects that come from the model show in grey italic. The **Update** button under the feature list builds them again from the current model, with the same settings. + +#### Isosurfaces +Right-click a feature and choose **Add Isosurface...** to add the surface of the scalar field at values that you choose. Enter a list of values (for example `0.1, 0.5, 0.9`), or a start, an end and a count. The default values are inside the range of the scalar field. The plugin adds one object for each value. The name has the value, for example `Fault_1_iso_0.50`. These objects do not replace the surfaces that the model gives. + +If the scalar field has no surface at a value (for example, the value is outside the range of the field), a message shows the value and the other surfaces are still added. + +To change the value of an isosurface, select it in the object list, enter a new value in the **Isosurface** panel of the properties and choose **Apply**. Only this object is built again. The name of the object does not change. After the model changes, **Update** builds each isosurface again at its value. + The header of the dock has **Save**, **Open**, **Reset** and **Settings**. They apply to all of the plugin. The toolbar has three actions: LoopStructural (the dock), 3D View and Help. The **Tools** submenu of the Plugins menu has the map2loop dialogs for advanced users. diff --git a/loopstructural/gui/modelling/geological_model_tab/feature_details_panel/_base.py b/loopstructural/gui/modelling/geological_model_tab/feature_details_panel/_base.py index 300f59b7..aafef8ad 100644 --- a/loopstructural/gui/modelling/geological_model_tab/feature_details_panel/_base.py +++ b/loopstructural/gui/modelling/geological_model_tab/feature_details_panel/_base.py @@ -478,7 +478,7 @@ def _on_evaluate_target_changed(index): 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()) + mesh_names = viewer.registry.names() self.meshObjectCombo.addItems(mesh_names) self.evaluate_target_combo.currentIndexChanged.connect(_on_evaluate_target_changed) @@ -757,7 +757,7 @@ def _export_scalar_points(self): mesh = self.meshObjectCombo.currentText() if not mesh: return - vtk_mesh = viewer.meshes[mesh]['mesh'] + vtk_mesh = viewer.registry.get(mesh).mesh self.model_manager.export_feature_values_to_vtk_mesh( self.feature.name, vtk_mesh, scalar_type=scalar_type ) diff --git a/loopstructural/gui/visualisation/feature_list_widget.py b/loopstructural/gui/visualisation/feature_list_widget.py index a174c7fb..8fa8cec1 100644 --- a/loopstructural/gui/visualisation/feature_list_widget.py +++ b/loopstructural/gui/visualisation/feature_list_widget.py @@ -1,48 +1,37 @@ import logging -from typing import Optional, Union -import numpy as np -import pyvista as pv -from LoopStructural.datatypes import VectorPoints -from qgis.core import QgsApplication, QgsMapLayerProxyModel -from qgis.gui import QgsMapLayerComboBox -from qgis.PyQt.QtCore import QSize -from qgis.PyQt.QtGui import QIcon +from qgis.core import QgsApplication from qgis.PyQt.QtWidgets import ( - QCheckBox, QDialog, - QDoubleSpinBox, - QFormLayout, - QHBoxLayout, - QLabel, QMenu, QMessageBox, QPushButton, - QSpinBox, - QTabWidget, - QToolButton, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget, ) -from loopstructural.__about__ import DIR_PLUGIN_ROOT - from ..background_task import finish_background_task, start_background_task -from ..compatibility import configure_layer_combo -from .cross_section_utils import ( - build_block_model_mesh, - build_line_extrusion_mesh, - build_plane_mesh, -) -from .line_layer import line_xy_from_layer -from .mesh_scalar_utils import stratigraphic_ids_to_rgb +from ..messages import push_info, push_warning +from .isosurface_dialog import IsosurfaceDialog +from .isovalues import isosurface_name +from .mesh_builders import FOLD_CONSTRAINT_COLOURS, FOLD_CONSTRAINT_LABELS, MeshBuilder +from .viewer_actions_widget import ViewerActionsWidget logger = logging.getLogger(__name__) class FeatureListWidget(QWidget): + """The features of the model, with the actions to add their meshes to the + viewer, and the button that builds out-of-date objects again. + + The meshes come from `MeshBuilder`. The viewer-wide actions (bounding box, + topography, cross sections, block model) are in `ViewerActionsWidget`. + """ + + FOLD_CONSTRAINT_LABELS = FOLD_CONSTRAINT_LABELS + def __init__(self, parent=None, *, model_manager=None, viewer=None, data_manager=None): super().__init__(parent) self.mainLayout = QVBoxLayout(self) @@ -53,53 +42,12 @@ def __init__(self, parent=None, *, model_manager=None, viewer=None, data_manager self.model_manager = model_manager self.viewer = viewer self.data_manager = data_manager + self.builder = MeshBuilder(model_manager) - # Add buttons - self.addBoundingBoxButton = self._make_tool_button("extents.svg", "Add Model Bounding Box") - self.addFaultSurfacesButton = self._make_custom_icon_tool_button( - "fault.svg", "Add Fault Surfaces" + self.actionsWidget = ViewerActionsWidget( + self, model_manager=model_manager, viewer=viewer, data_manager=data_manager ) - self.addStratigraphicSurfacesButton = self._make_custom_icon_tool_button( - "stratigraphic_column_icon.svg", "Add Stratigraphic Surfaces" - ) - self.addTopographyButton = self._make_tool_button( - "mActionAddRasterLayer.svg", "Add Topography Surface" - ) - self.colourTopographyByStratigraphyCheckBox = QCheckBox( - "Colour Topography by Stratigraphic Column", self - ) - self.colourTopographyByStratigraphyCheckBox.setEnabled(False) - - # Connect buttons to their respective methods - self.addBoundingBoxButton.clicked.connect(self.add_model_bounding_box) - self.addFaultSurfacesButton.clicked.connect(self.add_fault_surfaces) - self.addStratigraphicSurfacesButton.clicked.connect(self.add_stratigraphic_surfaces) - self.addTopographyButton.clicked.connect(self.add_topography_surface) - self.colourTopographyByStratigraphyCheckBox.toggled.connect( - self._on_colour_topography_toggled - ) - - # background task handles for the topography surface (grid sampling and - # stratigraphy evaluation both run off the GUI thread; see background_task.py) - self._topography_thread = None - self._topography_worker = None - self._topography_progress = None - - self._build_cross_section_controls() - self._build_block_model_controls() - - # A single row of icon-only actions, in workflow order, replaces the - # previous stack of full-width text buttons. - actionsRow = QHBoxLayout() - actionsRow.addWidget(self.addBoundingBoxButton) - actionsRow.addWidget(self.addFaultSurfacesButton) - actionsRow.addWidget(self.addStratigraphicSurfacesButton) - actionsRow.addWidget(self.addTopographyButton) - actionsRow.addWidget(self.crossSectionButton) - actionsRow.addWidget(self.blockModelButton) - actionsRow.addStretch(1) - self.mainLayout.addLayout(actionsRow) - self.mainLayout.addWidget(self.colourTopographyByStratigraphyCheckBox) + self.mainLayout.addWidget(self.actionsWidget) # Objects in the viewer are not rebuilt automatically when the model # changes (that can re-solve the whole model after each small edit). @@ -115,27 +63,11 @@ def __init__(self, parent=None, *, model_manager=None, viewer=None, data_manager self.viewer.outOfDateChanged.connect(self._refresh_update_objects_button) self._refresh_update_objects_button() - # background task handles shared by the plane and line cross-section - # actions (only one can run at a time) - self._cross_section_thread = None - self._cross_section_worker = None - self._cross_section_progress = None - self._pending_cross_section_name = None - - # background task handles for the block model - self._block_model_thread = None - self._block_model_worker = None - self._block_model_progress = None - self._pending_block_model_name = None - # Whether the user has hand-edited the plane's origin/normal/size -- - # while False, `update_feature_list` keeps re-syncing those fields to - # the model's current bounding box (see - # `_reset_plane_cross_section_defaults`). `model_manager.model` is - # never None -- it starts out as a placeholder unit-cube model before - # a real bounding box is set -- so this can't be a one-shot flag set - # on first model sight; it has to keep tracking the *current* box - # until the user opts out by typing a value in themselves. - self._plane_defaults_dirty = False + # background task handles for the isosurfaces (only one task at a time) + self._isosurface_thread = None + self._isosurface_worker = None + self._isosurface_progress = None + self._pending_isosurface = None # Populate the feature list self.update_feature_list() @@ -165,231 +97,6 @@ def __init__(self, parent=None, *, model_manager=None, viewer=None, data_manager except Exception: pass - def _make_tool_button(self, theme_icon_name: str, tooltip: str) -> QToolButton: - """Build a small icon-only tool button using a QGIS theme icon, with - the given tooltip standing in for the label text it no longer shows. - """ - return self._build_tool_button(QgsApplication.getThemeIcon(theme_icon_name), tooltip) - - def _make_custom_icon_tool_button(self, icon_filename: str, tooltip: str) -> QToolButton: - """Build a small icon-only tool button using one of this plugin's own - icons (see resources/images), for geological concepts QGIS's own - theme has no dedicated icon for. - """ - icon_path = str(DIR_PLUGIN_ROOT / "resources" / "images" / icon_filename) - return self._build_tool_button(QIcon(icon_path), tooltip) - - def _build_tool_button(self, icon: QIcon, tooltip: str) -> QToolButton: - button = QToolButton(self) - button.setIcon(icon) - button.setIconSize(QSize(22, 22)) - button.setToolTip(tooltip) - button.setAutoRaise(True) - return button - - def _build_cross_section_controls(self): - """Build the "Cross Section" icon button (added to the shared actions - row in __init__), which opens a dialog with a plane (origin + normal) - mode and a line-extrusion (pick a QGIS line layer) mode as separate - tabs. Both are added to the viewer coloured by the stratigraphic - column, reusing the same evaluate-points -> ids -> rgb pipeline as - topography colouring. - """ - self.crossSectionButton = self._make_custom_icon_tool_button( - "cross_section.svg", "Cross Section..." - ) - self.crossSectionButton.clicked.connect(self._show_cross_section_dialog) - - self.crossSectionDialog = QDialog(self) - self.crossSectionDialog.setWindowTitle("Cross Section") - dialogLayout = QVBoxLayout(self.crossSectionDialog) - tabs = QTabWidget(self.crossSectionDialog) - dialogLayout.addWidget(tabs) - - planeTab = QWidget(self.crossSectionDialog) - groupLayout = QVBoxLayout(planeTab) - - groupLayout.addWidget(QLabel("Plane (origin + normal)")) - planeForm = QFormLayout() - - def make_coord_spinbox(default=0.0): - box = QDoubleSpinBox(self) - box.setRange(-1e9, 1e9) - box.setDecimals(2) - box.setValue(default) - return box - - self.crossSectionOriginXSpinBox = make_coord_spinbox() - self.crossSectionOriginYSpinBox = make_coord_spinbox() - self.crossSectionOriginZSpinBox = make_coord_spinbox() - self.crossSectionNormalXSpinBox = make_coord_spinbox(1.0) - self.crossSectionNormalYSpinBox = make_coord_spinbox(0.0) - self.crossSectionNormalZSpinBox = make_coord_spinbox(0.0) - self.crossSectionSizeSpinBox = make_coord_spinbox(1000.0) - self.crossSectionSizeSpinBox.setRange(0.01, 1e9) - self.crossSectionResolutionSpinBox = QSpinBox(self) - self.crossSectionResolutionSpinBox.setRange(2, 500) - self.crossSectionResolutionSpinBox.setValue(50) - - # Track whether the user has hand-edited the plane's geometry so we - # know when to stop re-syncing it to the model's bounding box (see - # `_reset_plane_cross_section_defaults`). - for box in ( - self.crossSectionOriginXSpinBox, - self.crossSectionOriginYSpinBox, - self.crossSectionOriginZSpinBox, - self.crossSectionNormalXSpinBox, - self.crossSectionNormalYSpinBox, - self.crossSectionNormalZSpinBox, - self.crossSectionSizeSpinBox, - ): - box.valueChanged.connect(self._mark_plane_defaults_dirty) - - planeForm.addRow( - "Origin (x, y, z)", - self._hbox( - self.crossSectionOriginXSpinBox, - self.crossSectionOriginYSpinBox, - self.crossSectionOriginZSpinBox, - ), - ) - planeForm.addRow( - "Normal (x, y, z)", - self._hbox( - self.crossSectionNormalXSpinBox, - self.crossSectionNormalYSpinBox, - self.crossSectionNormalZSpinBox, - ), - ) - planeForm.addRow("Size", self.crossSectionSizeSpinBox) - planeForm.addRow("Resolution", self.crossSectionResolutionSpinBox) - groupLayout.addLayout(planeForm) - - self.addPlaneCrossSectionButton = QPushButton("Add Plane Cross Section", self) - self.addPlaneCrossSectionButton.clicked.connect(self.add_plane_cross_section) - groupLayout.addWidget(self.addPlaneCrossSectionButton) - groupLayout.addStretch(1) - tabs.addTab(planeTab, "Plane") - - lineTab = QWidget(self.crossSectionDialog) - lineTabLayout = QVBoxLayout(lineTab) - lineTabLayout.addWidget(QLabel("Extrude a QGIS line layer vertically")) - lineForm = QFormLayout() - - self.crossSectionLineLayerComboBox = QgsMapLayerComboBox(self) - configure_layer_combo( - self.crossSectionLineLayerComboBox, QgsMapLayerProxyModel.Filter.LineLayer - ) - lineForm.addRow("Line layer", self.crossSectionLineLayerComboBox) - - self.crossSectionLineResolutionSpinBox = QSpinBox(self) - self.crossSectionLineResolutionSpinBox.setRange(2, 500) - self.crossSectionLineResolutionSpinBox.setValue(50) - lineForm.addRow("Resolution along line", self.crossSectionLineResolutionSpinBox) - - self.crossSectionLineVerticalResolutionSpinBox = QSpinBox(self) - self.crossSectionLineVerticalResolutionSpinBox.setRange(2, 500) - self.crossSectionLineVerticalResolutionSpinBox.setValue(50) - lineForm.addRow("Vertical resolution", self.crossSectionLineVerticalResolutionSpinBox) - lineTabLayout.addLayout(lineForm) - - self.addLineCrossSectionButton = QPushButton("Add Cross Section from Line", self) - self.addLineCrossSectionButton.clicked.connect(self.add_line_cross_section) - lineTabLayout.addWidget(self.addLineCrossSectionButton) - lineTabLayout.addStretch(1) - tabs.addTab(lineTab, "Line") - - closeButton = QPushButton("Close", self.crossSectionDialog) - closeButton.clicked.connect(self.crossSectionDialog.close) - dialogLayout.addWidget(closeButton) - - def _build_block_model_controls(self): - """Build the "Block Model" icon button (added to the shared actions - row in __init__), which opens a dialog to set the number of blocks - along each axis. The block model fills the model bounding box and is - coloured by the stratigraphic column, like the cross sections. - """ - self.blockModelButton = self._make_custom_icon_tool_button( - "block_model.svg", "Block Model..." - ) - self.blockModelButton.clicked.connect(self._show_block_model_dialog) - self._block_model_resolution_initialised = False - - self.blockModelDialog = QDialog(self) - self.blockModelDialog.setWindowTitle("Block Model") - dialogLayout = QVBoxLayout(self.blockModelDialog) - dialogLayout.addWidget(QLabel("Blocks fill the model bounding box")) - form = QFormLayout() - - def make_cells_spinbox(): - box = QSpinBox(self) - box.setRange(1, 1000) - box.setValue(50) - return box - - self.blockModelNxSpinBox = make_cells_spinbox() - self.blockModelNySpinBox = make_cells_spinbox() - self.blockModelNzSpinBox = make_cells_spinbox() - form.addRow( - "Blocks (x, y, z)", - self._hbox( - self.blockModelNxSpinBox, self.blockModelNySpinBox, self.blockModelNzSpinBox - ), - ) - self.blockModelUseModelResolutionButton = QPushButton("Use Model Resolution", self) - self.blockModelUseModelResolutionButton.setToolTip( - "Set the number of blocks to the model's interpolation grid" - ) - self.blockModelUseModelResolutionButton.clicked.connect(self._reset_block_model_resolution) - form.addRow("", self.blockModelUseModelResolutionButton) - dialogLayout.addLayout(form) - - self.addBlockModelButton = QPushButton("Add Block Model", self) - self.addBlockModelButton.clicked.connect(self.add_block_model) - dialogLayout.addWidget(self.addBlockModelButton) - - closeButton = QPushButton("Close", self.blockModelDialog) - closeButton.clicked.connect(self.blockModelDialog.close) - dialogLayout.addWidget(closeButton) - - def _show_block_model_dialog(self): - # Start from the model's own resolution the first time only, so the - # user's block counts are kept between openings of the dialog. - if not self._block_model_resolution_initialised: - self._reset_block_model_resolution() - self._block_model_resolution_initialised = True - self.blockModelDialog.show() - self.blockModelDialog.raise_() - self.blockModelDialog.activateWindow() - - def _reset_block_model_resolution(self): - if not self.model_manager or self.model_manager.model is None: - return - try: - nsteps = np.asarray(self.model_manager.model.bounding_box.nsteps, dtype=int) - except Exception: - logger.info("Model bounding box has no resolution.") - return - for box, value in zip( - (self.blockModelNxSpinBox, self.blockModelNySpinBox, self.blockModelNzSpinBox), - nsteps, - ): - box.setValue(int(value)) - - def _show_cross_section_dialog(self): - self.crossSectionDialog.show() - self.crossSectionDialog.raise_() - self.crossSectionDialog.activateWindow() - - @staticmethod - def _hbox(*widgets): - container = QWidget() - layout = QHBoxLayout(container) - layout.setContentsMargins(0, 0, 0, 0) - for w in widgets: - layout.addWidget(w) - return container - def update_feature_list(self): if not self.model_manager: return @@ -398,20 +105,7 @@ def update_feature_list(self): for feature in self.model_manager.features(): if not feature.name.startswith('__'): self.add_feature(feature) - if self.model_manager.model is not None and not self._plane_defaults_dirty: - self._reset_plane_cross_section_defaults() - - def _get_vector_scale(self, scale: Optional[Union[float, int]] = None) -> float: - autoscale = 1.0 - if self.model_manager.model is not None: - # automatically scale vector data to be 5% of the bounding box length - autoscale = self.model_manager.model.bounding_box.length.max() * 0.05 - if scale is None: - scale = autoscale - else: - scale = scale * autoscale - - return scale + self.actionsWidget.on_model_updated() def add_feature(self, feature): """Add a feature to the feature list widget. @@ -429,12 +123,13 @@ def contextMenuEvent(self, event): add_scalar_action = menu.addAction("Add Scalar Field") add_surface_action = menu.addAction("Add Surface") + add_isosurface_action = menu.addAction("Add Isosurface...") add_vector_action = menu.addAction("Add Vector Field") add_data_action = menu.addAction("Add Data") selected_items = self.treeWidget.selectedItems() fold_actions = {} - if selected_items and self._get_fold(selected_items[0].text(0)) is not None: + if selected_items and self.builder.get_fold(selected_items[0].text(0)) is not None: fold_menu = menu.addMenu("Add Fold Constraints") for constraint, label in self.FOLD_CONSTRAINT_LABELS.items(): fold_actions[fold_menu.addAction(label)] = constraint @@ -463,40 +158,35 @@ def contextMenuEvent(self, event): self.add_scalar_field(feature_name) elif action == add_surface_action: self.add_surface(feature_name) + elif action == add_isosurface_action: + self.add_isosurfaces(feature_name) elif action == add_vector_action: self.add_vector_field(feature_name) elif action == add_data_action: self.add_data(feature_name) - def _build_scalar_field(self, feature_name): - return self.model_manager.model[feature_name].scalar_field().vtk() + # -- meshes of one feature ------------------------------------------- def add_scalar_field(self, feature_name): self.viewer.add_mesh_object( - self._build_scalar_field(feature_name), + self.builder.scalar_field(feature_name), name=f'{feature_name}_scalar_field', source_feature=feature_name, source_type='feature_scalar', ) def add_surface(self, feature_name): - surfaces = self.model_manager.model[feature_name].surfaces() + surfaces = self.builder.feature_surfaces(feature_name) for i, surface in enumerate(surfaces): # ensure unique names for multiple surfaces per feature mesh_name = f'{feature_name}_surface' if i == 0 else f'{feature_name}_surface_{i+1}' # try to determine an isovalue for this surface (may be an attribute or encoded in name) - isovalue = None - try: - isovalue = getattr(surface, 'isovalue', None) - except Exception: - isovalue = None + isovalue = getattr(surface, 'isovalue', None) if isovalue is None: # attempt to parse trailing numeric suffix in the surface name try: - parts = str(surface.name).rsplit('_', 1) - if len(parts) == 2: - isovalue = float(parts[1]) - except Exception: + isovalue = float(str(surface.name).rsplit('_', 1)[1]) + except (IndexError, ValueError): isovalue = None self.viewer.add_mesh_object( @@ -507,84 +197,14 @@ def add_surface(self, feature_name): isovalue=isovalue, ) - def _build_feature_surface(self, feature_name, isovalue): - feature = self.model_manager.model[feature_name] - surfaces = feature.surfaces(isovalue) if isovalue is not None else feature.surfaces() - if not surfaces: - raise ValueError(f"Feature '{feature_name}' has no surface at {isovalue}") - return surfaces[0].vtk() - - def _build_vector_field(self, feature_name): - vector_field = self.model_manager.model[feature_name].vector_field() - return vector_field.vtk(scale=self._get_vector_scale()) - def add_vector_field(self, feature_name): self.viewer.add_mesh_object( - self._build_vector_field(feature_name), + self.builder.vector_field(feature_name), name=f'{feature_name}_vector_field', source_feature=feature_name, source_type='feature_vector', ) - # Vectors used by the fold constraints in the DiscreteFoldInterpolator. - # direction: gradient . direction = 0 (fold orientation constraint) - # axis: gradient . axis = 0 (fold axis constraint) - # norm: gradient . norm = fold_norm (fold normalisation constraint) - FOLD_CONSTRAINT_LABELS = { - 'direction': "Fold Direction", - 'axis': "Fold Axis", - 'norm': "Fold Norm Direction", - } - FOLD_CONSTRAINT_COLOURS = { - 'direction': (0.2, 0.4, 1.0), - 'axis': (1.0, 0.2, 0.2), - 'norm': (0.1, 0.8, 0.2), - } - - def _get_fold(self, feature_name): - try: - feature = self.model_manager.model[feature_name] - except Exception: - return None - fold = getattr(feature, 'fold', None) - if fold is None: - fold = getattr(getattr(feature, 'builder', None), 'fold', None) - return fold - - def _build_fold_constraint(self, feature_name, constraint): - """Return the vectors of one fold constraint of a folded feature as - a mesh, or None if the feature is not folded or the vectors are not - defined. - - The vectors are evaluated on the model grid, in the same way as the - interpolator evaluates them on the element barycentres. - """ - fold = self._get_fold(feature_name) - if fold is None: - logger.info(f"Feature {feature_name} is not folded") - return None - feature = self.model_manager.model[feature_name] - # make sure the fold rotation angles are fitted - feature.builder.up_to_date() - bounding_box = self.model_manager.model.bounding_box - points = bounding_box.reproject(bounding_box.cell_centres()) - direction, axis, norm = fold.get_deformed_orientation(points) - vectors = np.array({'direction': direction, 'axis': axis, 'norm': norm}[constraint]) - if vectors.shape != points.shape: - # some fold settings (e.g. invert_norm) return a subset of the vectors - logger.warning( - f"Fold {constraint} vectors do not match the grid points ({vectors.shape} != {points.shape})" - ) - return None - length = np.linalg.norm(vectors, axis=1) - mask = np.all(np.isfinite(vectors), axis=1) & (length > 0) - if not np.any(mask): - logger.warning(f"Fold {constraint} vectors for {feature_name} are not defined") - return None - vectors = vectors[mask] / length[mask, None] - vector_points = VectorPoints(points[mask], vectors, f'{feature_name}_fold_{constraint}') - return vector_points.vtk(scale=self._get_vector_scale()) - def add_fold_constraint(self, feature_name, constraint): """Add the vectors of one fold constraint of a folded feature to the viewer. @@ -595,639 +215,140 @@ def add_fold_constraint(self, feature_name, constraint): constraint : str One of 'direction', 'axis' or 'norm'. """ - mesh = self._build_fold_constraint(feature_name, constraint) + mesh = self.builder.fold_constraint(feature_name, constraint) if mesh is None: return self.viewer.add_mesh_object( mesh, name=f'{feature_name}_fold_{constraint}', - color=self.FOLD_CONSTRAINT_COLOURS[constraint], + color=FOLD_CONSTRAINT_COLOURS[constraint], source_feature=feature_name, source_type=f'fold_constraint_{constraint}', ) - def _build_data_meshes(self, feature_name): - """Return (name, mesh, source_type) for each data set of a feature.""" - meshes = [] - for d in self.model_manager.model[feature_name].get_data(): - d.locations = self.model_manager.model.rescale(d.locations) - if issubclass(type(d), VectorPoints): - # tolerance is None means all points are shown - meshes.append( - ( - f'{feature_name}_{d.name}_points', - d.vtk(scale=self._get_vector_scale(), tolerance=None), - 'feature_points', - ) - ) - else: - meshes.append((f'{feature_name}_{d.name}', d.vtk(), 'feature_data')) - return meshes - def add_data(self, feature_name): - for name, mesh, source_type in self._build_data_meshes(feature_name): + for name, mesh, source_type in self.builder.data_meshes(feature_name): self.viewer.add_mesh_object( mesh, name=name, source_feature=feature_name, source_type=source_type ) logger.info(f"Adding data to feature: {feature_name}") - def _build_bounding_box(self): - return self.model_manager.model.bounding_box.vtk().outline() - - def add_model_bounding_box(self): - if not self.model_manager: - logger.info("Model manager is not set.") - return - self.viewer.add_mesh_object( - self._build_bounding_box(), - name='model_bounding_box', - source_feature='__model__', - source_type='bounding_box', - ) - logger.info("Adding model bounding box...") - - def add_fault_surfaces(self): - if not self.model_manager: - logger.info("Model manager is not set.") - return - self.model_manager.update_all_features(subset='faults') - fault_surfaces = self.model_manager.model.get_fault_surfaces() - for surface in fault_surfaces: - self.viewer.add_mesh_object( - surface.vtk(), - name=f'fault_surface_{surface.name}', - source_feature=surface.name, - source_type='fault_surface', - isovalue=0.0, - ) - logger.info("Adding fault surfaces...") - - def _find_fault_surface(self, name): - for surface in self.model_manager.model.get_fault_surfaces(): - if str(surface.name) == str(name): - return surface - raise ValueError(f"Fault surface '{name}' is not in the model") - - def _find_stratigraphic_surface(self, name): - for surface in self.model_manager.model.get_stratigraphic_surfaces(): - if str(surface.name) == str(name): - return surface - raise ValueError(f"Stratigraphic surface '{name}' is not in the model") - - def add_stratigraphic_surfaces(self): - if not self.model_manager: - logger.info("Model manager is not set.") - return - stratigraphic_surfaces = self.model_manager.model.get_stratigraphic_surfaces() - - for surface in stratigraphic_surfaces: - mesh = surface.vtk() - if mesh.n_points == 0: - # A unit with no digitised data of its own (e.g. an - # undigitised placeholder like "Top") can have no - # constrained geometry anywhere in the model, so its - # isovalue may not intersect the solved field at all -- - # pyvista refuses to plot an empty mesh, so skip it rather - # than crashing every surface after it in this loop. - logger.info(f"Skipping '{surface.name}': isosurface has no geometry.") - continue - self.viewer.add_mesh_object( - mesh, - name=surface.name, - color=surface.colour, - source_feature=surface.name, - isovalue=np.mean(surface.values), - source_type='stratigraphic_surface', - ) - - def add_topography_surface(self): - """Sample the current DEM across the model extent and add it to the - viewer as a surface, shaded by elevation. + # -- isosurfaces ----------------------------------------------------- - DEM sampling runs on a background thread (it calls into the QGIS - raster provider once per grid point) so the UI doesn't freeze; see - `_on_topography_grid_finished` for where the mesh is actually added. - """ - if not self.model_manager: - logger.info("Model manager is not set.") - return - if self.model_manager.model is None: - logger.info("No model available to build a topography surface.") + def add_isosurfaces(self, feature_name): + """Ask for the values, and add one isosurface object for each value.""" + if self.model_manager is None or self.model_manager.model is None: return - - def target(progress_callback): - progress_callback("Sampling DEM...") - return self.model_manager.sample_dem_grid() - - self.addTopographyButton.setEnabled(False) - self._topography_thread, self._topography_worker, self._topography_progress = ( - start_background_task( - self, - target, - title="Topography", - initial_label="Sampling DEM...", - on_progress=self._on_topography_progress, - on_finished=self._on_topography_grid_finished, - on_error=self._on_topography_error, - ) - ) - - def _on_topography_progress(self, message): + low = high = None try: - self._topography_progress.setLabelText(message) + low, high = self.builder.scalar_range(feature_name) except Exception: - pass - - def _on_topography_grid_finished(self, result): - finish_background_task( - self._topography_thread, self._topography_worker, self._topography_progress - ) - self.addTopographyButton.setEnabled(True) - - xx, yy, zz = result - mesh = pv.StructuredGrid(xx, yy, zz) - mesh['Elevation'] = mesh.points[:, 2] - self.viewer.add_mesh_object( - mesh, - name='topography_surface', - scalars='Elevation', - cmap='terrain', - show_scalar_bar=True, - source_type='topography_surface', - metadata={'coloured': False}, - ) - self.colourTopographyByStratigraphyCheckBox.setEnabled(True) - if self.colourTopographyByStratigraphyCheckBox.isChecked(): - self._colour_topography_surface() - logger.info("Adding topography surface...") - - def _on_topography_error(self, traceback_text): - finish_background_task( - self._topography_thread, self._topography_worker, self._topography_progress - ) - self.addTopographyButton.setEnabled(True) - self.colourTopographyByStratigraphyCheckBox.setEnabled( - 'topography_surface' in getattr(self.viewer, 'meshes', {}) - ) - logger.error(f"Failed to build topography surface: {traceback_text}") - - def _on_colour_topography_toggled(self, checked): - if 'topography_surface' not in getattr(self.viewer, 'meshes', {}): + logger.info(f"Cannot find the range of the scalar field of {feature_name}") + dialog = IsosurfaceDialog(feature_name, low, high, self) + if dialog.exec() != QDialog.DialogCode.Accepted: return - if checked: - self._colour_topography_surface() - else: - mesh = self.viewer.meshes['topography_surface']['mesh'] - self.viewer.add_mesh_object( - mesh, - name='topography_surface', - scalars='Elevation', - cmap='terrain', - show_scalar_bar=True, - source_type='topography_surface', - metadata={'coloured': False}, - ) - - def _colour_topography_surface(self): - """Evaluate the stratigraphic unit at each topography point and - recolour the surface using the stratigraphic column's unit colours. + self._start_isosurface_task(feature_name, dialog.values()) - Evaluating the full model at every grid point can be slow, so this - also runs on a background thread (see `add_topography_surface`). - """ - if not self.model_manager: - logger.info("Model manager is not set.") + def change_isovalue(self, object_name, value): + """Build the isosurface object `object_name` again at a new value.""" + obj = self.viewer.registry.get(object_name) + if obj is None or not obj.is_isosurface or obj.source_feature is None: return - missing = self.model_manager.get_units_without_colour() - if missing: - QMessageBox.warning( - self, - "Missing unit colour", - "Cannot colour the topography by stratigraphy. These units have no " - "valid colour in the stratigraphic column:\n\n" - + "\n".join(missing) - + "\n\nSet a colour for each unit and try again.", - ) - self.colourTopographyByStratigraphyCheckBox.blockSignals(True) - self.colourTopographyByStratigraphyCheckBox.setChecked(False) - self.colourTopographyByStratigraphyCheckBox.blockSignals(False) - return - mesh = self.viewer.meshes['topography_surface']['mesh'] - - def target(progress_callback): - progress_callback("Evaluating stratigraphy on topography...") - ids = self.model_manager.evaluate_stratigraphy_on_points(mesh.points) - colours = self.model_manager.get_stratigraphic_column_colours() - return ids, colours - - self.colourTopographyByStratigraphyCheckBox.setEnabled(False) - self._topography_thread, self._topography_worker, self._topography_progress = ( - start_background_task( - self, - target, - title="Topography", - initial_label="Evaluating stratigraphy on topography...", - on_progress=self._on_topography_progress, - on_finished=self._on_topography_colour_finished, - on_error=self._on_topography_error, - ) - ) - - def _on_topography_colour_finished(self, result): - finish_background_task( - self._topography_thread, self._topography_worker, self._topography_progress - ) - self.colourTopographyByStratigraphyCheckBox.setEnabled(True) - - ids, colours = result - mesh = self.viewer.meshes['topography_surface']['mesh'] - self._set_stratigraphy_arrays(mesh.point_data, ids, colours) - metadata = {'coloured': True, **self._stratigraphy_metadata(colours)} - self.viewer.add_mesh_object( - mesh, - name='topography_surface', - scalars='colour', - rgb=True, - show_scalar_bar=False, - # Directional lighting shades the same colour differently depending - # on the DEM's local slope, which makes a unit's colour on the - # topography look like it doesn't match that same unit's colour on - # the (much flatter) stratigraphic surfaces. Flat/unlit shading - # keeps it a true, direct colour-for-colour match. - lighting=False, - source_type='topography_surface', - metadata=metadata, - ) - logger.info("Coloured topography surface by stratigraphic column.") - - def _mark_plane_defaults_dirty(self, _value=None): - self._plane_defaults_dirty = True - - def _reset_plane_cross_section_defaults(self): - """Point the plane cross-section controls at the model: origin at the - bounding box centre, normal along X, size the bounding box diagonal. - - Called on every model update (see `update_feature_list`), not just - once, since `model_manager.model` -- and so its bounding box -- exists - (as a unit-cube placeholder) before the user has set a real one. - Values are set with signals blocked so this doesn't itself trip - `_mark_plane_defaults_dirty` and disable future re-syncing. - """ - bb = self.model_manager.model.bounding_box - origin = np.asarray(bb.origin, dtype=float) - maximum = np.asarray(bb.maximum, dtype=float) - centre = (origin + maximum) / 2.0 - diagonal = float(np.linalg.norm(maximum - origin)) - for box, value in ( - (self.crossSectionOriginXSpinBox, float(centre[0])), - (self.crossSectionOriginYSpinBox, float(centre[1])), - (self.crossSectionOriginZSpinBox, float(centre[2])), - (self.crossSectionNormalXSpinBox, 1.0), - (self.crossSectionNormalYSpinBox, 0.0), - (self.crossSectionNormalZSpinBox, 0.0), - (self.crossSectionSizeSpinBox, diagonal if diagonal > 0 else 1000.0), - ): - box.blockSignals(True) - box.setValue(value) - box.blockSignals(False) - - def _unique_cross_section_name(self, base: str) -> str: - if base not in self.viewer.meshes: - return base - i = 2 - while f'{base}_{i}' in self.viewer.meshes: - i += 1 - return f'{base}_{i}' - - def add_plane_cross_section(self): - """Build a plane (origin + normal) cross section and colour it by the - stratigraphic column. - - Building the plane is cheap; evaluating the stratigraphic column at - every plane point is not, so both run on a background thread (see - `add_topography_surface` for the same pattern). - """ - if not self.model_manager: - logger.info("Model manager is not set.") - return - if self.model_manager.model is None: - logger.info("No model available to build a cross section.") - return - - origin = ( - self.crossSectionOriginXSpinBox.value(), - self.crossSectionOriginYSpinBox.value(), - self.crossSectionOriginZSpinBox.value(), - ) - normal = ( - self.crossSectionNormalXSpinBox.value(), - self.crossSectionNormalYSpinBox.value(), - self.crossSectionNormalZSpinBox.value(), - ) - if np.allclose(normal, 0.0): - logger.info("Cross section normal cannot be the zero vector.") - return - size = self.crossSectionSizeSpinBox.value() - resolution = self.crossSectionResolutionSpinBox.value() - - def target(progress_callback): - progress_callback("Building cross section plane...") - mesh = build_plane_mesh(origin, normal, size, resolution) - progress_callback("Evaluating stratigraphy on cross section...") - ids = self.model_manager.evaluate_stratigraphy_on_points(mesh.points) - colours = self.model_manager.get_stratigraphic_column_colours() - return mesh, ids, colours - - self._pending_cross_section_name = self._unique_cross_section_name('cross_section_plane') - self.addPlaneCrossSectionButton.setEnabled(False) - self._cross_section_thread, self._cross_section_worker, self._cross_section_progress = ( - start_background_task( - self, - target, - title="Cross Section", - initial_label="Building cross section plane...", - on_progress=self._on_cross_section_progress, - on_finished=self._on_plane_cross_section_finished, - on_error=self._on_cross_section_error, - ) - ) - - def _extract_line_xy(self, layer) -> Optional[np.ndarray]: - """Return an (M, 2) array of ordered vertices for `layer`'s first - selected feature (or first feature, if nothing is selected), - reprojected into the model's CRS if one is available. - """ - target_crs = None - if self.data_manager is not None: - try: - target_crs = self.data_manager.get_model_crs() - except Exception: - target_crs = None - return line_xy_from_layer(layer, target_crs) + self._start_isosurface_task(obj.source_feature, [value], existing_name=object_name) - def add_line_cross_section(self): - """Extrude the selected QGIS line layer vertically across the model's - Z range, colouring the resulting cross section by the stratigraphic - column. See `add_plane_cross_section` for the plane-based equivalent. - """ - if not self.model_manager: - logger.info("Model manager is not set.") - return - if self.model_manager.model is None: - logger.info("No model available to build a cross section.") - return - layer = self.crossSectionLineLayerComboBox.currentLayer() - if layer is None: - logger.info("No line layer selected for cross section.") + def _start_isosurface_task(self, feature_name, values, existing_name=None): + if self._isosurface_thread is not None: + push_info("Isosurfaces", "Wait for the isosurfaces that are being built.") return - coords_xy = self._extract_line_xy(layer) - if coords_xy is None: - logger.info("Selected layer has no usable line geometry for a cross section.") + if not values: return - bb = self.model_manager.model.bounding_box - z_min, z_max = float(bb.origin[2]), float(bb.maximum[2]) - resolution = self.crossSectionLineResolutionSpinBox.value() - z_resolution = self.crossSectionLineVerticalResolutionSpinBox.value() - def target(progress_callback): - progress_callback("Building cross section from line...") - mesh = build_line_extrusion_mesh( - coords_xy, z_min, z_max, resolution=resolution, z_resolution=z_resolution - ) - progress_callback("Evaluating stratigraphy on cross section...") - ids = self.model_manager.evaluate_stratigraphy_on_points(mesh.points) - colours = self.model_manager.get_stratigraphic_column_colours() - return mesh, ids, colours + results = [] + for i, value in enumerate(values): + progress_callback(f"Isosurface {value:.4g} ({i + 1} of {len(values)})...") + try: + results.append((value, self.builder.isosurface(feature_name, value), None)) + except Exception as e: + results.append((value, None, str(e))) + return results - self._pending_cross_section_name = self._unique_cross_section_name( - f'cross_section_line_{layer.name()}' - ) - self.addLineCrossSectionButton.setEnabled(False) - self._cross_section_thread, self._cross_section_worker, self._cross_section_progress = ( + self._pending_isosurface = (feature_name, existing_name) + self._isosurface_thread, self._isosurface_worker, self._isosurface_progress = ( start_background_task( self, target, - title="Cross Section", - initial_label="Building cross section from line...", - on_progress=self._on_cross_section_progress, - on_finished=self._on_line_cross_section_finished, - on_error=self._on_cross_section_error, + title="Isosurfaces", + initial_label="Building isosurfaces...", + on_progress=self._on_isosurface_progress, + on_finished=self._on_isosurfaces_finished, + on_error=self._on_isosurfaces_error, ) ) - def _on_cross_section_progress(self, message): + def _on_isosurface_progress(self, message): try: - self._cross_section_progress.setLabelText(message) + self._isosurface_progress.setLabelText(message) except Exception: pass - def _add_cross_section_mesh(self, result, source_type): - mesh, ids, colours = result - self._set_stratigraphy_arrays(mesh.point_data, ids, colours) - self.viewer.add_mesh_object( - mesh, - name=self._pending_cross_section_name, - scalars='colour', - rgb=True, - show_scalar_bar=False, - # see `_on_topography_colour_finished` for why cross sections use - # flat/unlit shading: it keeps a unit's colour the same regardless - # of how the section plane/line happens to be oriented. - lighting=False, - source_type=source_type, - metadata=self._stratigraphy_metadata(colours), - ) - logger.info(f"Added cross section '{self._pending_cross_section_name}'.") - - def _on_plane_cross_section_finished(self, result): - finish_background_task( - self._cross_section_thread, self._cross_section_worker, self._cross_section_progress - ) - self.addPlaneCrossSectionButton.setEnabled(True) - self._add_cross_section_mesh(result, 'cross_section_plane') - - def _on_line_cross_section_finished(self, result): - finish_background_task( - self._cross_section_thread, self._cross_section_worker, self._cross_section_progress - ) - self.addLineCrossSectionButton.setEnabled(True) - self._add_cross_section_mesh(result, 'cross_section_line') - - def _on_cross_section_error(self, traceback_text): + def _finish_isosurface_task(self): finish_background_task( - self._cross_section_thread, self._cross_section_worker, self._cross_section_progress - ) - self.addPlaneCrossSectionButton.setEnabled(True) - self.addLineCrossSectionButton.setEnabled(True) - logger.error(f"Failed to build cross section: {traceback_text}") - - def add_block_model(self): - """Fill the model bounding box with blocks and colour each block by - the stratigraphic unit at its centre. - - Evaluating the model at every block centre can be slow, so this runs - on a background thread (see `add_topography_surface`). - """ - if not self.model_manager: - logger.info("Model manager is not set.") - return - if self.model_manager.model is None: - logger.info("No model available to build a block model.") - return - missing = self.model_manager.get_units_without_colour() - if missing: - QMessageBox.warning( - self, - "Missing unit colour", - "Cannot colour the block model by stratigraphy. These units have no " - "valid colour in the stratigraphic column:\n\n" - + "\n".join(missing) - + "\n\nSet a colour for each unit and try again.", + self._isosurface_thread, self._isosurface_worker, self._isosurface_progress + ) + self._isosurface_thread = None + self._isosurface_worker = None + self._isosurface_progress = None + + def _on_isosurfaces_finished(self, results): + self._finish_isosurface_task() + feature_name, existing_name = self._pending_isosurface + self._pending_isosurface = None + added, failed = [], [] + for value, mesh, error in results: + if mesh is None: + failed.append(value) + continue + if existing_name is not None: + if existing_name not in self.viewer.registry: + continue # removed from the viewer while the surface was built + self.viewer.replace_mesh_object( + existing_name, mesh, None, isovalue=float(value), out_of_date=False + ) + else: + name = self.viewer.registry.unique_name(isosurface_name(feature_name, value)) + self.viewer.add_mesh_object( + mesh, + name=name, + source_feature=feature_name, + source_type='feature_isosurface', + isovalue=float(value), + ) + added.append(value) + self._render_viewer() + if failed: + values_text = ", ".join(f"{v:.4g}" for v in failed) + push_warning( + "Isosurfaces", + f"No surface for {feature_name} at {values_text}. " + "The value may be outside the range of the scalar field.", ) - return - - bb = self.model_manager.model.bounding_box - origin = np.asarray(bb.origin, dtype=float) - maximum = np.asarray(bb.maximum, dtype=float) - ncells = ( - self.blockModelNxSpinBox.value(), - self.blockModelNySpinBox.value(), - self.blockModelNzSpinBox.value(), - ) - - def target(progress_callback): - progress_callback("Building block model grid...") - mesh = build_block_model_mesh(origin, maximum, ncells) - progress_callback("Evaluating stratigraphy on block model...") - ids = self.model_manager.evaluate_stratigraphy_on_points(mesh.cell_centers().points) - colours = self.model_manager.get_stratigraphic_column_colours() - return mesh, ids, colours + elif added: + logger.info(f"Added {len(added)} isosurface(s) of {feature_name}") - self._pending_block_model_name = self._unique_cross_section_name('block_model') - self.addBlockModelButton.setEnabled(False) - self._block_model_thread, self._block_model_worker, self._block_model_progress = ( - start_background_task( - self, - target, - title="Block Model", - initial_label="Building block model grid...", - on_progress=self._on_block_model_progress, - on_finished=self._on_block_model_finished, - on_error=self._on_block_model_error, - ) - ) + def _on_isosurfaces_error(self, traceback_text): + self._finish_isosurface_task() + self._pending_isosurface = None + logger.error(f"Failed to build isosurfaces: {traceback_text}") + push_warning("Isosurfaces", "Cannot build the isosurfaces. See the log for details.") - def _on_block_model_progress(self, message): + def _render_viewer(self): try: - self._block_model_progress.setLabelText(message) + self.viewer.render() except Exception: pass - def _on_block_model_finished(self, result): - finish_background_task( - self._block_model_thread, self._block_model_worker, self._block_model_progress - ) - self.addBlockModelButton.setEnabled(True) - - mesh, ids, colours = result - self._set_stratigraphy_arrays(mesh.cell_data, ids, colours) - self.viewer.add_mesh_object( - mesh, - name=self._pending_block_model_name, - scalars='colour', - rgb=True, - show_scalar_bar=False, - show_edges=False, - source_type='block_model', - metadata={ - 'ncells': [int(n) for n in np.asarray(mesh.dimensions) - 1], - **self._stratigraphy_metadata(colours), - }, - ) - logger.info(f"Added block model '{self._pending_block_model_name}'.") - - def _on_block_model_error(self, traceback_text): - finish_background_task( - self._block_model_thread, self._block_model_worker, self._block_model_progress - ) - self.addBlockModelButton.setEnabled(True) - logger.error(f"Failed to build block model: {traceback_text}") - - @staticmethod - def _set_stratigraphy_arrays(data, ids, colours): - """Store the unit ids and their colours as the 'stratigraphy' and - 'colour' arrays of `data` (a mesh's point_data or cell_data). - - Named arrays (not an RGB array passed straight to the viewer) let the - object properties panel use the unit ids, and let - `update_out_of_date_objects` add the object again with the same - viewer settings. - """ - data['stratigraphy'] = np.asarray(ids) - data['colour'] = stratigraphic_ids_to_rgb(ids, colours) - - def _stratigraphy_metadata(self, colours): - """Unit names and colours, indexed by unit id, for the unit check - boxes of the object properties panel.""" - return { - 'unit_names': list(self.model_manager.get_stratigraphic_unit_names()), - 'unit_colours': list(colours), - } - - def _colour_by_stratigraphy(self, points, data, metadata): - """Colour a mesh by the stratigraphic unit at `points`, and store - the unit names and colours in `metadata`.""" - ids = self.model_manager.evaluate_stratigraphy_on_points(points) - colours = self.model_manager.get_stratigraphic_column_colours() - self._set_stratigraphy_arrays(data, ids, colours) - metadata.update(self._stratigraphy_metadata(colours)) - - # Viewer objects that `_rebuild_object` can build again from the model, - # by `source_type` (fold constraints use the 'fold_constraint_' prefix). - REBUILDABLE_SOURCE_TYPES = { - 'feature_scalar', - 'feature_surface', - 'feature_vector', - 'feature_vectors', - 'feature_points', - 'feature_data', - 'bounding_box', - 'fault_surface', - 'stratigraphic_surface', - 'cross_section_plane', - 'cross_section_line', - 'block_model', - 'topography_surface', - } - # Source types that are coloured by the stratigraphic column - STRATIGRAPHY_COLOURED_SOURCE_TYPES = { - 'cross_section_plane', - 'cross_section_line', - 'block_model', - } - # Source types that are built from one feature of the model - FEATURE_SOURCE_TYPES = { - 'feature_scalar', - 'feature_surface', - 'feature_vector', - 'feature_vectors', - 'feature_points', - 'feature_data', - } - - def _is_rebuildable(self, meta) -> bool: - source_type = meta.get('source_type') or '' - return source_type in self.REBUILDABLE_SOURCE_TYPES or source_type.startswith( - 'fold_constraint_' - ) - - def _uses_stratigraphy_colours(self, spec) -> bool: - if spec['source_type'] in self.STRATIGRAPHY_COLOURED_SOURCE_TYPES: - return True - return spec['source_type'] == 'topography_surface' and bool( - spec['metadata'].get('coloured') - ) + # -- update out-of-date objects -------------------------------------- def _on_model_update(self, event: str, *args): """Mark the viewer objects built from the model as out of date. @@ -1240,9 +361,7 @@ def _on_model_update(self, event: str, *args): return if event not in ('model_updated', 'feature_updated'): return - names = [ - name for name, meta in list(self.viewer.meshes.items()) if self._is_rebuildable(meta) - ] + names = [obj.name for obj in self.viewer.registry.rebuildable()] self.viewer.set_out_of_date(names, True) def _refresh_update_objects_button(self): @@ -1275,26 +394,25 @@ def update_out_of_date_objects(self): if self.model_manager.model is None: logger.info("No model available to update the viewer objects.") return - names = self.viewer.out_of_date_objects() - if not names: + objects = [self.viewer.registry.get(n) for n in self.viewer.out_of_date_objects()] + if not objects: return specs = [] - for name in names: - meta = self.viewer.meshes[name] + for obj in objects: spec = { - 'name': name, - 'source_type': meta.get('source_type') or '', - 'source_feature': meta.get('source_feature'), - 'isovalue': meta.get('isovalue'), - 'metadata': dict(meta.get('metadata') or {}), + 'name': obj.name, + 'source_type': obj.source_type_text, + 'source_feature': obj.source_feature, + 'isovalue': obj.isovalue, + 'metadata': dict(obj.metadata), } if spec['source_type'] in ('cross_section_plane', 'cross_section_line'): # the section geometry does not change; copy it so the # background thread does not change the mesh on screen - spec['mesh'] = meta['mesh'].copy() + spec['mesh'] = obj.mesh.copy() specs.append(spec) - if any(self._uses_stratigraphy_colours(spec) for spec in specs): + if any(obj.uses_stratigraphy_colours for obj in objects): missing = self.model_manager.get_units_without_colour() if missing: QMessageBox.warning( @@ -1312,7 +430,7 @@ def target(progress_callback): for i, spec in enumerate(specs): progress_callback(f"Updating {spec['name']} ({i + 1} of {len(specs)})...") try: - mesh, overrides = self._rebuild_object(spec) + mesh, overrides = self.builder.rebuild(spec) results.append((spec['name'], mesh, overrides, spec['metadata'], None)) except Exception as e: results.append((spec['name'], None, {}, None, str(e))) @@ -1331,69 +449,6 @@ def target(progress_callback): ) self._refresh_update_objects_button() - def _rebuild_object(self, spec): - """Build one viewer object again from the current model. - - Runs on a background thread, so it must not touch the viewer. - Returns (mesh, overrides), where overrides are viewer settings that - come from the model (e.g. a unit colour). `spec['metadata']` is - updated with new values from the model (e.g. the unit names). Raises - if the object cannot be built. - """ - source_type = spec['source_type'] - feature_name = spec['source_feature'] - metadata = spec['metadata'] - model = self.model_manager.model - - if source_type in self.FEATURE_SOURCE_TYPES or source_type.startswith('fold_constraint_'): - if feature_name is None or model.get_feature_by_name(feature_name) is None: - raise ValueError(f"Feature '{feature_name}' is not in the model") - - overrides = {} - if source_type == 'feature_scalar': - mesh = self._build_scalar_field(feature_name) - elif source_type == 'feature_surface': - mesh = self._build_feature_surface(feature_name, spec['isovalue']) - elif source_type in ('feature_vector', 'feature_vectors'): - mesh = self._build_vector_field(feature_name) - elif source_type.startswith('fold_constraint_'): - constraint = source_type[len('fold_constraint_') :] - mesh = self._build_fold_constraint(feature_name, constraint) - if mesh is None: - raise ValueError("The fold constraint vectors are not defined") - elif source_type in ('feature_points', 'feature_data'): - meshes = {name: m for name, m, _ in self._build_data_meshes(feature_name)} - if spec['name'] not in meshes: - raise ValueError(f"Feature '{feature_name}' has no data for this object") - mesh = meshes[spec['name']] - elif source_type == 'bounding_box': - mesh = self._build_bounding_box() - elif source_type == 'fault_surface': - mesh = self._find_fault_surface(feature_name).vtk() - elif source_type == 'stratigraphic_surface': - surface = self._find_stratigraphic_surface(feature_name) - mesh = surface.vtk() - overrides['color'] = surface.colour - elif source_type in ('cross_section_plane', 'cross_section_line'): - mesh = spec['mesh'] - self._colour_by_stratigraphy(mesh.points, mesh.point_data, metadata) - elif source_type == 'block_model': - bb = model.bounding_box - mesh = build_block_model_mesh(bb.origin, bb.maximum, metadata['ncells']) - self._colour_by_stratigraphy(mesh.cell_centers().points, mesh.cell_data, metadata) - elif source_type == 'topography_surface': - xx, yy, zz = self.model_manager.sample_dem_grid() - mesh = pv.StructuredGrid(xx, yy, zz) - mesh['Elevation'] = mesh.points[:, 2] - if metadata.get('coloured'): - self._colour_by_stratigraphy(mesh.points, mesh.point_data, metadata) - else: - raise ValueError(f"Cannot update objects of type '{source_type}'") - - if getattr(mesh, 'n_points', 1) == 0: - raise ValueError("The object has no geometry in the current model") - return mesh, overrides - def _on_update_objects_progress(self, message): try: self._update_objects_progress.setLabelText(message) @@ -1412,8 +467,7 @@ def _on_update_objects_finished(self, results): self._finish_update_objects_task() failed = [] for name, mesh, overrides, metadata, error in results: - entry = self.viewer.meshes.get(name) - if entry is None: + if name not in self.viewer.registry: # removed from the viewer while the update ran continue if error is not None: @@ -1426,10 +480,7 @@ def _on_update_objects_finished(self, results): except Exception as e: logger.exception(f"Cannot add updated object '{name}' to the viewer") failed.append(f"{name}: {e}") - try: - self.viewer.render() - except Exception: - pass + self._render_viewer() self._refresh_update_objects_button() if failed: logger.warning("Cannot update viewer objects:\n" + "\n".join(failed)) diff --git a/loopstructural/gui/visualisation/geometry_object.py b/loopstructural/gui/visualisation/geometry_object.py deleted file mode 100644 index 15953db7..00000000 --- a/loopstructural/gui/visualisation/geometry_object.py +++ /dev/null @@ -1,15 +0,0 @@ -class GeometryObject: - def __init__(self, name, object, options=None): - self.name = name - self.object = object - self.options = options or {} - - def export(self): - # Placeholder for export functionality - print(f"Exporting {self.name} of type {self.object}") - - def set_option(self, key, value): - self.options[key] = value - - def get_option(self, key): - return self.options.get(key, None) diff --git a/loopstructural/gui/visualisation/isosurface_dialog.py b/loopstructural/gui/visualisation/isosurface_dialog.py new file mode 100644 index 00000000..dba0e08f --- /dev/null +++ b/loopstructural/gui/visualisation/isosurface_dialog.py @@ -0,0 +1,116 @@ +"""The dialog to choose the values of the isosurfaces of a model feature.""" + +from typing import List, Optional + +from qgis.PyQt.QtWidgets import ( + QDialog, + QDialogButtonBox, + QDoubleSpinBox, + QFormLayout, + QLabel, + QLineEdit, + QRadioButton, + QSpinBox, + QVBoxLayout, +) + +from .isovalues import ( + default_range_values, + values_from_list, + values_from_range, +) + + +class IsosurfaceDialog(QDialog): + """Enter the values as a list, or as a start, an end and a count. + + `low` and `high` are the range of the scalar field (None if not known). + They set the default values. + """ + + def __init__( + self, + feature_name: str, + low: Optional[float] = None, + high: Optional[float] = None, + parent=None, + ): + super().__init__(parent) + self.setWindowTitle(f"Add Isosurfaces: {feature_name}") + layout = QVBoxLayout(self) + if low is not None and high is not None: + layout.addWidget(QLabel(f"The scalar field is from {low:.4g} to {high:.4g}.")) + else: + layout.addWidget(QLabel("The range of the scalar field is not known.")) + + defaults = [0.0, 1.0, 2.0] + try: + defaults = default_range_values(low, high, 5) + except (TypeError, ValueError): + pass + + self.listRadio = QRadioButton("List of values", self) + self.listRadio.setChecked(True) + layout.addWidget(self.listRadio) + self.listEdit = QLineEdit(self) + self.listEdit.setText(", ".join(f"{v:.4g}" for v in defaults)) + self.listEdit.setToolTip("Separate the values with commas") + layout.addWidget(self.listEdit) + + self.rangeRadio = QRadioButton("Range of values", self) + layout.addWidget(self.rangeRadio) + form = QFormLayout() + self.startSpinBox = QDoubleSpinBox(self) + self.endSpinBox = QDoubleSpinBox(self) + for box, value in ((self.startSpinBox, defaults[0]), (self.endSpinBox, defaults[-1])): + box.setRange(-1e12, 1e12) + box.setDecimals(4) + box.setValue(value) + self.countSpinBox = QSpinBox(self) + self.countSpinBox.setRange(1, 100) + self.countSpinBox.setValue(len(defaults)) + form.addRow("Start", self.startSpinBox) + form.addRow("End", self.endSpinBox) + form.addRow("Count", self.countSpinBox) + layout.addLayout(form) + + self.errorLabel = QLabel("", self) + self.errorLabel.setStyleSheet("color: red;") + layout.addWidget(self.errorLabel) + + buttons = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, self + ) + buttons.accepted.connect(self._on_accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + self.listRadio.toggled.connect(self._update_enabled) + self._update_enabled() + self._values: List[float] = [] + + def _update_enabled(self, _checked=None): + use_list = self.listRadio.isChecked() + self.listEdit.setEnabled(use_list) + for widget in (self.startSpinBox, self.endSpinBox, self.countSpinBox): + widget.setEnabled(not use_list) + + def read_values(self) -> List[float]: + """The values from the controls. Raises ValueError if they are not valid.""" + if self.listRadio.isChecked(): + return values_from_list(self.listEdit.text()) + return values_from_range( + self.startSpinBox.value(), self.endSpinBox.value(), self.countSpinBox.value() + ) + + def _on_accept(self): + try: + self._values = self.read_values() + except ValueError as e: + self.errorLabel.setText(str(e)) + return + self.accept() + + def values(self) -> List[float]: + """The values that the user entered (after the dialog is accepted).""" + return list(self._values) diff --git a/loopstructural/gui/visualisation/isovalues.py b/loopstructural/gui/visualisation/isovalues.py new file mode 100644 index 00000000..2f30cc78 --- /dev/null +++ b/loopstructural/gui/visualisation/isovalues.py @@ -0,0 +1,84 @@ +"""Isosurface values: parse the user input and check it against the range of a +scalar field. No Qt code. +""" + +import re +from typing import Iterable, List, Sequence, Tuple + +import numpy as np + +# Values that differ by less than this (relative to the range) are the same value +_REL_TOLERANCE = 1e-9 + + +def unique_values(values: Iterable[float]) -> List[float]: + """Remove repeated values and keep the first occurrence of each.""" + result: List[float] = [] + for value in values: + value = float(value) + if not np.isfinite(value): + continue + if not any(np.isclose(value, v, rtol=_REL_TOLERANCE, atol=_REL_TOLERANCE) for v in result): + result.append(value) + return result + + +def values_from_list(text: str) -> List[float]: + """Read values separated by commas, semicolons or spaces. + + Raises ValueError if a part of the text is not a number. Repeated values + are removed. + """ + parts = [p for p in re.split(r'[,;\s]+', text.strip()) if p] + if not parts: + raise ValueError("Enter at least one value") + values = [] + for part in parts: + try: + value = float(part) + except ValueError: + raise ValueError(f"'{part}' is not a number") from None + if not np.isfinite(value): + raise ValueError(f"'{part}' is not a finite number") + values.append(value) + return unique_values(values) + + +def values_from_range(start: float, end: float, count: int) -> List[float]: + """Return `count` values from `start` to `end`, with equal steps. + + One value gives `start`. Raises ValueError if `count` is less than 1. + """ + if count < 1: + raise ValueError("The count must be 1 or more") + if count == 1: + return [float(start)] + return unique_values(np.linspace(start, end, count)) + + +def default_range_values(low: float, high: float, count: int = 5) -> List[float]: + """Values that are inside the range of a scalar field, with equal steps. + + The values are not at the minimum or the maximum of the field, where an + isosurface has no geometry. Raises ValueError for an empty range. + """ + if count < 1: + raise ValueError("The count must be 1 or more") + if not (np.isfinite(low) and np.isfinite(high)) or high <= low: + raise ValueError("The scalar field has no range of values") + return [float(v) for v in np.linspace(low, high, count + 2)[1:-1]] + + +def split_by_range( + values: Sequence[float], low: float, high: float +) -> Tuple[List[float], List[float]]: + """Split the values into (inside, outside) the range [low, high].""" + inside, outside = [], [] + for value in values: + (inside if low <= value <= high else outside).append(float(value)) + return inside, outside + + +def isosurface_name(feature_name: str, value: float) -> str: + """The object name of an isosurface, for example `Fault_1_iso_0.50`.""" + return f'{feature_name}_iso_{value:.2f}' diff --git a/loopstructural/gui/visualisation/loop_pyvistaqt_wrapper.py b/loopstructural/gui/visualisation/loop_pyvistaqt_wrapper.py index f0c6dfa6..fb1646aa 100644 --- a/loopstructural/gui/visualisation/loop_pyvistaqt_wrapper.py +++ b/loopstructural/gui/visualisation/loop_pyvistaqt_wrapper.py @@ -4,6 +4,7 @@ from qgis.PyQt.QtCore import pyqtSignal from .mesh_scalar_utils import threshold_mesh +from .object_registry import ObjectRegistry, ViewerObject class LoopPyVistaQTPlotter(QtInteractor): @@ -13,24 +14,9 @@ class LoopPyVistaQTPlotter(QtInteractor): 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('_') - if len(parts) == 1: - name = name + '_1' - while name in self.actors: - parts = name.split('_') - try: - parts[-1] = str(int(parts[-1]) + 1) - except ValueError: - parts.append('1') - name = '_'.join(parts) - return name + # the objects in the viewer, and the information to build them again + self.registry = ObjectRegistry() def add_mesh_object( self, @@ -95,15 +81,6 @@ def add_mesh_object( ------- None """ - # 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 @@ -139,21 +116,21 @@ def add_mesh_object( # attempt to add to the underlying pyvista plotter actor = self.add_mesh(display_mesh, name=name, **add_kwargs) - # store the mesh, actor and kwargs for future re-adds - # persist source metadata so callers can find meshes created from model features - self.meshes[name] = { - 'mesh': mesh, - 'actor': actor, - 'kwargs': {**add_kwargs}, - 'source_feature': source_feature, - 'source_type': source_type, - 'isovalue': isovalue, - 'metadata': dict(metadata or {}), - 'out_of_date': out_of_date, - 'threshold': dict(threshold) if threshold else None, - # the mesh shown in the viewer (the filtered part of `mesh`) - 'display_mesh': display_mesh, - } + self.registry.add( + ViewerObject( + name, + mesh, + actor=actor, + kwargs=add_kwargs, + source_feature=source_feature, + source_type=source_type, + isovalue=isovalue, + metadata=metadata, + out_of_date=out_of_date, + threshold=threshold, + display_mesh=display_mesh, + ) + ) self.objectAdded.emit(self) def get_source_metadata(self, name: str) -> Dict[str, Any]: @@ -162,17 +139,8 @@ def get_source_metadata(self, name: str) -> Dict[str, Any]: example to change its colour map) can still be built again from the model. """ - entry = self.meshes.get(name) - if not entry: - return {} - return { - 'source_feature': entry.get('source_feature'), - 'source_type': entry.get('source_type'), - 'isovalue': entry.get('isovalue'), - 'metadata': entry.get('metadata'), - 'out_of_date': bool(entry.get('out_of_date', False)), - 'threshold': entry.get('threshold'), - } + obj = self.registry.get(name) + return obj.source_values() if obj else {} def replace_mesh_object(self, name: str, mesh=None, overrides=None, **source_updates) -> None: """Add the object `name` again, and keep its source values, viewer @@ -196,22 +164,19 @@ def replace_mesh_object(self, name: str, mesh=None, overrides=None, **source_upd pyvista replaces the actor that has the same name, so if the new object cannot be added, the old object stays and the error is raised. """ - entry = self.meshes[name] + obj = self.registry.get(name) if mesh is None: - mesh = entry['mesh'] + mesh = obj.mesh source = self.get_source_metadata(name) source.update(source_updates) kwargs = { - key: value - for key, value in (entry.get('kwargs') or {}).items() - if key not in source and key != 'name' + key: value for key, value in obj.kwargs.items() if key not in source and key != 'name' } kwargs.update(overrides or {}) - user_colour = entry.get('color') + user_colour = obj.color if user_colour is not None: kwargs['color'] = user_colour - actor = entry.get('actor') - visible = bool(getattr(actor, 'visibility', True)) + visible = bool(getattr(obj.actor, 'visibility', True)) try: self.add_mesh_object(mesh, name=name, **source, **kwargs) @@ -222,63 +187,52 @@ def replace_mesh_object(self, name: str, mesh=None, overrides=None, **source_upd kwargs.pop(key, None) self.add_mesh_object(mesh, name=name, **source, **kwargs) - new_entry = self.meshes[name] + new_obj = self.registry.get(name) if user_colour is not None: - new_entry['color'] = user_colour - if not visible and new_entry.get('actor') is not None: - new_entry['actor'].visibility = False + new_obj.color = user_colour + if not visible and new_obj.actor is not None: + new_obj.actor.visibility = False def set_out_of_date(self, names, out_of_date: bool = True) -> None: """Mark the named objects as out of date (or up to date).""" changed = False for name in names: - entry = self.meshes.get(name) - if entry is not None and bool(entry.get('out_of_date')) != out_of_date: - entry['out_of_date'] = out_of_date + obj = self.registry.get(name) + if obj is not None and bool(obj.out_of_date) != out_of_date: + obj.out_of_date = out_of_date changed = True if changed: self.outOfDateChanged.emit() def out_of_date_objects(self): """Return the names of the objects that are out of date.""" - return [name for name, entry in self.meshes.items() if entry.get('out_of_date')] + return self.registry.out_of_date() 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: + obj = self.registry.get(name) + if obj is None: 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'): + if obj.actor is not None: + try: + self.remove_actor(obj.actor) + except Exception: self.remove_mesh(name) except Exception: # ignore errors during actor removal pass - # finally delete metadata - try: - del self.meshes[name] - except Exception: - pass - if entry.get('out_of_date'): + self.registry.remove(name) + if obj.out_of_date: self.outOfDateChanged.emit() def set_object_visibility(self, name: str, visibility): """Change the visibility of an object.""" - if name in self.meshes: - self.meshes[name]['actor'].visibility = visibility - self.update() - else: + obj = self.registry.get(name) + if obj is None: raise ValueError(f"Object '{name}' not found in the plotter.") + obj.actor.visibility = visibility + self.update() diff --git a/loopstructural/gui/visualisation/mesh_builders.py b/loopstructural/gui/visualisation/mesh_builders.py new file mode 100644 index 00000000..58708a74 --- /dev/null +++ b/loopstructural/gui/visualisation/mesh_builders.py @@ -0,0 +1,280 @@ +"""Build the meshes of the viewer objects from the geological model. + +No Qt code. The widgets call these functions and add the result to the viewer. +Some of them are slow (they can solve the model), so they do not touch the +viewer and are safe to call on a background thread. +""" + +import logging +from typing import Any, Dict, List, Optional, Tuple + +import numpy as np +import pyvista as pv +from LoopStructural.datatypes import VectorPoints + +from .cross_section_utils import build_block_model_mesh +from .mesh_scalar_utils import stratigraphic_ids_to_rgb +from .object_registry import FEATURE_SOURCE_TYPES, FOLD_CONSTRAINT_PREFIX + +logger = logging.getLogger(__name__) + +# Vectors used by the fold constraints in the DiscreteFoldInterpolator. +# direction: gradient . direction = 0 (fold orientation constraint) +# axis: gradient . axis = 0 (fold axis constraint) +# norm: gradient . norm = fold_norm (fold normalisation constraint) +FOLD_CONSTRAINT_LABELS = { + 'direction': "Fold Direction", + 'axis': "Fold Axis", + 'norm': "Fold Norm Direction", +} +FOLD_CONSTRAINT_COLOURS = { + 'direction': (0.2, 0.4, 1.0), + 'axis': (1.0, 0.2, 0.2), + 'norm': (0.1, 0.8, 0.2), +} + + +def set_stratigraphy_arrays(data, ids, colours) -> None: + """Store the unit ids and their colours as the 'stratigraphy' and + 'colour' arrays of `data` (a mesh's point_data or cell_data). + + Named arrays (not an RGB array passed straight to the viewer) let the + object properties panel use the unit ids, and let the update action add + the object again with the same viewer settings. + """ + data['stratigraphy'] = np.asarray(ids) + data['colour'] = stratigraphic_ids_to_rgb(ids, colours) + + +class MeshBuilder: + """Build viewer meshes from the model of a model manager.""" + + def __init__(self, model_manager): + self.model_manager = model_manager + + @property + def model(self): + return self.model_manager.model + + def vector_scale(self, scale: Optional[float] = None) -> float: + """Vector length: 5% of the longest side of the model bounding box.""" + autoscale = 1.0 + if self.model is not None: + autoscale = self.model.bounding_box.length.max() * 0.05 + return autoscale if scale is None else scale * autoscale + + # -- feature meshes -------------------------------------------------- + + def scalar_field(self, feature_name: str): + return self.model[feature_name].scalar_field().vtk() + + def feature_surfaces(self, feature_name: str) -> List[Any]: + """The surfaces that the model gives for a feature.""" + return list(self.model[feature_name].surfaces()) + + def feature_surface(self, feature_name: str, isovalue: Optional[float]): + feature = self.model[feature_name] + surfaces = feature.surfaces(isovalue) if isovalue is not None else feature.surfaces() + if not surfaces: + raise ValueError(f"Feature '{feature_name}' has no surface at {isovalue}") + return surfaces[0].vtk() + + def scalar_range(self, feature_name: str) -> Tuple[float, float]: + """The minimum and maximum of the scalar field of a feature.""" + feature = self.model[feature_name] + return float(feature.min()), float(feature.max()) + + def isosurface(self, feature_name: str, value: float): + """The isosurface of a feature at one value. + + Raises ValueError if the surface has no geometry (for example when + the value is outside the range of the field). + """ + surfaces = self.model[feature_name].surfaces(float(value)) + if not surfaces: + raise ValueError(f"The isosurface of '{feature_name}' at {value:g} cannot be built") + mesh = surfaces[0].vtk() + if getattr(mesh, 'n_points', 1) == 0: + raise ValueError(f"The isosurface of '{feature_name}' at {value:g} has no geometry") + return mesh + + def vector_field(self, feature_name: str): + vector_field = self.model[feature_name].vector_field() + return vector_field.vtk(scale=self.vector_scale()) + + def get_fold(self, feature_name: str): + try: + feature = self.model[feature_name] + except Exception: + return None + fold = getattr(feature, 'fold', None) + if fold is None: + fold = getattr(getattr(feature, 'builder', None), 'fold', None) + return fold + + def fold_constraint(self, feature_name: str, constraint: str): + """Return the vectors of one fold constraint of a folded feature as + a mesh, or None if the feature is not folded or the vectors are not + defined. + + The vectors are evaluated on the model grid, in the same way as the + interpolator evaluates them on the element barycentres. + """ + fold = self.get_fold(feature_name) + if fold is None: + logger.info(f"Feature {feature_name} is not folded") + return None + feature = self.model[feature_name] + # make sure the fold rotation angles are fitted + feature.builder.up_to_date() + bounding_box = self.model.bounding_box + points = bounding_box.reproject(bounding_box.cell_centres()) + direction, axis, norm = fold.get_deformed_orientation(points) + vectors = np.array({'direction': direction, 'axis': axis, 'norm': norm}[constraint]) + if vectors.shape != points.shape: + # some fold settings (e.g. invert_norm) return a subset of the vectors + logger.warning( + f"Fold {constraint} vectors do not match the grid points ({vectors.shape} != {points.shape})" + ) + return None + length = np.linalg.norm(vectors, axis=1) + mask = np.all(np.isfinite(vectors), axis=1) & (length > 0) + if not np.any(mask): + logger.warning(f"Fold {constraint} vectors for {feature_name} are not defined") + return None + vectors = vectors[mask] / length[mask, None] + vector_points = VectorPoints(points[mask], vectors, f'{feature_name}_fold_{constraint}') + return vector_points.vtk(scale=self.vector_scale()) + + def data_meshes(self, feature_name: str) -> List[Tuple[str, Any, str]]: + """Return (name, mesh, source_type) for each data set of a feature.""" + meshes = [] + for d in self.model[feature_name].get_data(): + d.locations = self.model.rescale(d.locations) + if issubclass(type(d), VectorPoints): + # tolerance is None means all points are shown + meshes.append( + ( + f'{feature_name}_{d.name}_points', + d.vtk(scale=self.vector_scale(), tolerance=None), + 'feature_points', + ) + ) + else: + meshes.append((f'{feature_name}_{d.name}', d.vtk(), 'feature_data')) + return meshes + + # -- model meshes ---------------------------------------------------- + + def bounding_box(self): + return self.model.bounding_box.vtk().outline() + + def fault_surface(self, name: str): + for surface in self.model.get_fault_surfaces(): + if str(surface.name) == str(name): + return surface + raise ValueError(f"Fault surface '{name}' is not in the model") + + def stratigraphic_surface(self, name: str): + for surface in self.model.get_stratigraphic_surfaces(): + if str(surface.name) == str(name): + return surface + raise ValueError(f"Stratigraphic surface '{name}' is not in the model") + + def topography(self): + """The DEM sampled across the model extent, with an 'Elevation' array.""" + xx, yy, zz = self.model_manager.sample_dem_grid() + return self.topography_from_grid(xx, yy, zz) + + @staticmethod + def topography_from_grid(xx, yy, zz): + mesh = pv.StructuredGrid(xx, yy, zz) + mesh['Elevation'] = mesh.points[:, 2] + return mesh + + def block_model(self, ncells): + bb = self.model.bounding_box + return build_block_model_mesh(bb.origin, bb.maximum, ncells) + + # -- stratigraphy colours ------------------------------------------- + + def stratigraphy_metadata(self, colours) -> Dict[str, Any]: + """Unit names and colours, indexed by unit id, for the unit check + boxes of the object properties panel.""" + return { + 'unit_names': list(self.model_manager.get_stratigraphic_unit_names()), + 'unit_colours': list(colours), + } + + def colour_by_stratigraphy(self, points, data, metadata: Dict[str, Any]) -> None: + """Colour a mesh by the stratigraphic unit at `points`, and store + the unit names and colours in `metadata`.""" + ids = self.model_manager.evaluate_stratigraphy_on_points(points) + colours = self.model_manager.get_stratigraphic_column_colours() + set_stratigraphy_arrays(data, ids, colours) + metadata.update(self.stratigraphy_metadata(colours)) + + # -- build again ----------------------------------------------------- + + def rebuild(self, spec: Dict[str, Any]) -> Tuple[Any, Dict[str, Any]]: + """Build one viewer object again from the current model. + + `spec` has the keys name, source_type, source_feature, isovalue and + metadata (and mesh, for a cross section). Returns (mesh, overrides), + where overrides are viewer settings that come from the model (e.g. a + unit colour). `spec['metadata']` is updated with new values from the + model (e.g. the unit names). Raises if the object cannot be built. + """ + source_type = spec['source_type'] + feature_name = spec['source_feature'] + metadata = spec['metadata'] + + if source_type in FEATURE_SOURCE_TYPES or source_type.startswith(FOLD_CONSTRAINT_PREFIX): + if feature_name is None or self.model.get_feature_by_name(feature_name) is None: + raise ValueError(f"Feature '{feature_name}' is not in the model") + + overrides: Dict[str, Any] = {} + if source_type == 'feature_scalar': + mesh = self.scalar_field(feature_name) + elif source_type == 'feature_surface': + mesh = self.feature_surface(feature_name, spec['isovalue']) + elif source_type == 'feature_isosurface': + if spec['isovalue'] is None: + raise ValueError("The isosurface has no value") + mesh = self.isosurface(feature_name, spec['isovalue']) + elif source_type in ('feature_vector', 'feature_vectors'): + mesh = self.vector_field(feature_name) + elif source_type.startswith(FOLD_CONSTRAINT_PREFIX): + constraint = source_type[len(FOLD_CONSTRAINT_PREFIX) :] + mesh = self.fold_constraint(feature_name, constraint) + if mesh is None: + raise ValueError("The fold constraint vectors are not defined") + elif source_type in ('feature_points', 'feature_data'): + meshes = {name: m for name, m, _ in self.data_meshes(feature_name)} + if spec['name'] not in meshes: + raise ValueError(f"Feature '{feature_name}' has no data for this object") + mesh = meshes[spec['name']] + elif source_type == 'bounding_box': + mesh = self.bounding_box() + elif source_type == 'fault_surface': + mesh = self.fault_surface(feature_name).vtk() + elif source_type == 'stratigraphic_surface': + surface = self.stratigraphic_surface(feature_name) + mesh = surface.vtk() + overrides['color'] = surface.colour + elif source_type in ('cross_section_plane', 'cross_section_line'): + mesh = spec['mesh'] + self.colour_by_stratigraphy(mesh.points, mesh.point_data, metadata) + elif source_type == 'block_model': + mesh = self.block_model(metadata['ncells']) + self.colour_by_stratigraphy(mesh.cell_centers().points, mesh.cell_data, metadata) + elif source_type == 'topography_surface': + mesh = self.topography() + if metadata.get('coloured'): + self.colour_by_stratigraphy(mesh.points, mesh.point_data, metadata) + else: + raise ValueError(f"Cannot update objects of type '{source_type}'") + + if getattr(mesh, 'n_points', 1) == 0: + raise ValueError("The object has no geometry in the current model") + return mesh, overrides diff --git a/loopstructural/gui/visualisation/object_list_widget.py b/loopstructural/gui/visualisation/object_list_widget.py index a94e423f..7ef9d33c 100644 --- a/loopstructural/gui/visualisation/object_list_widget.py +++ b/loopstructural/gui/visualisation/object_list_widget.py @@ -6,7 +6,7 @@ QAbstractItemView, QCheckBox, QFileDialog, - QHBoxLayout, # Add missing import + QHBoxLayout, QLabel, QMenu, QPushButton, @@ -18,8 +18,43 @@ logger = logging.getLogger(__name__) +# Text for the type of an object in the list, by source type +SOURCE_TYPE_LABELS = { + 'feature_scalar': 'scalar field', + 'feature_surface': 'surface', + 'feature_isosurface': 'isosurface', + 'feature_vector': 'vector field', + 'feature_vectors': 'vector field', + 'feature_points': 'data', + 'feature_data': 'data', + 'bounding_box': 'bounding box', + 'fault_surface': 'fault surface', + 'stratigraphic_surface': 'stratigraphic surface', + 'cross_section_plane': 'cross section', + 'cross_section_line': 'cross section', + 'block_model': 'block model', + 'topography_surface': 'topography', +} + + +def describe_object(obj) -> str: + """Short text for the list: the type of the object and its isovalue.""" + source_type = obj.source_type or '' + if source_type.startswith('fold_constraint_'): + text = 'fold constraint' + else: + text = SOURCE_TYPE_LABELS.get(source_type, '') + if obj.isovalue is not None and source_type in ('feature_isosurface', 'feature_surface'): + text = f"{text}, value {obj.isovalue:.4g}" + return text + class ObjectListWidget(QWidget): + """The objects in the viewer, in groups by source feature. + + The list reads the objects from the registry of the viewer. + """ + def __init__(self, parent=None, *, viewer=None, properties_widget=None): super().__init__(parent) self.mainLayout = QVBoxLayout(self) @@ -36,239 +71,111 @@ def __init__(self, parent=None, *, viewer=None, properties_widget=None): self.viewer = viewer self.viewer.objectAdded.connect(self.update_object_list) self.viewer.outOfDateChanged.connect(self._on_out_of_date_changed) + # groups that the user closed; they stay closed when the list is built again + self._collapsed_groups = set() self.treeWidget.installEventFilter(self) self.treeWidget.itemSelectionChanged.connect(self.on_object_selected) self.treeWidget.itemDoubleClicked.connect(self.onDoubleClick) + self.treeWidget.itemCollapsed.connect(self._on_group_collapsed) + self.treeWidget.itemExpanded.connect(self._on_group_expanded) def onDoubleClick(self, item, column): self.viewer.reset_camera() - def _selected_top_level_items(self): - """Return only the selected items that represent objects (top-level - rows with a checkbox/label widget), ignoring child rows such as - 'Point Data'/'Cell Data' entries which have no such widget. - """ - items = [] - for item in self.treeWidget.selectedItems(): - if item.parent() is not None: - continue - if self.treeWidget.itemWidget(item, 0) is None: - continue - items.append(item) - return items + def _on_group_collapsed(self, item): + if item.parent() is None: + self._collapsed_groups.add(item.text(0)) + + def _on_group_expanded(self, item): + if item.parent() is None: + self._collapsed_groups.discard(item.text(0)) + + def _selected_object_items(self): + """Return the selected items that are objects (rows with a + checkbox/label widget), not the group rows.""" + return [ + item + for item in self.treeWidget.selectedItems() + if self.treeWidget.itemWidget(item, 0) is not None + ] - def _object_label_for_item(self, item): - item_widget = self.treeWidget.itemWidget(item, 0) - if item_widget is None: - return None - label = item_widget.findChild(QLabel) - return label.text() if label else None + def _object_name_for_item(self, item): + return item.data(0, Qt.ItemDataRole.UserRole) def on_object_selected(self): - selected_items = self._selected_top_level_items() + selected_items = self._selected_object_items() 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] - object_label = self._object_label_for_item(item) - - if object_label and 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. + object_name = self._object_name_for_item(selected_items[0]) + if object_name and self.properties_widget: + self.properties_widget.setCurrentObject(object_name) + + def update_object_list(self, new_object=None): + """Build the tree again from the registry of the viewer: a group for + each source feature, with the objects of the feature. Each object has + a visibility check box, its name, its type and its isovalue. """ if not self.viewer: return - - # Clear and rebuild the tree to reflect current meshes + selected = {self._object_name_for_item(i) for i in self._selected_object_items()} self.treeWidget.clear() - - meshes = getattr(self.viewer, 'meshes', {}) or {} - for mesh_name in sorted(meshes.keys()): - mesh = meshes[mesh_name] - self.add_mesh_item(mesh_name, mesh) + for group_name, objects in self.viewer.registry.grouped_by_feature(): + group = QTreeWidgetItem(self.treeWidget) + group.setText(0, group_name) + font = group.font(0) + font.setBold(True) + group.setFont(0, font) + for obj in objects: + item = self.add_object_item(group, obj) + if obj.name in selected: + item.setSelected(True) + group.setExpanded(group_name not in self._collapsed_groups) def _on_out_of_date_changed(self): - self.update_object_list(None) + self.update_object_list() - 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(mesh.visibility) - except Exception: - initial_visibility = True + def add_object_item(self, group, obj): + """Add a row for an object to a group row of the tree.""" + item = QTreeWidgetItem(group) + item.setData(0, Qt.ItemDataRole.UserRole, obj.name) 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.CheckState.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) + visibilityCheckbox.setChecked(bool(getattr(obj.actor, 'visibility', True))) + visibilityCheckbox.toggled.connect( + lambda checked, name=obj.name: self._set_visibility(name, checked) + ) + itemWidget = QWidget() itemLayout = QHBoxLayout(itemWidget) itemLayout.setContentsMargins(0, 0, 0, 0) itemLayout.addWidget(visibilityCheckbox) - nameLabel = QLabel(mesh_name) - if isinstance(mesh, dict) and mesh.get('out_of_date'): - # the label text is the object name used elsewhere, so show the - # state with the style and tooltip only + nameLabel = QLabel(obj.name) + itemLayout.addWidget(nameLabel) + description = describe_object(obj) + if description: + typeLabel = QLabel(f"({description})") + typeLabel.setStyleSheet("color: gray;") + itemLayout.addWidget(typeLabel) + itemLayout.addStretch(1) + if obj.out_of_date: + # show the state with the style and tooltip only nameLabel.setStyleSheet("color: gray; font-style: italic;") nameLabel.setToolTip("Out of date: the model changed after this object was added") - itemLayout.addWidget(nameLabel) - itemWidget.setLayout(itemLayout) + self.treeWidget.setItemWidget(item, 0, itemWidget) + return item - self.treeWidget.setItemWidget(top, 0, itemWidget) - top.setExpanded(False) - - # Add children: Point Data and Cell Data groups + def _set_visibility(self, object_name, visible): try: - point_data = getattr(mesh, 'point_data', None) - cell_data = getattr(mesh, 'cell_data', None) - - if point_data is not None and len(point_data.keys()) > 0: - pd_group = QTreeWidgetItem(top) - pd_group.setText(0, 'Point Data') - for array_name in sorted(point_data.keys()): - arr_item = QTreeWidgetItem(pd_group) - # show name and length/type if available - try: - vals = point_data[array_name] - meta = f" ({len(vals)})" if hasattr(vals, '__len__') else '' - except Exception: - meta = '' - arr_item.setText(0, f"{array_name}{meta}") - - if cell_data is not None and len(cell_data.keys()) > 0: - cd_group = QTreeWidgetItem(top) - cd_group.setText(0, 'Cell Data') - for array_name in sorted(cell_data.keys()): - arr_item = QTreeWidgetItem(cd_group) - try: - vals = cell_data[array_name] - meta = f" ({len(vals)})" if hasattr(vals, '__len__') else '' - except Exception: - meta = '' - arr_item.setText(0, f"{array_name}{meta}") - except Exception: - # If mesh lacks expected attributes, silently continue - pass - - def add_object_item(self, object_name, instance=None): - """Add a generic object entry to the tree. This mirrors add_actor but works - for objects/meshes that are not present in viewer.actors.""" - objectItem = QTreeWidgetItem(self.treeWidget) - - # Determine initial visibility - visibility = False - if instance is not None and hasattr(instance, 'visibility'): - visibility = bool(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.CheckState.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 - if not hasattr(self.viewer.actors[actor_name], 'visibility'): - return - objectItem = QTreeWidgetItem(self.treeWidget) - - # Add a checkbox for visibility toggle in front of the name - visibilityCheckbox = QCheckBox() - visibilityCheckbox.setChecked(self.viewer.actors[actor_name].visibility) - visibilityCheckbox.stateChanged.connect( - lambda state, name=self.viewer.actors[actor_name].name: self.set_object_visibility( - name, state == Qt.CheckState.Checked - ) - ) - - # Create a widget to hold the checkbox and name on a single line - itemWidget = QWidget() - itemLayout = QHBoxLayout(itemWidget) # Use horizontal layout for single line - itemLayout.setContentsMargins(0, 0, 0, 0) - itemLayout.addWidget(visibilityCheckbox) - itemLayout.addWidget(QLabel(self.viewer.actors[actor_name].name)) - itemWidget.setLayout(itemLayout) - - self.treeWidget.setItemWidget(objectItem, 0, itemWidget) - objectItem.setExpanded(False) # Initially collapsed - - def set_object_visibility(self, object_name, visibility): - self.viewer.actors[object_name].visibility = visibility - - # self.object_manager.set_object_visibility(object_name, visibility) - # Logic to update visibility in the list widget + self.viewer.set_object_visibility(object_name, visible) + except ValueError: + logger.info(f"Object '{object_name}' is not in the viewer") def contextMenuEvent(self, event): - selected_items = self._selected_top_level_items() + selected_items = self._selected_object_items() multiple = len(selected_items) > 1 menu = QMenu(self) @@ -304,45 +211,34 @@ def set_selected_objects_visibility(self, visible): """Show or hide every currently-selected object by driving each item's visibility checkbox (so viewer state and checkbox state stay in sync).""" - for item in self._selected_top_level_items(): + for item in self._selected_object_items(): item_widget = self.treeWidget.itemWidget(item, 0) checkbox = item_widget.findChild(QCheckBox) if item_widget else None if checkbox is not None: checkbox.setChecked(visible) - def zoom_to_selected_object(self): - selected_items = self._selected_top_level_items() + def _first_selected_object(self): + """The first selected object in the registry, or None.""" + selected_items = self._selected_object_items() if not selected_items: - return + return None + return self.viewer.registry.get(self._object_name_for_item(selected_items[0])) - object_label = self._object_label_for_item(selected_items[0]) - if object_label is None: - return - 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 or not hasattr(mesh, 'bounds'): + def zoom_to_selected_object(self): + obj = self._first_selected_object() + if obj is None or not hasattr(obj.mesh, 'bounds'): return try: - self.viewer.reset_camera(bounds=mesh.bounds) + self.viewer.reset_camera(bounds=obj.mesh.bounds) except Exception as e: - logger.error(f"Failed to zoom to object {object_label}: {e}") + logger.error(f"Failed to zoom to object {obj.name}: {e}") def export_selected_object(self): - selected_items = self._selected_top_level_items() - if not selected_items: - return - - object_label = self._object_label_for_item(selected_items[0]) - if object_label is None: - return - 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: + obj = self._first_selected_object() + if obj is None or obj.mesh is None: return + object_label = obj.name + mesh = obj.mesh # Determine available formats based on object type and dependencies formats = [] try: @@ -468,41 +364,26 @@ def _export_grid_ascii(self, mesh, file_path, object_label): f.write(f"{x:.6f} {y:.6f} {z:.6f} {value:.6f}\n") def remove_selected_object(self): - selected_items = self._selected_top_level_items() - if not selected_items: - return - for item in selected_items: - object_label = self._object_label_for_item(item) - if object_label is None: - continue - # Logic for removing the object - if self.viewer and hasattr(self.viewer, 'remove_object'): - self.viewer.remove_object(object_label) - else: - print("Error: Viewer is not initialized or does not support object removal.") - self.treeWidget.takeTopLevelItem(self.treeWidget.indexOfTopLevelItem(item)) + names = [self._object_name_for_item(i) for i in self._selected_object_items()] + for name in names: + self.viewer.remove_object(name) + if names: + self.update_object_list() def show_add_object_menu(self): menu = QMenu(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) - if action == addFeatureAction: - self.add_feature_from_geological_model() - elif action == loadFeatureAction: + if 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. @@ -619,13 +500,10 @@ def add_object_from_qgis_layer(self): 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.") + try: + self.viewer.add_mesh_object(mesh, name=layer.name()) + except Exception as e: + logger.error(f"Failed to add mesh to viewer: {e}") def load_feature_from_file(self): file_path, _ = QFileDialog.getOpenFileName( @@ -638,17 +516,13 @@ def load_feature_from_file(self): try: mesh = pv.read(file_path) # Add the mesh to the viewer - if self.viewer and hasattr(self.viewer, 'add_mesh'): - self.viewer.add_mesh_object(mesh, name=file_name) - else: - print("Error: Viewer is not initialized or does not support adding meshes.") - - print(f"Loaded mesh from file: {file_path}") + self.viewer.add_mesh_object(mesh, name=file_name) + logger.info(f"Loaded mesh from file: {file_path}") except Exception as e: - print(f"Failed to load mesh: {e}") + logger.error(f"Failed to load mesh: {e}") def _toggle_selected_visibility(self): - for item in self._selected_top_level_items(): + for item in self._selected_object_items(): item_widget = self.treeWidget.itemWidget(item, 0) checkbox = item_widget.findChild(QCheckBox) if item_widget else None if checkbox is not None: diff --git a/loopstructural/gui/visualisation/object_properties_widget.py b/loopstructural/gui/visualisation/object_properties_widget.py index 2705bfd7..a331ea2e 100644 --- a/loopstructural/gui/visualisation/object_properties_widget.py +++ b/loopstructural/gui/visualisation/object_properties_widget.py @@ -3,7 +3,7 @@ # Add plotting imports for scalar histogram from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas -from qgis.PyQt.QtCore import Qt +from qgis.PyQt.QtCore import Qt, pyqtSignal from qgis.PyQt.QtGui import QColor, QIcon, QPixmap from qgis.PyQt.QtWidgets import ( QCheckBox, @@ -31,8 +31,17 @@ render_histogram, ) - class ObjectPropertiesWidget(QWidget): + """The properties of the selected viewer object. + + The widget reads and changes the object only through `viewer.registry`. + Which controls it shows depends on the source type of the object (see + `_update_visible_controls`). + """ + + # (object name, new value): the owner builds the isosurface again + isovalueChangeRequested = pyqtSignal(str, float) + def __init__(self, parent=None, *, viewer=None): super().__init__(parent) layout = QVBoxLayout() @@ -45,8 +54,24 @@ def __init__(self, parent=None, *, viewer=None): self.title_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) layout.addWidget(self.title_label) + # Isosurface value (only for isosurfaces of a model feature) + self.isovalue_group = QGroupBox("Isosurface") + isovalue_layout = QHBoxLayout(self.isovalue_group) + isovalue_layout.addWidget(QLabel("Value:")) + self.isovalue_spinbox = QDoubleSpinBox() + self.isovalue_spinbox.setRange(-1e12, 1e12) + self.isovalue_spinbox.setDecimals(4) + self.isovalue_spinbox.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + isovalue_layout.addWidget(self.isovalue_spinbox) + self.isovalue_apply_button = QPushButton("Apply") + self.isovalue_apply_button.clicked.connect(self._on_isovalue_apply) + isovalue_layout.addWidget(self.isovalue_apply_button) + self.isovalue_group.setVisible(False) + layout.addWidget(self.isovalue_group) + # Scalar selection - layout.addWidget(QLabel("Active Scalar:")) + self.scalar_label = QLabel("Active Scalar:") + layout.addWidget(self.scalar_label) self.scalar_combo = QComboBox() self.scalar_combo.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) self.scalar_combo.currentTextChanged.connect(self._on_scalar_changed) @@ -68,7 +93,8 @@ def __init__(self, parent=None, *, viewer=None): layout.addWidget(self.scalar_bar_checkbox) # Colormap - layout.addWidget(QLabel("Colormap:")) + self.colormap_label = QLabel("Colormap:") + layout.addWidget(self.colormap_label) self.colormap_combo = QComboBox() self.colormap_combo.addItems(["viridis", "plasma", "inferno", "magma", "greys"]) self.colormap_combo.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) @@ -107,7 +133,8 @@ def __init__(self, parent=None, *, viewer=None): # Colormap Range range_layout = QHBoxLayout() range_layout.setSpacing(6) - range_layout.addWidget(QLabel("Colormap Range:")) + self.range_label = QLabel("Colormap Range:") + range_layout.addWidget(self.range_label) self.range_min = QLineEdit() self.range_min.setPlaceholderText("Min") self.range_min.setMaximumWidth(120) @@ -121,7 +148,8 @@ def __init__(self, parent=None, *, viewer=None): layout.addLayout(range_layout) # Scalar Histogram (matplotlib canvas) - layout.addWidget(QLabel("Scalar Histogram:")) + self.hist_label = QLabel("Scalar Histogram:") + layout.addWidget(self.hist_label) self.hist_fig = plt.Figure(figsize=(4, 2)) self.hist_canvas = FigureCanvas(self.hist_fig) self.hist_ax = self.hist_fig.subplots() @@ -214,329 +242,219 @@ def __init__(self, parent=None, *, viewer=None): self.color_with_scalar_checkbox.setChecked(False) self._on_color_with_scalar_toggled(False) + # -- the selected object --------------------------------------------- + + def _object(self): + """The selected object in the registry of the viewer, or None.""" + if self.viewer is None: + return None + return self.viewer.registry.get(self.current_object_name) + + def _render(self): + if self.viewer is None: + return + try: + self.viewer.render() + except Exception: + pass + + def _actor_prop(self): + """The property of the actor of the selected object, or None.""" + obj = self._object() + return getattr(obj.actor, 'prop', None) if obj else None + 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 + obj = self._object() + if obj is None: + return + rgb = (color.redF(), color.greenF(), color.blueF()) + prop = self._actor_prop() + if prop is not None: + try: + prop.color = rgb + except Exception: + pass + obj.color = rgb + self._render() def set_opacity(self, value: float): - if self.current_object_name is None or self.viewer is None: + obj = self._object() + if obj 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', {}): - entry = self.viewer.meshes[self.current_object_name] - entry['kwargs'] = {**(entry.get('kwargs') or {}), 'opacity': value} - except Exception: - pass + prop = self._actor_prop() + if prop is not None: + try: + prop.opacity = value + except Exception: + pass + obj.update_kwargs(opacity=value) + self._render() 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: + """Enable or disable edge display for the current object.""" + obj = self._object() + if obj 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 + prop = self._actor_prop() + if prop is not None: 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 + prop.edge_visibility = bool(show) 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 + obj.update_kwargs(show_edges=bool(show)) + self._render() 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: + obj = self._object() + if obj is None: 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 + width = float(value) + prop = self._actor_prop() + if prop is not None: 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 + prop.line_width = width 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 + obj.update_kwargs(line_width=width) + self._render() 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: + obj = self._object() + if obj 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.current_mesh = obj.mesh 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() + self._populate_scalar_combo(obj) + self._show_data_range() + self._restore_display_state(obj) - # populate scalar combo - self.scalar_combo.blockSignals(True) - self.scalar_combo.clear() + # update histogram display + vals = self._get_scalar_values(self.scalar_combo.currentText()) + self._update_histogram(vals if self.color_with_scalar_checkbox.isChecked() else None) - 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 + self._load_filter_state(obj) + self._update_visible_controls(obj) - # 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 + def _populate_scalar_combo(self, obj): + """List the arrays of the mesh, and select the one that is used.""" + self.scalar_combo.blockSignals(True) + self.scalar_combo.clear() + 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}") + used = obj.kwargs.get('scalars') + if used: + idx = self.scalar_combo.findText(used) + if idx < 0: + idx = self.scalar_combo.findText(f"cell:{used}") + if idx >= 0: + self.scalar_combo.setCurrentIndex(idx) 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: - import numpy as _np - - arr = _np.asarray(vals, dtype=float) - finite = arr[_np.isfinite(arr)] - if finite.size > 0: - self.range_min.setText(str(float(_np.min(finite)))) - self.range_max.setText(str(float(_np.max(finite)))) - else: - self.range_min.clear() - self.range_max.clear() - 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(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(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(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 + def _show_data_range(self): + """Show the range of values of the first array in the colormap range.""" + self.range_min.clear() + self.range_max.clear() + mesh = self.current_mesh + if mesh is None: + return + pdata = getattr(mesh, 'point_data', None) or {} + cdata = getattr(mesh, 'cell_data', None) or {} + values = None + if len(pdata.keys()) > 0: + values = next(iter(pdata.values())) + elif len(cdata.keys()) > 0: + values = next(iter(cdata.values())) + if values is None: + return 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) - - self._load_filter_state(mesh_entry) + arr = np.asarray(values, dtype=float) + except (TypeError, ValueError): + return + finite = arr[np.isfinite(arr)] + if finite.size > 0: + self.range_min.setText(str(float(finite.min()))) + self.range_max.setText(str(float(finite.max()))) + + def _restore_display_state(self, obj): + """Set the controls from the stored settings, or from the actor.""" + mapper = getattr(obj.actor, 'mapper', None) + self.scalar_bar_checkbox.setChecked(bool(getattr(mapper, 'scalar_visibility', False))) + + prop = self._actor_prop() + show_edges = obj.kwargs.get('show_edges') + if show_edges is None: + show_edges = bool(getattr(prop, 'edge_visibility', False)) + line_width = obj.kwargs.get('line_width') + if line_width is None: + line_width = getattr(prop, '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) + self.line_width_slider.setValue(int(round(float(line_width)))) + self.line_width_slider.blockSignals(False) + + color_with_scalar = bool(obj.kwargs.get('scalars') or obj.kwargs.get('cmap')) + self.color_with_scalar_checkbox.blockSignals(True) + self.color_with_scalar_checkbox.setChecked(color_with_scalar) + self.color_with_scalar_checkbox.blockSignals(False) + self._on_color_with_scalar_toggled(color_with_scalar) + + self.isovalue_spinbox.setValue(float(obj.isovalue) if obj.isovalue is not None else 0.0) + + def _update_visible_controls(self, obj): + """Show the controls that fit the source type of the object. + + - The value control is for an isosurface of a model feature only. + - The scalar controls need at least one array on the mesh. + - The filter needs an array to filter on. + """ + self.isovalue_group.setVisible(obj.is_isosurface) + has_arrays = self.scalar_combo.count() > 0 + for widget in ( + self.scalar_label, + self.scalar_combo, + self.color_with_scalar_checkbox, + self.scalar_bar_checkbox, + self.colormap_label, + self.colormap_combo, + self.range_label, + self.range_min, + self.range_max, + self.hist_label, + ): + widget.setVisible(has_arrays) + self.hist_canvas.setVisible(has_arrays and self.color_with_scalar_checkbox.isChecked()) + self.filter_group.setVisible(self.filter_array_combo.count() > 0) + + def _on_isovalue_apply(self): + obj = self._object() + if obj is None or not obj.is_isosurface: + return + value = self.isovalue_spinbox.value() + if obj.isovalue is not None and np.isclose(value, obj.isovalue): + return + self.isovalueChangeRequested.emit(obj.name, float(value)) - def _load_filter_state(self, mesh_entry): + def _load_filter_state(self, obj): """Show the filter of the current object, or the full value range of an array if the object has no filter.""" names = filter_array_names(self.current_mesh) if self.current_mesh is not None else [] - threshold = mesh_entry.get('threshold') if isinstance(mesh_entry, dict) else None + threshold = obj.threshold self.filter_array_combo.blockSignals(True) self.filter_array_combo.clear() self.filter_array_combo.addItems(names) @@ -567,8 +485,8 @@ def _load_filter_state(self, mesh_entry): def _on_filter_array_changed(self, array_name: str): if array_name: self._set_filter_range_to_data(array_name) - entry = self.viewer.meshes.get(self.current_object_name) if self.viewer else None - threshold = entry.get('threshold') if entry else None + obj = self._object() + threshold = obj.threshold if obj else None self._update_filter_mode(array_name, threshold) @staticmethod @@ -594,8 +512,8 @@ def _populate_unit_list(self, array_name: str, threshold): """ from matplotlib.colors import to_hex - entry = self.viewer.meshes.get(self.current_object_name, {}) if self.viewer else {} - metadata = entry.get('metadata') or {} + obj = self._object() + metadata = obj.metadata if obj else {} names = metadata.get('unit_names') or [] colours = metadata.get('unit_colours') or [] values = self._get_scalar_values(array_name) @@ -700,246 +618,129 @@ def clear_filter(self): def _set_threshold(self, threshold): name = self.current_object_name - if not name or self.viewer is None or name not in self.viewer.meshes: + if self._object() is None: return try: self.viewer.replace_mesh_object(name, threshold=threshold) except Exception as e: QMessageBox.warning(self, "Filter", f"Cannot apply the filter:\n{e}") return - try: - self.viewer.render() - except Exception: - pass + self._render() self._update_filter_status() def _update_filter_status(self): - entry = self.viewer.meshes.get(self.current_object_name) if self.viewer else None - if not entry or not entry.get('threshold'): + obj = self._object() + if obj is None or not obj.threshold: self.filter_status_label.setText("No filter") return - total = getattr(entry.get('mesh'), 'n_cells', 0) - shown = getattr(entry.get('display_mesh'), 'n_cells', 0) + total = getattr(obj.mesh, 'n_cells', 0) + shown = getattr(obj.display_mesh, 'n_cells', 0) self.filter_status_label.setText(f"Showing {shown} of {total} cells") - 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 + @staticmethod + def _array_name(scalar_name: str): + """The array name of a combo box entry, or None for no array.""" + if not scalar_name or scalar_name == "": + return None + return scalar_name.split(':', 1)[1] if scalar_name.startswith('cell:') else scalar_name - # try in-place update + def _colormap_range(self): + """The colormap range from the two text boxes, or None.""" try: - self._apply_scalar_to_actor(self.current_object_name, scalar_name) - return - except Exception: + if self.range_min.text() and self.range_max.text(): + return (float(self.range_min.text()), float(self.range_max.text())) + except ValueError: pass + return None - # 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: + def _on_scalar_changed(self, scalar_name: str): + vals = self._get_scalar_values(scalar_name) + self._update_histogram(vals if self.color_with_scalar_checkbox.isChecked() else None) + obj = self._object() + if obj 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() + # if not coloring by scalar, only update metadata + if not self.color_with_scalar_checkbox.isChecked(): + obj.update_kwargs(scalars=None) + return + self._color_by_array(obj, scalar_name, self.colormap_combo.currentText() or None) - source = self.viewer.get_source_metadata(self.current_object_name) + def _color_by_array(self, obj, scalar_name: str, cmap): + """Colour the object with an array: try an in-place update of the + actor, and add the object again if that fails.""" try: - self.viewer.remove_object(self.current_object_name) + self._apply_scalar_to_actor(obj, scalar_name) + return except Exception: pass - + scalars = self._array_name(scalar_name) + source = self.viewer.get_source_metadata(obj.name) + old_kwargs = dict(obj.kwargs) + self.viewer.remove_object(obj.name) try: self.viewer.add_mesh_object( - mesh, - name=self.current_object_name, + obj.mesh, + name=obj.name, scalars=scalars, cmap=cmap, - clim=clim, - opacity=opacity, - show_scalar_bar=show_scalar_bar, + clim=self._colormap_range(), + opacity=old_kwargs.get('opacity'), + show_scalar_bar=self.scalar_bar_checkbox.isChecked(), **source, ) - 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, **source) - self.current_mesh = self.viewer.meshes.get(self.current_object_name, {}).get('mesh') - except Exception: - pass + self.viewer.add_mesh_object(obj.mesh, name=obj.name, **source) + new_obj = self._object() + self.current_mesh = new_obj.mesh if new_obj else None 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 + 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 and self.scalar_combo.count() > 0) + + obj = self._object() + if obj is None: + return + if checked: 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 + self._apply_scalar_to_actor(obj, self.scalar_combo.currentText()) 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 + return + mapper = getattr(obj.actor, 'mapper', None) + if mapper is not None: try: - self._apply_scalar_to_actor(self.current_object_name, scalar_name) - return + mapper.scalar_visibility = False 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 + color = obj.color or obj.kwargs.get('color') + prop = self._actor_prop() + if color is not None and prop is not 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() - - source = self.viewer.get_source_metadata(self.current_object_name) - try: - self.viewer.remove_object(self.current_object_name) + prop.color = color except Exception: pass + self._render() - 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, - **source, - ) - 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, **source) - self.current_mesh = self.viewer.meshes.get(self.current_object_name, {}).get( - 'mesh' - ) - except Exception: - pass - except Exception: - pass + def _on_colormap_changed(self, cmap: str): + """Apply or persist selected colormap for the current object.""" + obj = self._object() + if obj is None: + return + # persist cmap in metadata even when not coloring by scalar + obj.update_kwargs(cmap=cmap or None) + # 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 self._array_name(scalar_name) is None: + return + self._color_by_array(obj, scalar_name, cmap or None) def _get_scalar_values(self, scalar_name: str): return get_scalar_values(self.current_mesh, scalar_name) @@ -951,168 +752,49 @@ def _update_histogram(self, values): except Exception: pass - def _update_actor_mapper(self, mesh_entry, scalars, cmap, clim, values, actor, plotter): - """Centralized actor/mapper update: - - select/enable scalar array - - set scalar range - - build and assign a LUT from matplotlib cmap when possible - - persist kwargs and trigger render - """ - try: - mapper = getattr(actor, 'mapper', None) - # if plotter can update scalars more directly, prefer that - if plotter is not None and hasattr(plotter, 'update_scalars') and values is not None: - try: - plotter.update_scalars( - values, - mesh=mesh_entry.get('mesh'), - render=False, - name=self.current_object_name, - ) - except Exception: - pass - - if mapper is None: - return - - # select color array - try: - if scalars and hasattr(mapper, 'SelectColorArray'): - try: - mapper.SelectColorArray(scalars) - except Exception: - pass - except Exception: - pass - try: - if scalars and hasattr(mapper, 'SetArrayName'): - try: - mapper.SetArrayName(scalars) - except Exception: - pass - except Exception: - pass - - # enable scalar visibility - try: - if hasattr(mapper, 'scalar_visibility'): - try: - mapper.scalar_visibility = True - except Exception: - pass - except Exception: - pass - try: - if hasattr(mapper, 'ScalarVisibilityOn'): - try: - mapper.ScalarVisibilityOn() - except Exception: - pass - except Exception: - pass - - # set scalar range - try: - mn = mx = None - if clim: - mn, mx = float(clim[0]), float(clim[1]) - else: - try: - import numpy as _np - - arr = _np.asarray(values, dtype=float) if values is not None else None - finite = arr[_np.isfinite(arr)] if arr is not None else None - if finite is not None and finite.size > 0: - mn = float(_np.min(finite)) - mx = float(_np.max(finite)) - 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 - apply_colormap_lut(mapper, cmap, clim) - - # 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) + def _apply_scalar_to_actor(self, obj, scalar_name: str): + """Colour the actor of an object with an array, without adding the + object again. Raises if the array is not on the mesh.""" + scalars = self._array_name(scalar_name) + if scalars is None: + # no array: switch the mapping off + mapper = getattr(obj.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 + mapper.scalar_visibility = False + obj.update_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') + self._update_actor_mapper( + obj, + scalars, + self.colormap_combo.currentText() or None, + self._colormap_range(), + 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 + def _update_actor_mapper(self, obj, scalars, cmap, clim, values): + """Select the array, set the scalar range, assign a LUT from a + matplotlib colormap, and store the settings.""" + mapper = getattr(obj.actor, 'mapper', None) + if mapper is None: + return + mapper.SelectColorArray(scalars) + mapper.scalar_visibility = True + if clim: + low, high = float(clim[0]), float(clim[1]) + else: + finite = np.asarray(values, dtype=float) + finite = finite[np.isfinite(finite)] + low, high = (float(finite.min()), float(finite.max())) if finite.size else (None, None) + if low is not None: + mapper.SetScalarRange(low, high) + apply_colormap_lut(mapper, cmap, clim) + + settings = {'scalars': scalars, 'cmap': cmap or None} + if clim is not None: + settings['clim'] = (float(clim[0]), float(clim[1])) + obj.update_kwargs(**settings) + self._render() diff --git a/loopstructural/gui/visualisation/object_registry.py b/loopstructural/gui/visualisation/object_registry.py new file mode 100644 index 00000000..e47acb69 --- /dev/null +++ b/loopstructural/gui/visualisation/object_registry.py @@ -0,0 +1,183 @@ +"""The objects of the 3D viewer, and the information to build them again. + +No Qt code. The viewer owns one `ObjectRegistry`. The widgets read and change +the objects only through it. +""" + +from typing import Any, Dict, Iterator, List, Optional, Tuple + +# Objects that are built from one feature of the model, by `source_type` +FEATURE_SOURCE_TYPES = frozenset( + { + 'feature_scalar', + 'feature_surface', + 'feature_isosurface', + 'feature_vector', + 'feature_vectors', + 'feature_points', + 'feature_data', + } +) +# Objects that are coloured by the stratigraphic column +STRATIGRAPHY_COLOURED_SOURCE_TYPES = frozenset( + {'cross_section_plane', 'cross_section_line', 'block_model'} +) +# Objects that the update action can build again from the model +REBUILDABLE_SOURCE_TYPES = FEATURE_SOURCE_TYPES | frozenset( + { + 'bounding_box', + 'fault_surface', + 'stratigraphic_surface', + 'cross_section_plane', + 'cross_section_line', + 'block_model', + 'topography_surface', + } +) +FOLD_CONSTRAINT_PREFIX = 'fold_constraint_' + +# Text for the object list: the group of objects without a source feature +NO_FEATURE_GROUP = 'Other objects' +MODEL_GROUP = 'Model' + + +class ViewerObject: + """One mesh in the viewer, with its source and its display settings.""" + + def __init__( + self, + name: str, + mesh, + actor=None, + kwargs: Optional[Dict[str, Any]] = None, + source_feature: Optional[str] = None, + source_type: Optional[str] = None, + isovalue: Optional[float] = None, + metadata: Optional[Dict[str, Any]] = None, + out_of_date: bool = False, + threshold: Optional[Dict[str, Any]] = None, + display_mesh=None, + ): + self.name = name + self.mesh = mesh + self.actor = actor + # the arguments that were given to `add_mesh` + self.kwargs: Dict[str, Any] = dict(kwargs or {}) + self.source_feature = source_feature + self.source_type = source_type + self.isovalue = isovalue + self.metadata: Dict[str, Any] = dict(metadata or {}) + self.out_of_date = out_of_date + self.threshold = dict(threshold) if threshold else None + # the part of `mesh` that is shown (the mesh without a filter) + self.display_mesh = display_mesh if display_mesh is not None else mesh + # the colour that the user picked + self.color: Optional[Tuple[float, float, float]] = None + + @property + def source_type_text(self) -> str: + return self.source_type or '' + + @property + def is_rebuildable(self) -> bool: + source_type = self.source_type_text + return source_type in REBUILDABLE_SOURCE_TYPES or source_type.startswith( + FOLD_CONSTRAINT_PREFIX + ) + + @property + def uses_stratigraphy_colours(self) -> bool: + if self.source_type_text in STRATIGRAPHY_COLOURED_SOURCE_TYPES: + return True + return self.source_type_text == 'topography_surface' and bool(self.metadata.get('coloured')) + + @property + def is_isosurface(self) -> bool: + return self.source_type == 'feature_isosurface' + + def source_values(self) -> Dict[str, Any]: + """The source values as keyword arguments for `add_mesh_object`.""" + return { + 'source_feature': self.source_feature, + 'source_type': self.source_type, + 'isovalue': self.isovalue, + 'metadata': self.metadata, + 'out_of_date': bool(self.out_of_date), + 'threshold': self.threshold, + } + + def update_kwargs(self, **values) -> None: + """Change the stored display settings.""" + self.kwargs = {**self.kwargs, **values} + + +class ObjectRegistry: + """The viewer objects by name, in the order they were added.""" + + def __init__(self): + self._objects: Dict[str, ViewerObject] = {} + + def __contains__(self, name: str) -> bool: + return name in self._objects + + def __len__(self) -> int: + return len(self._objects) + + def __iter__(self) -> Iterator[ViewerObject]: + return iter(list(self._objects.values())) + + def get(self, name: Optional[str]) -> Optional[ViewerObject]: + """Return the object with this name, or None.""" + if name is None: + return None + return self._objects.get(name) + + def names(self) -> List[str]: + return list(self._objects) + + def add(self, obj: ViewerObject) -> ViewerObject: + """Add an object. An object with the same name is replaced.""" + self._objects[obj.name] = obj + return obj + + def remove(self, name: str) -> Optional[ViewerObject]: + return self._objects.pop(name, None) + + def unique_name(self, base: str) -> str: + """Return `base`, or `base_2`, `base_3`, ... if the name is used.""" + if base not in self._objects: + return base + i = 2 + while f'{base}_{i}' in self._objects: + i += 1 + return f'{base}_{i}' + + def rebuildable(self) -> List[ViewerObject]: + return [obj for obj in self if obj.is_rebuildable] + + def out_of_date(self) -> List[str]: + return [obj.name for obj in self if obj.out_of_date] + + def of_feature(self, feature: str, source_type: Optional[str] = None) -> List[ViewerObject]: + """The objects that are built from a feature.""" + return [ + obj + for obj in self + if obj.source_feature == feature + and (source_type is None or obj.source_type == source_type) + ] + + def grouped_by_feature(self) -> List[Tuple[str, List[ViewerObject]]]: + """The objects in groups by source feature, sorted by group name and + then by object name. Objects without a feature are in the last group. + """ + groups: Dict[str, List[ViewerObject]] = {} + for obj in self: + if obj.source_feature == '__model__': + key = MODEL_GROUP + else: + key = obj.source_feature or NO_FEATURE_GROUP + groups.setdefault(key, []).append(obj) + special = (MODEL_GROUP, NO_FEATURE_GROUP) + keys = sorted(k for k in groups if k not in special) + [k for k in special if k in groups] + return [(k, sorted(groups[k], key=lambda o: o.name)) for k in keys] diff --git a/loopstructural/gui/visualisation/viewer_actions_widget.py b/loopstructural/gui/visualisation/viewer_actions_widget.py new file mode 100644 index 00000000..06cf9ecb --- /dev/null +++ b/loopstructural/gui/visualisation/viewer_actions_widget.py @@ -0,0 +1,856 @@ +import logging +from typing import Optional + +import numpy as np +from qgis.core import QgsApplication, QgsMapLayerProxyModel +from qgis.gui import QgsMapLayerComboBox +from qgis.PyQt.QtCore import QSize +from qgis.PyQt.QtGui import QIcon +from qgis.PyQt.QtWidgets import ( + QCheckBox, + QDialog, + QDoubleSpinBox, + QFormLayout, + QHBoxLayout, + QLabel, + QMessageBox, + QPushButton, + QSpinBox, + QTabWidget, + QToolButton, + QVBoxLayout, + QWidget, +) + +from loopstructural.__about__ import DIR_PLUGIN_ROOT + +from ..background_task import finish_background_task, start_background_task +from ..compatibility import configure_layer_combo +from .cross_section_utils import ( + build_block_model_mesh, + build_line_extrusion_mesh, + build_plane_mesh, +) +from .line_layer import line_xy_from_layer +from .mesh_builders import MeshBuilder, set_stratigraphy_arrays + +logger = logging.getLogger(__name__) + + +class ViewerActionsWidget(QWidget): + """The row of actions that add model-wide objects to the viewer: the + bounding box, the fault and stratigraphic surfaces, the topography, cross + sections and the block model. + + The long actions (topography, cross sections, block model) run on a + background thread. The meshes come from `MeshBuilder`; this widget adds + them to the viewer. + """ + + def __init__(self, parent=None, *, model_manager=None, viewer=None, data_manager=None): + super().__init__(parent) + self.model_manager = model_manager + self.viewer = viewer + self.data_manager = data_manager + self.builder = MeshBuilder(model_manager) + + self.addBoundingBoxButton = self._make_tool_button("extents.svg", "Add Model Bounding Box") + self.addFaultSurfacesButton = self._make_custom_icon_tool_button( + "fault.svg", "Add Fault Surfaces" + ) + self.addStratigraphicSurfacesButton = self._make_custom_icon_tool_button( + "stratigraphic_column_icon.svg", "Add Stratigraphic Surfaces" + ) + self.addTopographyButton = self._make_tool_button( + "mActionAddRasterLayer.svg", "Add Topography Surface" + ) + self.colourTopographyByStratigraphyCheckBox = QCheckBox( + "Colour Topography by Stratigraphic Column", self + ) + self.colourTopographyByStratigraphyCheckBox.setEnabled(False) + + # Connect buttons to their respective methods + self.addBoundingBoxButton.clicked.connect(self.add_model_bounding_box) + self.addFaultSurfacesButton.clicked.connect(self.add_fault_surfaces) + self.addStratigraphicSurfacesButton.clicked.connect(self.add_stratigraphic_surfaces) + self.addTopographyButton.clicked.connect(self.add_topography_surface) + self.colourTopographyByStratigraphyCheckBox.toggled.connect( + self._on_colour_topography_toggled + ) + + # background task handles for the topography surface (grid sampling and + # stratigraphy evaluation both run off the GUI thread; see background_task.py) + self._topography_thread = None + self._topography_worker = None + self._topography_progress = None + + self._build_cross_section_controls() + self._build_block_model_controls() + + # A single row of icon-only actions, in workflow order, replaces the + # previous stack of full-width text buttons. + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + actionsRow = QHBoxLayout() + actionsRow.addWidget(self.addBoundingBoxButton) + actionsRow.addWidget(self.addFaultSurfacesButton) + actionsRow.addWidget(self.addStratigraphicSurfacesButton) + actionsRow.addWidget(self.addTopographyButton) + actionsRow.addWidget(self.crossSectionButton) + actionsRow.addWidget(self.blockModelButton) + actionsRow.addStretch(1) + layout.addLayout(actionsRow) + layout.addWidget(self.colourTopographyByStratigraphyCheckBox) + + # background task handles shared by the plane and line cross-section + # actions (only one can run at a time) + self._cross_section_thread = None + self._cross_section_worker = None + self._cross_section_progress = None + self._pending_cross_section_name = None + + # background task handles for the block model + self._block_model_thread = None + self._block_model_worker = None + self._block_model_progress = None + self._pending_block_model_name = None + # Whether the user has hand-edited the plane's origin/normal/size -- + # while False, `on_model_updated` keeps re-syncing those fields to + # the model's current bounding box (see + # `_reset_plane_cross_section_defaults`). `model_manager.model` is + # never None -- it starts out as a placeholder unit-cube model before + # a real bounding box is set -- so this can't be a one-shot flag set + # on first model sight; it has to keep tracking the *current* box + # until the user opts out by typing a value in themselves. + self._plane_defaults_dirty = False + + def on_model_updated(self): + """Point the cross-section defaults at the current model.""" + if self.model_manager.model is not None and not self._plane_defaults_dirty: + self._reset_plane_cross_section_defaults() + + def _make_tool_button(self, theme_icon_name: str, tooltip: str) -> QToolButton: + """Build a small icon-only tool button using a QGIS theme icon, with + the given tooltip standing in for the label text it no longer shows. + """ + return self._build_tool_button(QgsApplication.getThemeIcon(theme_icon_name), tooltip) + + def _make_custom_icon_tool_button(self, icon_filename: str, tooltip: str) -> QToolButton: + """Build a small icon-only tool button using one of this plugin's own + icons (see resources/images), for geological concepts QGIS's own + theme has no dedicated icon for. + """ + icon_path = str(DIR_PLUGIN_ROOT / "resources" / "images" / icon_filename) + return self._build_tool_button(QIcon(icon_path), tooltip) + + def _build_tool_button(self, icon: QIcon, tooltip: str) -> QToolButton: + button = QToolButton(self) + button.setIcon(icon) + button.setIconSize(QSize(22, 22)) + button.setToolTip(tooltip) + button.setAutoRaise(True) + return button + + def _build_cross_section_controls(self): + """Build the "Cross Section" icon button (added to the shared actions + row in __init__), which opens a dialog with a plane (origin + normal) + mode and a line-extrusion (pick a QGIS line layer) mode as separate + tabs. Both are added to the viewer coloured by the stratigraphic + column, reusing the same evaluate-points -> ids -> rgb pipeline as + topography colouring. + """ + self.crossSectionButton = self._make_custom_icon_tool_button( + "cross_section.svg", "Cross Section..." + ) + self.crossSectionButton.clicked.connect(self._show_cross_section_dialog) + + self.crossSectionDialog = QDialog(self) + self.crossSectionDialog.setWindowTitle("Cross Section") + dialogLayout = QVBoxLayout(self.crossSectionDialog) + tabs = QTabWidget(self.crossSectionDialog) + dialogLayout.addWidget(tabs) + + planeTab = QWidget(self.crossSectionDialog) + groupLayout = QVBoxLayout(planeTab) + + groupLayout.addWidget(QLabel("Plane (origin + normal)")) + planeForm = QFormLayout() + + def make_coord_spinbox(default=0.0): + box = QDoubleSpinBox(self) + box.setRange(-1e9, 1e9) + box.setDecimals(2) + box.setValue(default) + return box + + self.crossSectionOriginXSpinBox = make_coord_spinbox() + self.crossSectionOriginYSpinBox = make_coord_spinbox() + self.crossSectionOriginZSpinBox = make_coord_spinbox() + self.crossSectionNormalXSpinBox = make_coord_spinbox(1.0) + self.crossSectionNormalYSpinBox = make_coord_spinbox(0.0) + self.crossSectionNormalZSpinBox = make_coord_spinbox(0.0) + self.crossSectionSizeSpinBox = make_coord_spinbox(1000.0) + self.crossSectionSizeSpinBox.setRange(0.01, 1e9) + self.crossSectionResolutionSpinBox = QSpinBox(self) + self.crossSectionResolutionSpinBox.setRange(2, 500) + self.crossSectionResolutionSpinBox.setValue(50) + + # Track whether the user has hand-edited the plane's geometry so we + # know when to stop re-syncing it to the model's bounding box (see + # `_reset_plane_cross_section_defaults`). + for box in ( + self.crossSectionOriginXSpinBox, + self.crossSectionOriginYSpinBox, + self.crossSectionOriginZSpinBox, + self.crossSectionNormalXSpinBox, + self.crossSectionNormalYSpinBox, + self.crossSectionNormalZSpinBox, + self.crossSectionSizeSpinBox, + ): + box.valueChanged.connect(self._mark_plane_defaults_dirty) + + planeForm.addRow( + "Origin (x, y, z)", + self._hbox( + self.crossSectionOriginXSpinBox, + self.crossSectionOriginYSpinBox, + self.crossSectionOriginZSpinBox, + ), + ) + planeForm.addRow( + "Normal (x, y, z)", + self._hbox( + self.crossSectionNormalXSpinBox, + self.crossSectionNormalYSpinBox, + self.crossSectionNormalZSpinBox, + ), + ) + planeForm.addRow("Size", self.crossSectionSizeSpinBox) + planeForm.addRow("Resolution", self.crossSectionResolutionSpinBox) + groupLayout.addLayout(planeForm) + + self.addPlaneCrossSectionButton = QPushButton("Add Plane Cross Section", self) + self.addPlaneCrossSectionButton.clicked.connect(self.add_plane_cross_section) + groupLayout.addWidget(self.addPlaneCrossSectionButton) + groupLayout.addStretch(1) + tabs.addTab(planeTab, "Plane") + + lineTab = QWidget(self.crossSectionDialog) + lineTabLayout = QVBoxLayout(lineTab) + lineTabLayout.addWidget(QLabel("Extrude a QGIS line layer vertically")) + lineForm = QFormLayout() + + self.crossSectionLineLayerComboBox = QgsMapLayerComboBox(self) + configure_layer_combo( + self.crossSectionLineLayerComboBox, QgsMapLayerProxyModel.Filter.LineLayer + ) + lineForm.addRow("Line layer", self.crossSectionLineLayerComboBox) + + self.crossSectionLineResolutionSpinBox = QSpinBox(self) + self.crossSectionLineResolutionSpinBox.setRange(2, 500) + self.crossSectionLineResolutionSpinBox.setValue(50) + lineForm.addRow("Resolution along line", self.crossSectionLineResolutionSpinBox) + + self.crossSectionLineVerticalResolutionSpinBox = QSpinBox(self) + self.crossSectionLineVerticalResolutionSpinBox.setRange(2, 500) + self.crossSectionLineVerticalResolutionSpinBox.setValue(50) + lineForm.addRow("Vertical resolution", self.crossSectionLineVerticalResolutionSpinBox) + lineTabLayout.addLayout(lineForm) + + self.addLineCrossSectionButton = QPushButton("Add Cross Section from Line", self) + self.addLineCrossSectionButton.clicked.connect(self.add_line_cross_section) + lineTabLayout.addWidget(self.addLineCrossSectionButton) + lineTabLayout.addStretch(1) + tabs.addTab(lineTab, "Line") + + closeButton = QPushButton("Close", self.crossSectionDialog) + closeButton.clicked.connect(self.crossSectionDialog.close) + dialogLayout.addWidget(closeButton) + + def _build_block_model_controls(self): + """Build the "Block Model" icon button (added to the shared actions + row in __init__), which opens a dialog to set the number of blocks + along each axis. The block model fills the model bounding box and is + coloured by the stratigraphic column, like the cross sections. + """ + self.blockModelButton = self._make_custom_icon_tool_button( + "block_model.svg", "Block Model..." + ) + self.blockModelButton.clicked.connect(self._show_block_model_dialog) + self._block_model_resolution_initialised = False + + self.blockModelDialog = QDialog(self) + self.blockModelDialog.setWindowTitle("Block Model") + dialogLayout = QVBoxLayout(self.blockModelDialog) + dialogLayout.addWidget(QLabel("Blocks fill the model bounding box")) + form = QFormLayout() + + def make_cells_spinbox(): + box = QSpinBox(self) + box.setRange(1, 1000) + box.setValue(50) + return box + + self.blockModelNxSpinBox = make_cells_spinbox() + self.blockModelNySpinBox = make_cells_spinbox() + self.blockModelNzSpinBox = make_cells_spinbox() + form.addRow( + "Blocks (x, y, z)", + self._hbox( + self.blockModelNxSpinBox, self.blockModelNySpinBox, self.blockModelNzSpinBox + ), + ) + self.blockModelUseModelResolutionButton = QPushButton("Use Model Resolution", self) + self.blockModelUseModelResolutionButton.setToolTip( + "Set the number of blocks to the model's interpolation grid" + ) + self.blockModelUseModelResolutionButton.clicked.connect(self._reset_block_model_resolution) + form.addRow("", self.blockModelUseModelResolutionButton) + dialogLayout.addLayout(form) + + self.addBlockModelButton = QPushButton("Add Block Model", self) + self.addBlockModelButton.clicked.connect(self.add_block_model) + dialogLayout.addWidget(self.addBlockModelButton) + + closeButton = QPushButton("Close", self.blockModelDialog) + closeButton.clicked.connect(self.blockModelDialog.close) + dialogLayout.addWidget(closeButton) + + def _show_block_model_dialog(self): + # Start from the model's own resolution the first time only, so the + # user's block counts are kept between openings of the dialog. + if not self._block_model_resolution_initialised: + self._reset_block_model_resolution() + self._block_model_resolution_initialised = True + self.blockModelDialog.show() + self.blockModelDialog.raise_() + self.blockModelDialog.activateWindow() + + def _reset_block_model_resolution(self): + if not self.model_manager or self.model_manager.model is None: + return + try: + nsteps = np.asarray(self.model_manager.model.bounding_box.nsteps, dtype=int) + except Exception: + logger.info("Model bounding box has no resolution.") + return + for box, value in zip( + (self.blockModelNxSpinBox, self.blockModelNySpinBox, self.blockModelNzSpinBox), + nsteps, + ): + box.setValue(int(value)) + + def _show_cross_section_dialog(self): + self.crossSectionDialog.show() + self.crossSectionDialog.raise_() + self.crossSectionDialog.activateWindow() + + @staticmethod + def _hbox(*widgets): + container = QWidget() + layout = QHBoxLayout(container) + layout.setContentsMargins(0, 0, 0, 0) + for w in widgets: + layout.addWidget(w) + return container + + def add_model_bounding_box(self): + if not self.model_manager: + logger.info("Model manager is not set.") + return + self.viewer.add_mesh_object( + self.builder.bounding_box(), + name='model_bounding_box', + source_feature='__model__', + source_type='bounding_box', + ) + logger.info("Adding model bounding box...") + + def add_fault_surfaces(self): + if not self.model_manager: + logger.info("Model manager is not set.") + return + self.model_manager.update_all_features(subset='faults') + fault_surfaces = self.model_manager.model.get_fault_surfaces() + for surface in fault_surfaces: + self.viewer.add_mesh_object( + surface.vtk(), + name=f'fault_surface_{surface.name}', + source_feature=surface.name, + source_type='fault_surface', + isovalue=0.0, + ) + logger.info("Adding fault surfaces...") + + def add_stratigraphic_surfaces(self): + if not self.model_manager: + logger.info("Model manager is not set.") + return + stratigraphic_surfaces = self.model_manager.model.get_stratigraphic_surfaces() + + for surface in stratigraphic_surfaces: + mesh = surface.vtk() + if mesh.n_points == 0: + # A unit with no digitised data of its own (e.g. an + # undigitised placeholder like "Top") can have no + # constrained geometry anywhere in the model, so its + # isovalue may not intersect the solved field at all -- + # pyvista refuses to plot an empty mesh, so skip it rather + # than crashing every surface after it in this loop. + logger.info(f"Skipping '{surface.name}': isosurface has no geometry.") + continue + self.viewer.add_mesh_object( + mesh, + name=surface.name, + color=surface.colour, + source_feature=surface.name, + isovalue=np.mean(surface.values), + source_type='stratigraphic_surface', + ) + + def add_topography_surface(self): + """Sample the current DEM across the model extent and add it to the + viewer as a surface, shaded by elevation. + + DEM sampling runs on a background thread (it calls into the QGIS + raster provider once per grid point) so the UI doesn't freeze; see + `_on_topography_grid_finished` for where the mesh is actually added. + """ + if not self.model_manager: + logger.info("Model manager is not set.") + return + if self.model_manager.model is None: + logger.info("No model available to build a topography surface.") + return + + def target(progress_callback): + progress_callback("Sampling DEM...") + return self.model_manager.sample_dem_grid() + + self.addTopographyButton.setEnabled(False) + self._topography_thread, self._topography_worker, self._topography_progress = ( + start_background_task( + self, + target, + title="Topography", + initial_label="Sampling DEM...", + on_progress=self._on_topography_progress, + on_finished=self._on_topography_grid_finished, + on_error=self._on_topography_error, + ) + ) + + def _on_topography_progress(self, message): + try: + self._topography_progress.setLabelText(message) + except Exception: + pass + + def _on_topography_grid_finished(self, result): + finish_background_task( + self._topography_thread, self._topography_worker, self._topography_progress + ) + self.addTopographyButton.setEnabled(True) + + mesh = self.builder.topography_from_grid(*result) + self.viewer.add_mesh_object( + mesh, + name='topography_surface', + scalars='Elevation', + cmap='terrain', + show_scalar_bar=True, + source_type='topography_surface', + metadata={'coloured': False}, + ) + self.colourTopographyByStratigraphyCheckBox.setEnabled(True) + if self.colourTopographyByStratigraphyCheckBox.isChecked(): + self._colour_topography_surface() + logger.info("Adding topography surface...") + + def _on_topography_error(self, traceback_text): + finish_background_task( + self._topography_thread, self._topography_worker, self._topography_progress + ) + self.addTopographyButton.setEnabled(True) + self.colourTopographyByStratigraphyCheckBox.setEnabled( + 'topography_surface' in self.viewer.registry + ) + logger.error(f"Failed to build topography surface: {traceback_text}") + + def _on_colour_topography_toggled(self, checked): + if 'topography_surface' not in self.viewer.registry: + return + if checked: + self._colour_topography_surface() + else: + mesh = self.viewer.registry.get('topography_surface').mesh + self.viewer.add_mesh_object( + mesh, + name='topography_surface', + scalars='Elevation', + cmap='terrain', + show_scalar_bar=True, + source_type='topography_surface', + metadata={'coloured': False}, + ) + + def _colour_topography_surface(self): + """Evaluate the stratigraphic unit at each topography point and + recolour the surface using the stratigraphic column's unit colours. + + Evaluating the full model at every grid point can be slow, so this + also runs on a background thread (see `add_topography_surface`). + """ + if not self.model_manager: + logger.info("Model manager is not set.") + return + missing = self.model_manager.get_units_without_colour() + if missing: + QMessageBox.warning( + self, + "Missing unit colour", + "Cannot colour the topography by stratigraphy. These units have no " + "valid colour in the stratigraphic column:\n\n" + + "\n".join(missing) + + "\n\nSet a colour for each unit and try again.", + ) + self.colourTopographyByStratigraphyCheckBox.blockSignals(True) + self.colourTopographyByStratigraphyCheckBox.setChecked(False) + self.colourTopographyByStratigraphyCheckBox.blockSignals(False) + return + mesh = self.viewer.registry.get('topography_surface').mesh + + def target(progress_callback): + progress_callback("Evaluating stratigraphy on topography...") + ids = self.model_manager.evaluate_stratigraphy_on_points(mesh.points) + colours = self.model_manager.get_stratigraphic_column_colours() + return ids, colours + + self.colourTopographyByStratigraphyCheckBox.setEnabled(False) + self._topography_thread, self._topography_worker, self._topography_progress = ( + start_background_task( + self, + target, + title="Topography", + initial_label="Evaluating stratigraphy on topography...", + on_progress=self._on_topography_progress, + on_finished=self._on_topography_colour_finished, + on_error=self._on_topography_error, + ) + ) + + def _on_topography_colour_finished(self, result): + finish_background_task( + self._topography_thread, self._topography_worker, self._topography_progress + ) + self.colourTopographyByStratigraphyCheckBox.setEnabled(True) + + ids, colours = result + mesh = self.viewer.registry.get('topography_surface').mesh + set_stratigraphy_arrays(mesh.point_data, ids, colours) + metadata = {'coloured': True, **self.builder.stratigraphy_metadata(colours)} + self.viewer.add_mesh_object( + mesh, + name='topography_surface', + scalars='colour', + rgb=True, + show_scalar_bar=False, + # Directional lighting shades the same colour differently depending + # on the DEM's local slope, which makes a unit's colour on the + # topography look like it doesn't match that same unit's colour on + # the (much flatter) stratigraphic surfaces. Flat/unlit shading + # keeps it a true, direct colour-for-colour match. + lighting=False, + source_type='topography_surface', + metadata=metadata, + ) + logger.info("Coloured topography surface by stratigraphic column.") + + def _mark_plane_defaults_dirty(self, _value=None): + self._plane_defaults_dirty = True + + def _reset_plane_cross_section_defaults(self): + """Point the plane cross-section controls at the model: origin at the + bounding box centre, normal along X, size the bounding box diagonal. + + Called on every model update (see `update_feature_list`), not just + once, since `model_manager.model` -- and so its bounding box -- exists + (as a unit-cube placeholder) before the user has set a real one. + Values are set with signals blocked so this doesn't itself trip + `_mark_plane_defaults_dirty` and disable future re-syncing. + """ + bb = self.model_manager.model.bounding_box + origin = np.asarray(bb.origin, dtype=float) + maximum = np.asarray(bb.maximum, dtype=float) + centre = (origin + maximum) / 2.0 + diagonal = float(np.linalg.norm(maximum - origin)) + for box, value in ( + (self.crossSectionOriginXSpinBox, float(centre[0])), + (self.crossSectionOriginYSpinBox, float(centre[1])), + (self.crossSectionOriginZSpinBox, float(centre[2])), + (self.crossSectionNormalXSpinBox, 1.0), + (self.crossSectionNormalYSpinBox, 0.0), + (self.crossSectionNormalZSpinBox, 0.0), + (self.crossSectionSizeSpinBox, diagonal if diagonal > 0 else 1000.0), + ): + box.blockSignals(True) + box.setValue(value) + box.blockSignals(False) + + def _unique_cross_section_name(self, base: str) -> str: + return self.viewer.registry.unique_name(base) + + def add_plane_cross_section(self): + """Build a plane (origin + normal) cross section and colour it by the + stratigraphic column. + + Building the plane is cheap; evaluating the stratigraphic column at + every plane point is not, so both run on a background thread (see + `add_topography_surface` for the same pattern). + """ + if not self.model_manager: + logger.info("Model manager is not set.") + return + if self.model_manager.model is None: + logger.info("No model available to build a cross section.") + return + + origin = ( + self.crossSectionOriginXSpinBox.value(), + self.crossSectionOriginYSpinBox.value(), + self.crossSectionOriginZSpinBox.value(), + ) + normal = ( + self.crossSectionNormalXSpinBox.value(), + self.crossSectionNormalYSpinBox.value(), + self.crossSectionNormalZSpinBox.value(), + ) + if np.allclose(normal, 0.0): + logger.info("Cross section normal cannot be the zero vector.") + return + size = self.crossSectionSizeSpinBox.value() + resolution = self.crossSectionResolutionSpinBox.value() + + def target(progress_callback): + progress_callback("Building cross section plane...") + mesh = build_plane_mesh(origin, normal, size, resolution) + progress_callback("Evaluating stratigraphy on cross section...") + ids = self.model_manager.evaluate_stratigraphy_on_points(mesh.points) + colours = self.model_manager.get_stratigraphic_column_colours() + return mesh, ids, colours + + self._pending_cross_section_name = self._unique_cross_section_name('cross_section_plane') + self.addPlaneCrossSectionButton.setEnabled(False) + self._cross_section_thread, self._cross_section_worker, self._cross_section_progress = ( + start_background_task( + self, + target, + title="Cross Section", + initial_label="Building cross section plane...", + on_progress=self._on_cross_section_progress, + on_finished=self._on_plane_cross_section_finished, + on_error=self._on_cross_section_error, + ) + ) + + def _extract_line_xy(self, layer) -> Optional[np.ndarray]: + """Return an (M, 2) array of ordered vertices for `layer`'s first + selected feature (or first feature, if nothing is selected), + reprojected into the model's CRS if one is available. + """ + target_crs = None + if self.data_manager is not None: + try: + target_crs = self.data_manager.get_model_crs() + except Exception: + target_crs = None + return line_xy_from_layer(layer, target_crs) + + def add_line_cross_section(self): + """Extrude the selected QGIS line layer vertically across the model's + Z range, colouring the resulting cross section by the stratigraphic + column. See `add_plane_cross_section` for the plane-based equivalent. + """ + if not self.model_manager: + logger.info("Model manager is not set.") + return + if self.model_manager.model is None: + logger.info("No model available to build a cross section.") + return + layer = self.crossSectionLineLayerComboBox.currentLayer() + if layer is None: + logger.info("No line layer selected for cross section.") + return + coords_xy = self._extract_line_xy(layer) + if coords_xy is None: + logger.info("Selected layer has no usable line geometry for a cross section.") + return + + bb = self.model_manager.model.bounding_box + z_min, z_max = float(bb.origin[2]), float(bb.maximum[2]) + resolution = self.crossSectionLineResolutionSpinBox.value() + z_resolution = self.crossSectionLineVerticalResolutionSpinBox.value() + + def target(progress_callback): + progress_callback("Building cross section from line...") + mesh = build_line_extrusion_mesh( + coords_xy, z_min, z_max, resolution=resolution, z_resolution=z_resolution + ) + progress_callback("Evaluating stratigraphy on cross section...") + ids = self.model_manager.evaluate_stratigraphy_on_points(mesh.points) + colours = self.model_manager.get_stratigraphic_column_colours() + return mesh, ids, colours + + self._pending_cross_section_name = self._unique_cross_section_name( + f'cross_section_line_{layer.name()}' + ) + self.addLineCrossSectionButton.setEnabled(False) + self._cross_section_thread, self._cross_section_worker, self._cross_section_progress = ( + start_background_task( + self, + target, + title="Cross Section", + initial_label="Building cross section from line...", + on_progress=self._on_cross_section_progress, + on_finished=self._on_line_cross_section_finished, + on_error=self._on_cross_section_error, + ) + ) + + def _on_cross_section_progress(self, message): + try: + self._cross_section_progress.setLabelText(message) + except Exception: + pass + + def _add_cross_section_mesh(self, result, source_type): + mesh, ids, colours = result + set_stratigraphy_arrays(mesh.point_data, ids, colours) + self.viewer.add_mesh_object( + mesh, + name=self._pending_cross_section_name, + scalars='colour', + rgb=True, + show_scalar_bar=False, + # see `_on_topography_colour_finished` for why cross sections use + # flat/unlit shading: it keeps a unit's colour the same regardless + # of how the section plane/line happens to be oriented. + lighting=False, + source_type=source_type, + metadata=self.builder.stratigraphy_metadata(colours), + ) + logger.info(f"Added cross section '{self._pending_cross_section_name}'.") + + def _on_plane_cross_section_finished(self, result): + finish_background_task( + self._cross_section_thread, self._cross_section_worker, self._cross_section_progress + ) + self.addPlaneCrossSectionButton.setEnabled(True) + self._add_cross_section_mesh(result, 'cross_section_plane') + + def _on_line_cross_section_finished(self, result): + finish_background_task( + self._cross_section_thread, self._cross_section_worker, self._cross_section_progress + ) + self.addLineCrossSectionButton.setEnabled(True) + self._add_cross_section_mesh(result, 'cross_section_line') + + def _on_cross_section_error(self, traceback_text): + finish_background_task( + self._cross_section_thread, self._cross_section_worker, self._cross_section_progress + ) + self.addPlaneCrossSectionButton.setEnabled(True) + self.addLineCrossSectionButton.setEnabled(True) + logger.error(f"Failed to build cross section: {traceback_text}") + + def add_block_model(self): + """Fill the model bounding box with blocks and colour each block by + the stratigraphic unit at its centre. + + Evaluating the model at every block centre can be slow, so this runs + on a background thread (see `add_topography_surface`). + """ + if not self.model_manager: + logger.info("Model manager is not set.") + return + if self.model_manager.model is None: + logger.info("No model available to build a block model.") + return + missing = self.model_manager.get_units_without_colour() + if missing: + QMessageBox.warning( + self, + "Missing unit colour", + "Cannot colour the block model by stratigraphy. These units have no " + "valid colour in the stratigraphic column:\n\n" + + "\n".join(missing) + + "\n\nSet a colour for each unit and try again.", + ) + return + + bb = self.model_manager.model.bounding_box + origin = np.asarray(bb.origin, dtype=float) + maximum = np.asarray(bb.maximum, dtype=float) + ncells = ( + self.blockModelNxSpinBox.value(), + self.blockModelNySpinBox.value(), + self.blockModelNzSpinBox.value(), + ) + + def target(progress_callback): + progress_callback("Building block model grid...") + mesh = build_block_model_mesh(origin, maximum, ncells) + progress_callback("Evaluating stratigraphy on block model...") + ids = self.model_manager.evaluate_stratigraphy_on_points(mesh.cell_centers().points) + colours = self.model_manager.get_stratigraphic_column_colours() + return mesh, ids, colours + + self._pending_block_model_name = self._unique_cross_section_name('block_model') + self.addBlockModelButton.setEnabled(False) + self._block_model_thread, self._block_model_worker, self._block_model_progress = ( + start_background_task( + self, + target, + title="Block Model", + initial_label="Building block model grid...", + on_progress=self._on_block_model_progress, + on_finished=self._on_block_model_finished, + on_error=self._on_block_model_error, + ) + ) + + def _on_block_model_progress(self, message): + try: + self._block_model_progress.setLabelText(message) + except Exception: + pass + + def _on_block_model_finished(self, result): + finish_background_task( + self._block_model_thread, self._block_model_worker, self._block_model_progress + ) + self.addBlockModelButton.setEnabled(True) + + mesh, ids, colours = result + set_stratigraphy_arrays(mesh.cell_data, ids, colours) + self.viewer.add_mesh_object( + mesh, + name=self._pending_block_model_name, + scalars='colour', + rgb=True, + show_scalar_bar=False, + show_edges=False, + source_type='block_model', + metadata={ + 'ncells': [int(n) for n in np.asarray(mesh.dimensions) - 1], + **self.builder.stratigraphy_metadata(colours), + }, + ) + logger.info(f"Added block model '{self._pending_block_model_name}'.") + + def _on_block_model_error(self, traceback_text): + finish_background_task( + self._block_model_thread, self._block_model_worker, self._block_model_progress + ) + self.addBlockModelButton.setEnabled(True) + logger.error(f"Failed to build block model: {traceback_text}") diff --git a/loopstructural/gui/visualisation/visualisation_widget.py b/loopstructural/gui/visualisation/visualisation_widget.py index f495dcab..ec503b63 100644 --- a/loopstructural/gui/visualisation/visualisation_widget.py +++ b/loopstructural/gui/visualisation/visualisation_widget.py @@ -57,6 +57,10 @@ def __init__( data_manager=self.data_manager, ) sidebarSplitter.addWidget(self.featureList) + # the properties panel asks to build an isosurface again at a new value + self.objectPropertiesWidget.isovalueChangeRequested.connect( + self.featureList.change_isovalue + ) splitter.addWidget(sidebarSplitter) splitter.addWidget(self.plotter) # Add properties panel but start it collapsed (size 0) diff --git a/tests/qgis/test_viewer_isosurfaces.py b/tests/qgis/test_viewer_isosurfaces.py new file mode 100644 index 00000000..50f51f9c --- /dev/null +++ b/tests/qgis/test_viewer_isosurfaces.py @@ -0,0 +1,91 @@ +"""Isosurfaces from a model feature: the objects get the right names and +isovalues, and a changed value changes only that object. + +The viewer is a small stand-in (it keeps the objects in the registry, as the +real viewer does), so the tests do not need pyvistaqt. +""" + +from unittest.mock import Mock, patch + +import pytest + +from loopstructural.gui.visualisation.feature_list_widget import FeatureListWidget +from loopstructural.gui.visualisation.object_registry import ObjectRegistry, ViewerObject + + +class FakeViewer: + def __init__(self): + self.registry = ObjectRegistry() + self.outOfDateChanged = Mock() + + def add_mesh_object(self, mesh, name, **kwargs): + self.registry.add( + ViewerObject( + name, + mesh, + source_feature=kwargs.get('source_feature'), + source_type=kwargs.get('source_type'), + isovalue=kwargs.get('isovalue'), + ) + ) + + def replace_mesh_object(self, name, mesh=None, overrides=None, **source_updates): + obj = self.registry.get(name) + obj.mesh = mesh + obj.isovalue = source_updates.get('isovalue', obj.isovalue) + + def out_of_date_objects(self): + return self.registry.out_of_date() + + def render(self): + pass + + +@pytest.fixture +def widget(): + model_manager = Mock() + model_manager.model = None + model_manager.features.return_value = [] + widget = FeatureListWidget(model_manager=model_manager, viewer=FakeViewer()) + widget._finish_isosurface_task = lambda: None + return widget + + +def finish(widget, feature, existing, results): + widget._pending_isosurface = (feature, existing) + widget._on_isosurfaces_finished(results) + + +def test_five_isosurfaces_are_added_with_their_values(widget): + values = [0.1, 0.3, 0.5, 0.7, 0.9] + finish(widget, 'Fault_1', None, [(v, object(), None) for v in values]) + objects = widget.viewer.registry.of_feature('Fault_1', 'feature_isosurface') + assert [o.name for o in objects] == [ + 'Fault_1_iso_0.10', + 'Fault_1_iso_0.30', + 'Fault_1_iso_0.50', + 'Fault_1_iso_0.70', + 'Fault_1_iso_0.90', + ] + assert [o.isovalue for o in objects] == values + + +def test_a_value_outside_the_field_gives_a_message_not_an_error(widget): + with patch('loopstructural.gui.visualisation.feature_list_widget.push_warning') as warning: + finish(widget, 'Fault_1', None, [(0.5, object(), None), (99.0, None, 'no surface')]) + assert widget.viewer.registry.names() == ['Fault_1_iso_0.50'] + warning.assert_called_once() + assert '99' in warning.call_args[0][1] + + +def test_changing_a_value_changes_only_that_object(widget): + finish(widget, 'Fault_1', None, [(0.2, object(), None), (0.8, object(), None)]) + other = widget.viewer.registry.get('Fault_1_iso_0.80') + other_mesh = other.mesh + new_mesh = object() + finish(widget, 'Fault_1', 'Fault_1_iso_0.20', [(0.4, new_mesh, None)]) + changed = widget.viewer.registry.get('Fault_1_iso_0.20') + assert changed.isovalue == 0.4 + assert changed.mesh is new_mesh + assert other.isovalue == 0.8 + assert other.mesh is other_mesh diff --git a/tests/unit/test_isovalues.py b/tests/unit/test_isovalues.py new file mode 100644 index 00000000..e0715591 --- /dev/null +++ b/tests/unit/test_isovalues.py @@ -0,0 +1,72 @@ +"""Tests for the isosurface values (see loopstructural/gui/visualisation/isovalues.py).""" + +import numpy as np +import pytest + +from loopstructural.gui.visualisation.isovalues import ( + default_range_values, + isosurface_name, + split_by_range, + values_from_list, + values_from_range, +) + + +def test_list_of_values(): + assert values_from_list("0.1, 0.5; 2 3") == [0.1, 0.5, 2.0, 3.0] + + +def test_list_removes_repeated_values_and_keeps_order(): + assert values_from_list("1, 0.5, 1.0, 0.5") == [1.0, 0.5] + + +@pytest.mark.parametrize("text", ["", " ", ",,"]) +def test_empty_list_is_an_error(text): + with pytest.raises(ValueError): + values_from_list(text) + + +@pytest.mark.parametrize("text", ["1, abc", "nan", "inf"]) +def test_list_with_a_bad_value_is_an_error(text): + with pytest.raises(ValueError): + values_from_list(text) + + +def test_range_of_values(): + np.testing.assert_allclose(values_from_range(0, 1, 5), [0, 0.25, 0.5, 0.75, 1]) + + +def test_range_with_one_value_is_the_start(): + assert values_from_range(2.0, 10.0, 1) == [2.0] + + +def test_range_with_equal_ends_gives_one_value(): + assert values_from_range(3.0, 3.0, 4) == [3.0] + + +def test_range_needs_a_count(): + with pytest.raises(ValueError): + values_from_range(0, 1, 0) + + +def test_default_values_are_inside_the_field_range(): + values = default_range_values(0.0, 6.0, 5) + assert len(values) == 5 + assert min(values) > 0.0 and max(values) < 6.0 + np.testing.assert_allclose(np.diff(values), np.diff(values)[0]) + + +@pytest.mark.parametrize("low,high", [(1.0, 1.0), (2.0, 1.0), (np.nan, 1.0), (None, 1.0)]) +def test_default_values_need_a_range(low, high): + with pytest.raises((ValueError, TypeError)): + default_range_values(low, high) + + +def test_values_outside_the_range(): + inside, outside = split_by_range([-1.0, 0.0, 0.5, 1.0, 2.0], 0.0, 1.0) + assert inside == [0.0, 0.5, 1.0] + assert outside == [-1.0, 2.0] + + +def test_isosurface_name_has_the_value(): + assert isosurface_name("Fault_1", 0.5) == "Fault_1_iso_0.50" diff --git a/tests/unit/test_mesh_builders.py b/tests/unit/test_mesh_builders.py new file mode 100644 index 00000000..b662d11b --- /dev/null +++ b/tests/unit/test_mesh_builders.py @@ -0,0 +1,101 @@ +"""Tests for the mesh builders of the viewer (see +loopstructural/gui/visualisation/mesh_builders.py), with a stand-in model.""" + +from unittest.mock import Mock + +import pyvista as pv +import pytest + +from loopstructural.gui.visualisation.mesh_builders import MeshBuilder + + +class FakeSurface: + def __init__(self, mesh): + self._mesh = mesh + + def vtk(self): + return self._mesh + + +class FakeFeature: + """A feature whose surface exists only for values from 0 to 1.""" + + def __init__(self): + self.calls = [] + + def surfaces(self, value=None): + self.calls.append(value) + if value is None or 0.0 <= value <= 1.0: + return [FakeSurface(pv.Sphere())] + return [] + + def min(self): + return 0.0 + + def max(self): + return 1.0 + + +@pytest.fixture +def builder(): + model = Mock() + model.__getitem__ = Mock(side_effect=lambda name: feature) + model.get_feature_by_name = Mock(return_value=object()) + feature = FakeFeature() + manager = Mock() + manager.model = model + result = MeshBuilder(manager) + result.feature = feature + return result + + +def test_isosurface_is_built_at_the_value(builder): + mesh = builder.isosurface('f', 0.5) + assert mesh.n_points > 0 + assert builder.feature.calls == [0.5] + + +def test_isosurface_outside_the_field_raises(builder): + with pytest.raises(ValueError): + builder.isosurface('f', 5.0) + + +def test_scalar_range(builder): + assert builder.scalar_range('f') == (0.0, 1.0) + + +def test_rebuild_an_isosurface_uses_its_value(builder): + spec = { + 'name': 'f_iso_0.25', + 'source_type': 'feature_isosurface', + 'source_feature': 'f', + 'isovalue': 0.25, + 'metadata': {}, + } + mesh, overrides = builder.rebuild(spec) + assert mesh.n_points > 0 and overrides == {} + assert builder.feature.calls == [0.25] + + +def test_rebuild_an_isosurface_without_a_value_raises(builder): + spec = { + 'name': 'x', + 'source_type': 'feature_isosurface', + 'source_feature': 'f', + 'isovalue': None, + 'metadata': {}, + } + with pytest.raises(ValueError): + builder.rebuild(spec) + + +def test_rebuild_an_unknown_type_raises(builder): + spec = { + 'name': 'x', + 'source_type': 'other', + 'source_feature': None, + 'isovalue': None, + 'metadata': {}, + } + with pytest.raises(ValueError): + builder.rebuild(spec) diff --git a/tests/unit/test_object_registry.py b/tests/unit/test_object_registry.py new file mode 100644 index 00000000..26431232 --- /dev/null +++ b/tests/unit/test_object_registry.py @@ -0,0 +1,103 @@ +"""Tests for the object registry of the viewer (see +loopstructural/gui/visualisation/object_registry.py). No Qt or viewer is needed. +""" + +from loopstructural.gui.visualisation.object_registry import ( + MODEL_GROUP, + NO_FEATURE_GROUP, + ObjectRegistry, + ViewerObject, +) + + +def make(name, **kwargs): + return ViewerObject(name, mesh=object(), **kwargs) + + +def test_add_get_remove(): + registry = ObjectRegistry() + obj = registry.add(make("a")) + assert registry.get("a") is obj + assert "a" in registry and len(registry) == 1 + assert registry.remove("a") is obj + assert registry.get("a") is None + assert registry.get(None) is None + assert registry.remove("a") is None + + +def test_adding_the_same_name_replaces_the_object(): + registry = ObjectRegistry() + registry.add(make("a", isovalue=1.0)) + new = registry.add(make("a", isovalue=2.0)) + assert len(registry) == 1 + assert registry.get("a") is new + + +def test_unique_name(): + registry = ObjectRegistry() + assert registry.unique_name("section") == "section" + registry.add(make("section")) + assert registry.unique_name("section") == "section_2" + registry.add(make("section_2")) + assert registry.unique_name("section") == "section_3" + + +def test_rebuildable_objects(): + registry = ObjectRegistry() + registry.add(make("iso", source_type="feature_isosurface", source_feature="f")) + registry.add(make("fold", source_type="fold_constraint_axis", source_feature="f")) + registry.add(make("from_file")) + registry.add(make("layer", source_type="unknown")) + assert [o.name for o in registry.rebuildable()] == ["iso", "fold"] + + +def test_out_of_date_names(): + registry = ObjectRegistry() + registry.add(make("a", out_of_date=True)) + registry.add(make("b")) + assert registry.out_of_date() == ["a"] + + +def test_stratigraphy_colours_only_when_the_topography_is_coloured(): + assert make("s", source_type="block_model").uses_stratigraphy_colours + plain = make("t", source_type="topography_surface", metadata={"coloured": False}) + coloured = make("t", source_type="topography_surface", metadata={"coloured": True}) + assert not plain.uses_stratigraphy_colours + assert coloured.uses_stratigraphy_colours + + +def test_source_values_can_be_used_to_add_the_object_again(): + obj = make( + "iso", + source_type="feature_isosurface", + source_feature="f", + isovalue=0.5, + metadata={"a": 1}, + ) + assert obj.is_isosurface + assert obj.source_values() == { + "source_feature": "f", + "source_type": "feature_isosurface", + "isovalue": 0.5, + "metadata": {"a": 1}, + "out_of_date": False, + "threshold": None, + } + + +def test_update_kwargs_keeps_the_other_settings(): + obj = make("a", kwargs={"opacity": 0.5, "cmap": "viridis"}) + obj.update_kwargs(opacity=0.2) + assert obj.kwargs == {"opacity": 0.2, "cmap": "viridis"} + + +def test_group_by_feature(): + registry = ObjectRegistry() + registry.add(make("z_iso_1", source_feature="Fault_1", isovalue=1.0)) + registry.add(make("z_iso_0", source_feature="Fault_1", isovalue=0.0)) + registry.add(make("box", source_feature="__model__")) + registry.add(make("file")) + registry.add(make("surface", source_feature="Basement")) + groups = registry.grouped_by_feature() + assert [name for name, _ in groups] == ["Basement", "Fault_1", MODEL_GROUP, NO_FEATURE_GROUP] + assert [o.name for o in dict(groups)["Fault_1"]] == ["z_iso_0", "z_iso_1"]