diff --git a/docs/development/usability-plan.md b/docs/development/usability-plan.md index 45ead63..bf23bd3 100644 --- a/docs/development/usability-plan.md +++ b/docs/development/usability-plan.md @@ -410,10 +410,10 @@ input is the polygon geology layer, so the user cannot select it there. Tasks: -- [ ] Remove `StratigraphicLayersWidget` from `ModelDefinitionTab`. Step 1 has +- [x] Remove `StratigraphicLayersWidget` from `ModelDefinitionTab`. Step 1 has only the bounding box, the CRS and the DEM. -- [ ] Add the widget to `StratigraphyStep`, as a collapsible group (see 6.3). -- [ ] The first layer picker depends on the contacts source: +- [x] Add the widget to `StratigraphyStep`, as a collapsible group (see 6.3). +- [x] The first layer picker depends on the contacts source: - "Calculate from geology polygons": the picker shows polygon layers. It reads and writes the `geology` and `geology_unit_field` roles. It does not call `set_basal_contacts`. The Z-coordinate check box is hidden. @@ -421,19 +421,19 @@ Tasks: reads and writes the `basal_contacts` role. - Change the group title and the label to match ("Geology layer" or "Contacts layer"). -- [ ] When the user changes the source, do not write the old selection to the +- [x] When the user changes the source, do not write the old selection to the other role. Keep one selection for each source. -- [ ] The `set_basal_contacts` callback (a tool or a build made a new contacts +- [x] The `set_basal_contacts` callback (a tool or a build made a new contacts layer) does not change the picker in the "geology" source. The layer is for display. -- [ ] Keep the geology picker of the column group in sync with the new picker +- [x] Keep the geology picker of the column group in sync with the new picker (both use the same roles). Decide in the review if one of them must be removed (see the open questions). - [x] Move the "Select the geology layer" and "Select the structure layer" items from `check_data` to `check_stratigraphy`. -- [ ] Update the text that says "in step 1" for these layers +- [x] Update the text that says "in step 1" for these layers (`derived_refresh.py`, `checks.py`, `pages.py`, the docs). -- [ ] Keep the saved widget settings key `stratigraphic_layers_widget`, so old +- [x] Keep the saved widget settings key `stratigraphic_layers_widget`, so old state files load. Acceptance: with "Calculate from geology polygons", the user selects the diff --git a/docs/usage/interface.md b/docs/usage/interface.md index 83b247e..54611f7 100644 --- a/docs/usage/interface.md +++ b/docs/usage/interface.md @@ -47,7 +47,7 @@ The faults trace layer is usually a line layer that contains the trace of the fa ![Fault Layer](../static/fault_layers.png) ### Stratigraphy -Two layers can be used to constrain the stratigraphy of the model: +The **Source layers** group is at the top of step 2. Two layers can be used to constrain the stratigraphy of the model: 1. Basal contacts - this layer defines the basal contacts of the stratigraphy. The layer should contain a line layer with the contact traces. The attributes can be used to define the name of the contact. 2. Structural data - this layer defines the structural data that is used to constrain the model. The layer should contain a point layer with the structural data. The attributes can be used to define the orientation of the data, such as dip and dip direction. @@ -58,9 +58,9 @@ You select each layer one time. The plugin keeps the geology layer, the unit nam The geology layer is the layer that you select in the Stratigraphic Column tab. If you select a layer in one of the tools and no geology layer is set, the plugin uses that layer for the other tools. The plugin saves the shared layers with the application state. -The **Source** setting in the basal contacts group selects where the basal contacts come from: -- **Calculate from geology polygons** (default). The Basal Contacts tool extracts the contacts from the geology layer and the stratigraphic column. When it finishes, the new layer becomes the basal contacts layer of the model. -- **Use a contacts layer**. Your own layer is an input. The plugin does not change it, and the Thickness Calculator uses it. +The **Source** setting in the **Source layers** group of step 2 selects where the basal contacts come from. The first layer picker follows the setting. Each source keeps its own selection: +- **Calculate from geology polygons** (default). The picker shows polygon layers. It sets the geology layer and the unit name field. The Basal Contacts tool extracts the contacts from the geology layer and the stratigraphic column. When it finishes, the new layer becomes the basal contacts layer of the model. +- **Use a contacts layer**. The picker shows line and point layers. Your own layer is an input. The plugin does not change it, and the Thickness Calculator uses it. ## Stratigraphic Column The stratigraphic column defines the order of the contacts and any unconformable relationships between them. The column is defined by a list of units - these units are ordered from oldest at the bottom to youngest at the top. Unconformities can be inserted between units to define an unconformable relationship. The thicknesses define the true thickness of each unit and are used to parameterise the interpolation. The unit names should match the names of the contacts in the basal contacts layer. Units without basal contacts can be included in the stratigraphic column but will not be constrained by any data. diff --git a/loopstructural/gui/modelling/model_definition/model_definition_tab.py b/loopstructural/gui/modelling/model_definition/model_definition_tab.py index 7635da6..e25da31 100644 --- a/loopstructural/gui/modelling/model_definition/model_definition_tab.py +++ b/loopstructural/gui/modelling/model_definition/model_definition_tab.py @@ -4,14 +4,13 @@ from .bounding_box import BoundingBoxWidget from .dem import DEMWidget -from .stratigraphic_layers import StratigraphicLayersWidget class ModelDefinitionTab(BaseTab): - """The area, the elevation and the source layers (step 1 of the dock). + """The area and the elevation (step 1 of the dock). - The fault layers are in step 3. Save, Open and Reset are in the header of - the dock. + The geology, contacts and structure layers are in step 2. 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): @@ -19,12 +18,10 @@ def __init__(self, parent=None, data_manager=None): # Add widgets to the QToolBox self.bounding_box = BoundingBoxWidget(self, data_manager) self.dem = DEMWidget(self, data_manager) - self.stratigraphy_layers = StratigraphicLayersWidget(self, data_manager) # Set uniform size policy for all widgets - for widget in [self.bounding_box, self.dem, self.stratigraphy_layers]: + for widget in [self.bounding_box, self.dem]: widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) self.add_widget(self.bounding_box, 'Bounding Box') self.add_widget(self.dem, 'DEM') - self.add_widget(self.stratigraphy_layers, 'Stratigraphic Layers') diff --git a/loopstructural/gui/modelling/model_definition/stratigraphic_layers.py b/loopstructural/gui/modelling/model_definition/stratigraphic_layers.py index 8798059..0e3e489 100644 --- a/loopstructural/gui/modelling/model_definition/stratigraphic_layers.py +++ b/loopstructural/gui/modelling/model_definition/stratigraphic_layers.py @@ -15,6 +15,7 @@ def __init__(self, parent=None, data_manager=None): if data_manager is None: raise ValueError("data_manager must be provided") self.data_manager = data_manager + self._loading = False super().__init__(parent) ui_path = os.path.join(os.path.dirname(__file__), "stratigraphic_layers.ui") uic.loadUi(ui_path, self) @@ -55,8 +56,58 @@ def __init__(self, parent=None, data_manager=None): self.useStructuralPointsZCoordinatesCheckBox.stateChanged.connect( self.onStructuralDataFieldChanged ) - self._guess_layers_and_fields() + self._guess_structure_layer() self._restore_selection() + self._apply_contacts_source(self.data_manager.layer_roles.contacts_source) + + @property + def _from_geology(self): + return self.data_manager.layer_roles.contacts_source == layer_roles.CONTACTS_FROM_GEOLOGY + + def _apply_contacts_source(self, source): + """Set the filter, the labels and the selection of the first picker for a source. + + The geology source has one selection (the `geology` roles) and the layer + source has another (the `basal_contacts` role). Each one is kept. + """ + from_geology = source == layer_roles.CONTACTS_FROM_GEOLOGY + self._loading = True + try: + if from_geology: + configure_layer_combo( + self.basalContactsLayer, + QgsMapLayerProxyModel.Filter.PolygonLayer, + allow_empty=True, + ) + self.groupBox_basalContacts.setTitle("Geology") + self.basalContactsLabel.setText("Geology layer") + self.label.setText("Unit name field") + roles = self.data_manager.layer_roles + layer = roles.get(layer_roles.GEOLOGY) + field = roles.get(layer_roles.GEOLOGY_UNIT_FIELD) + else: + configure_layer_combo( + self.basalContactsLayer, + QgsMapLayerProxyModel.Filter.LineLayer | QgsMapLayerProxyModel.Filter.PointLayer, + allow_empty=True, + ) + self.groupBox_basalContacts.setTitle("Basal contacts") + self.basalContactsLabel.setText("Contacts layer") + self.label.setText("Unit name field") + layer = self.data_manager.layer_roles.get(layer_roles.BASAL_CONTACTS) + field = (self.data_manager._basal_contacts or {}).get('unitname_field') + if layer is None: + self._guess_basal_contacts() + layer = self.basalContactsLayer.currentLayer() + field = self.unitNameField.currentField() or field + self.basalContactsLayer.setLayer(layer) + self.unitNameField.setLayer(layer) + if field: + self.unitNameField.setField(field) + self.useBasalContactsZCoordinatesCheckBox.setVisible(not from_geology) + self.useZCoordinateLabel.setVisible(not from_geology) + finally: + self._loading = False def _add_contacts_source_combo(self): """Add the choice of where the basal contacts come from.""" @@ -87,9 +138,14 @@ def _on_contacts_source_selected(self, index): self.data_manager.layer_roles.contacts_source = self.contactsSourceComboBox.itemData(index) def _on_layer_role_changed(self, role, value): - """Show a contacts source that was set by a loaded state.""" + """Show a contacts source or a geology layer that something else set.""" if role == 'contacts_source': self._show_contacts_source(value) + self._apply_contacts_source(value) + elif role in (layer_roles.GEOLOGY, layer_roles.GEOLOGY_UNIT_FIELD) and self._from_geology: + self._apply_contacts_source(layer_roles.CONTACTS_FROM_GEOLOGY) + elif role == layer_roles.BASAL_CONTACTS and not self._from_geology: + self._apply_contacts_source(layer_roles.CONTACTS_FROM_LAYER) def enableBasalContactsZCheckBox(self, enable): self.useBasalContactsZCoordinatesCheckBox.setEnabled(enable) @@ -106,6 +162,9 @@ def enableStructuralPointsZCheckBox(self, enable): self.useStructuralPointsZCoordinatesCheckBox.setChecked(False) def set_basal_contacts(self, layer, unitname_field=None, use_z_coordinate=False): + if self._from_geology: + # The contacts layer is for display. The picker shows the geology layer. + return self.basalContactsLayer.setLayer(layer) if layer is not None and layer.isValid(): if layer.wkbType() != QgsWkbTypes.Type.Unknown: @@ -153,9 +212,22 @@ def set_orientations_layer( self.structural_points_use_z = use_z_coordinate self.useStructuralPointsZCoordinatesCheckBox.setChecked(use_z_coordinate) + def _write_first_picker(self, layer, field): + """Write the first picker to the role of the current source.""" + if self._from_geology: + roles = self.data_manager.layer_roles + roles.set(layer_roles.GEOLOGY, layer) + roles.set(layer_roles.GEOLOGY_UNIT_FIELD, field or None) + else: + self.data_manager.set_basal_contacts( + layer, field, use_z_coordinate=self.basal_contacts_use_z + ) + def onBasalContactsChanged(self, layer): + if self._loading: + return self.unitNameField.setLayer(layer) - self.data_manager.set_basal_contacts(layer, self.unitNameField.currentField()) + self._write_first_picker(layer, self.unitNameField.currentField()) self._persist_selection() def onOrientationTypeChanged(self, index): @@ -198,16 +270,13 @@ def onStructuralDataFieldChanged(self, field): # self.updateDataManager() def onUnitFieldChanged(self, field): - self.data_manager.set_basal_contacts( - self.basalContactsLayer.currentLayer(), - field, - use_z_coordinate=self.basal_contacts_use_z, - ) + if self._loading: + return + self._write_first_picker(self.basalContactsLayer.currentLayer(), field) self._persist_selection() - # self.updateDataManager() - - def _guess_layers_and_fields(self): + def _guess_basal_contacts(self): + """Select a layer that is named like a contacts layer, in the layer source.""" if not self.data_manager: return # Basal contacts @@ -222,7 +291,10 @@ def _guess_layers_and_fields(self): fmatcher = ColumnMatcher(fields) if unit_match := fmatcher.find_match('UNITNAME'): self.unitNameField.setField(unit_match) - # Structural data + + def _guess_structure_layer(self): + if not self.data_manager: + return structural_names = get_layer_names(self.structuralDataLayer) structural_matcher = ColumnMatcher(structural_names) structural_match = structural_matcher.find_match( @@ -244,23 +316,27 @@ def _guess_layers_and_fields(self): def _persist_selection(self): if not self.data_manager: return - settings = { - 'basal_layer': ( + settings = dict(self.data_manager.get_widget_settings('stratigraphic_layers_widget', {})) + if not self._from_geology: + # In the geology source the first picker holds the geology layer. The + # `geology` roles save it. Keep the contacts layer of the other source. + settings['basal_layer'] = ( self.basalContactsLayer.currentLayer().name() if self.basalContactsLayer.currentLayer() else None - ), + ) + settings['unit_name_field'] = self.unitNameField.currentField() + settings['use_basal_z'] = self.useBasalContactsZCoordinatesCheckBox.isChecked() + settings |= { 'structural_layer': ( self.structuralDataLayer.currentLayer().name() if self.structuralDataLayer.currentLayer() else None ), - 'unit_name_field': self.unitNameField.currentField(), 'orientation_field': self.orientationField.currentField(), 'dip_field': self.dipField.currentField(), 'structural_unit_field': self.structuralDataUnitName.currentField(), 'orientation_type': self.orientationType.currentText(), - 'use_basal_z': self.useBasalContactsZCoordinatesCheckBox.isChecked(), 'use_structural_z': self.useStructuralPointsZCoordinatesCheckBox.isChecked(), } self.data_manager.set_widget_settings('stratigraphic_layers_widget', settings) @@ -271,16 +347,18 @@ def _restore_selection(self): settings = self.data_manager.get_widget_settings('stratigraphic_layers_widget', {}) if not settings: return - if layer_name := settings.get('basal_layer'): - layer = self.data_manager.find_layer_by_name(layer_name) - if layer: - self.basalContactsLayer.setLayer(layer) + # The first picker of the geology source comes from the `geology` roles + if not self._from_geology: + if layer_name := settings.get('basal_layer'): + layer = self.data_manager.find_layer_by_name(layer_name) + if layer: + self.basalContactsLayer.setLayer(layer) + if field := settings.get('unit_name_field'): + self.unitNameField.setField(field) if layer_name := settings.get('structural_layer'): layer = self.data_manager.find_layer_by_name(layer_name) if layer: self.structuralDataLayer.setLayer(layer) - if field := settings.get('unit_name_field'): - self.unitNameField.setField(field) if field := settings.get('orientation_field'): self.orientationField.setField(field) if field := settings.get('dip_field'): @@ -293,7 +371,7 @@ def _restore_selection(self): ) if idx >= 0: self.orientationType.setCurrentIndex(idx) - if 'use_basal_z' in settings: + if 'use_basal_z' in settings and not self._from_geology: self.useBasalContactsZCoordinatesCheckBox.setChecked(settings['use_basal_z']) if 'use_structural_z' in settings: self.useStructuralPointsZCoordinatesCheckBox.setChecked(settings['use_structural_z']) diff --git a/loopstructural/gui/modelling/steps/pages.py b/loopstructural/gui/modelling/steps/pages.py index 5adaa8b..9ab23af 100644 --- a/loopstructural/gui/modelling/steps/pages.py +++ b/loopstructural/gui/modelling/steps/pages.py @@ -12,7 +12,10 @@ QHBoxLayout, QLabel, QMenu, + QFrame, QPushButton, + QScrollArea, + QTabWidget, QToolButton, QVBoxLayout, QWidget, @@ -24,6 +27,9 @@ 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 loopstructural.gui.modelling.model_definition.stratigraphic_layers import ( + StratigraphicLayersWidget, +) from loopstructural.main.workflow_mode import WORKFLOW_MODE_LABELS, WORKFLOW_MODES @@ -64,7 +70,7 @@ def _tool_button(self, text, icon, tooltip, actions): class DataStep(StepPage): - """Step 1: the bounding box, the CRS, the DEM and the source layers.""" + """Step 1: the bounding box, the CRS and the DEM.""" key = checks.STEP_DATA @@ -77,7 +83,7 @@ def __init__(self, parent=None, **kwargs): 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(QLabel("Select the area and the elevation.", self), 1) row.addWidget(convert) layout.addLayout(row) @@ -111,7 +117,7 @@ def _on_mode_changed(self, _index): class StratigraphyStep(StepPage): - """Step 2: the stratigraphic column, and the results that come from the map.""" + """Step 2: the source layers, the stratigraphic column and the results from the map.""" key = checks.STEP_STRATIGRAPHY @@ -134,6 +140,12 @@ def __init__(self, parent=None, **kwargs): "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.") + self.stratigraphy_layers = StratigraphicLayersWidget(self, self.data_manager) + layers_scroll = QScrollArea(self) + layers_scroll.setWidgetResizable(True) + layers_scroll.setFrameShape(QFrame.Shape.NoFrame) + layers_scroll.setWidget(self.stratigraphy_layers) + derive = self._tool_button( "Derive from map", QgsApplication.getThemeIcon('mActionSharingExport.svg'), @@ -148,7 +160,13 @@ def __init__(self, parent=None, **kwargs): row.addStretch(1) row.addWidget(derive) layout.addLayout(row) - layout.addWidget(self.tab, 1) + + # Two tabs, so the page is not tall: the source layers and the column + self.tabs = QTabWidget(self) + self.tabs.addTab(layers_scroll, "Source layers") + self.tabs.addTab(self.tab, "Column") + self.tabs.setCurrentWidget(self.tab) + layout.addWidget(self.tabs, 1) class FaultsStep(StepPage): diff --git a/loopstructural/main/derived_refresh.py b/loopstructural/main/derived_refresh.py index 66dde38..f8c5386 100644 --- a/loopstructural/main/derived_refresh.py +++ b/loopstructural/main/derived_refresh.py @@ -51,7 +51,7 @@ def _check_contacts_inputs(inputs): if not inputs.get('geology') or not inputs.get('unit_field'): raise DerivedRefreshError( "The basal contacts cannot be calculated: select the geology layer and the " - "unit name field in step 1." + "unit name field in step 2." ) @@ -141,11 +141,11 @@ def layer(key, role=None): if geology is None or not unit_field: raise DerivedRefreshError( "The thicknesses cannot be calculated: select the geology layer and the " - "unit name field in step 1." + "unit name field in step 2." ) if calculator_type == 'StructuralPoint' and structure is None: raise DerivedRefreshError( - "The thicknesses cannot be calculated: select the structure layer in step 1." + "The thicknesses cannot be calculated: select the structure layer in step 2." ) if calculator_type == 'AlongSection' and cross_sections is None: raise DerivedRefreshError(