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, + }