From 3f3b1b2898ea877e5c40ddc7c01ff0481c9ef6fe Mon Sep 17 00:00:00 2001 From: lachlangrose Date: Wed, 7 Oct 2026 09:46:57 -0300 Subject: [PATCH] feat: step-based dock with a status for each step Replace the tabs of the modelling dock with five steps (Data, Stratigraphy, Faults, Model, View). Each step has a check function, and the step bar and the footer show the result. Move Save, Open, Reset and Settings to the dock header, put the map2loop dialogs in the steps, and reduce the toolbar to three actions with a Tools submenu. --- docs/development/usability-plan.md | 16 +- docs/usage/interface.md | 13 + loopstructural/gui/loop_widget.py | 43 ++-- .../gui/map2loop_tools/launchers.py | 57 +++++ .../gui/modelling/geological_history_tab.py | 4 +- .../model_definition/model_definition_tab.py | 119 +-------- .../gui/modelling/modelling_widget.py | 155 ++++++++++-- .../gui/modelling/steps/__init__.py | 5 + loopstructural/gui/modelling/steps/checks.py | 137 +++++++++++ loopstructural/gui/modelling/steps/header.py | 131 ++++++++++ loopstructural/gui/modelling/steps/pages.py | 191 +++++++++++++++ loopstructural/gui/modelling/steps/status.py | 52 ++++ .../gui/modelling/steps/step_bar.py | 112 +++++++++ .../stratigraphic_column.py | 2 +- loopstructural/main/data_manager.py | 4 + loopstructural/plugin_main.py | 150 ++++++------ tests/unit/test_step_checks.py | 229 ++++++++++++++++++ 17 files changed, 1179 insertions(+), 241 deletions(-) create mode 100644 loopstructural/gui/map2loop_tools/launchers.py create mode 100644 loopstructural/gui/modelling/steps/__init__.py create mode 100644 loopstructural/gui/modelling/steps/checks.py create mode 100644 loopstructural/gui/modelling/steps/header.py create mode 100644 loopstructural/gui/modelling/steps/pages.py create mode 100644 loopstructural/gui/modelling/steps/status.py create mode 100644 loopstructural/gui/modelling/steps/step_bar.py create mode 100644 tests/unit/test_step_checks.py diff --git a/docs/development/usability-plan.md b/docs/development/usability-plan.md index cdb4cbd..b3a9270 100644 --- a/docs/development/usability-plan.md +++ b/docs/development/usability-plan.md @@ -282,17 +282,17 @@ Files: `stratigraphic_column/*.py`, `stratigraphic_column/*.ui`, ### Phase 3: Step-based dock -- [ ] Add a step navigation widget with a status for each step. -- [ ] Add a `check()` function for each step. It returns a status and a list of +- [x] Add a step navigation widget with a status for each step. +- [x] Add a `check()` function for each step. It returns a status and a list of problems. -- [ ] Move the existing tabs into the steps, in the order of the target design. -- [ ] Move Save, Open, Reset and Settings into the dock header. -- [ ] Move the dialogs into their steps as buttons or menus. Keep the dialogs. +- [x] Move the existing tabs into the steps, in the order of the target design. +- [x] Move Save, Open, Reset and Settings into the dock header. +- [x] Move the dialogs into their steps as buttons or menus. Keep the dialogs. Do not rewrite them in this phase. -- [ ] Move the Fault Topology Calculator into step 3. -- [ ] Reduce the toolbar to three actions. Put the dialogs in a "Tools" +- [x] Move the Fault Topology Calculator into step 3. +- [x] Reduce the toolbar to three actions. Put the dialogs in a "Tools" submenu. -- [ ] Keep the `separate_dock_widgets` setting. +- [x] Keep the `separate_dock_widgets` setting. Files: `loop_widget.py`, `modelling/modelling_widget.py`, `plugin_main.py`, new `gui/modelling/steps/` module. diff --git a/docs/usage/interface.md b/docs/usage/interface.md index 5a1c417..a333f5b 100644 --- a/docs/usage/interface.md +++ b/docs/usage/interface.md @@ -1,5 +1,18 @@ # Plugin interface +## The steps of the dock +The LoopStructural dock has five steps. The step buttons are at the top of the dock. Each button shows its status: a check mark (done), a warning triangle (a problem) or a hollow circle (not done). Point at a button to see the reasons. The footer shows the most important message of the current step, with **Back** and **Next** buttons. The steps are a guide: you can go to any step at any time. + +1. **Data**: bounding box, DEM and the source layers. **Convert data...** opens the data conversion tool. +2. **Stratigraphy**: the stratigraphic column. The **Build column** menu has the sorters and Paint Order. The **Derive from map** menu has Basal Contacts, Thickness and Sampler. +3. **Faults**: the fault layer, **Calculate topology...** and the adjacency tables. This step is optional. +4. **Model**: the features and the build of the model. +5. **View**: the 3D view. With the setting "separate dock widgets", this step has a button that opens the 3D view dock. + +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. + ## Selecting Layers The LoopStructural plugin interfaces with QGIS to define the model input data and parameters. diff --git a/loopstructural/gui/loop_widget.py b/loopstructural/gui/loop_widget.py index ca53c14..40fc48e 100644 --- a/loopstructural/gui/loop_widget.py +++ b/loopstructural/gui/loop_widget.py @@ -5,28 +5,38 @@ interface for interacting with LoopStructural features inside QGIS. """ -from qgis.PyQt.QtWidgets import QTabWidget, QVBoxLayout, QWidget +from qgis.PyQt.QtWidgets import QVBoxLayout, QWidget from .modelling.modelling_widget import ModellingWidget +from .modelling.steps import checks from .visualisation.visualisation_widget import VisualisationWidget class LoopWidget(QWidget): """Main dock widget that contains modelling and visualisation tools. - The widget composes multiple tabs and controls used to construct and - inspect geological models. + The widget has the steps of the modelling workflow. The last step is the + 3D view. With ``separate_docks``, the 3D view is a widget for its own dock, + and the last step has a button that opens that dock. """ def __init__( - self, parent=None, *, mapCanvas=None, logger=None, data_manager=None, model_manager=None + self, + parent=None, + *, + mapCanvas=None, + logger=None, + data_manager=None, + model_manager=None, + separate_docks=False, ): """Initialize the Loop widget. Parameters ---------- - *args, **kwargs - Forwarded to the parent widget constructor. + separate_docks : bool + If True, the visualisation widget is not in the steps. The caller + puts it in its own dock. """ super().__init__(parent) self.mapCanvas = mapCanvas @@ -34,28 +44,29 @@ def __init__( self.data_manager = data_manager self.model_manager = model_manager - mainLayout = QVBoxLayout(self) - self.setLayout(mainLayout) - tabWidget = QTabWidget(self) - tabWidget.setTabPosition(QTabWidget.TabPosition.South) - mainLayout.addWidget(tabWidget) - self.modelling_widget = ModellingWidget( + self.visualisation_widget = VisualisationWidget( self, mapCanvas=self.mapCanvas, logger=self.logger, data_manager=self.data_manager, model_manager=self.model_manager, ) - - self.visualisation_widget = VisualisationWidget( + self.modelling_widget = ModellingWidget( self, mapCanvas=self.mapCanvas, logger=self.logger, data_manager=self.data_manager, model_manager=self.model_manager, + view_widget=None if separate_docks else self.visualisation_widget, ) - tabWidget.addTab(self.modelling_widget, "Modelling") - tabWidget.addTab(self.visualisation_widget, "Visualisation") + + mainLayout = QVBoxLayout(self) + mainLayout.setContentsMargins(0, 0, 0, 0) + mainLayout.addWidget(self.modelling_widget) + + def show_view_step(self): + """Go to the last step, which has the 3D view.""" + self.modelling_widget.show_step(checks.STEP_VIEW) def get_modelling_widget(self): """Return the modelling widget instance. diff --git a/loopstructural/gui/map2loop_tools/launchers.py b/loopstructural/gui/map2loop_tools/launchers.py new file mode 100644 index 0000000..e14f4aa --- /dev/null +++ b/loopstructural/gui/map2loop_tools/launchers.py @@ -0,0 +1,57 @@ +"""Open the tool dialogs from the dock steps and from the plugin menu.""" + +SAMPLER = 'sampler' +SORTER = 'sorter' +USER_SORTER = 'user_sorter' +BASAL_CONTACTS = 'basal_contacts' +THICKNESS = 'thickness' +PAINT_STRAT_ORDER = 'paint_strat_order' +FAULT_TOPOLOGY = 'fault_topology' +DATA_CONVERSION = 'data_conversion' + + +def _dialog_class(tool): + """Import the dialog class of a tool when it is needed (the imports are slow).""" + if tool == DATA_CONVERSION: + from loopstructural.gui.data_conversion import AutomaticConversionDialog + + return AutomaticConversionDialog + if tool == FAULT_TOPOLOGY: + from loopstructural.gui.map2loop_tools.fault_topology_widget import FaultTopologyWidget + + return FaultTopologyWidget + from loopstructural.gui import map2loop_tools + + names = { + SAMPLER: 'SamplerDialog', + SORTER: 'SorterDialog', + USER_SORTER: 'UserDefinedSorterDialog', + BASAL_CONTACTS: 'BasalContactsDialog', + THICKNESS: 'ThicknessCalculatorDialog', + PAINT_STRAT_ORDER: 'PaintStratigraphicOrderDialog', + } + return getattr(map2loop_tools, names[tool]) + + +def show_tool_dialog(tool, parent, *, data_manager, debug_manager=None): + """Open the dialog of a tool and wait until the user closes it. + + Parameters + ---------- + tool : str + One of the names in this module, for example `BASAL_CONTACTS`. + parent : QWidget + Parent of the dialog. + data_manager : ModellingDataManager + The dialogs read the shared layers from the data manager. + debug_manager : DebugManager, optional + If this is None, the debug manager of the data manager is used. + """ + dialog_class = _dialog_class(tool) + if tool == DATA_CONVERSION: + dialog = dialog_class(parent, project=data_manager.project if data_manager else None) + else: + if debug_manager is None and data_manager is not None: + debug_manager = data_manager.debug_manager + dialog = dialog_class(parent, data_manager=data_manager, debug_manager=debug_manager) + dialog.exec() diff --git a/loopstructural/gui/modelling/geological_history_tab.py b/loopstructural/gui/modelling/geological_history_tab.py index c927818..4403f58 100644 --- a/loopstructural/gui/modelling/geological_history_tab.py +++ b/loopstructural/gui/modelling/geological_history_tab.py @@ -6,6 +6,6 @@ class GeologialHistoryTab(BaseTab): def __init__(self, parent=None, data_manager=None): super().__init__(parent, data_manager, scrollable=False) # Load the UI file for Tab 1 - stratigraphic_column_widget = StratColumnWidget(self, data_manager=data_manager) + self.stratigraphic_column_widget = StratColumnWidget(self, data_manager=data_manager) # Add the loaded UI widget to the container layout - self.add_widget(stratigraphic_column_widget, group_box=False) + self.add_widget(self.stratigraphic_column_widget, group_box=False) diff --git a/loopstructural/gui/modelling/model_definition/model_definition_tab.py b/loopstructural/gui/modelling/model_definition/model_definition_tab.py index a6e969f..7635da6 100644 --- a/loopstructural/gui/modelling/model_definition/model_definition_tab.py +++ b/loopstructural/gui/modelling/model_definition/model_definition_tab.py @@ -1,129 +1,30 @@ -from qgis.core import QgsApplication -from qgis.PyQt.QtWidgets import ( - QFileDialog, - QHBoxLayout, - QMessageBox, - QPushButton, - QSizePolicy, - QWidget, -) +from qgis.PyQt.QtWidgets import QSizePolicy -from loopstructural.gui.messages import push_success from loopstructural.gui.modelling.base_tab import BaseTab from .bounding_box import BoundingBoxWidget from .dem import DEMWidget -from .fault_layers import FaultLayersWidget from .stratigraphic_layers import StratigraphicLayersWidget -STATE_FILE_SUFFIX = '.loopstate.json' -STATE_FILE_FILTER = f"LoopStructural State (*{STATE_FILE_SUFFIX})" - class ModelDefinitionTab(BaseTab): + """The area, the elevation and the source layers (step 1 of the dock). + + The fault layers are in step 3. Save, Open and Reset are in the header of + the dock. + """ + def __init__(self, parent=None, data_manager=None): super().__init__(parent, data_manager, scrollable=True) # Add widgets to the QToolBox self.bounding_box = BoundingBoxWidget(self, data_manager) self.dem = DEMWidget(self, data_manager) - self.fault_layers = FaultLayersWidget(self, data_manager) self.stratigraphy_layers = StratigraphicLayersWidget(self, data_manager) # Set uniform size policy for all widgets - for widget in [self.bounding_box, self.fault_layers, self.dem, self.stratigraphy_layers]: + for widget in [self.bounding_box, self.dem, self.stratigraphy_layers]: widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) - actions_widget = QWidget(self) - actions_layout = QHBoxLayout(actions_widget) - actions_layout.setContentsMargins(0, 0, 0, 0) - - self.saveStateButton = QPushButton("Save State") - self.saveStateButton.setIcon(QgsApplication.getThemeIcon("mActionFileSave.svg")) - self.saveStateButton.setToolTip( - "Save the currently loaded data and geological model to a file." - ) - self.saveStateButton.clicked.connect(self.on_save_state_clicked) - - self.loadStateButton = QPushButton("Load State") - self.loadStateButton.setIcon(QgsApplication.getThemeIcon("mActionFileOpen.svg")) - self.loadStateButton.setToolTip( - "Load a previously saved application state, replacing all currently " - "loaded data and the geological model." - ) - self.loadStateButton.clicked.connect(self.on_load_state_clicked) - - self.resetStateButton = QPushButton("Reset Application State") - self.resetStateButton.setIcon(QgsApplication.getThemeIcon("mActionUndo.svg")) - self.resetStateButton.setToolTip( - "Clear all loaded data, the stratigraphic column, fault topology and " - "the geological model, restoring the plugin to its initial state." - ) - self.resetStateButton.clicked.connect(self.on_reset_state_clicked) - - actions_layout.addWidget(self.saveStateButton) - actions_layout.addWidget(self.loadStateButton) - actions_layout.addWidget(self.resetStateButton) - self.add_widget(actions_widget, group_box=False) - - self.add_widget(self.bounding_box, 'Bounding Box') # , "Bounding Box") + self.add_widget(self.bounding_box, 'Bounding Box') self.add_widget(self.dem, 'DEM') - self.add_widget(self.fault_layers, 'Fault Layers') # , "Fault Layers") - self.add_widget( - self.stratigraphy_layers, 'Stratigraphic Layers' - ) # , "Stratigraphic Layers") - - def on_save_state_clicked(self): - """Prompt for a destination file and save the current app state to it.""" - filepath, _ = QFileDialog.getSaveFileName( - self, "Save Application State", "", STATE_FILE_FILTER - ) - if not filepath: - return - if not filepath.endswith(STATE_FILE_SUFFIX): - filepath += STATE_FILE_SUFFIX - try: - self.data_manager.save_state(filepath) - except Exception as err: - QMessageBox.critical( - self, "Save Application State", f"Failed to save application state:\n{err}" - ) - else: - push_success("Save Application State", f"Application state saved to: {filepath}") - - def on_load_state_clicked(self): - """Prompt for a state file and, after confirmation, load it.""" - filepath, _ = QFileDialog.getOpenFileName( - self, "Load Application State", "", STATE_FILE_FILTER - ) - if not filepath: - return - reply = QMessageBox.question( - self, - "Load Application State", - "Loading a saved state will replace all currently loaded data and " - "the geological model. This cannot be undone.\n\nContinue?", - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, - QMessageBox.StandardButton.No, - ) - if reply != QMessageBox.StandardButton.Yes: - return - try: - self.data_manager.load_state(filepath) - except Exception as err: - QMessageBox.critical( - self, "Load Application State", f"Failed to load application state:\n{err}" - ) - - def on_reset_state_clicked(self): - """Prompt for confirmation, then reset the data and model managers.""" - reply = QMessageBox.question( - self, - "Reset Application State", - "This will clear all loaded data, the stratigraphic column, fault " - "topology and the geological model. This cannot be undone.\n\n" - "Continue?", - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, - QMessageBox.StandardButton.No, - ) - if reply == QMessageBox.StandardButton.Yes: - self.data_manager.reset() + self.add_widget(self.stratigraphy_layers, 'Stratigraphic Layers') diff --git a/loopstructural/gui/modelling/modelling_widget.py b/loopstructural/gui/modelling/modelling_widget.py index 143c579..9b57d3d 100644 --- a/loopstructural/gui/modelling/modelling_widget.py +++ b/loopstructural/gui/modelling/modelling_widget.py @@ -1,12 +1,53 @@ -from qgis.PyQt.QtWidgets import QTabWidget, QVBoxLayout, QWidget +from qgis.PyQt.QtCore import QTimer, pyqtSignal +from qgis.PyQt.QtWidgets import ( + QHBoxLayout, + QLabel, + QPushButton, + QStackedWidget, + QVBoxLayout, + QWidget, +) -from loopstructural.gui.modelling.fault_adjacency_tab import FaultAdjacencyTab -from loopstructural.gui.modelling.geological_history_tab import GeologialHistoryTab -from loopstructural.gui.modelling.geological_model_tab import GeologicalModelTab -from loopstructural.gui.modelling.model_definition import ModelDefinitionTab +from loopstructural.gui.modelling.steps import checks +from loopstructural.gui.modelling.steps.header import DockHeader +from loopstructural.gui.modelling.steps.pages import ( + DataStep, + FaultsStep, + ModelStep, + StratigraphyStep, + ViewStep, +) +from loopstructural.gui.modelling.steps.step_bar import StepBar + +# How often the checks of the steps run again, in milliseconds. Several +# widgets keep only one callback in the data manager, so the dock cannot +# listen to all changes. The checks are cheap. +CHECK_INTERVAL_MS = 750 + +STEPS = [ + (checks.STEP_DATA, "Data"), + (checks.STEP_STRATIGRAPHY, "Stratigraphy"), + (checks.STEP_FAULTS, "Faults"), + (checks.STEP_MODEL, "Model"), + (checks.STEP_VIEW, "View"), +] class ModellingWidget(QWidget): + """The modelling dock: a header, the steps, and a footer with Back and Next. + + Each step has a status. The footer shows the most important problem of the + current step. The steps are a guide: the user can go to any step. + + Parameters + ---------- + view_widget : QWidget, optional + The visualisation widget, for step 5. If this is None, step 5 has a + button that emits `open_view_requested`. + """ + + open_view_requested = pyqtSignal() + def __init__( self, parent: QWidget = None, @@ -15,6 +56,7 @@ def __init__( logger=None, data_manager=None, model_manager=None, + view_widget=None, ): super().__init__(parent) @@ -22,23 +64,90 @@ def __init__( self.logger = logger self.data_manager = data_manager # ModellingDataManager(mapCanvas=mapCanvas, logger=logger) self.model_manager = model_manager - self.geological_history_tab_widget = None - self.stratigraphic_column_tab_widget = None - self.fault_graph_tab_widget = None - self.model_definition_tab_widget = ModelDefinitionTab(self, data_manager=self.data_manager) - self.geological_history_tab_widget = GeologialHistoryTab( - self, data_manager=self.data_manager - ) - self.fault_adjacency_tab_widget = FaultAdjacencyTab(self, data_manager=self.data_manager) - self.geological_model_tab_widget = GeologicalModelTab( - self, model_manager=self.model_manager, data_manager=self.data_manager - ) + + managers = {'data_manager': self.data_manager, 'model_manager': self.model_manager} + self.data_step = DataStep(self, **managers) + self.stratigraphy_step = StratigraphyStep(self, **managers) + self.faults_step = FaultsStep(self, **managers) + self.model_step = ModelStep(self, **managers) + self.view_step = ViewStep(self, view_widget=view_widget, **managers) + self.view_step.open_view_requested.connect(self.open_view_requested) + self.pages = [ + self.data_step, + self.stratigraphy_step, + self.faults_step, + self.model_step, + self.view_step, + ] + + self.header = DockHeader(self, data_manager=self.data_manager) + self.step_bar = StepBar(STEPS, self) + self.stack = QStackedWidget(self) + for page in self.pages: + self.stack.addWidget(page) + + self.footer_label = QLabel(self) + self.footer_label.setWordWrap(True) + self.back_button = QPushButton("< Back", self) + self.next_button = QPushButton("Next >", self) + self.back_button.clicked.connect(lambda _checked=False: self.go_to(self.current_index - 1)) + self.next_button.clicked.connect(lambda _checked=False: self.go_to(self.current_index + 1)) + footer = QHBoxLayout() + footer.addWidget(self.footer_label, 1) + footer.addWidget(self.back_button) + footer.addWidget(self.next_button) mainLayout = QVBoxLayout(self) - self.setLayout(mainLayout) - tabWidget = QTabWidget(self) - mainLayout.addWidget(tabWidget) - tabWidget.addTab(self.model_definition_tab_widget, "Load Data") - tabWidget.addTab(self.geological_history_tab_widget, "Stratigraphic Column") - tabWidget.addTab(self.fault_adjacency_tab_widget, "Fault Adjacency") - tabWidget.addTab(self.geological_model_tab_widget, "Geological Model") + mainLayout.addWidget(self.header) + mainLayout.addWidget(self.step_bar) + mainLayout.addWidget(self.stack, 1) + mainLayout.addLayout(footer) + + self.step_bar.currentChanged.connect(self.go_to) + self._timer = QTimer(self) + self._timer.setInterval(CHECK_INTERVAL_MS) + self._timer.timeout.connect(self.refresh_status) + self.go_to(0) + + @property + def current_index(self): + return self.stack.currentIndex() + + def go_to(self, index): + """Show a step. The index is limited to the steps that exist.""" + index = max(0, min(index, len(self.pages) - 1)) + self.stack.setCurrentIndex(index) + self.step_bar.set_current_index(index) + self.back_button.setEnabled(index > 0) + self.next_button.setEnabled(index < len(self.pages) - 1) + self.refresh_status() + + def show_step(self, key): + """Show the step with this key, for example `checks.STEP_VIEW`.""" + self.go_to([page.key for page in self.pages].index(key)) + + def check_all(self): + """Return the `StepCheck` of each step, by step key.""" + return {page.key: page.check() for page in self.pages} + + def refresh_status(self): + """Run the checks again and show the results in the bar and the footer.""" + results = self.check_all() + self.step_bar.set_checks(results) + page = self.pages[self.current_index] + check = results[page.key] + if check.messages: + self.footer_label.setText(check.summary) + self.footer_label.setToolTip("\n".join(check.messages)) + else: + self.footer_label.setText("This step is done.") + self.footer_label.setToolTip("") + + def showEvent(self, event): + super().showEvent(event) + self.refresh_status() + self._timer.start() + + def hideEvent(self, event): + super().hideEvent(event) + self._timer.stop() diff --git a/loopstructural/gui/modelling/steps/__init__.py b/loopstructural/gui/modelling/steps/__init__.py new file mode 100644 index 0000000..c0bcdf7 --- /dev/null +++ b/loopstructural/gui/modelling/steps/__init__.py @@ -0,0 +1,5 @@ +"""The steps of the modelling dock. + +The modules `status` and `checks` do not import QGIS, so the unit tests can run +them. Do not import widgets in this file. +""" diff --git a/loopstructural/gui/modelling/steps/checks.py b/loopstructural/gui/modelling/steps/checks.py new file mode 100644 index 0000000..4606291 --- /dev/null +++ b/loopstructural/gui/modelling/steps/checks.py @@ -0,0 +1,137 @@ +"""Check functions of the steps of the modelling dock. + +Each function reads the state of the data manager and the model manager and +returns a `StepCheck`. The functions are cheap, because the dock calls them +often. They do not change the state. +""" + +from loopstructural.main import derived_data, layer_roles + +from .status import StepCheck + +STEP_DATA = 'data' +STEP_STRATIGRAPHY = 'stratigraphy' +STEP_FAULTS = 'faults' +STEP_MODEL = 'model' +STEP_VIEW = 'view' + +# The words for the inputs that changed, in the message of an out-of-date result +_INPUT_WORDS = { + 'unit_order': 'the order of the units', + 'geology': 'the geology layer', + 'unit_field': 'the unit name field', + 'faults': 'the faults layer', + 'ignore_units': 'the ignored units', + 'override_units': 'the basal override units', + 'contacts': 'the basal contacts settings', + 'calculator_type': 'the calculator type', + 'structure': 'the structure layer', + 'cross_sections': 'the cross-sections layer', + 'thicknesses': 'a unit thickness', +} + + +def _out_of_date_messages(data_manager): + """Return a message for each derived result that is out of date.""" + derived = data_manager.derived + messages = [] + for name in (derived_data.BASAL_CONTACTS, derived_data.THICKNESS, derived_data.STYLED_FIELDS): + if not derived.is_out_of_date(name): + continue + # With "Use a contacts layer", the contacts are an input of the user. + if ( + name == derived_data.BASAL_CONTACTS + and data_manager.layer_roles.contacts_source == layer_roles.CONTACTS_FROM_LAYER + ): + continue + words = [ + _INPUT_WORDS.get(key, key.replace('_', ' ')) for key in derived.changed_inputs(name) + ] + reason = f" ({', '.join(words)} changed)" if words else "" + messages.append(f"{derived_data.DESCRIPTIONS[name]} are out of date{reason}.") + return messages + + +def check_data(data_manager, model_manager=None) -> StepCheck: + """Step 1: the area, the coordinate system and the source layers.""" + problems, todo = [], [] + if not data_manager.is_bounding_box_set(): + todo.append("Set the bounding box.") + if not data_manager.is_model_crs_valid(): + problems.append("The model CRS must be a projected CRS (in metres).") + if data_manager.get_layer_role(layer_roles.GEOLOGY) is None: + todo.append("Select the geology layer.") + if data_manager.get_layer_role(layer_roles.STRUCTURE) is None: + todo.append("Select the structure layer.") + return StepCheck(tuple(problems), tuple(todo)) + + +def check_stratigraphy(data_manager, model_manager=None) -> StepCheck: + """Step 2: the column and the results that come from it.""" + problems, todo = [], [] + unit_names = data_manager.get_stratigraphic_unit_names() + if not unit_names: + todo.append("Add units to the stratigraphic column.") + problems.extend(_out_of_date_messages(data_manager)) + + contact_units = set(data_manager.get_unique_basal_units()) + if unit_names and contact_units: + missing = [name for name in unit_names if name not in contact_units] + if missing: + count = len(missing) + names = ', '.join(missing[:3]) + (', ...' if count > 3 else '') + noun = 'unit has' if count == 1 else 'units have' + problems.append(f"{count} {noun} no basal contacts: {names}.") + if unit_names and data_manager.get_layer_role(layer_roles.BASAL_CONTACTS) is None: + todo.append("Extract the basal contacts.") + return StepCheck(tuple(problems), tuple(todo)) + + +def check_faults(data_manager, model_manager=None) -> StepCheck: + """Step 3: the faults. A model can have no faults, so this step is optional.""" + traces = data_manager.get_fault_traces() + if not traces or traces.get('layer') is None: + return StepCheck(todo=("No fault layer is selected. Faults are optional.",)) + if not traces.get('fault_name_field'): + return StepCheck(todo=("Select the fault name field.",)) + if not data_manager._fault_topology.faults: + return StepCheck(problems=("The fault layer has no faults.",)) + return StepCheck() + + +def check_model(data_manager, model_manager=None) -> StepCheck: + """Step 4: the model.""" + problems, todo = [], [] + if not data_manager.is_bounding_box_set(): + problems.append("Set the bounding box in step 1.") + if not data_manager.is_model_crs_valid(): + problems.append("The model CRS must be a projected CRS (in metres).") + problems.extend(_out_of_date_messages(data_manager)) + changed = data_manager.get_changed_layers() + if changed: + problems.append("Input layers changed after the model was built: " + ", ".join(changed)) + state = model_manager.model_state if model_manager is not None else 'empty' + if state == 'empty': + todo.append("Initialise the model.") + elif state == 'stale': + problems.append("Inputs changed after the model was built. Initialise the model again.") + elif state == 'initialized': + todo.append("Solve the model.") + return StepCheck(tuple(problems), tuple(todo)) + + +def check_view(data_manager, model_manager=None) -> StepCheck: + """Step 5: the view. It needs a solved model.""" + state = model_manager.model_state if model_manager is not None else 'empty' + if state != 'solved': + return StepCheck(todo=("Solve the model in step 4 first.",)) + return StepCheck() + + +STEP_CHECKS = { + STEP_DATA: check_data, + STEP_STRATIGRAPHY: check_stratigraphy, + STEP_FAULTS: check_faults, + STEP_MODEL: check_model, + STEP_VIEW: check_view, +} diff --git a/loopstructural/gui/modelling/steps/header.py b/loopstructural/gui/modelling/steps/header.py new file mode 100644 index 0000000..fe461bd --- /dev/null +++ b/loopstructural/gui/modelling/steps/header.py @@ -0,0 +1,131 @@ +"""The header of the modelling dock: Save, Open, Reset and Settings.""" + +from qgis.core import QgsApplication +from qgis.PyQt.QtWidgets import ( + QFileDialog, + QHBoxLayout, + QLabel, + QMessageBox, + QToolButton, + QWidget, +) +from qgis.utils import iface + +from loopstructural.__about__ import __title__ +from loopstructural.gui.messages import push_success + +STATE_FILE_SUFFIX = '.loopstate.json' +STATE_FILE_FILTER = f"LoopStructural State (*{STATE_FILE_SUFFIX})" + + +class DockHeader(QWidget): + """The actions that apply to all of the plugin, not to one step.""" + + def __init__(self, parent=None, data_manager=None): + super().__init__(parent) + self.data_manager = data_manager + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(QLabel("LoopStructural", self)) + layout.addStretch(1) + + self.saveButton = self._add_button( + layout, + "mActionFileSave.svg", + "Save", + "Save the loaded data and the geological model to a file.", + self.on_save_state_clicked, + ) + self.openButton = self._add_button( + layout, + "mActionFileOpen.svg", + "Open", + "Open a saved state. It replaces all loaded data and the geological model.", + self.on_load_state_clicked, + ) + self.resetButton = self._add_button( + layout, + "mActionUndo.svg", + "Reset", + "Clear all loaded data, the stratigraphic column, the fault topology and " + "the geological model.", + self.on_reset_state_clicked, + ) + self.settingsButton = self._add_button( + layout, + "console/iconSettingsConsole.svg", + "Settings", + "Open the settings of the plugin.", + self.on_settings_clicked, + ) + + def _add_button(self, layout, icon_name, text, tooltip, slot): + button = QToolButton(self) + button.setIcon(QgsApplication.getThemeIcon(icon_name)) + button.setText(text) + button.setToolTip(tooltip) + button.setAutoRaise(True) + button.clicked.connect(lambda _checked=False: slot()) + layout.addWidget(button) + return button + + def on_save_state_clicked(self): + """Prompt for a destination file and save the current app state to it.""" + filepath, _ = QFileDialog.getSaveFileName( + self, "Save Application State", "", STATE_FILE_FILTER + ) + if not filepath: + return + if not filepath.endswith(STATE_FILE_SUFFIX): + filepath += STATE_FILE_SUFFIX + try: + self.data_manager.save_state(filepath) + except Exception as err: + QMessageBox.critical( + self, "Save Application State", f"Failed to save application state:\n{err}" + ) + else: + push_success("Save Application State", f"Application state saved to: {filepath}") + + def on_load_state_clicked(self): + """Prompt for a state file and, after confirmation, load it.""" + filepath, _ = QFileDialog.getOpenFileName( + self, "Load Application State", "", STATE_FILE_FILTER + ) + if not filepath: + return + reply = QMessageBox.question( + self, + "Load Application State", + "Loading a saved state will replace all currently loaded data and " + "the geological model. This cannot be undone.\n\nContinue?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if reply != QMessageBox.StandardButton.Yes: + return + try: + self.data_manager.load_state(filepath) + except Exception as err: + QMessageBox.critical( + self, "Load Application State", f"Failed to load application state:\n{err}" + ) + + def on_reset_state_clicked(self): + """Prompt for confirmation, then reset the data and model managers.""" + reply = QMessageBox.question( + self, + "Reset Application State", + "This will clear all loaded data, the stratigraphic column, fault " + "topology and the geological model. This cannot be undone.\n\n" + "Continue?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if reply == QMessageBox.StandardButton.Yes: + self.data_manager.reset() + + def on_settings_clicked(self): + """Open the settings page of the plugin.""" + if iface is not None: + iface.showOptionsDialog(currentPage=f"mOptionsPage{__title__}") diff --git a/loopstructural/gui/modelling/steps/pages.py b/loopstructural/gui/modelling/steps/pages.py new file mode 100644 index 0000000..7c7ec72 --- /dev/null +++ b/loopstructural/gui/modelling/steps/pages.py @@ -0,0 +1,191 @@ +"""The pages of the steps of the modelling dock. + +Each page has a `check()` method. It returns the `StepCheck` of the step. +The existing tabs and dialogs are the contents of the pages. +""" + +from qgis.core import QgsApplication +from qgis.gui import QgsCollapsibleGroupBox +from qgis.PyQt.QtCore import Qt, pyqtSignal +from qgis.PyQt.QtWidgets import ( + QHBoxLayout, + QLabel, + QMenu, + QPushButton, + QToolButton, + QVBoxLayout, + QWidget, +) + +from loopstructural.gui.map2loop_tools import launchers +from loopstructural.gui.modelling.fault_adjacency_tab import FaultAdjacencyTab +from loopstructural.gui.modelling.geological_history_tab import GeologialHistoryTab +from loopstructural.gui.modelling.geological_model_tab import GeologicalModelTab +from loopstructural.gui.modelling.model_definition import ModelDefinitionTab +from loopstructural.gui.modelling.model_definition.fault_layers import FaultLayersWidget + +from . import checks + + +class StepPage(QWidget): + """Base class of the pages: a key, the managers and a check function.""" + + key = None + + def __init__(self, parent=None, *, data_manager=None, model_manager=None): + super().__init__(parent) + self.data_manager = data_manager + self.model_manager = model_manager + + def check(self): + """Return the `StepCheck` of this step.""" + return checks.STEP_CHECKS[self.key](self.data_manager, self.model_manager) + + def _show_tool(self, tool): + launchers.show_tool_dialog(tool, self, data_manager=self.data_manager) + + def _tool_button(self, text, icon, tooltip, actions): + """Return a button with a menu. ``actions`` are ``(text, tool)`` pairs.""" + menu = QMenu(self) + for action_text, tool in actions: + menu.addAction(action_text, lambda _checked=False, t=tool: self._show_tool(t)) + button = QToolButton(self) + button.setText(text) + button.setIcon(icon) + button.setToolTip(tooltip) + button.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) + button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) + button.setMenu(menu) + return button + + +class DataStep(StepPage): + """Step 1: the bounding box, the CRS, the DEM and the source layers.""" + + key = checks.STEP_DATA + + def __init__(self, parent=None, **kwargs): + super().__init__(parent, **kwargs) + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + convert = QPushButton("Convert data...", self) + convert.setIcon(QgsApplication.getThemeIcon('mActionSharingImport.svg')) + convert.setToolTip("Convert the columns of your map data to the names that the tools use.") + convert.clicked.connect(lambda _checked=False: self._show_tool(launchers.DATA_CONVERSION)) + row = QHBoxLayout() + row.addWidget(QLabel("Select the area and the source layers.", self), 1) + row.addWidget(convert) + layout.addLayout(row) + self.tab = ModelDefinitionTab(self, data_manager=self.data_manager) + layout.addWidget(self.tab, 1) + + +class StratigraphyStep(StepPage): + """Step 2: the stratigraphic column, and the results that come from the map.""" + + key = checks.STEP_STRATIGRAPHY + + def __init__(self, parent=None, **kwargs): + super().__init__(parent, **kwargs) + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + self.tab = GeologialHistoryTab(self, data_manager=self.data_manager) + + # More ways to build the column, in the "Build column" menu of the column + build_menu = self.tab.stratigraphic_column_widget.buildMenu + build_menu.addSeparator() + build_menu.addAction( + "Sort automatically...", lambda: self._show_tool(launchers.SORTER) + ).setToolTip("Calculate the order of the units from the map.") + build_menu.addAction( + "Define the order by hand...", lambda: self._show_tool(launchers.USER_SORTER) + ) + build_menu.addAction( + "Paint the order on the map...", lambda: self._show_tool(launchers.PAINT_STRAT_ORDER) + ).setToolTip("Show the order of the column on the geology polygons.") + + derive = self._tool_button( + "Derive from map", + QgsApplication.getThemeIcon('mActionSharingExport.svg'), + "Calculate data from the geology map and the column.", + [ + ("Extract basal contacts...", launchers.BASAL_CONTACTS), + ("Calculate thickness...", launchers.THICKNESS), + ("Sample the contacts...", launchers.SAMPLER), + ], + ) + row = QHBoxLayout() + row.addStretch(1) + row.addWidget(derive) + layout.addLayout(row) + layout.addWidget(self.tab, 1) + + +class FaultsStep(StepPage): + """Step 3: the fault layer, the fault topology and the adjacency tables.""" + + key = checks.STEP_FAULTS + + def __init__(self, parent=None, **kwargs): + super().__init__(parent, **kwargs) + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + self.fault_layers = FaultLayersWidget(self, self.data_manager) + group = QgsCollapsibleGroupBox("Fault layer", self) + group_layout = QVBoxLayout(group) + group_layout.addWidget(self.fault_layers) + layout.addWidget(group) + + topology = QPushButton("Calculate topology...", self) + topology.setToolTip("Find which faults touch each other, from the fault traces.") + topology.clicked.connect(lambda _checked=False: self._show_tool(launchers.FAULT_TOPOLOGY)) + row = QHBoxLayout() + row.addStretch(1) + row.addWidget(topology) + layout.addLayout(row) + + self.adjacency = FaultAdjacencyTab(self, data_manager=self.data_manager) + layout.addWidget(self.adjacency, 1) + + +class ModelStep(StepPage): + """Step 4: the features, their constraints and the build of the model.""" + + key = checks.STEP_MODEL + + def __init__(self, parent=None, **kwargs): + super().__init__(parent, **kwargs) + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + self.tab = GeologicalModelTab( + self, model_manager=self.model_manager, data_manager=self.data_manager + ) + layout.addWidget(self.tab) + + +class ViewStep(StepPage): + """Step 5: the 3D view. + + In one dock, the visualisation widget is in this page. With separate docks, + the page has a button that shows the visualisation dock. + """ + + key = checks.STEP_VIEW + open_view_requested = pyqtSignal() + + def __init__(self, parent=None, *, view_widget=None, **kwargs): + super().__init__(parent, **kwargs) + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + self.view_widget = view_widget + if view_widget is not None: + layout.addWidget(view_widget) + else: + label = QLabel("The 3D view is in its own dock.", self) + label.setWordWrap(True) + button = QPushButton("Open 3D view", self) + button.setIcon(QgsApplication.getThemeIcon('mActionShowAllLayers.svg')) + button.clicked.connect(lambda _checked=False: self.open_view_requested.emit()) + layout.addWidget(label) + layout.addWidget(button) + layout.addStretch(1) diff --git a/loopstructural/gui/modelling/steps/status.py b/loopstructural/gui/modelling/steps/status.py new file mode 100644 index 0000000..dcdf36b --- /dev/null +++ b/loopstructural/gui/modelling/steps/status.py @@ -0,0 +1,52 @@ +"""The status of a step of the modelling dock.""" + +from dataclasses import dataclass +from enum import Enum +from typing import Tuple + + +class Status(str, Enum): + """The result of the check of a step.""" + + DONE = 'done' + PROBLEM = 'problem' + NOT_STARTED = 'not_started' + + +@dataclass(frozen=True) +class StepCheck: + """The result of the check of one step. + + Parameters + ---------- + problems : tuple of str + Things that are wrong, for example a result that is out of date. + todo : tuple of str + Things that the user did not do yet. + """ + + problems: Tuple[str, ...] = () + todo: Tuple[str, ...] = () + + @property + def status(self) -> Status: + if self.problems: + return Status.PROBLEM + if self.todo: + return Status.NOT_STARTED + return Status.DONE + + @property + def messages(self) -> Tuple[str, ...]: + """All the reasons why the step is not done, the problems first.""" + return self.problems + self.todo + + @property + def summary(self) -> str: + """The most important message, for the footer of the dock.""" + messages = self.messages + if not messages: + return '' + if len(messages) == 1: + return messages[0] + return f"{messages[0]} (+{len(messages) - 1} more)" diff --git a/loopstructural/gui/modelling/steps/step_bar.py b/loopstructural/gui/modelling/steps/step_bar.py new file mode 100644 index 0000000..e8d96c0 --- /dev/null +++ b/loopstructural/gui/modelling/steps/step_bar.py @@ -0,0 +1,112 @@ +"""The row of step buttons at the top of the modelling dock.""" + +from qgis.core import QgsApplication +from qgis.PyQt.QtCore import QSize, Qt, pyqtSignal +from qgis.PyQt.QtGui import QColor, QIcon, QPainter, QPen, QPixmap +from qgis.PyQt.QtWidgets import QButtonGroup, QHBoxLayout, QSizePolicy, QToolButton, QWidget + +from .status import Status, StepCheck + +_STATUS_WORDS = { + Status.DONE: 'done', + Status.PROBLEM: 'has a problem', + Status.NOT_STARTED: 'not done', +} + + +def _not_started_icon(size=16) -> QIcon: + """Draw a hollow circle. The shape is not the same as the shape of the + other two icons, so the status does not depend on colour.""" + pixmap = QPixmap(size, size) + pixmap.fill(Qt.GlobalColor.transparent) + painter = QPainter(pixmap) + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + painter.setBrush(Qt.BrushStyle.NoBrush) + painter.setPen(QPen(QColor('#7f7f7f'), 1.5)) + painter.drawEllipse(2, 2, size - 4, size - 4) + painter.end() + return QIcon(pixmap) + + +def status_icon(status: Status) -> QIcon: + """Return the icon of a status: a check, a warning triangle or a hollow circle.""" + if status == Status.DONE: + return QgsApplication.getThemeIcon('mIconSuccess.svg') + if status == Status.PROBLEM: + return QgsApplication.getThemeIcon('mIconWarning.svg') + return _not_started_icon() + + +class StepBar(QWidget): + """A row of buttons, one for each step, with the status of the step. + + Parameters + ---------- + steps : list of tuple + ``(key, title)`` for each step, in order. + """ + + currentChanged = pyqtSignal(int) + + def __init__(self, steps, parent=None): + super().__init__(parent) + self._keys = [key for key, _title in steps] + self._titles = {key: title for key, title in steps} + self._checks = {} + self._buttons = [] + self._group = QButtonGroup(self) + self._group.setExclusive(True) + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(2) + for index, (key, title) in enumerate(steps): + button = QToolButton(self) + button.setCheckable(True) + button.setText(f"{index + 1} {title}") + button.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) + button.setIcon(status_icon(Status.NOT_STARTED)) + button.setIconSize(QSize(14, 14)) + button.setAutoRaise(True) + button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + button.setToolTip(f"Step {index + 1}: {title}") + self._group.addButton(button, index) + layout.addWidget(button) + self._buttons.append(button) + self._buttons[0].setChecked(True) + self._group.idClicked.connect(self.currentChanged.emit) + + @property + def count(self): + return len(self._buttons) + + def current_index(self): + return self._group.checkedId() + + def set_current_index(self, index): + """Select a step. This does not emit `currentChanged`.""" + if 0 <= index < len(self._buttons): + self._buttons[index].setChecked(True) + + def set_checks(self, checks): + """Show the check result of each step. + + Parameters + ---------- + checks : dict + Step key -> `StepCheck`. The buttons change only if a result + changed. + """ + for index, key in enumerate(self._keys): + check = checks.get(key) + if check is None or check == self._checks.get(key): + continue + self._checks[key] = check + button = self._buttons[index] + button.setIcon(status_icon(check.status)) + title = self._titles[key] + lines = [f"Step {index + 1}: {title} - {_STATUS_WORDS[check.status]}"] + lines.extend(f"- {message}" for message in check.messages) + button.setToolTip("\n".join(lines)) + + def check(self, key) -> StepCheck: + return self._checks.get(key, StepCheck()) diff --git a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py index 488ebde..313e33d 100644 --- a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py +++ b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py @@ -315,7 +315,7 @@ def _build_actions_row(self): addUnconformityButton.setToolTip("Add an unconformity to the top of the column.") addUnconformityButton.clicked.connect(lambda _checked=False: self.add_unconformity()) - buildMenu = QMenu(self) + buildMenu = self.buildMenu = QMenu(self) buildMenu.addAction( QgsApplication.getThemeIcon("mActionSharingImport.svg"), "From the basal contacts of the map", diff --git a/loopstructural/main/data_manager.py b/loopstructural/main/data_manager.py index a345dee..cea7c0d 100644 --- a/loopstructural/main/data_manager.py +++ b/loopstructural/main/data_manager.py @@ -713,6 +713,10 @@ def set_basal_contacts(self, basal_contacts, unitname_field=None, use_z_coordina self.basal_contacts_callback(**self._basal_contacts) self.refresh_layer_watchers() + def get_unique_basal_units(self): + """Return the unit names that the basal contacts layer has.""" + return list(self._unique_basal_units) + def calculate_unique_basal_units(self): if ( self._basal_contacts is not None diff --git a/loopstructural/plugin_main.py b/loopstructural/plugin_main.py index 8c81878..cccdaa1 100644 --- a/loopstructural/plugin_main.py +++ b/loopstructural/plugin_main.py @@ -13,7 +13,7 @@ from qgis.gui import QgisInterface from qgis.PyQt.QtCore import QCoreApplication, QLocale, Qt, QTranslator, QUrl from qgis.PyQt.QtGui import QDesktopServices, QIcon -from qgis.PyQt.QtWidgets import QAction, QDockWidget +from qgis.PyQt.QtWidgets import QAction, QDockWidget, QMenu # project from loopstructural.__about__ import ( @@ -34,6 +34,7 @@ from loopstructural.debug_manager import DebugManager from loopstructural.gui.dlg_settings import PlgOptionsFactory from loopstructural.gui.loop_widget import LoopWidget +from loopstructural.gui.map2loop_tools import launchers from loopstructural.main.data_manager import ModellingDataManager from loopstructural.main.model_manager import GeologicalModelManager from loopstructural.processing import ( @@ -143,7 +144,7 @@ def initGui(self): # -- Actions self.action_fault_topology = QAction( - "Fault Topology Calculator", + self.tr("Fault Topology Calculator"), self.iface.mainWindow(), ) self.action_fault_topology.triggered.connect(self.show_fault_topology_dialog) @@ -166,7 +167,7 @@ def initGui(self): ) self.action_modelling = QAction( QIcon(os.path.dirname(__file__) + "/icon.png"), - self.tr("LoopStructural Modelling"), + self.tr("LoopStructural"), self.iface.mainWindow(), ) self.action_data_conversion = QAction( @@ -176,16 +177,19 @@ def initGui(self): self.action_data_conversion.triggered.connect(self.show_data_conversion_dialog) self.action_visualisation = QAction( QIcon(os.path.dirname(__file__) + "/3D_icon.png"), - self.tr("LoopStructural Visualisation"), + self.tr("3D View"), self.iface.mainWindow(), ) + # -- Toolbar: the dock, the 3D view and the help self.toolbar.addAction(self.action_modelling) - self.toolbar.addAction(self.action_fault_topology) + self.toolbar.addAction(self.action_visualisation) + self.toolbar.addAction(self.action_help) # -- Menu + self.iface.addPluginToMenu(__title__, self.action_modelling) + self.iface.addPluginToMenu(__title__, self.action_visualisation) self.iface.addPluginToMenu(__title__, self.action_settings) self.iface.addPluginToMenu(__title__, self.action_help) - self.iface.addPluginToMenu(__title__, self.action_data_conversion) self.initProcessing() # Map2Loop tool actions @@ -230,21 +234,22 @@ def initGui(self): ) self.action_paint_strat_order.triggered.connect(self.show_paint_strat_order_dialog) - # Add all map2loop tool actions to the toolbar - self.toolbar.addAction(self.action_sampler) - self.toolbar.addAction(self.action_sorter) - self.toolbar.addAction(self.action_user_sorter) - self.toolbar.addAction(self.action_basal_contacts) - self.toolbar.addAction(self.action_thickness) - self.toolbar.addAction(self.action_paint_strat_order) - - self.iface.addPluginToMenu(__title__, self.action_sampler) - self.iface.addPluginToMenu(__title__, self.action_sorter) - self.iface.addPluginToMenu(__title__, self.action_user_sorter) - self.iface.addPluginToMenu(__title__, self.action_basal_contacts) - self.iface.addPluginToMenu(__title__, self.action_thickness) - self.iface.addPluginToMenu(__title__, self.action_paint_strat_order) - self.iface.addPluginToMenu(__title__, self.action_fault_topology) + # The tools are in the steps of the dock. The "Tools" submenu has them + # for the advanced users. + self.tools_menu = QMenu(self.tr("Tools"), self.iface.mainWindow()) + for action in ( + self.action_data_conversion, + self.action_sorter, + self.action_user_sorter, + self.action_paint_strat_order, + self.action_basal_contacts, + self.action_thickness, + self.action_sampler, + self.action_fault_topology, + ): + self.tools_menu.addAction(action) + self.action_tools = self.tools_menu.menuAction() + self.iface.addPluginToMenu(__title__, self.action_tools) # -- Help menu @@ -273,8 +278,8 @@ def initGui(self): logger=self.log, data_manager=self.data_manager, model_manager=self.model_manager, + separate_docks=True, ) - self.toolbar.addAction(self.action_visualisation) # Create modelling dock self.modelling_dockwidget = QDockWidget( @@ -325,6 +330,10 @@ def initGui(self): self.action_visualisation.triggered.connect( self.visualisation_dockwidget.toggleViewAction().trigger ) + # The last step of the modelling dock has a button for the 3D view + self.loop_widget.get_modelling_widget().open_view_requested.connect( + self._show_visualisation_dock + ) # Store reference to main dock as None for unload compatibility self.loop_dockwidget = None else: @@ -360,97 +369,62 @@ def initGui(self): # -- Connect actions self.action_modelling.triggered.connect(self.loop_dockwidget.toggleViewAction().trigger) + self.action_visualisation.triggered.connect(self._show_view_step) # Store references to separate docks as None for unload compatibility self.modelling_dockwidget = None self.visualisation_dockwidget = None - def show_sampler_dialog(self): - """Show the sampler dialog.""" - from loopstructural.gui.map2loop_tools import SamplerDialog + def _show_visualisation_dock(self): + """Show the visualisation dock (when the docks are separate).""" + self.visualisation_dockwidget.show() + self.visualisation_dockwidget.raise_() + + def _show_view_step(self): + """Show the dock with the last step, which has the 3D view.""" + self.loop_dockwidget.show() + self.loop_dockwidget.raise_() + self.loop_widget.show_view_step() - dialog = SamplerDialog( + def _show_tool(self, tool): + launchers.show_tool_dialog( + tool, self.iface.mainWindow(), data_manager=self.data_manager, debug_manager=self.debug_manager, ) - dialog.exec() + + def show_sampler_dialog(self): + """Show the sampler dialog.""" + self._show_tool(launchers.SAMPLER) def show_data_conversion_dialog(self): """Show the data conversion dialog.""" - from loopstructural.gui.data_conversion import AutomaticConversionDialog - - dialog = AutomaticConversionDialog( - self.iface.mainWindow(), - project=self.data_manager.project if self.data_manager else None, - ) - dialog.exec() + self._show_tool(launchers.DATA_CONVERSION) def show_sorter_dialog(self): """Show the automatic stratigraphic sorter dialog.""" - from loopstructural.gui.map2loop_tools import SorterDialog - - dialog = SorterDialog( - self.iface.mainWindow(), - data_manager=self.data_manager, - debug_manager=self.debug_manager, - ) - dialog.exec() + self._show_tool(launchers.SORTER) def show_user_sorter_dialog(self): """Show the user-defined stratigraphic column dialog.""" - from loopstructural.gui.map2loop_tools import UserDefinedSorterDialog - - dialog = UserDefinedSorterDialog( - self.iface.mainWindow(), - data_manager=self.data_manager, - debug_manager=self.debug_manager, - ) - dialog.exec() + self._show_tool(launchers.USER_SORTER) def show_basal_contacts_dialog(self): """Show the basal contacts extractor dialog.""" - from loopstructural.gui.map2loop_tools import BasalContactsDialog - - dialog = BasalContactsDialog( - self.iface.mainWindow(), - data_manager=self.data_manager, - debug_manager=self.debug_manager, - ) - dialog.exec() + self._show_tool(launchers.BASAL_CONTACTS) def show_thickness_dialog(self): """Show the thickness calculator dialog.""" - from loopstructural.gui.map2loop_tools import ThicknessCalculatorDialog - - dialog = ThicknessCalculatorDialog( - self.iface.mainWindow(), - data_manager=self.data_manager, - debug_manager=self.debug_manager, - ) - dialog.exec() + self._show_tool(launchers.THICKNESS) def show_paint_strat_order_dialog(self): """Show the paint stratigraphic order dialog.""" - from loopstructural.gui.map2loop_tools import PaintStratigraphicOrderDialog - - dialog = PaintStratigraphicOrderDialog( - self.iface.mainWindow(), - data_manager=self.data_manager, - debug_manager=self.debug_manager, - ) - dialog.exec() + self._show_tool(launchers.PAINT_STRAT_ORDER) def show_fault_topology_dialog(self): """Show the fault topology calculator dialog.""" - from loopstructural.gui.map2loop_tools.fault_topology_widget import FaultTopologyWidget - - dialog = FaultTopologyWidget( - self.iface.mainWindow(), - data_manager=self.data_manager, - debug_manager=self.debug_manager, - ) - dialog.exec() + self._show_tool(launchers.FAULT_TOPOLOGY) def tr(self, message: str) -> str: """Translate a string using Qt translation API. @@ -507,6 +481,7 @@ def unload(self): "action_fault_topology", "action_modelling", "action_visualisation", + "action_tools", ): act = getattr(self, attr, None) if act: @@ -519,6 +494,17 @@ def unload(self): except Exception: pass + tools_menu = getattr(self, "tools_menu", None) + if tools_menu: + try: + tools_menu.deleteLater() + except Exception: + pass + try: + delattr(self, "tools_menu") + except Exception: + pass + # -- Clean up preferences panel in QGIS settings options_factory = getattr(self, "options_factory", None) if options_factory: diff --git a/tests/unit/test_step_checks.py b/tests/unit/test_step_checks.py new file mode 100644 index 0000000..38da9ed --- /dev/null +++ b/tests/unit/test_step_checks.py @@ -0,0 +1,229 @@ +"""Pytest tests for the status and the check functions of the dock steps. + +The modules do not import QGIS, so the tests use fake managers and run in the +fast tests/unit/ job. +""" + +from types import SimpleNamespace + +import pytest + +from loopstructural.gui.modelling.steps import checks +from loopstructural.gui.modelling.steps.status import Status, StepCheck +from loopstructural.main import derived_data, layer_roles +from loopstructural.main.derived_data import DerivedData + + +class FakeRoles: + def __init__(self): + self.values = {} + self.contacts_source = layer_roles.CONTACTS_FROM_GEOLOGY + + +class FakeDataManager: + """The parts of the data manager that the checks read.""" + + def __init__(self): + self.bounding_box_set = False + self.crs_valid = True + self.units = [] + self.contact_units = [] + self.fault_traces = None + self.changed_layers = [] + self.layer_roles = FakeRoles() + self._fault_topology = SimpleNamespace(faults=[]) + self.inputs = {'unit_order': ['a']} + self.derived = DerivedData() + for name in (derived_data.BASAL_CONTACTS, derived_data.THICKNESS, derived_data.STYLED_FIELDS): + self.derived.register(name, lambda: self.inputs) + + def is_bounding_box_set(self): + return self.bounding_box_set + + def is_model_crs_valid(self): + return self.crs_valid + + def get_layer_role(self, role): + return self.layer_roles.values.get(role) + + def get_stratigraphic_unit_names(self): + return list(self.units) + + def get_unique_basal_units(self): + return list(self.contact_units) + + def get_fault_traces(self): + return self.fault_traces + + def get_changed_layers(self): + return list(self.changed_layers) + + +class FakeModelManager: + def __init__(self, state='empty'): + self.model_state = state + + +@pytest.fixture +def dm(): + return FakeDataManager() + + +class TestStepCheck: + def test_no_message_is_done(self): + assert StepCheck().status == Status.DONE + assert StepCheck().summary == '' + + def test_a_todo_is_not_started(self): + assert StepCheck(todo=('Select a layer.',)).status == Status.NOT_STARTED + + def test_a_problem_has_priority_over_a_todo(self): + check = StepCheck(problems=('Bad CRS.',), todo=('Select a layer.',)) + assert check.status == Status.PROBLEM + assert check.messages == ('Bad CRS.', 'Select a layer.') + assert check.summary == 'Bad CRS. (+1 more)' + + +class TestDataStep: + def test_a_new_project_is_not_started(self, dm): + check = checks.check_data(dm) + assert check.status == Status.NOT_STARTED + assert 'Set the bounding box.' in check.todo + + def test_all_inputs_set_is_done(self, dm): + dm.bounding_box_set = True + dm.layer_roles.values = {layer_roles.GEOLOGY: object(), layer_roles.STRUCTURE: object()} + assert checks.check_data(dm).status == Status.DONE + + def test_a_geographic_crs_is_a_problem(self, dm): + dm.crs_valid = False + assert checks.check_data(dm).status == Status.PROBLEM + + +class TestStratigraphyStep: + def test_an_empty_column_is_not_started(self, dm): + check = checks.check_stratigraphy(dm) + assert check.status == Status.NOT_STARTED + assert check.todo == ('Add units to the stratigraphic column.',) + + def test_units_without_contacts_are_a_problem(self, dm): + dm.units = ['a', 'b', 'c'] + dm.contact_units = ['a'] + dm.layer_roles.values[layer_roles.BASAL_CONTACTS] = object() + check = checks.check_stratigraphy(dm) + assert check.status == Status.PROBLEM + assert check.problems == ('2 units have no basal contacts: b, c.',) + + def test_one_unit_uses_the_singular(self, dm): + dm.units = ['a', 'b'] + dm.contact_units = ['a'] + dm.layer_roles.values[layer_roles.BASAL_CONTACTS] = object() + assert checks.check_stratigraphy(dm).problems == ('1 unit has no basal contacts: b.',) + + def test_long_list_of_names_is_cut(self, dm): + dm.units = ['a', 'b', 'c', 'd', 'e'] + dm.contact_units = ['z'] + dm.layer_roles.values[layer_roles.BASAL_CONTACTS] = object() + assert 'a, b, c, ...' in checks.check_stratigraphy(dm).problems[0] + + def test_no_contacts_layer_is_a_todo(self, dm): + dm.units = ['a'] + assert checks.check_stratigraphy(dm).todo == ('Extract the basal contacts.',) + + def test_out_of_date_contacts_are_a_problem(self, dm): + dm.units = ['a'] + dm.layer_roles.values[layer_roles.BASAL_CONTACTS] = object() + dm.derived.record(derived_data.BASAL_CONTACTS) + assert checks.check_stratigraphy(dm).status == Status.DONE + dm.inputs = {'unit_order': ['b', 'a']} + check = checks.check_stratigraphy(dm) + assert check.status == Status.PROBLEM + assert check.problems == ( + 'Basal contacts are out of date (the order of the units changed).', + ) + + def test_a_move_and_its_undo_is_not_a_problem(self, dm): + dm.units = ['a'] + dm.layer_roles.values[layer_roles.BASAL_CONTACTS] = object() + dm.derived.record(derived_data.BASAL_CONTACTS) + dm.inputs = {'unit_order': ['b', 'a']} + dm.inputs = {'unit_order': ['a']} + assert checks.check_stratigraphy(dm).status == Status.DONE + + def test_contacts_from_a_layer_are_an_input_and_never_out_of_date(self, dm): + dm.units = ['a'] + dm.layer_roles.values[layer_roles.BASAL_CONTACTS] = object() + dm.layer_roles.contacts_source = layer_roles.CONTACTS_FROM_LAYER + dm.derived.record(derived_data.BASAL_CONTACTS) + dm.inputs = {'unit_order': ['b', 'a']} + assert checks.check_stratigraphy(dm).status == Status.DONE + + +class TestFaultsStep: + def test_faults_are_optional(self, dm): + check = checks.check_faults(dm) + assert check.status == Status.NOT_STARTED + assert 'optional' in check.todo[0] + + def test_a_layer_without_a_name_field_is_a_todo(self, dm): + dm.fault_traces = {'layer': object(), 'fault_name_field': None} + assert checks.check_faults(dm).todo == ('Select the fault name field.',) + + def test_a_layer_without_faults_is_a_problem(self, dm): + dm.fault_traces = {'layer': object(), 'fault_name_field': 'id'} + assert checks.check_faults(dm).status == Status.PROBLEM + + def test_faults_found_is_done(self, dm): + dm.fault_traces = {'layer': object(), 'fault_name_field': 'id'} + dm._fault_topology.faults = ['F1'] + assert checks.check_faults(dm).status == Status.DONE + + +class TestModelStep: + @pytest.mark.parametrize( + 'state, status', + [ + ('empty', Status.NOT_STARTED), + ('initialized', Status.NOT_STARTED), + ('stale', Status.PROBLEM), + ('solved', Status.DONE), + ], + ) + def test_status_follows_the_model_state(self, dm, state, status): + dm.bounding_box_set = True + assert checks.check_model(dm, FakeModelManager(state)).status == status + + def test_no_bounding_box_is_a_problem(self, dm): + check = checks.check_model(dm, FakeModelManager('empty')) + assert check.status == Status.PROBLEM + assert check.problems[0] == 'Set the bounding box in step 1.' + + def test_out_of_date_data_is_a_problem_before_the_build(self, dm): + dm.bounding_box_set = True + dm.derived.record(derived_data.THICKNESS) + dm.inputs = {'unit_order': ['b', 'a']} + check = checks.check_model(dm, FakeModelManager('solved')) + assert check.status == Status.PROBLEM + + def test_changed_layers_are_a_problem(self, dm): + dm.bounding_box_set = True + dm.changed_layers = ['geology'] + check = checks.check_model(dm, FakeModelManager('solved')) + assert check.status == Status.PROBLEM + assert 'geology' in check.problems[0] + + +class TestViewStep: + def test_needs_a_solved_model(self, dm): + assert checks.check_view(dm, FakeModelManager('initialized')).status == Status.NOT_STARTED + assert checks.check_view(dm, FakeModelManager('solved')).status == Status.DONE + + +def test_every_step_has_a_check(): + assert set(checks.STEP_CHECKS) == { + checks.STEP_DATA, + checks.STEP_STRATIGRAPHY, + checks.STEP_FAULTS, + checks.STEP_MODEL, + checks.STEP_VIEW, + }