diff --git a/.github/labeler.yml b/.github/labeler.yml index 9b48837..a16bed4 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -44,7 +44,7 @@ tooling: - any-glob-to-any-file: - .pre-commit-config.yaml - setup.cfg - + UI: - head-branch: diff --git a/.github/workflows/auto-labeler.yml b/.github/workflows/auto-labeler.yml index baa4bf2..b033530 100644 --- a/.github/workflows/auto-labeler.yml +++ b/.github/workflows/auto-labeler.yml @@ -1,9 +1,9 @@ -name: "🏷 PR Labeler" +name: "Pull Request Labeler" on: - - pull_request_target +- pull_request_target jobs: - triage: + labeler: permissions: contents: read pull-requests: write diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index 3118001..56a74f5 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -10,7 +10,7 @@ on: - "loopstructural/**/*.py" - "loopstructural/metadata.txt" - 'requirements/documentation.txt' - tags: + tags: - "*" pull_request: @@ -87,9 +87,8 @@ jobs: with: # Upload entire repository path: docs/_build/html/ - + - name: Deploy to GitHub Pages id: deployment if: ${{ github.event_name == 'push' && ( startsWith(github.ref, 'refs/tags/') || github.ref == 'refs/heads/main' ) }} uses: actions/deploy-pages@v4 - diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml index 4b2efe7..b30fd70 100644 --- a/.github/workflows/linter.yml +++ b/.github/workflows/linter.yml @@ -16,6 +16,8 @@ on: env: PROJECT_FOLDER: "loopstructural" PYTHON_VERSION: 3.9 +permissions: + contents: write jobs: @@ -44,8 +46,7 @@ jobs: - name: Lint with ruff run: | ruff check ${{env.PROJECT_FOLDER}} --fix - - uses: stefanzweifel/git-auto-commit-action@v6 - with: - repo-token: "${{ secrets.GH_PAT }}" - commit_message: "style: style fixes by ruff and autoformatting by black" + # - uses: stefanzweifel/git-auto-commit-action@v6 + # with: + # commit_message: "style: style fixes by ruff and autoformatting by black" diff --git a/.github/workflows/packager.yml b/.github/workflows/packager.yml index ca4c824..07fdf63 100644 --- a/.github/workflows/packager.yml +++ b/.github/workflows/packager.yml @@ -35,7 +35,7 @@ jobs: python -m pip install -U -r requirements/packaging.txt python -m pip install --no-deps -U -r requirements/embedded.txt -t ${{ env.PROJECT_FOLDER }}/embedded_external_libs ls loopstructural - + - name: Update translations run: pylupdate5 -noobsolete -verbose ${{ env.PROJECT_FOLDER }}/resources/i18n/plugin_translation.pro @@ -50,4 +50,3 @@ jobs: name: ${{ env.PROJECT_FOLDER }}-latest path: ${{ env.PROJECT_FOLDER }}.*.zip if-no-files-found: error - diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 80241a6..3803b3d 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -18,7 +18,7 @@ jobs: id: release - name: debug run: echo "release_created=${{ steps.release.outputs.loopstructural--tag_name }}" - + outputs: release_created: ${{ steps.release.outputs.releases_created }} package: @@ -40,4 +40,3 @@ jobs: -H "Accept: application/vnd.github.v3+json" \ https://api.github.com/repos/Loop3d/${{ env.PACKAGE_NAME }}/actions/workflows/release.yml/dispatches \ -d "{\"ref\":\"${{ steps.tag.outputs.tag }}\"}" - diff --git a/.github/workflows/releaser.yml b/.github/workflows/releaser.yml index d46c210..6611f5e 100644 --- a/.github/workflows/releaser.yml +++ b/.github/workflows/releaser.yml @@ -56,5 +56,3 @@ jobs: --create-plugin-repo --osgeo-username "$OSGEO_USERNAME" --osgeo-password "$OSGEO_PASSWORD" - - diff --git a/.github/workflows/tester.yml b/.github/workflows/tester.yml index 6bca815..84176cf 100644 --- a/.github/workflows/tester.yml +++ b/.github/workflows/tester.yml @@ -80,4 +80,3 @@ jobs: # run: | # Xvfb :1 & # python3 -m pytest tests/qgis/ - diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5cccd9f..665aaf8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -26,33 +26,7 @@ repos: - --fix-only - --target-version=py39 - - repo: https://github.com/python/black - rev: 24.1.1 - hooks: - - id: black - args: - - --target-version=py39 - - - repo: https://github.com/pycqa/isort - rev: 5.13.2 - hooks: - - id: isort - args: - - --profile - - black - - --filter-files - - repo: https://github.com/pycqa/flake8 - rev: 6.0.0 - hooks: - - id: flake8 - files: ^loopstructural/.*\.py$ - additional_dependencies: ["flake8-qgis<2"] - args: - [ - "--config=setup.cfg", - "--select=E9,F63,F7,F82,QGS101,QGS102,QGS103,QGS104,QGS106", - ] ci: autoupdate_schedule: quarterly diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 84fbd3b..430625e 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -2,9 +2,9 @@ "recommendations": [ "davidanson.vscode-markdownlint", "ms-python.black-formatter", - + "ms-python.isort", - + "ms-python.python", "njpwerner.autodocstring", "redhat.vscode-yaml", diff --git a/CHANGELOG.md b/CHANGELOG.md index 58d37ec..d16cc4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## 0.1.3 - 2025-04-15 -### Fixed +### Fixed - updating CI so that plugin is automatically pushed to QGIS plugin repository on release diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f022098..c1a8653 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,4 +17,3 @@ Make sure your code *roughly* follows [PEP-8](https://www.python.org/dev/peps/pe - docstrings: [sphinx-style](https://sphinx-rtd-tutorial.readthedocs.io/en/latest/docstrings.html#the-sphinx-docstring-format) is used to write technical documentation. - formatting: [black](https://black.readthedocs.io/) is used to automatically format the code without debate. - sorted imports: [isort](https://pycqa.github.io/isort/) is used to sort imports - diff --git a/docs/conf.py b/docs/conf.py index da6b3ab..f652ebb 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,7 +1,6 @@ #!python3 -""" - Configuration for project documentation using Sphinx. +"""Configuration for project documentation using Sphinx. """ # standard @@ -12,7 +11,6 @@ sys.path.insert(0, path.abspath("..")) # move into project package # 3rd party -import sphinx_rtd_theme # theme of Read the Docs # Package from loopstructural import __about__ diff --git a/docs/development/design.md b/docs/development/design.md new file mode 100644 index 0000000..ad91706 --- /dev/null +++ b/docs/development/design.md @@ -0,0 +1,62 @@ +# Design Document for LoopStructural Plugin +## Overview +The LoopStructural plugin is designed to integrate geological modeling capabilities from LoopStructural into QGIS. It provides tools for visualizing, managing, and analyzing geological data, enabling users to create and refine geological models directly within the QGIS environment. + +### Design Choices +1. Modular Architecture +The plugin is structured into multiple modules, each responsible for specific functionalities: + + - GUI Module: Contains user interface components such as dialogs, widgets, and tabs for interacting with the plugin. + - Main Module: Handles core functionalities like data management, model management, and project handling. + - Processing Module: Provides processing algorithms and tools for geological data analysis. + - Toolbelt Module: Includes utility functions like logging and preferences management. + - Resources Module: Stores static resources such as images, translations, and help files. + + This modular design ensures separation of concerns, making the codebase easier to maintain and extend. + +2. Integration with QGIS +The plugin leverages QGIS's PyQt-based framework for GUI development and its processing framework for data analysis. Key integration points include: + + - Use of QgsCollapsibleGroupBox for organizing UI components. + - Implementation of custom processing algorithms using the Processing module. + - Utilization of QGIS's vector and raster layers for geological data representation. + +3. Dynamic UI Components +The plugin dynamically generates UI components based on the data provided by the data_manager. For example: + + - Fault adjacency tables are created dynamically based on unique faults retrieved from the data manager. + - Stratigraphic units tables are similarly generated, allowing users to interact with geological units. + +4. Custom Interactivity +Interactive elements like QPushButton are used extensively in the UI. These buttons allow users to perform actions such as toggling states or cycling through options (e.g., changing colors to represent different statuses). + +5. Extensibility +The plugin is designed to be extensible, allowing developers to add new features or modify existing ones with minimal impact on the overall architecture. Examples include: + + - Adding new tabs or widgets to the GUI. + - Extending the data_manager to support additional geological data types. + - Implementing new processing algorithms in the Processing module. + +6. Resource Management +Static resources such as images and translations are stored in the resources module. This ensures that all assets are centralized and easily accessible. + +7. Testing +The plugin includes a tests directory with unit tests for various components. This ensures that changes to the codebase do not introduce regressions. + +## Key Components +### GUI Module +- Fault Adjacency Tab: Displays a table of faults with interactive buttons for adjacency settings. +- Stratigraphic Units Tab: Similar to the fault adjacency tab but focused on stratigraphic units. +- Visualization Widgets: Tools for rendering geological models and features. +### Main Module +- Data Manager: Handles loading, saving, and querying geological data. +- Model Manager: Manages geological models, including their creation and modification. +### Processing Module +- Provider: Implements custom processing algorithms for geological data analysis. +### Toolbelt Module +- Log Handler: Provides logging utilities for debugging and monitoring. +- Preferences: Manages user preferences and settings. +## Future Enhancements +- Support for additional geological data types. +- Improved visualization capabilities using advanced rendering libraries. +- Enhanced interactivity with more intuitive UI components. diff --git a/loopstructural/__about__.py b/loopstructural/__about__.py index 028ef48..91b7ac0 100644 --- a/loopstructural/__about__.py +++ b/loopstructural/__about__.py @@ -1,8 +1,7 @@ #! python3 -""" - Metadata about the package to easily retrieve informations about it. - See: https://packaging.python.org/guides/single-sourcing-package-version/ +"""Metadata about the package to easily retrieve informations about it. +See: https://packaging.python.org/guides/single-sourcing-package-version/ """ # ############################################################################ @@ -12,7 +11,7 @@ # standard library from configparser import ConfigParser from datetime import date -from pathlib import Path +from pathlib import Path # ############################################################################ # ########## Globals ############### @@ -44,6 +43,7 @@ def plugin_metadata_as_dict() -> dict: Returns: dict: dict of dicts. + """ config = ConfigParser() if PLG_METADATA_FILE.is_file(): diff --git a/loopstructural/__init__.py b/loopstructural/__init__.py index 1bf454e..e8ae2c9 100644 --- a/loopstructural/__init__.py +++ b/loopstructural/__init__.py @@ -20,4 +20,4 @@ def classFactory(iface): """ from .plugin_main import LoopstructuralPlugin - return LoopstructuralPlugin(iface) + return LoopstructuralPlugin(iface) diff --git a/loopstructural/gui/dlg_settings.py b/loopstructural/gui/dlg_settings.py index 660e69b..dbb975e 100644 --- a/loopstructural/gui/dlg_settings.py +++ b/loopstructural/gui/dlg_settings.py @@ -1,8 +1,6 @@ #! python3 -""" - Plugin settings form integrated into QGIS 'Options' menu. -""" +"""Plugin settings form integrated into QGIS 'Options' menu.""" # standard import platform @@ -84,11 +82,16 @@ def __init__(self, parent): def apply(self): """Called to permanently apply the settings shown in the options page (e.g. \ save them to QgsSettings objects). This is usually called when the options \ - dialog is accepted.""" + dialog is accepted. + """ settings = self.plg_settings.get_plg_settings() # misc settings.debug_mode = self.opt_debug.isChecked() + settings.interpolator_nelements = self.n_elements_spin_box.value() + settings.interpolator_npw = self.npw_spin_box.value() + settings.interpolator_cpw = self.cpw_spin_box.value() + settings.interpolator_regularisation = self.regularisation_spin_box.value() settings.version = __version__ # dump new settings into QgsSettings @@ -107,6 +110,11 @@ def load_settings(self): # global self.opt_debug.setChecked(settings.debug_mode) self.lbl_version_saved_value.setText(settings.version) + # self.interpolator_type_combo.setCurrentText(settings.interpolator_type) + self.n_elements_spin_box.setValue(settings.interpolator_nelements) + self.regularisation_spin_box.setValue(settings.interpolator_regularisation) + self.cpw_spin_box.setValue(settings.interpolator_cpw) + self.npw_spin_box.setValue(settings.interpolator_npw) def reset_settings(self): """Reset settings to default values (set in preferences.py module).""" diff --git a/loopstructural/gui/dlg_settings.ui b/loopstructural/gui/dlg_settings.ui index 595e644..b910f9f 100644 --- a/loopstructural/gui/dlg_settings.ui +++ b/loopstructural/gui/dlg_settings.ui @@ -1,245 +1,285 @@ - wdg_loopstructural_settings - - - - 0 - 0 - 538 - 273 - - - - LoopStructural - Settings + wdg_loopstructural_settings + + + + 0 + 0 + 1089 + 467 + + + + LoopStructural - Settings + + + + + + + + + + 0 + 25 + + + + + 16777215 + 30 + + + + + 75 + true + + + + + + + <html><head/><body><p align="center"><span style=" font-weight:600;">PluginTitle - Version X.X.X</span></p></body></html> + + + true + + + Qt::AlignCenter + + + true + + + false + + + Qt::TextSelectableByMouse + + + + + + + + 0 + 100 + + + + + + + Miscellaneous + + + false + + + + + + + 200 + 25 + + + + + 16777215 + 30 + + + + true + + + Reset setttings to factory defaults + + + + + + + + 0 + 25 + + + + + 16777215 + 30 + + + + + + + X.X.X + + + Qt::NoTextInteraction + + + + + + + + 0 + 25 + + + + + 16777215 + 30 + + + + + + + Version used to save settings: + + + + + + + + 200 + 25 + + + + + 500 + 30 + + + + + + + Help + + + + + + + Interpolation Number of Elements + + + + + + + Contact point weight + + + + + + + + + + + + + + + + + + + + 0 + 25 + + + + + 16777215 + 30 + + + + Enable debug mode. + + + true + + + + + + Debug mode (degraded performances) + + + false + + + + + + + + 200 + 25 + + + + + 500 + 30 + - - - - - - - - 0 - 25 - - - - - 16777215 - 30 - - - - - 75 - true - - - - - - - <html><head/><body><p align="center"><span style=" font-weight:600;">PluginTitle - Version X.X.X</span></p></body></html> - - - true - - - Qt::AlignCenter - - - true - - - false - - - Qt::TextSelectableByMouse - - - - - - - - 0 - 100 - - - - - - - Miscellaneous - - - false - - - - - - - 0 - 25 - - - - - 16777215 - 30 - - - - - - - X.X.X - - - Qt::NoTextInteraction - - - - - - - - 200 - 25 - - - - - 500 - 30 - - - - - - - Report an issue - - - - - - - - 0 - 25 - - - - - 16777215 - 30 - - - - - - - Version used to save settings: - - - - - - - - 200 - 25 - - - - - 500 - 30 - - - - - - - Help - - - - - - - - 200 - 25 - - - - - 16777215 - 30 - - - - true - - - Reset setttings to factory defaults - - - - - - - - 0 - 25 - - - - - 16777215 - 30 - - - - Enable debug mode. - - - true - - - - - - Debug mode (degraded performances) - - - false - - - - - - - - - - Qt::Vertical - - - - 20 - 56 - - - - - + + + + Report an issue + + + + + + + Orientation weight + + + + + + + Regularisation weight + + + + - - + + + + + Qt::Vertical + + + + 20 + 56 + + + + + + + + diff --git a/loopstructural/gui/loop_widget.py b/loopstructural/gui/loop_widget.py new file mode 100644 index 0000000..c807fe2 --- /dev/null +++ b/loopstructural/gui/loop_widget.py @@ -0,0 +1,33 @@ +from PyQt5.QtWidgets import QTabWidget, QVBoxLayout, QWidget +from .modelling.modelling_widget import ModellingWidget +from .visualisation.visualisation_widget import VisualisationWidget + + +class LoopWidget(QWidget): + def __init__( + self, parent=None, *, mapCanvas=None, logger=None, data_manager=None, model_manager=None + ): + super().__init__(parent) + self.mapCanvas = mapCanvas + self.logger = logger + self.data_manager = data_manager + self.model_manager = model_manager + + mainLayout = QVBoxLayout(self) + self.setLayout(mainLayout) + tabWidget = QTabWidget(self) + tabWidget.setTabPosition(QTabWidget.South) + mainLayout.addWidget(tabWidget) + self.modelling_widget = ModellingWidget( + self, + mapCanvas=self.mapCanvas, + logger=self.logger, + data_manager=self.data_manager, + model_manager=self.model_manager, + ) + + self.visualisation_widget = VisualisationWidget( + self, mapCanvas=self.mapCanvas, logger=self.logger, model_manager=self.model_manager + ) + tabWidget.addTab(self.modelling_widget, "Modelling") + tabWidget.addTab(self.visualisation_widget, "Visualisation") diff --git a/loopstructural/gui/modelling/base_tab.py b/loopstructural/gui/modelling/base_tab.py new file mode 100644 index 0000000..f930a0a --- /dev/null +++ b/loopstructural/gui/modelling/base_tab.py @@ -0,0 +1,62 @@ +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import QScrollArea, QSizePolicy, QVBoxLayout, QWidget +from qgis.gui import QgsCollapsibleGroupBox + + +class BaseTab(QWidget): + def __init__(self, parent=None, data_manager=None, scrollable=False): + super().__init__(parent) + self.data_manager = data_manager + # Initialize a default layout for all tabs + if scrollable: + self.setAttribute(Qt.WA_TransparentForMouseEvents, True) + self.scroll_area = QScrollArea(self) + self.scroll_area.setWidgetResizable(True) + self.scroll_area.setAttribute(Qt.WA_TransparentForMouseEvents, False) + # Create a container widget for the scroll area + self.container_widget = QWidget() + self.scroll_area.setWidget(self.container_widget) + # Ensure the scroll area and its container widget can handle focus and mouse events + self.scroll_area.setFocusPolicy(Qt.NoFocus) + self.scroll_area.setFrameShape(QScrollArea.NoFrame) # Remove any unnecessary frame + + # Explicitly set size policies to ensure proper interaction + self.scroll_area.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + # Set up a layout for the container widget + self.container_layout = QVBoxLayout(self.container_widget) + # Set the main layout for the BaseTab + self.main_layout = QVBoxLayout(self) + self.setAttribute(Qt.WA_TransparentForMouseEvents, False) + self.main_layout.addWidget(self.scroll_area) + + self.container_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) + + # Ensure the container widget propagates mouse events properly + self.container_widget.setAttribute(Qt.WA_TransparentForMouseEvents, False) + + self.setLayout(self.main_layout) + else: + # If not scrollable, use a simple layout + self.container_layout = QVBoxLayout(self) + self.setLayout(self.container_layout) + + # Set the layout for the tab + + def add_widget(self, widget, name=None, group_box=True): + """Add a widget to the tab.""" + if group_box: + group_box = QgsCollapsibleGroupBox() + group_box.setTitle(name) + group_box_layout = QVBoxLayout() + group_box.setLayout(group_box_layout) + group_box_layout.addWidget(widget) + widget = group_box + self.container_layout.addWidget(widget) + + def set_data_manager(self, data_manager): + """Set the shared data manager for the tab.""" + self.data_manager = data_manager + + def get_data_manager(self): + """Get the shared data manager.""" + return self.data_manager diff --git a/loopstructural/gui/modelling/export_tab.ui b/loopstructural/gui/modelling/export_tab.ui new file mode 100644 index 0000000..e97a61c --- /dev/null +++ b/loopstructural/gui/modelling/export_tab.ui @@ -0,0 +1,400 @@ + + + Form + + + + 0 + 0 + 530 + 665 + + + + Form + + + + + 0 + 1 + 531 + 641 + + + + + + + Export Model + + + + + 10 + 20 + 551 + 224 + + + + + + + + + File Format + + + + + + + + vtk + + + + + python + + + + + geoh5 + + + + + + + + Stratigraphic Surfaces + + + + + + + + + + true + + + + + + + Fault Surfaces + + + + + + + + + + true + + + + + + + Block Model + + + + + + + + + + true + + + + + + + Stratigraphy Data + + + + + + + + + + true + + + + + + + Fault Data + + + + + + + + + + true + + + + + + + Model Name + + + + + + + + + + Directory + + + + + + + + + + + + ... + + + + + + + + + + + + + 20 + 230 + 75 + 23 + + + + Save + + + + + + + + + + Interogate Model + + + + + 10 + 20 + 551 + 271 + + + + + + + <html><head/><body><p>Extract the basal contacts by intersecting the model surfaces with the DTM. </p></body></html> + + + Model contacts + + + + + + + false + + + Add to Project + + + + + + + <html><head/><body><p>Calculate the magnitude of the fault displacements and show this on the map</p></body></html> + + + Fault Displacements + + + + + + + false + + + Add to Project + + + + + + + + + + + + Add to map + + + + + + + + + <html><head/><body><p>Evaluate the stratigraphic ID on a pointset. If the points are not in the model the unit will be -1</p></body></html> + + + Evaluate Model on layer + + + + + + + Mapped Lithologies + + + + + + + Add to map + + + + + + + Add fault traces + + + + + + + <html><head/><body><p>Evaluate the scalar value or the gradient of a geological feature on a vector pointset.</p></body></html> + + + Evaluate feature on layer + + + + + + + + + + + + + + false + + + Gradient + + + + + + + + + + + + + + Add + + + + + + + + + + + false + + + Add to map + + + + + + + Add scalar field + + + + + + + + + + + + Add to Project + + + + + + + + + + + + + + + + + QgsMapLayerComboBox + QComboBox +
qgsmaplayercombobox.h
+
+
+ + +
diff --git a/loopstructural/gui/modelling/fault_adjacency_tab.py b/loopstructural/gui/modelling/fault_adjacency_tab.py new file mode 100644 index 0000000..f47c92b --- /dev/null +++ b/loopstructural/gui/modelling/fault_adjacency_tab.py @@ -0,0 +1,197 @@ +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import ( + QGroupBox, + QLabel, + QPushButton, + QTableWidget, + QTableWidgetItem, + QVBoxLayout, + QWidget, +) + +from LoopStructural.modelling.core.fault_topology import FaultRelationshipType + + +class FaultAdjacencyTab(QWidget): + def __init__(self, parent=None, data_manager=None): + super().__init__(parent) + self.data_manager = data_manager + self.setLayout(QVBoxLayout()) + + # Initialize the UI components for fault adjacency + self.init_ui() + # self.data_manager.set + + def init_ui(self): + """Initialize the user interface components for fault adjacency.""" + + # Create collapsible group boxes for the tables + self.fault_table_group = QGroupBox("Fault Adjacency Table", self) + self.fault_table_layout = QVBoxLayout(self.fault_table_group) + self.stratigraphic_table_group = QGroupBox("Stratigraphic Units Table", self) + self.stratigraphic_table_layout = QVBoxLayout(self.stratigraphic_table_group) + # Create the fault adjacency table + self.fault_fault_instructions = ( + "Rows: faults being affected\n" + "Columns: affecting faults\n" + "Toggle cell colour to indicate fault interaction:\n" + "Green: row fault is cut by column fault\n" + "Red: row fault stops at column fault\n" + "White: no interaction" + ) + self.fault_fault_instructions_label = QLabel(self.fault_fault_instructions) + self.fault_fault_instructions_label.setWordWrap(True) + self.update_fault_adjacency_table() + self.layout().addWidget(self.fault_table_group) + + # Create the stratigraphic units table + self.strat_fault_instructions = ( + "Rows: stratigraphic units\n" + "Columns: faults\n" + "Toggle cell colour to indicate interaction:\n" + "Red: unit is faulted by fault\n" + "White: no interaction" + ) + self.strat_fault_instructions_label = QLabel(self.strat_fault_instructions) + self.strat_fault_instructions_label.setWordWrap(True) + self.update_stratigraphic_units_table() + + self.layout().addWidget(self.stratigraphic_table_group) + self.update = self._update + self.data_manager._stratigraphic_column.attach(self.update) + self.data_manager._fault_topology.attach(self.update) + + def _update(self, event, *args, **kwargs): + if ( + args[0] == "fault_relationship_updated" + or args[0] == "stratigraphy_fault_relationship_updated" + ): + return + + self.update_fault_adjacency_table() + self.update_stratigraphic_units_table() + + def change_button_color(self, button, row, col): + """Cycle the button color and update the fault relationship.""" + current_color = button.styleSheet() + if "red" in current_color: + new_color = "green" + relationship = FaultRelationshipType.FAULTED + elif "green" in current_color: + new_color = "white" + relationship = FaultRelationshipType.NONE + else: + new_color = "red" + relationship = FaultRelationshipType.ABUTTING + + button.setStyleSheet(f"background-color: {new_color};") + f1 = self.data_manager._fault_topology.faults[row] + f2 = self.data_manager._fault_topology.faults[col] + self.data_manager._fault_topology.update_fault_relationship(f1, f2, relationship) + + def update_fault_adjacency_table(self): + """Update the fault adjacency table with QPushButtons.""" + faults = ( + self.data_manager._fault_topology.faults + ) # Assuming faults is a list of fault names + if not faults: + self.fault_table_group.hide() + return + + self.fault_table_group.show() + self.fault_fault_instructions_label.setText(self.fault_fault_instructions) + + if not hasattr(self, 'table'): + self.table = QTableWidget(self) + self.fault_table_layout.addWidget(self.table) + + self.table.setRowCount(len(faults)) + self.table.setColumnCount(len(faults)) + self.table.setHorizontalHeaderLabels(faults) + self.table.setVerticalHeaderLabels(faults) + + for row in range(len(faults)): + for col in range(len(faults)): + if row == col: + # If it's the same fault, set a label instead of a button + item = QTableWidgetItem(faults[row]) + item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable) + self.table.setItem(row, col, item) + else: + button = QPushButton() + if ( + self.data_manager._fault_topology.get_fault_relationship( + faults[row], faults[col] + ) + == FaultRelationshipType.FAULTED + ): + button.setStyleSheet("background-color: green;") + elif ( + self.data_manager._fault_topology.get_fault_relationship( + faults[row], faults[col] + ) + == FaultRelationshipType.ABUTTING + ): + button.setStyleSheet("background-color: red;") + else: + button.setStyleSheet("background-color: white;") + button.clicked.connect( + lambda _, b=button, r=row, c=col: self.change_button_color(b, r, c) + ) + self.table.setCellWidget(row, col, button) + + def update_stratigraphic_units_table(self): + """Update the stratigraphic units table with QPushButtons.""" + faults = ( + self.data_manager._fault_topology.faults + ) # Assuming faults is a list of fault names + group_units_pairs = self.data_manager._stratigraphic_column.get_group_unit_pairs() + + if not faults or not group_units_pairs: + self.stratigraphic_table_group.hide() + return + + self.stratigraphic_table_group.show() + self.strat_fault_instructions_label.setText(self.strat_fault_instructions) + + units = [u[1] for u in group_units_pairs] # Extracting unit names + + if not hasattr(self, 'stratigraphic_table'): + self.stratigraphic_table = QTableWidget(self) + self.stratigraphic_table_layout.addWidget(self.stratigraphic_table) + + self.stratigraphic_table.setRowCount(len(units)) + self.stratigraphic_table.setColumnCount(len(faults)) + self.stratigraphic_table.setHorizontalHeaderLabels(faults) + self.stratigraphic_table.setVerticalHeaderLabels(units) + + for row in range(len(units)): + for col in range(len(faults)): + button = QPushButton() + if self.data_manager._fault_topology.get_fault_stratigraphic_relationship( + units[row], faults[col] + ): + button.setStyleSheet("background-color: red;") + else: + # Default to white if no relationship or not faulted + button.setStyleSheet("background-color: white;") + button.clicked.connect( + lambda _, b=button, r=row, c=col: self.change_button_colour_binary(b, r, c) + ) + self.stratigraphic_table.setCellWidget(row, col, button) + + def change_button_colour_binary(self, button, row, col): + """Cycle the button color between red, green, and black.""" + + current_color = button.styleSheet() + if "red" in current_color: + button.setStyleSheet("background-color: white;") + flag = False + else: + button.setStyleSheet("background-color: red;") + flag = True + fault = self.data_manager._fault_topology.faults[col] + unit = self.data_manager._stratigraphic_column.get_group_unit_pairs()[row] + self.data_manager._fault_topology.update_fault_stratigraphy_relationship( + unit[1], fault, flag + ) diff --git a/loopstructural/gui/modelling/fault_graph/fault.svg b/loopstructural/gui/modelling/fault_graph/fault.svg new file mode 100644 index 0000000..8becc18 --- /dev/null +++ b/loopstructural/gui/modelling/fault_graph/fault.svg @@ -0,0 +1,258 @@ + + + + diff --git a/loopstructural/gui/modelling/fault_graph/fault_graph.py b/loopstructural/gui/modelling/fault_graph/fault_graph.py new file mode 100644 index 0000000..7d1810d --- /dev/null +++ b/loopstructural/gui/modelling/fault_graph/fault_graph.py @@ -0,0 +1,284 @@ +import os + +from PyQt5 import QtCore, QtGui, QtWidgets +from PyQt5.QtSvg import QGraphicsSvgItem + + +class TopologyNode(QtWidgets.QGraphicsItem): + def __init__(self, name, scene: 'TopologyScene', node_type="fault"): + super().__init__() + self.name = name + self.scene_ref = scene # reference to scene + self.node_type = node_type + self.edges = [] + + # Set shape based on node type + if node_type == "fault": + self.shape_item = QGraphicsSvgItem(os.path.join(os.path.dirname(__file__), "fault.svg")) + elif node_type == "stratigraphy": + self.shape_item = QGraphicsSvgItem( + os.path.join(os.path.dirname(__file__), "stratigraphy.svg") + ) + elif node_type == "unconformity": + self.shape_item = QGraphicsSvgItem( + os.path.join(os.path.dirname(__file__), "unconformity.svg") + ) + + self.shape_item.setParentItem(self) + self.shape_item.setScale(0.5) # Adjust scale if needed + # Enable interactivity for the node + self.setFlags( + QtWidgets.QGraphicsItem.ItemIsMovable + | QtWidgets.QGraphicsItem.ItemIsSelectable + | QtWidgets.QGraphicsItem.ItemSendsGeometryChanges + ) + self.label = QtWidgets.QGraphicsTextItem(name, self) + self.label.setDefaultTextColor(QtCore.Qt.black) + self.label.setPos(-self.label.boundingRect().width() / 2, -30) + self.shape_item.setAcceptedMouseButtons(QtCore.Qt.NoButton) + + def boundingRect(self): + """Return a bounding rectangle that includes the shape and the label.""" + shape_rect = self.shape_item.boundingRect() + label_rect = self.label.boundingRect() + combined_rect = shape_rect.united( + label_rect.translated(0, -30) + ) # Adjust for label position + return combined_rect + + def mousePressEvent(self, event): + scene = self.scene_ref + if event.button() == QtCore.Qt.LeftButton: + if scene.connecting_from is None: + scene.connecting_from = self + self.setBrush(QtGui.QBrush(QtCore.Qt.yellow)) # highlight source + elif scene.connecting_from == self: + # cancel connection + scene.connecting_from = None + self.setBrush(QtGui.QBrush(QtCore.Qt.lightGray)) + else: + # create edge + scene.add_edge_between(scene.connecting_from, self) + scene.connecting_from.setBrush(QtGui.QBrush(QtCore.Qt.lightGray)) + scene.connecting_from = None + else: + super().mousePressEvent(event) + + def paint(self, painter, option, widget=None): + """Delegate paint to the shape_item.""" + # No custom painting is needed since the shape_item handles rendering. + pass + + def add_edge(self, edge): + self.edges.append(edge) + + def itemChange(self, change, value): + if change == QtWidgets.QGraphicsItem.ItemPositionChange: + for edge in self.edges: + edge.update_position() + return super().itemChange(change, value) + + +class TopologyEdge(QtWidgets.QGraphicsLineItem): + def __init__(self, source, target): + super().__init__() + self.source = source + self.target = target + self.setPen(QtGui.QPen(QtCore.Qt.darkRed, 2)) + self.setFlags( + QtWidgets.QGraphicsItem.ItemIsSelectable | QtWidgets.QGraphicsItem.ItemIsFocusable + ) + self.setAcceptHoverEvents(True) + + self.source.add_edge(self) + self.target.add_edge(self) + self.update_position() + + def update_position(self): + line = QtCore.QLineF(self.source.pos(), self.target.pos()) + self.setLine(line) + + def contextMenuEvent(self, event): + menu = QtWidgets.QMenu() + edit_action = menu.addAction("Edit Edge") + delete_action = menu.addAction("Delete Edge") + selected_action = menu.exec_(event.screenPos()) + + if selected_action == edit_action: + self.edit_edge() + elif selected_action == delete_action: + self.delete_edge() + + def edit_edge(self): + QtWidgets.QMessageBox.information( + None, "Edit", f"Editing relationship between {self.source.name} and {self.target.name}" + ) + + def delete_edge(self): + # Remove from the scene + self.source.edges.remove(self) + self.target.edges.remove(self) + # Remove from the scene's edge list + if self in self.scene().edges: + self.scene().edges.remove(self) + + self.scene().removeItem(self) + + def hoverEnterEvent(self, event): + self.setPen(QtGui.QPen(QtCore.Qt.blue, 3)) + super().hoverEnterEvent(event) + + def hoverLeaveEvent(self, event): + self.setPen(QtGui.QPen(QtCore.Qt.darkRed, 2)) + super().hoverLeaveEvent(event) + + +class TopologyScene(QtWidgets.QGraphicsScene): + def __init__(self): + super().__init__() + self.setSceneRect(0, 0, 600, 400) + self.nodes = {} + self.edges = [] + self.connecting_from = None # <-- store selected node for connecting + self.temp_line = None # Temporary line for visual feedback + + # self._create_static_graph() + self.finalize_layout() + + # def _create_static_graph(self): + # positions = { + # "fault_1": (100, 100), + # "fault_2": (200, 150), + # "fault_3": (100, 300), + # "fault_4": (300, 250), + # "fault_5": (400, 150), + # } + + # for name, pos in positions.items(): + # node = TopologyNode(name, self) + # self.addItem(node) + # node.setPos(*pos) + # self.nodes[name] = node + + # for src, tgt in edge_defs: + # self.add_edge_between(self.nodes[src], self.nodes[tgt]) + + def finalize_layout(self): + for edge in self.edges: + edge.update_position() + + def add_edge_between(self, source, target): + # Avoid duplicate edges + print(f"Adding edge between {source.name} and {target.name}") + if any( + (e.source == source and e.target == target) + or (e.source == target and e.target == source) + for e in self.edges + ): + print(f"Edge already exists between {source.name} and {target.name}") + return + edge = TopologyEdge(source, target) + self.addItem(edge) + self.edges.append(edge) + + def mouseMoveEvent(self, event): + if self.connecting_from and self.temp_line: + # Update the temporary line to follow the cursor + line = QtCore.QLineF(self.connecting_from.pos(), event.scenePos()) + self.temp_line.setLine(line) + super().mouseMoveEvent(event) + + def start_temporary_line(self, source): + """Start drawing a temporary line from the source node.""" + self.temp_line = QtWidgets.QGraphicsLineItem() + self.temp_line.setPen(QtGui.QPen(QtCore.Qt.DotLine)) + self.addItem(self.temp_line) + + def remove_temporary_line(self): + """Remove the temporary line from the scene.""" + if self.temp_line: + self.removeItem(self.temp_line) + self.temp_line = None + + def keyPressEvent(self, event): + """Handle key press events for deleting nodes or edges.""" + if event.key() == QtCore.Qt.Key_Delete: + for item in self.selectedItems(): + if isinstance(item, TopologyNode): + # Remove all edges connected to the node + for edge in item.edges[:]: + edge.delete_edge() + # Remove the node itself + self.removeItem(item) + del self.nodes[item.name] + elif isinstance(item, TopologyEdge): + # Remove the edge + item.delete_edge() + elif event.key() == QtCore.Qt.Key_Escape: + # Deselect all selected items + for item in self.selectedItems(): + item.setSelected(False) + else: + super().keyPressEvent(event) + + def mouseDoubleClickEvent(self, event): + """Handle double-click events to create a new node.""" + if not self.itemAt(event.scenePos(), QtGui.QTransform()): + # Create a new node at the double-click position + node_name = f"fault_{len(self.nodes) + 1}" + new_node = TopologyNode(node_name, self) + self.addItem(new_node) + new_node.setPos(event.scenePos()) + self.nodes[node_name] = new_node + else: + super().mouseDoubleClickEvent(event) + + +class FaultGraph(QtWidgets.QWidget): + def __init__(self, parent=None, data_manager=None): + super().__init__() + layout = QtWidgets.QVBoxLayout(self) + self.view = QtWidgets.QGraphicsView() + self.scene = TopologyScene() + self.view.setScene(self.scene) + layout.addWidget(self.view) + + # Add a "big plus button" in the top-right corner + self.add_button = QtWidgets.QPushButton("+") + self.add_button.setFixedSize(50, 50) + self.add_button.setStyleSheet("font-size: 24px; font-weight: bold;") + self.add_button.clicked.connect(self.show_add_node_menu) + + # Create a layout for the button + button_layout = QtWidgets.QHBoxLayout() + button_layout.addStretch() + button_layout.addWidget(self.add_button) + layout.addLayout(button_layout) + + self.setWindowTitle("Stratigraphic Topology Viewer") + self.resize(640, 480) + + def show_add_node_menu(self): + """Show a menu to add different types of nodes.""" + menu = QtWidgets.QMenu(self) + + # Add options for different node types + fault_action = menu.addAction("Add Fault Node") + stratigraphy_action = menu.addAction("Add Stratigraphy Node") + unconformity_action = menu.addAction("Add Unconformity Node") + + # Connect actions to methods + fault_action.triggered.connect(lambda: self.add_node("fault")) + stratigraphy_action.triggered.connect(lambda: self.add_node("stratigraphy")) + unconformity_action.triggered.connect(lambda: self.add_node("unconformity")) + + # Show the menu below the button + menu.exec_(self.add_button.mapToGlobal(QtCore.QPoint(0, self.add_button.height()))) + + def add_node(self, node_type): + """Add a new node of the specified type to the scene.""" + node_name = f"{node_type}_{len(self.scene.nodes) + 1}" + new_node = TopologyNode(node_name, self.scene, node_type=node_type) + self.scene.addItem(new_node) + new_node.setPos(300, 200) # Default position for new nodes + self.scene.nodes[node_name] = new_node diff --git a/loopstructural/gui/modelling/fault_graph/stratigraphy.svg b/loopstructural/gui/modelling/fault_graph/stratigraphy.svg new file mode 100644 index 0000000..af4d618 --- /dev/null +++ b/loopstructural/gui/modelling/fault_graph/stratigraphy.svg @@ -0,0 +1,255 @@ + + + + diff --git a/loopstructural/gui/modelling/fault_graph/unconformity.svg b/loopstructural/gui/modelling/fault_graph/unconformity.svg new file mode 100644 index 0000000..438cb67 --- /dev/null +++ b/loopstructural/gui/modelling/fault_graph/unconformity.svg @@ -0,0 +1,250 @@ + + + + diff --git a/loopstructural/gui/modelling/feature_details_panel.py b/loopstructural/gui/modelling/feature_details_panel.py new file mode 100644 index 0000000..c369ae5 --- /dev/null +++ b/loopstructural/gui/modelling/feature_details_panel.py @@ -0,0 +1,201 @@ +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import ( + QComboBox, + QDoubleSpinBox, + QFormLayout, + QLabel, + QScrollArea, + QVBoxLayout, + QWidget, +) + +from LoopStructural.modelling.features import StructuralFrame +from LoopStructural.utils import normal_vector_to_strike_and_dip + + +class BaseFeatureDetailsPanel(QWidget): + def __init__(self, parent=None, *, feature=None): + super().__init__(parent) + self.feature = feature + # Create a scroll area for horizontal scrolling + scroll = QScrollArea(self) + scroll.setWidgetResizable(True) + scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) + scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) + + # Create content widget to hold the form layout + content = QWidget() + self.layout = QVBoxLayout(content) + # Set the content widget as the scroll area's widget + scroll.setWidget(content) + + # Add scroll area to main layout + mainLayout = QVBoxLayout(self) + mainLayout.addWidget(scroll) + + # Set the main layout + self.setLayout(mainLayout) + + ## define interpolator parameters + # Regularisation spin box + self.regularisation_spin_box = QDoubleSpinBox() + self.regularisation_spin_box.setRange(0, 100) + self.regularisation_spin_box.setValue( + feature.builder.build_arguments.get('regularisation', 1.0) + ) + self.regularisation_spin_box.valueChanged.connect( + lambda value: feature.builder.update_build_arguments({'regularisation': value}) + ) + self.cpw_spin_box = QDoubleSpinBox() + self.cpw_spin_box.setRange(0, 100) + self.cpw_spin_box.setValue(feature.builder.build_arguments.get('cpw', 1.0)) + self.cpw_spin_box.valueChanged.connect( + lambda value: feature.builder.update_build_arguments({'cpw': value}) + ) + + self.npw_spin_box = QDoubleSpinBox() + self.npw_spin_box.setRange(0, 100) + self.npw_spin_box.setValue(feature.builder.build_arguments.get('npw', 1.0)) + self.npw_spin_box.valueChanged.connect( + lambda value: feature.builder.update_build_arguments({'npw': value}) + ) + self.interpolator_type_label = QLabel("Interpolator Type:") + self.interpolator_type_combo = QComboBox() + self.interpolator_type_combo.addItems(["FDI", "PLI", "surfe"]) + + self.n_elements_spinbox = QDoubleSpinBox() + self.n_elements_spinbox.setRange(100, 1000000) + self.n_elements_spinbox.setValue(self.getNelements(feature)) + self.n_elements_spinbox.setPrefix("Number of Elements: ") + + self.n_elements_spinbox.valueChanged.connect(self.updateNelements) + + # Form layout for better organization + form_layout = QFormLayout() + form_layout.addRow(self.interpolator_type_label, self.interpolator_type_combo) + form_layout.addRow("Number of Elements:", self.n_elements_spinbox) + form_layout.addRow('Regularisation', self.regularisation_spin_box) + form_layout.addRow('Contact points weight', self.cpw_spin_box) + form_layout.addRow('Orientation point weight', self.npw_spin_box) + + QgsCollapsibleGroupBox = QWidget() + QgsCollapsibleGroupBox.setLayout(form_layout) + self.layout.addWidget(QgsCollapsibleGroupBox) + + # self.layout.addLayout(form_layout) + + def updateNelements(self, value): + """Update the number of elements in the feature's interpolator.""" + if self.feature: + if issubclass(type(self.feature), StructuralFrame): + for i in range(3): + if self.feature[i].interpolator is not None: + self.feature[i].interpolator.nelements = value + self.feature[i].builder.update_build_arguments({'nelements': value}) + self.feature[i].builder.build() + elif self.feature.interpolator is not None: + + self.feature.interpolator.nelements = value + self.feature.builder.update_build_arguments({'nelements': value}) + self.feature.builder.build() + else: + print("Error: Feature is not initialized.") + + def getNelements(self, feature): + """Get the number of elements from the feature's interpolator.""" + if feature: + if issubclass(type(feature), StructuralFrame): + return feature[0].interpolator.n_elements + elif feature.interpolator is not None: + return feature.interpolator.n_elements + return 1000 + + +class FaultFeatureDetailsPanel(BaseFeatureDetailsPanel): + def __init__(self, parent=None, *, fault=None): + super().__init__(parent, feature=fault) + if fault is None: + raise ValueError("Fault must be provided.") + self.fault = fault + dip = normal_vector_to_strike_and_dip(fault.fault_normal_vector)[0, 0] + pitch = 0 + self.fault_parameters = { + 'displacement': fault.displacement, + 'major_axis_length': fault.fault_major_axis, + 'minor_axis_length': fault.fault_minor_axis, + 'intermediate_axis_length': fault.fault_intermediate_axis, + 'dip': dip, + 'pitch': pitch, + # 'enabled': fault.fault_enabled + } + + # # Fault displacement slider + # self.displacement_spinbox = QDoubleSpinBox() + # self.displacement_spinbox.setRange(0, 1000000) # Example range + # self.displacement_spinbox.setValue(self.fault.displacement) + # self.displacement_spinbox.valueChanged.connect( + # lambda value: self.fault_parameters.__setitem__('displacement', value) + # ) + + # # Fault axis lengths + # self.major_axis_spinbox = QDoubleSpinBox() + # self.major_axis_spinbox.setRange(0, float('inf')) + # self.major_axis_spinbox.setValue(self.fault_parameters['major_axis_length']) + # # self.major_axis_spinbox.setPrefix("Major Axis Length: ") + # self.major_axis_spinbox.valueChanged.connect( + # lambda value: self.fault_parameters.__setitem__('major_axis_length', value) + # ) + # self.minor_axis_spinbox = QDoubleSpinBox() + # self.minor_axis_spinbox.setRange(0, float('inf')) + # self.minor_axis_spinbox.setValue(self.fault_parameters['minor_axis_length']) + # # self.minor_axis_spinbox.setPrefix("Minor Axis Length: ") + # self.minor_axis_spinbox.valueChanged.connect( + # lambda value: self.fault_parameters.__setitem__('minor_axis_length', value) + # ) + # self.intermediate_axis_spinbox = QDoubleSpinBox() + # self.intermediate_axis_spinbox.setRange(0, float('inf')) + # self.intermediate_axis_spinbox.setValue(self.fault_parameters['intermediate_axis_length']) + # self.intermediate_axis_spinbox.valueChanged.connect( + # lambda value: self.fault_parameters.__setitem__('intermediate_axis_length', value) + # ) + # # self.intermediate_axis_spinbox.setPrefix("Intermediate Axis Length: ") + + # # Fault dip field + # self.dip_spinbox = QDoubleSpinBox() + # self.dip_spinbox.setRange(0, 90) # Dip angle range + # self.dip_spinbox.setValue(self.fault_parameters['dip']) + # # self.dip_spinbox.setPrefix("Fault Dip: ") + # self.dip_spinbox.valueChanged.connect( + # lambda value: self.fault_parameters.__setitem__('dip', value) + # ) + # self.pitch_spinbox = QDoubleSpinBox() + # self.pitch_spinbox.setRange(0, 180) + # self.pitch_spinbox.setValue(self.fault_parameters['pitch']) + # self.pitch_spinbox.valueChanged.connect( + # lambda value: self.fault_parameters.__setitem__('pitch', value) + # ) + # # self.dip_spinbox.valueChanged.connect( + + # # Enabled field + # # self.enabled_checkbox = QCheckBox("Enabled") + # # self.enabled_checkbox.setChecked(False) + + # # Form layout for better organization + # form_layout = QFormLayout() + # form_layout.addRow("Fault displacement", self.displacement_spinbox) + # form_layout.addRow("Major Axis Length", self.major_axis_spinbox) + # form_layout.addRow("Minor Axis Length", self.minor_axis_spinbox) + # form_layout.addRow("Intermediate Axis Length", self.intermediate_axis_spinbox) + # form_layout.addRow("Fault Dip", self.dip_spinbox) + # # form_layout.addRow("Enabled:", self.enabled_checkbox) + + # self.layout.addLayout(form_layout) + # self.setLayout(self.layout) + + +class FoliationFeatureDetailsPanel(BaseFeatureDetailsPanel): + def __init__(self, parent=None, *, feature=None): + super().__init__(parent, feature=feature) + + # Remove redundant layout setting + # self.setLayout(self.layout) diff --git a/loopstructural/gui/modelling/geological_history_tab.py b/loopstructural/gui/modelling/geological_history_tab.py new file mode 100644 index 0000000..c21c216 --- /dev/null +++ b/loopstructural/gui/modelling/geological_history_tab.py @@ -0,0 +1,11 @@ +from loopstructural.gui.modelling.base_tab import BaseTab +from loopstructural.gui.modelling.stratigraphic_column.stratigraphic_column import StratColumnWidget + + +class GeologialHistoryTab(BaseTab): + def __init__(self, parent=None, data_manager=None): + super().__init__(parent, data_manager, scrollable=True) + # Load the UI file for Tab 1 + 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) diff --git a/loopstructural/gui/modelling/geological_history_tab.ui b/loopstructural/gui/modelling/geological_history_tab.ui new file mode 100644 index 0000000..a4f383a --- /dev/null +++ b/loopstructural/gui/modelling/geological_history_tab.ui @@ -0,0 +1,359 @@ + + + Form + + + + 0 + 0 + 622 + 785 + + + + Form + + + + + 20 + 10 + 591 + 701 + + + + + + + + + Stratigraphic Column + + + + + 10 + 30 + 561 + 281 + + + + + QLayout::SetMinimumSize + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + <html><head/><body><p>Save the stratigraphic order and thickness as new columns in the contacts basal contacts layer. Note this will overwrite any existing data in the &quot;LS_order' and 'LS_thickness' columns</p></body></html> + + + Add Unit + + + + + + + + + + + + + + + + + Fault Properties + + + + + 10 + 20 + 679 + 540 + + + + + + + + + + + + Displacement + + + + + + + false + + + -10000000000.000000000000000 + + + 10000000000.000000000000000 + + + + + + + Dip + + + + + + + false + + + 0.000000000000000 + + + 90.000000000000000 + + + + + + + Center + + + + + + + + + + + + + x + + + + + + + false + + + -100000000000.000000000000000 + + + 100000000000.000000000000000 + + + + + + + false + + + -100000000000.000000000000000 + + + 100000000000.000000000000000 + + + + + + + y + + + + + + + z + + + + + + + false + + + -100000000000.000000000000000 + + + 0.000000000000000 + + + + + + + + + false + + + Select on Map + + + + + + + + + + + Major Axis + + + + + + + false + + + 10000000.000000000000000 + + + + + + + Minor Axis + + + + + + + false + + + 10000000.000000000000000 + + + + + + + Intermediate Axis + + + + + + + false + + + 1000000.000000000000000 + + + + + + + Active + + + + + + + false + + + + + + + Pitch + + + + + + + false + + + 0.000000000000000 + + + 180.000000000000000 + + + + + + + + + Add Elipse to Map + + + + + + + + + + + + + + + QgsDoubleSpinBox + QDoubleSpinBox +
qgsdoublespinbox.h
+
+
+ + +
diff --git a/loopstructural/gui/modelling/geological_model_tab.py b/loopstructural/gui/modelling/geological_model_tab.py new file mode 100644 index 0000000..c7121d4 --- /dev/null +++ b/loopstructural/gui/modelling/geological_model_tab.py @@ -0,0 +1,92 @@ +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import ( + QPushButton, + QSplitter, + QTreeWidget, + QTreeWidgetItem, + QVBoxLayout, + QWidget, +) + +from loopstructural.gui.modelling.feature_details_panel import ( + FaultFeatureDetailsPanel, + FoliationFeatureDetailsPanel, +) +from LoopStructural.modelling.features import FeatureType + + +class GeologicalModelTab(QWidget): + def __init__(self, parent=None, *, model_manager=None): + super().__init__(parent) + self.model_manager = model_manager + + # Main layout + mainLayout = QVBoxLayout(self) + + # Splitter for collapsible layout + splitter = QSplitter(self) + mainLayout.addWidget(splitter) + + # Feature list panel + self.featureList = QTreeWidget() + self.featureList.setHeaderLabel("Geological Features") + splitter.addWidget(self.featureList) + + # Feature details panel + self.featureDetailsPanel = QWidget() + splitter.addWidget(self.featureDetailsPanel) + + # Limit feature details panel expansion + splitter.setStretchFactor(0, 1) # Feature list panel + splitter.setStretchFactor(1, 0) # Feature details panel + splitter.setOrientation(Qt.Horizontal) # Add horizontal slider + + # Initialize Model button + self.initializeModelButton = QPushButton("Initialize Model") + mainLayout.insertWidget(0, self.initializeModelButton) + + # Action buttons + + self.initializeModelButton.clicked.connect(self.initialize_model) + + # Connect feature selection to update details panel + self.featureList.itemClicked.connect(self.on_feature_selected) + + def save_changes(self): + # Logic to save changes + pass + + def reset_parameters(self): + # Logic to reset parameters + pass + + def initialize_model(self): + self.model_manager.update_model() + self.featureList.clear() # Clear the feature list before populating it + for feature in self.model_manager.features(): + if feature.name.startswith("__"): + continue + items = self.featureList.findItems(feature.name, Qt.MatchExactly) + if items: + # If the feature already exists, skip adding it again + continue + item = QTreeWidgetItem(self.featureList) + item.setText(0, feature.name) + item.setData(0, 1, feature) + self.featureList.addTopLevelItem(item) + # self.featureList.itemClicked.connect(self.on_feature_selected) + + def on_feature_selected(self, item): + feature = item.data(0, 1) + if feature.type == FeatureType.FAULT: + print("Fault feature selected") + self.featureDetailsPanel = FaultFeatureDetailsPanel(fault=feature) + elif feature.type == FeatureType.INTERPOLATED: + self.featureDetailsPanel = FoliationFeatureDetailsPanel(feature=feature) + else: + self.featureDetailsPanel = QWidget() # Default empty panel + + # Dynamically replace the featureDetailsPanel widget + splitter = self.layout().itemAt(1).widget() + splitter.widget(1).deleteLater() # Remove the existing widget + splitter.addWidget(self.featureDetailsPanel) # Add the new widget diff --git a/loopstructural/gui/modelling/model_definition/__init__.py b/loopstructural/gui/modelling/model_definition/__init__.py new file mode 100644 index 0000000..d0736d7 --- /dev/null +++ b/loopstructural/gui/modelling/model_definition/__init__.py @@ -0,0 +1 @@ +from .model_definition_tab import ModelDefinitionTab diff --git a/loopstructural/gui/modelling/model_definition/bounding_box.py b/loopstructural/gui/modelling/model_definition/bounding_box.py new file mode 100644 index 0000000..126ca52 --- /dev/null +++ b/loopstructural/gui/modelling/model_definition/bounding_box.py @@ -0,0 +1,75 @@ +import os + +import numpy as np +from PyQt5.QtWidgets import QWidget +from qgis.PyQt import uic + +from loopstructural.main.data_manager import default_bounding_box + + +class BoundingBoxWidget(QWidget): + def __init__(self, parent=None, data_manager=None): + self.data_manager = data_manager + super().__init__(parent) + ui_path = os.path.join(os.path.dirname(__file__), "bounding_box.ui") + uic.loadUi(ui_path, self) + self.originXSpinBox.valueChanged.connect(lambda x: self.onChangeExtent({'xmin': x})) + self.maxXSpinBox.valueChanged.connect(lambda x: self.onChangeExtent({'xmax': x})) + self.originYSpinBox.valueChanged.connect(lambda y: self.onChangeExtent({'ymin': y})) + self.maxYSpinBox.valueChanged.connect(lambda y: self.onChangeExtent({'ymax': y})) + self.originZSpinBox.valueChanged.connect(lambda z: self.onChangeExtent({'zmin': z})) + self.maxZSpinBox.valueChanged.connect(lambda z: self.onChangeExtent({'zmax': z})) + self.useCurrentViewExtentButton.clicked.connect(self.useCurrentViewExtent) + self.selectFromCurrentLayerButton.clicked.connect(self.selectFromCurrentLayer) + self.data_manager.set_bounding_box_update_callback(self.set_bounding_box) + + def set_bounding_box(self, bounding_box): + """ + Set the bounding box values in the UI. + :param bounding_box: BoundingBox object with xmin, xmax, ymin, ymax, zmin, zmax attributes. + """ + self.originXSpinBox.setValue(bounding_box.origin[0]) + self.maxXSpinBox.setValue(bounding_box.maximum[0]) + self.originYSpinBox.setValue(bounding_box.origin[1]) + self.maxYSpinBox.setValue(bounding_box.maximum[1]) + self.originZSpinBox.setValue(bounding_box.origin[2]) + self.maxZSpinBox.setValue(bounding_box.maximum[2]) + + def useCurrentViewExtent(self): + """ + Use the current view extent from the map canvas. + This method should be connected to a button or action in the UI. + """ + if self.data_manager.map_canvas: + extent = self.data_manager.map_canvas.extent() + self.originXSpinBox.setValue(extent.xMinimum()) + self.originYSpinBox.setValue(extent.yMinimum()) + self.originZSpinBox.setValue(0) + self.maxXSpinBox.setValue(extent.xMaximum()) + self.maxYSpinBox.setValue(extent.yMaximum()) + self.maxZSpinBox.setValue(1000) + + def selectFromCurrentLayer(self): + """ + Select the bounding box from the current layer. + This method should be connected to a button or action in the UI. + """ + layer = self.data_manager.map_canvas.currentLayer() + if layer: + extent = layer.extent3D() + self.originXSpinBox.setValue(extent.xMinimum()) + self.originYSpinBox.setValue(extent.yMinimum()) + if np.isnan(extent.zMinimum()): + self.originZSpinBox.setValue(default_bounding_box['zmin']) + else: + self.originZSpinBox.setValue(extent.zMinimum()) + + self.maxXSpinBox.setValue(extent.xMaximum()) + self.maxYSpinBox.setValue(extent.yMaximum()) + if np.isnan(extent.zMaximum()): + self.maxZSpinBox.setValue(default_bounding_box['zmax']) + else: + self.maxZSpinBox.setValue(extent.zMaximum()) + + def onChangeExtent(self, value): + self.data_manager.set_bounding_box(**value) diff --git a/loopstructural/gui/modelling/model_definition/bounding_box.ui b/loopstructural/gui/modelling/model_definition/bounding_box.ui new file mode 100644 index 0000000..54e6d89 --- /dev/null +++ b/loopstructural/gui/modelling/model_definition/bounding_box.ui @@ -0,0 +1,190 @@ + + + Form + + + + 0 + 0 + 750 + 326 + + + + Form + + + + + + Define the bounding box for the regular grid. + + + + + X + + + + + + + Y + + + + + + + Z + + + + + + + Origin + + + + + + + -1000000000.000000000000000 + + + 1000000000.000000000000000 + + + + + + + -1000000000.000000000000000 + + + 1000000000.000000000000000 + + + + + + + -1000000000.000000000000000 + + + 1000000000.000000000000000 + + + + + + + Maximum + + + + + + + -1000000000.000000000000000 + + + 1000000000.000000000000000 + + + + + + + -1000000000.000000000000000 + + + 1000000000.000000000000000 + + + + + + + -1000000000.000000000000000 + + + 1000000000.000000000000000 + + + + + + + Steps + + + + + + + 1 + + + 1000 + + + 50 + + + + + + + 1 + + + 1000 + + + 50 + + + + + + + 1 + + + 1000 + + + 25 + + + + + + + + + Select from layer current + + + + + + + Use current view extent + + + + + + + Draw on map + + + + + + + + diff --git a/loopstructural/gui/modelling/model_definition/dem.py b/loopstructural/gui/modelling/model_definition/dem.py new file mode 100644 index 0000000..a2d3a6c --- /dev/null +++ b/loopstructural/gui/modelling/model_definition/dem.py @@ -0,0 +1,52 @@ +import os + +from PyQt5.QtWidgets import QWidget +from qgis.core import QgsMapLayerProxyModel +from qgis.PyQt import uic + + +class DEMWidget(QWidget): + def __init__(self, parent=None, data_manager=None): + self.data_manager = data_manager + super().__init__(parent) + ui_path = os.path.join(os.path.dirname(__file__), "dem.ui") + uic.loadUi(ui_path, self) + self.demLayerQgsMapLayerComboBox.setFilters(QgsMapLayerProxyModel.RasterLayer) + self.useDEMCheckBox.stateChanged.connect(self.onUseDEMClicked) + self.elevationQgsDoubleSpinBox.valueChanged.connect(self.onElevationChanged) + self.onElevationChanged() + self.data_manager.set_dem_callback(self.set_dem_layer) + + def set_dem_layer(self, layer): + """Set the DEM layer in the combo box.""" + # if layer: + # self.demLayerQgsMapLayerComboBox.setLayer(layer) + pass + + def onUseDEMClicked(self): + if self.useDEMCheckBox.isChecked(): + self.demLayerQgsMapLayerComboBox.setEnabled(True) + self.elevationQgsDoubleSpinBox.setEnabled(False) + self.data_manager.set_use_dem(True) + self.onDEMLayerChanged() + else: + self.demLayerQgsMapLayerComboBox.setEnabled(False) + self.elevationQgsDoubleSpinBox.setEnabled(True) + self.data_manager.set_dem_layer(None) + self.data_manager.set_elevation(self.elevationQgsDoubleSpinBox.value()) + self.data_manager.set_use_dem(False) + + def onDEMLayerChanged(self): + """Handle changes to the DEM layer selection.""" + selected_layer = self.demLayerQgsMapLayerComboBox.currentLayer() + if selected_layer: + self.data_manager.set_dem_layer(selected_layer) + else: + self.data_manager.set_dem_layer(None) + self.data_manager.set_use_dem(True) + + def onElevationChanged(self): + """Handle changes to the elevation value.""" + elevation = self.elevationQgsDoubleSpinBox.value() + self.data_manager.set_elevation(elevation) + self.data_manager.set_use_dem(False) diff --git a/loopstructural/gui/modelling/model_definition/dem.ui b/loopstructural/gui/modelling/model_definition/dem.ui new file mode 100644 index 0000000..2d1bbaa --- /dev/null +++ b/loopstructural/gui/modelling/model_definition/dem.ui @@ -0,0 +1,85 @@ + + + Form + + + + 0 + 0 + 825 + 158 + + + + Form + + + + + + + + DEM Layer + + + + + + + false + + + + + + + Use DEM + + + + + + + false + + + + + + + Elevation + + + + + + + true + + + -100000000.000000000000000 + + + 1000000000.000000000000000 + + + + + + + + + + QgsDoubleSpinBox + QDoubleSpinBox +
qgsdoublespinbox.h
+
+ + QgsMapLayerComboBox + QComboBox +
qgsmaplayercombobox.h
+
+
+ + +
diff --git a/loopstructural/gui/modelling/model_definition/fault_layers.py b/loopstructural/gui/modelling/model_definition/fault_layers.py new file mode 100644 index 0000000..dbaf486 --- /dev/null +++ b/loopstructural/gui/modelling/model_definition/fault_layers.py @@ -0,0 +1,91 @@ +import os + +from PyQt5.QtWidgets import QWidget +from qgis.core import QgsFieldProxyModel, QgsMapLayerProxyModel, QgsWkbTypes +from qgis.PyQt import uic + + +class FaultLayersWidget(QWidget): + def __init__(self, parent=None, data_manager=None): + self.data_manager = data_manager + super().__init__(parent) + ui_path = os.path.join(os.path.dirname(__file__), "fault_layers.ui") + uic.loadUi(ui_path, self) + self.faultTraceLayer.setFilters( + QgsMapLayerProxyModel.LineLayer | QgsMapLayerProxyModel.PointLayer + ) + self.faultTraceLayer.setAllowEmptyLayer(True) + self.faultDipField.setFilters(QgsFieldProxyModel.Numeric) + # fault displacement field can only be double or int + self.faultDisplacementField.setFilters(QgsFieldProxyModel.Numeric) + self.faultTraceLayer.layerChanged.connect(self.onFaultTraceLayerChanged) + self.faultNameField.fieldChanged.connect(self.onFaultFieldChanged) + self.faultDipField.fieldChanged.connect(self.onFaultFieldChanged) + self.faultDisplacementField.fieldChanged.connect(self.onFaultFieldChanged) + self.data_manager.set_fault_trace_layer_callback(self.set_fault_trace_layer) + self.useZCoordinateCheckBox.stateChanged.connect(self.onUseZCoordinateClicked) + self.useZCoordinateCheckBox.stateChanged.connect(self.onFaultFieldChanged) + self.useZCoordinate = False + + def enableZCheckbox(self, enable): + """Enable or disable the Z coordinate checkbox.""" + self.useZCoordinateCheckBox.setEnabled(enable) + if enable: + self.useZCoordinateCheckBox.setChecked(self.useZCoordinate) + else: + self.useZCoordinateCheckBox.setChecked(False) + + def onUseZCoordinateClicked(self): + """Handle changes to the Z coordinate checkbox.""" + self.useZCoordinate = self.useZCoordinateCheckBox.isChecked() + + def set_fault_trace_layer( + self, + layer, + fault_name_field=None, + fault_dip_field=None, + fault_displacement_field=None, + use_z_coordinate=False, + ): + self.faultTraceLayer.setLayer(layer) + if fault_name_field: + self.faultNameField.setField(fault_name_field) + if fault_dip_field: + self.faultDipField.setField(fault_dip_field) + if fault_displacement_field: + self.faultDisplacementField.setField(fault_displacement_field) + if layer is not None and layer.isValid(): + if layer.wkbType() != QgsWkbTypes.Unknown: + has_z = QgsWkbTypes.hasZ(layer.wkbType()) + self.enableZCheckbox(has_z) + self.useZCoordinateCheckBox.setChecked(use_z_coordinate) + self.useZCoordinate = use_z_coordinate + else: + self.data_manager.logger(message="Unknown geometry type.", log_level=2) + + def onFaultTraceLayerChanged(self, layer): + self.faultNameField.setLayer(layer) + self.faultDipField.setLayer(layer) + self.faultDisplacementField.setLayer(layer) + if layer is not None and layer.isValid(): + if layer.wkbType() != QgsWkbTypes.Unknown: + + has_z = QgsWkbTypes.hasZ(layer.wkbType()) + self.enableZCheckbox(has_z) + if layer is None or not layer.isValid(): + self.data_manager.set_fault_trace_layer( + None, + fault_name_field=None, + fault_dip_field=None, + fault_displacement_field=None, + use_z_coordinate=self.useZCoordinate, + ) + + def onFaultFieldChanged(self): + self.data_manager.set_fault_trace_layer( + self.faultTraceLayer.currentLayer(), + fault_name_field=self.faultNameField.currentField(), + fault_dip_field=self.faultDipField.currentField(), + fault_displacement_field=self.faultDisplacementField.currentField(), + use_z_coordinate=self.useZCoordinate, + ) diff --git a/loopstructural/gui/modelling/model_definition/fault_layers.ui b/loopstructural/gui/modelling/model_definition/fault_layers.ui new file mode 100644 index 0000000..999bb4f --- /dev/null +++ b/loopstructural/gui/modelling/model_definition/fault_layers.ui @@ -0,0 +1,112 @@ + + + Form + + + + 0 + 0 + 750 + 300 + + + + Form + + + + + + + + Fault Traces + + + + + + + + + + Fault Name + + + + + + + + + + Dip + + + + + + + true + + + + + + + Displacement + + + + + + + true + + + + + + + Pitch + + + + + + + + + + Use Z coordinate + + + + + + + false + + + <html><head/><body><p>Use the Z coordinate from the layer if available. </p></body></html> + + + + + + + + + + QgsFieldComboBox + QComboBox +
qgsfieldcombobox.h
+
+ + QgsMapLayerComboBox + QComboBox +
qgsmaplayercombobox.h
+
+
+ + +
diff --git a/loopstructural/gui/modelling/model_definition/model_definition_tab.py b/loopstructural/gui/modelling/model_definition/model_definition_tab.py new file mode 100644 index 0000000..d80cf64 --- /dev/null +++ b/loopstructural/gui/modelling/model_definition/model_definition_tab.py @@ -0,0 +1,29 @@ +from PyQt5.QtWidgets import QSizePolicy + +from loopstructural.gui.modelling.base_tab import BaseTab + +from .bounding_box import BoundingBoxWidget +from .fault_layers import FaultLayersWidget +from .stratigraphic_layers import StratigraphicLayersWidget +from .dem import DEMWidget + + +class ModelDefinitionTab(BaseTab): + 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]: + widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + + self.add_widget(self.bounding_box, '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") diff --git a/loopstructural/gui/modelling/model_definition/stratigraphic_layers.py b/loopstructural/gui/modelling/model_definition/stratigraphic_layers.py new file mode 100644 index 0000000..8df3de3 --- /dev/null +++ b/loopstructural/gui/modelling/model_definition/stratigraphic_layers.py @@ -0,0 +1,165 @@ +import os + +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import QWidget +from qgis.core import QgsMapLayerProxyModel, QgsWkbTypes +from qgis.PyQt import uic + + +class StratigraphicLayersWidget(QWidget): + 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 + super().__init__(parent) + ui_path = os.path.join(os.path.dirname(__file__), "stratigraphic_layers.ui") + uic.loadUi(ui_path, self) + self.basalContactsLayer.setFilters( + QgsMapLayerProxyModel.LineLayer | QgsMapLayerProxyModel.PointLayer + ) + self.basalContactsLayer.setAllowEmptyLayer(True) + # Structural data can only be points + self.structuralDataLayer.setFilters(QgsMapLayerProxyModel.PointLayer) + self.basalContactsLayer.setAllowEmptyLayer(True) + self.basalContactsLayer.layerChanged.connect(self.onBasalContactsChanged) + self.structuralDataLayer.layerChanged.connect(self.onStructuralDataLayerChanged) + self.unitNameField.fieldChanged.connect(self.onUnitFieldChanged) + self.orientationField.setLayer(self.structuralDataLayer.currentLayer()) + self.dipField.fieldChanged.connect(self.onStructuralDataFieldChanged) + self.orientationField.fieldChanged.connect(self.onStructuralDataFieldChanged) + self.structuralDataUnitName.setLayer(self.structuralDataLayer.currentLayer()) + self.structuralDataUnitName.fieldChanged.connect(self.onStructuralDataFieldChanged) + self.orientationType.currentIndexChanged.connect(self.onOrientationTypeChanged) + self.data_manager.set_basal_contacts_callback(self.set_basal_contacts) + self.data_manager.set_structural_orientations_callback(self.set_orientations_layer) + self.basal_contacts_use_z = False + self.structural_points_use_z = False + self.useBasalContactsZCoordinatesCheckBox.stateChanged.connect( + lambda: self.enableBasalContactsZCheckBox( + self.useBasalContactsZCoordinatesCheckBox.isChecked() + ) + ) + self.useBasalContactsZCoordinatesCheckBox.stateChanged.connect( + self.onStructuralDataFieldChanged + ) + self.useStructuralPointsZCoordinatesCheckBox.stateChanged.connect( + lambda: self.enableStructuralPointsZCheckBox( + self.useStructuralPointsZCoordinatesCheckBox.isChecked() + ) + ) + self.useStructuralPointsZCoordinatesCheckBox.stateChanged.connect( + self.onStructuralDataFieldChanged + ) + + def enableBasalContactsZCheckBox(self, enable): + self.useBasalContactsZCoordinatesCheckBox.setEnabled(enable) + if enable: + self.useBasalContactsZCoordinatesCheckBox.setChecked(self.basal_contacts_use_z) + else: + self.useBasalContactsZCoordinatesCheckBox.setChecked(False) + + def enableStructuralPointsZCheckBox(self, enable): + self.useStructuralPointsZCoordinatesCheckBox.setEnabled(enable) + if enable: + self.useStructuralPointsZCoordinatesCheckBox.setChecked(self.structural_points_use_z) + else: + self.useStructuralPointsZCoordinatesCheckBox.setChecked(False) + + def set_basal_contacts(self, layer, unitname_field=None, use_z_coordinate=False): + self.basalContactsLayer.setLayer(layer) + if layer is not None and layer.isValid(): + if layer.wkbType() != QgsWkbTypes.Unknown: + has_z = QgsWkbTypes.hasZ(layer.wkbType()) + + self.enableBasalContactsZCheckBox(has_z) + else: + self.data_manager.logger(message="Unknown geometry type.", log_level=2) + else: + self.enableBasalContactsZCheckBox(False) + if unitname_field: + self.unitNameField.setField(unitname_field) + self.basal_contacts_use_z = use_z_coordinate + self.useBasalContactsZCoordinatesCheckBox.setChecked(use_z_coordinate) + + def set_orientations_layer( + self, + layer, + strike_field=None, + dip_field=None, + unitname_field=None, + orientation_type=None, + use_z_coordinate=False, + ): + self.structuralDataLayer.setLayer(layer) + if layer is not None and layer.isValid(): + if layer.wkbType() != QgsWkbTypes.Unknown: + has_z = QgsWkbTypes.hasZ(layer.wkbType()) + self.enableStructuralPointsZCheckBox(has_z) + else: + self.data_manager.logger(message="Unknown geometry type.", level=2) + else: + self.enableStructuralPointsZCheckBox(False) + if strike_field: + self.orientationField.setField(strike_field) + if dip_field: + self.dipField.setField(dip_field) + if unitname_field: + self.structuralDataUnitName.setField(unitname_field) + if orientation_type: + index = self.orientationType.findText(orientation_type, Qt.MatchFixedString) + if index >= 0: + self.orientationType.setCurrentIndex(index) + if use_z_coordinate: + self.structural_points_use_z = use_z_coordinate + self.useStructuralPointsZCoordinatesCheckBox.setChecked(use_z_coordinate) + + def onBasalContactsChanged(self, layer): + self.unitNameField.setLayer(layer) + self.data_manager.set_basal_contacts(layer, self.unitNameField.currentField()) + + def onOrientationTypeChanged(self, index): + if index == 0: + self.orientationLabel.setText("Strike") + else: + self.orientationLabel.setText("Dip Direction") + + def onStructuralDataLayerChanged(self, layer): + self.orientationField.setLayer(layer) + self.dipField.setLayer(layer) + self.structuralDataUnitName.setLayer(layer) + if self.dipField.currentField() is None or self.orientationField.currentField() is None: + return + self.data_manager.set_structural_orientations( + layer, + self.orientationField.currentField(), + self.dipField.currentField(), + self.structuralDataUnitName.currentField(), + use_z_coordinate=self.structural_points_use_z, + ) + + def onStructuralDataFieldChanged(self, field): + if self.structuralDataLayer.currentLayer() is None: + return + if self.orientationField.currentField() is None or self.dipField.currentField() is None: + return + if self.structuralDataUnitName.currentField() is None: + return + + self.data_manager.set_structural_orientations( + self.structuralDataLayer.currentLayer(), + self.orientationField.currentField(), + self.dipField.currentField(), + self.structuralDataUnitName.currentField(), + self.orientationType.currentText(), + use_z_coordinate=self.structural_points_use_z, + ) + # self.updateDataManager() + + def onUnitFieldChanged(self, field): + self.data_manager.set_basal_contacts( + self.basalContactsLayer.currentLayer(), + field, + use_z_coordinate=self.basal_contacts_use_z, + ) + + # self.updateDataManager() diff --git a/loopstructural/gui/modelling/model_definition/stratigraphic_layers.ui b/loopstructural/gui/modelling/model_definition/stratigraphic_layers.ui new file mode 100644 index 0000000..1b5278d --- /dev/null +++ b/loopstructural/gui/modelling/model_definition/stratigraphic_layers.ui @@ -0,0 +1,182 @@ + + + Form + + + + 0 + 0 + 750 + 391 + + + + Form + + + + + + + + <html><head/><body><p>A vector layer of points/lines that represents the basal (or top) contacts of the units being modelled. There must be a column identifying the unit.</p></body></html> + + + + + + + Unit Name + + + + + + + <html><head/><body><p>Identifier for the geological unit.</p></body></html> + + + + + + + Structural Data + + + + + + + <html><head/><body><p>Shape file representing orientation data assocaited with stratigraphy/lithology. </p></body></html> + + + true + + + + + + + Format + + + + + + + <html><head/><body><p>Strike and dip using right hand rule or dip direction/dip convention</p></body></html> + + + + Strike/Dip + + + + + Dip Direction/Dip + + + + + + + + Strike + + + + + + + <html><head/><body><p>Column representing the strike/dip direction angle in degrees</p></body></html> + + + + + + + Dip + + + + + + + <html><head/><body><p>Column representing the dip angle in degrees</p></body></html> + + + + + + + Unit Name + + + + + + + <html><head/><body><p>Column representing which unit the orientation measurement belongs to. This is used to differentiate between non-conformable units.</p></body></html> + + + + + + + Basal Contacts + + + + + + + Use Z coordinate + + + + + + + false + + + <html><head/><body><p>Use the Z coordinate for contact points if available instead of projecting point onto the elevation model</p></body></html> + + + + + + + Use Z coordinate + + + + + + + false + + + <html><head/><body><p>Use the Z coordinate for structural points if available instead of projecting point onto the elevation model</p></body></html> + + + + + + + + + + QgsFieldComboBox + QComboBox +
qgsfieldcombobox.h
+
+ + QgsMapLayerComboBox + QComboBox +
qgsmaplayercombobox.h
+
+
+ + +
diff --git a/loopstructural/gui/modelling/model_setup_tab.py b/loopstructural/gui/modelling/model_setup_tab.py new file mode 100644 index 0000000..e69de29 diff --git a/loopstructural/gui/modelling/model_setup_tab.ui b/loopstructural/gui/modelling/model_setup_tab.ui new file mode 100644 index 0000000..978b1b9 --- /dev/null +++ b/loopstructural/gui/modelling/model_setup_tab.ui @@ -0,0 +1,136 @@ + + + Form + + + + 0 + 0 + 593 + 364 + + + + Form + + + + + 0 + 0 + 591 + 361 + + + + + + + + + Initialise Model + + + + + + + Run + + + + + + + Interpolation Parameters + + + + + -1 + 19 + 591 + 263 + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + Number of elements + + + + + + + 10000000 + + + + + + + Value point weight + + + + + + + Norm point weight + + + + + + + Regularisation + + + + + + + 50.000000000000000 + + + + + + + + + + + + + + + + + + + + + + + diff --git a/loopstructural/gui/modelling/modelling_widget.py b/loopstructural/gui/modelling/modelling_widget.py index dd2fd5f..242c6e7 100644 --- a/loopstructural/gui/modelling/modelling_widget.py +++ b/loopstructural/gui/modelling/modelling_widget.py @@ -1,981 +1,44 @@ -from calendar import c -import os -import random -import json -from PyQt5.QtCore import QVariant -import numpy as np -from qgis.PyQt import uic -from qgis.PyQt.QtGui import QColor -from qgis.PyQt.QtWidgets import ( - QCheckBox, - QColorDialog, - QComboBox, - QDoubleSpinBox, - QFileDialog, - QLabel, - QListWidgetItem, - QPushButton, - QWidget, - QLineEdit, -) -from qgis.core import ( - QgsEllipse, - QgsFeature, - QgsField, - QgsMapLayerProxyModel, - QgsFieldProxyModel, - QgsPoint, - QgsProject, - QgsVectorLayer, -) +from PyQt5.QtWidgets import QTabWidget, QVBoxLayout, QWidget -from pyvistaqt import QtInteractor -import pyvista as pv -from LoopStructural.utils import random_hex_colour +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 ...main import QgsProcessInputData -from ...main.geometry.calculateLineAzimuth import calculateAverageAzimuth -from ...main.rasterFromModel import callableToRaster -from ...main.callableToLayer import callableToLayer - -# from .feature_widget import FeatureWidget -# from LoopStructural.visualisation import Loop3DView -# from loopstructural.gui.modelling.stratigraphic_column import StratigraphicColumnWidget -__title__ = "LoopStructural" class ModellingWidget(QWidget): - def __init__(self, parent: QWidget = None, mapCanvas=None, logger=None): + def __init__( + self, + parent: QWidget = None, + *, + mapCanvas=None, + logger=None, + data_manager=None, + model_manager=None, + ): + super().__init__(parent) - uic.loadUi(os.path.join(os.path.dirname(__file__), "modelling_widget.ui"), self) - self.project = QgsProject.instance() self.mapCanvas = mapCanvas - self.rotationDoubleSpinBox.setValue(mapCanvas.rotation()) - self._set_layer_filters() - # self.unitNameField.setLayer(self.basalContactsLayer.currentLayer()) self.logger = logger - self._basalContacts = None - self._units = {} - self._faults = {} - self._connectSignals() - self.view = None - self.model = None - self.outputPath = "" - self.activeFeature = None - self.groups = [] - self.plotter = QtInteractor(parent) - self.plotter.add_axes() - self.pyvista_layout.addWidget(self.plotter) - self.loadFromProject() - def setLayerComboBoxFromProject(self, comboBox: QComboBox, layerKey: str): - layerName, flag = self.project.readEntry(__title__, layerKey) - if flag: - - layers = self.project.mapLayersByName(layerName) - print(layerName,layers) - if len(layers) == 0: - self.logger( - message=f"Layer {layerName} not found in project", - log_level=2, - push=True, - ) - return - comboBox.setLayer(None) - comboBox.setLayer(layers[0]) - def setLayerFieldComboBoxFromProject(self, comboBox: QComboBox, fieldKey: str, layer: QgsVectorLayer): - if layer is None: - self.logger(message="Layer is None", log_level=2, push=True) - return - fieldName, flag = self.project.readEntry(__title__, fieldKey) - if not flag or not fieldName: - self.logger(message=f"Field {fieldKey} not found in project", log_level=0, push=True) - return - field_names = [field.name() for field in layer.fields()] - if fieldName not in field_names: - self.logger( - message=f"Field {fieldName} not found in layer {layer.name()}", - log_level=0, - push=True, - ) - return - comboBox.setField(fieldName) - - - - def loadFromProject(self): - # Load settings from project - self.setLayerComboBoxFromProject(self.basalContactsLayer, "basal_contacts_layer") - self.setLayerFieldComboBoxFromProject(self.unitNameField, "unitname_field", self.basalContactsLayer.currentLayer()) - self.setLayerComboBoxFromProject(self.structuralDataLayer, "structural_data_layer") - self.setLayerFieldComboBoxFromProject(self.dipField, "dip_field", self.structuralDataLayer.currentLayer()) - self.setLayerFieldComboBoxFromProject(self.orientationField, "orientation_field", self.structuralDataLayer.currentLayer()) - self.setLayerFieldComboBoxFromProject(self.structuralDataUnitName, "structuraldata_unitname_field", self.structuralDataLayer.currentLayer()) - self.setLayerComboBoxFromProject(self.faultTraceLayer, "fault_trace_layer") - self.setLayerFieldComboBoxFromProject(self.faultNameField, "faultname_field", self.faultTraceLayer.currentLayer()) - self.setLayerFieldComboBoxFromProject(self.faultDipField, "fault_dip_field", self.faultTraceLayer.currentLayer()) - self.setLayerFieldComboBoxFromProject(self.faultDisplacementField, "fault_displacement_field", self.faultTraceLayer.currentLayer()) - self.setLayerFieldComboBoxFromProject(self.faultPitchField, "fault_pitch_field", self.faultTraceLayer.currentLayer()) - self.setLayerComboBoxFromProject(self.DtmLayer, "dtm_layer") - self.setLayerComboBoxFromProject(self.roiLayer, "roi_layer") - label, flag = self.project.readEntry(__title__, "orientation_label", "Strike") - if flag: - self.orientationType.setCurrentText(label) - resp, flag = self.project.readEntry(__title__,"units","") - if flag: - self._units = json.loads(resp) - if len(self._units) > 0: - self._initialiseStratigraphicColumn() - resp, flag = self.project.readEntry(__title__,"faults","") - if flag: - # try: - self._faults = json.loads(resp) - self.initFaultNetwork() - # except: - # self.logger(message="Faults not loaded", log_level=2, push=True) - - - - def _set_layer_filters(self): - # Set filters for the layer selection comboboxes - # basal contacts can be line or points - self.basalContactsLayer.setFilters( - QgsMapLayerProxyModel.LineLayer | QgsMapLayerProxyModel.PointLayer - ) - self.basalContactsLayer.setAllowEmptyLayer(True) - # Structural data can only be points - self.structuralDataLayer.setFilters(QgsMapLayerProxyModel.PointLayer) - self.basalContactsLayer.setAllowEmptyLayer(True) - # fault traces can be lines or points - self.faultTraceLayer.setFilters( - QgsMapLayerProxyModel.LineLayer | QgsMapLayerProxyModel.PointLayer - ) - self.faultTraceLayer.setAllowEmptyLayer(True) - # dtm can only be a raster - self.DtmLayer.setFilters(QgsMapLayerProxyModel.RasterLayer) - self.DtmLayer.setAllowEmptyLayer(True) - - # evaluate on model layer - self.evaluateModelOnLayerSelector.setFilters(QgsMapLayerProxyModel.PointLayer) - self.evaluateModelOnLayerSelector.setAllowEmptyLayer(True) - # evaluate on feature layer - self.evaluateFeatureLayerSelector.setFilters(QgsMapLayerProxyModel.PointLayer) - self.evaluateFeatureLayerSelector.setAllowEmptyLayer(True) - # orientation field can only be double or int - self.orientationField.setFilters(QgsFieldProxyModel.Numeric) - self.dipField.setFilters(QgsFieldProxyModel.Numeric) - # fault dip field can only be double or int - self.faultDipField.setFilters(QgsFieldProxyModel.Numeric) - # fault displacement field can only be double or int - self.faultDisplacementField.setFilters(QgsFieldProxyModel.Numeric) - def saveLayerComboBoxState(self, comboBox: QComboBox, layerKey: str): - layer = comboBox.currentLayer() - if layer is not None: - self.project.writeEntry(__title__, layerKey, layer.name()) - - def saveLayerFieldComboBoxState(self, comboBox: QComboBox, fieldKey: str): - field = comboBox.currentField() - if field is not None: - self.project.writeEntry(__title__, fieldKey, field) - def saveSettingToProject(self,key:str,value:str): - self.project.writeEntry(__title__, key, value) - - def _connectSignals(self): - self.basalContactsLayer.layerChanged.connect(self.onBasalContactsChanged) - self.structuralDataLayer.layerChanged.connect(self.onStructuralDataLayerChanged) - self.unitNameField.fieldChanged.connect(self.onUnitFieldChanged) - self.faultTraceLayer.layerChanged.connect(self.onFaultTraceLayerChanged) - self.faultNameField.fieldChanged.connect(self.onFaultFieldChanged) - self.faultDipField.fieldChanged.connect(self.onFaultFieldChanged) - self.faultDisplacementField.fieldChanged.connect(self.onFaultFieldChanged) - self.orientationType.currentIndexChanged.connect(self.onOrientationTypeChanged) - self.orientationField.fieldChanged.connect(self.onOrientationFieldChanged) - self.initModel.clicked.connect(self.onInitialiseModel) - self.rotationDoubleSpinBox.valueChanged.connect(self.onRotationChanged) - self.runModelButton.clicked.connect(self.onRunModel) - self.pathButton.clicked.connect(self.onClickPath) - self.saveButton.clicked.connect(self.onSaveModel) - self.path.textChanged.connect(self.onPathTextChanged) - self.faultSelection.currentIndexChanged.connect(self.onSelectedFaultChanged) - - self.basalContactsLayer.layerChanged.connect(lambda: self.saveLayerComboBoxState(self.basalContactsLayer,'basal_contacts_layer')) - self.unitNameField.fieldChanged.connect(lambda: self.saveLayerFieldComboBoxState(self.unitNameField,'unitname_field')) - self.structuralDataLayer.layerChanged.connect(lambda: self.saveLayerComboBoxState(self.structuralDataLayer,'structural_data_layer')) - self.orientationField.fieldChanged.connect(lambda: self.saveLayerFieldComboBoxState(self.orientationField,'orientation_field')) - - self.dipField.fieldChanged.connect(lambda: self.saveLayerFieldComboBoxState(self.dipField,'dip_field')) - self.roiLayer.layerChanged.connect(lambda: self.saveLayerComboBoxState(self.roiLayer,'roi_layer')) - self.faultTraceLayer.layerChanged.connect(lambda: self.saveLayerComboBoxState(self.faultTraceLayer,'fault_trace_layer')) - self.faultNameField.fieldChanged.connect(lambda: self.saveLayerFieldComboBoxState(self.faultNameField,'faultname_field')) - self.faultDipField.fieldChanged.connect(lambda: self.saveLayerFieldComboBoxState(self.faultDipField,'fault_dip_field')) - self.faultDisplacementField.fieldChanged.connect(lambda: self.saveLayerFieldComboBoxState(self.faultDisplacementField,'fault_displacement_field')) - self.faultPitchField.fieldChanged.connect(lambda: self.saveLayerFieldComboBoxState(self.faultPitchField,'fault_pitch_field')) - self.faultDipValue.valueChanged.connect( - lambda value: self.updateFaultProperty('fault_dip', value) - ) - - - self.structuralDataUnitName.fieldChanged.connect( - lambda: self.saveLayerFieldComboBoxState(self.structuralDataUnitName,'structuraldata_unitname_field') - ) - self.faultPitchValue.valueChanged.connect( - lambda value: self.updateFaultProperty('fault_pitch', value) - ) - self.faultDisplacementValue.valueChanged.connect( - lambda value: self.updateFaultProperty('displacement', value) - ) - self.faultActiveCheckBox.stateChanged.connect( - lambda value: self.updateFaultProperty('active', value) - ) - self.faultMajorAxisLength.valueChanged.connect( - lambda value: self.updateFaultProperty('major_axis', value) - ) - self.faultIntermediateAxisLength.valueChanged.connect( - lambda value: self.updateFaultProperty('intermediate_axis', value) - ) - self.faultMinorAxisLength.valueChanged.connect( - lambda value: self.updateFaultProperty('minor_axis', value) - ) - - self.orientationType.currentIndexChanged.connect(lambda value: self.saveSettingToProject('orientation_type', self.orientationLabel.text())) - - # self.faultCentreX.valueChanged.connect(lambda value: self.updateFaultProperty('centre', value)) - # self.faultCentreY.valueChanged.connect(lambda value: self.updateFaultProperty('centre', value)) - # self.faultCentreZ.valueChanged.connect(lambda value: self.updateFaultProperty('centre', value)) - self.addFaultElipseToMap.clicked.connect(self.drawFaultElipse) - self.addModelContactsToProject.clicked.connect(self.onAddModelContactsToProject) - self.addFaultDisplacementsToProject.clicked.connect(self.onAddFaultDisplacmentsToProject) - self.evaluateModelOnLayer.clicked.connect(self.onEvaluateModelOnLayer) - self.evaluateFeatureOnLayer.clicked.connect(self.onEvaluateFeatureOnLayer) - self.addMappedLithologiesToProject.clicked.connect(self.onAddModelledLithologiesToProject) - self.addFaultTracesToProject.clicked.connect(self.onAddFaultTracesToProject) - self.addScalarFieldToProject.clicked.connect(self.onAddScalarFieldToProject) - # self.saveThicknessOrderButton.clicked.connect(self.saveThicknessOrder) - self.addUnitButton.clicked.connect(self.addUnitToStratigraphicColumn) - self.addBlockModelToPyvistaButton.clicked.connect(self.addBlockModelToPyvista) - self.clearPyvistaButton.clicked.connect(self.clearPyvista) - self.addSurfacesToPyvistaButton.clicked.connect(self.addModelSurfacesToPyvista) - self.addDataToPyvistaButton.clicked.connect(self.addDataToPyvista) - QgsProject.instance().readProject.connect(self.loadFromProject) - def onModelListItemClicked(self, feature): - self.activeFeature = self.model[feature.text()] - self.numberOfElementsSpinBox.setValue( - self.activeFeature.builder.build_arguments['nelements'] - ) - self.numberOfElementsSpinBox.valueChanged.connect( - lambda nelements: self.activeFeature.builder.update_build_arguments( - {'nelements': nelements} - ) - ) - self.regularisationSpin.setValue( - self.activeFeature.builder.build_arguments['regularisation'] - ) - self.regularisationSpin.valueChanged.connect( - lambda regularisation: self.activeFeature.builder.update_builupdate_build_argumentsd_args( - {'regularisation': regularisation} - ) - ) - self.npwSpin.setValue(self.activeFeature.builder.build_arguments['npw']) - self.npwSpin.valueChanged.connect( - lambda npw: self.activeFeature.builder.update_build_arguments({'npw': npw}) - ) - self.cpwSpin.setValue(self.activeFeature.builder.build_arguments['cpw']) - self.cpwSpin.valueChanged.connect( - lambda cpw: self.activeFeature.builder.update_build_arguments({'cpw': cpw}) - ) - # self.updateButton.clicked.connect(lambda : feature.builder.update()) - - def onInitialiseModel(self): - - columnmap = { - 'unitname': self.unitNameField.currentField(), - 'faultname': self.faultNameField.currentField(), - 'dip': self.dipField.currentField(), - 'orientation': self.orientationField.currentField(), - 'structure_unitname': self.structuralDataUnitName.currentField(), - # 'pitch': self.faultPitchField.currentField() - } - faultNetwork = np.zeros((len(self._faults), len(self._faults))) - for i in range(len(self._faults)): - for j in range(len(self._faults)): - if i != j: - item = self.faultNetworkTable.cellWidget(i, j) - if item.currentText() == 'Abuts': - faultNetwork[i, j] = 1 - elif item.currentText() == 'Cuts': - faultNetwork[i, j] = -1 - faultStratigraphy = np.zeros((len(self._faults), len(self.groups))) - for i in range(len(self._faults)): - for j in range(len(self.groups)): - item = self.faultStratigraphyTable.cellWidget(i, j) - faultStratigraphy[i, j] = item.isChecked() - - processor = QgsProcessInputData( - basal_contacts=self.basalContactsLayer.currentLayer(), - groups=self.groups, - fault_trace=self.faultTraceLayer.currentLayer(), - fault_properties=self._faults, - structural_data=self.structuralDataLayer.currentLayer(), - dtm=self.DtmLayer.currentLayer(), - columnmap=columnmap, - roi=self.roiLayer.currentLayer(), - top=self.heightSpinBox.value(), - bottom=self.depthSpinBox.value(), - dip_direction=self.orientationType.currentIndex() == 1, - rotation=self.rotationDoubleSpinBox.value(), - faultNetwork=faultNetwork, - faultStratigraphy=faultStratigraphy, - faultlist=list(self._faults.keys()), - ) - self.processor = processor - self.model = processor.get_model() - self.logger(message="Model initialised", log_level=0, push=True) - self.modelList.clear() - for feature in self.model.features: - if feature.name[0] == '_': - continue - item = QListWidgetItem() - item.setText(feature.name) - item.setBackground( - QColor(random.randint(0, 255), random.randint(0, 255), random.randint(0, 255)) - ) - - self.modelList.addItem(item) - self.modelList.itemClicked.connect(self.onModelListItemClicked) - self.plotter.add_mesh(self.model.bounding_box.vtk().outline()) - def onOrientationTypeChanged(self, index): - if index == 0: - self.orientationLabel.setText("Strike") - else: - self.orientationLabel.setText("Dip Direction") - - def onRotationChanged(self, rotation): - self.mapCanvas.setRotation(rotation) - - def onOrientationFieldChanged(self, field): - pass - - def onStructuralDataLayerChanged(self, layer): - self.orientationField.setLayer(layer) - self.dipField.setLayer(layer) - self.structuralDataUnitName.setLayer(layer) - # self.saveLayersToProject() - # self.dipField.setValidator(QDoubleValidator(0.0, 360.0, 2)) - # self.orientationField.setValidator(QDoubleValidator(0.0, 360.0, 2)) - - def onRunModel(self): - try: - self.model.update(progressbar=False) - self._model_updated() - self.logger(message="Model run", log_level=0, push=True) - - except Exception as e: - self.logger( - message=str(e), - log_level=2, - push=True, - ) - - def _model_updated(self): - self.addScalarFieldComboBox.clear() - self.evaluateFeatureFeatureSelector.clear() - for feature in self.model.features: - ## make sure that private features are not added to the list - if feature.name[0] != "_": - self.addScalarFieldComboBox.addItem(feature.name) - self.evaluateFeatureFeatureSelector.addItem(feature.name) - self.addScalarFieldComboBox.setCurrentIndex(0) - self.evaluateFeatureFeatureSelector.setCurrentIndex(0) - - def onAddModelContactsToProject(self): - pass - - def onAddFaultDisplacmentsToProject(self): - pass - def addBlockModelToPyvista(self): - if self.model is None: - self.logger(message="Model not initialised", log_level=2, push=True) - return - self.plotter.add_mesh(self.model.get_block_model()[0].vtk(),show_scalar_bar=False) - def addModelSurfacesToPyvista(self): - if self.model is None: - self.logger(message="Model not initialised", log_level=2, push=True) - return - surfaces = self.model.get_stratigraphic_surfaces() - for surface in surfaces: - self.plotter.add_mesh(surface.vtk(),show_scalar_bar=False,color=surface.colour) - fault_surfaces = self.model.get_fault_surfaces() - for surface in fault_surfaces: - self.plotter.add_mesh(surface.vtk(),show_scalar_bar=False,color='black') - def addDataToPyvista(self): - if self.model is None: - self.logger(message="Model not initialised", log_level=2, push=True) - return - for f in self.model.features: - if f.name[0] != "_": - for d in f.get_data(): - self.plotter.add_mesh(d.vtk(), show_scalar_bar=False) - def clearPyvista(self): - self.plotter.clear() - if self.model is not None: - self.plotter.add_mesh(self.model.bounding_box.vtk().outline()) - def onEvaluateModelOnLayer(self): - layer = self.evaluateModelOnLayerSelector.currentLayer() - - callableToLayer( - lambda xyz: self.model.evaluate_model(xyz), - layer, - self.DtmLayer.currentLayer(), - 'unit_id', - ) - - def onEvaluateFeatureOnLayer(self): - feature_name = self.evaluateFeatureFeatureSelector.currentText() - layer = self.evaluateFeatureLayerSelector.currentLayer() - callableToLayer( - lambda xyz: self.model.evaluate_feature_value(feature_name, xyz), - layer, - self.DtmLayer.currentLayer(), - feature_name, - ) - pass - - def onAddModelledLithologiesToProject(self): - if self.model is None: - self.logger(message="Model not initialised", log_level=2, push=True) - return - bounding_box = self.model.bounding_box - feature_layer = callableToRaster( - lambda xyz: self.model.evaluate_model(xyz), - dtm=self.DtmLayer.currentLayer(), - bounding_box=bounding_box, - crs=QgsProject.instance().crs(), - layer_name='modelled_lithologies', - ) - if feature_layer.isValid(): - QgsProject.instance().addMapLayer(feature_layer) - else: - self.logger(message="Failed to add scalar field to project", log_level=2, push=True) - pass - - def onAddFaultTracesToProject(self): - pass - - def onAddScalarFieldToProject(self): - feature_name = self.addScalarFieldComboBox.currentText() - if self.model is None: - self.logger(message="Model not initialised", log_level=2, push=True) - return - bounding_box = self.model.bounding_box - feature_layer = callableToRaster( - lambda xyz: self.model.evaluate_feature_value(feature_name, xyz), - dtm=self.DtmLayer.currentLayer(), - bounding_box=bounding_box, - crs=QgsProject.instance().crs(), - layer_name=f'{feature_name}_scalar_field', - ) - if feature_layer.isValid(): - QgsProject.instance().addMapLayer(feature_layer) - else: - self.logger(message="Failed to add scalar field to project", log_level=2, push=True) - - def onBasalContactsChanged(self, layer): - self.unitNameField.setLayer(layer) - # self.saveLayersToProject() - def onFaultTraceLayerChanged(self, layer): - self.faultNameField.setLayer(layer) - self.faultDipField.setLayer(layer) - self.faultDisplacementField.setLayer(layer) - self._faults = {} # reset faults - self.onSelectedFaultChanged(-1) - self.initFaultSelector() - self.initFaultNetwork() - # self.saveLayersToProject() - - def onUnitFieldChanged(self, field): - if len(self._units) == 0: - - unique_values = set() - attributes = {} - layer = self.unitNameField.layer() - if layer: - fields = {} - fields['unitname'] = layer.fields().indexFromName(field) - if '_ls_th' in [field.name() for field in layer.fields()]: - fields['thickness'] = layer.fields().indexFromName('_ls_th') - if '_ls_or' in [field.name() for field in layer.fields()]: - fields['order'] = layer.fields().indexFromName('_ls_or') - if '_ls_col' in [field.name() for field in layer.fields()]: - fields['colour'] = layer.fields().indexFromName('_ls_col') - field_index = layer.fields().indexFromName(field) - - for feature in layer.getFeatures(): - unique_values.add(str(feature[field_index])) - attributes[str(feature[field_index])] = {} - for k in fields: - if feature[fields[k]] is not None: - attributes[str(feature[field_index])][k] = feature[fields[k]] - - colours = random_hex_colour(n=len(unique_values)) - self._units = dict( - zip( - list(unique_values), - [ - { - 'thickness': ( - attributes[u]['thickness'] if 'thickness' in attributes[u] else 10.0 - ), - 'order': int(attributes[u]['order']) if 'order' in attributes[u] else i, - 'name': u, - 'colour': ( - str(attributes[u]['colour']) - if 'colour' in attributes[u] - else colours[i] - ), - 'contact': ( - str(attributes[u]['contact']) - if 'contact' in attributes[u] - else 'Conformable' - ), - } - for i, u in enumerate(unique_values) - ], - ) - ) - self._initialiseStratigraphicColumn() - - def initFaultSelector(self): - self.faultSelection.clear() - self.resetFaultField() - if self._faults: - faults = list(self._faults.keys()) - self.faultSelection.addItems(faults) - - def initFaultNetwork(self): - # faultNetwork - self.faultNetworkTable.clear() - self.faultNetworkTable.setRowCount(0) - self.faultNetworkTable.setColumnCount(0) - self.faultStratigraphyTable.clear() - self.faultStratigraphyTable.setRowCount(0) - self.faultStratigraphyTable.setColumnCount(0) - if not self._faults: - return - - faults = list(self._faults.keys()) - self.faultNetworkTable.setRowCount(len(faults)) - self.faultNetworkTable.setColumnCount(len(faults)) - - # Set headers - self.faultNetworkTable.setHorizontalHeaderLabels(faults) - self.faultNetworkTable.setVerticalHeaderLabels(faults) - - # Fill table with empty items - for i in range(len(faults)): - for j in range(len(faults)): - if i == j: - flag = QLabel() - flag.setText('') - else: - flag = QComboBox() - flag.addItem('') - flag.addItem('Abuts') - flag.addItem('Cuts') - # item = QTableWidgetItem(flag) - self.faultNetworkTable.setCellWidget(i, j, flag) - - # Make cells more visible - self.faultNetworkTable.setShowGrid(True) - self.faultNetworkTable.resizeColumnsToContents() - self.faultNetworkTable.resizeRowsToContents() - - self.faultStratigraphyTable.clear() - - faults = list(self._faults.keys()) - groups = [g['name'] for g in self.groups] - self.faultStratigraphyTable.setRowCount(len(faults)) - self.faultStratigraphyTable.setColumnCount(len(groups)) - - # Set headers - self.faultStratigraphyTable.setHorizontalHeaderLabels(groups) - self.faultStratigraphyTable.setVerticalHeaderLabels(faults) - - # Fill table with empty items - for j in range(len(groups)): - for i in range(len(faults)): - flag = QCheckBox() - flag.setChecked(True) - self.faultStratigraphyTable.setCellWidget(i, j, flag) - - # Make cells more visible - self.faultStratigraphyTable.setShowGrid(True) - self.faultStratigraphyTable.resizeColumnsToContents() - self.faultStratigraphyTable.resizeRowsToContents() - - def onFaultFieldChanged(self, field): - name_field = self.faultNameField.currentField() - dip_field = self.faultDipField.currentField() - displacement_field = self.faultDisplacementField.currentField() - layer = self.faultNameField.layer() - - if name_field and layer: - self._faults = {} - for feature in layer.getFeatures(): - self._faults[str(feature[name_field])] = { - 'fault_dip': feature.attributeMap().get(dip_field, 90), - 'displacement': feature.attributeMap().get(displacement_field, 0.1*feature.geometry().length()), - 'fault_centre': {'x':feature.geometry().centroid().asPoint().x(), 'y':feature.geometry().centroid().asPoint().y()}, - 'major_axis': feature.geometry().length(), - 'intermediate_axis': feature.geometry().length(), - 'minor_axis': feature.geometry().length() / 3, - 'active': True, - "azimuth": calculateAverageAzimuth(feature.geometry()), - "fault_pitch": feature.attributeMap().get('pitch', 90), - "crs": layer.crs().authid(), - } - self.initFaultSelector() - self.initFaultNetwork() - # self.saveLayersToProject() - - def saveLayersToProject(self): - if self.basalContactsLayer.currentLayer() is not None: - self.project.writeEntry( - __title__, "basal_contacts_layer", self.basalContactsLayer.currentLayer().name() - ) - if self.structuralDataLayer.currentLayer() is not None: - self.project.writeEntry( - __title__, "structural_data_layer", self.structuralDataLayer.currentLayer().name() - ) - if self.faultTraceLayer.currentLayer() is not None: - self.project.writeEntry( - __title__, "fault_trace_layer", self.faultTraceLayer.currentLayer().name() - ) - if self.DtmLayer.currentLayer() is not None: - self.project.writeEntry(__title__, "dtm_layer", self.DtmLayer.currentLayer().name()) - if self.roiLayer.currentLayer() is not None: - self.project.writeEntry(__title__, "roi_layer", self.roiLayer.currentLayer().name()) - if self.unitNameField.currentField() is not None: - self.project.writeEntry( - __title__, "unitname_field", self.unitNameField.currentField() - ) - if self.dipField.currentField() is not None: - self.project.writeEntry(__title__, "dip_field", self.dipField.currentField()) - if self.orientationField.currentField() is not None: - self.project.writeEntry( - __title__, "orientation_field", self.orientationField.currentField() - ) - if self.faultNameField.currentField() is not None: - self.project.writeEntry( - __title__, "faultname_field", self.faultNameField.currentField() - ) - if self.faultDipField.currentField() is not None: - self.project.writeEntry( - __title__, "fault_dip_field", self.faultDipField.currentField() - ) - if self.faultDisplacementField.currentField() is not None: - self.project.writeEntry( - __title__, "fault_displacement_field", self.faultDisplacementField.currentField() - ) - if self.faultPitchField.currentField() is not None: - self.project.writeEntry( - __title__, "fault_pitch_field", self.faultPitchField.currentField() - ) - if self._units: - self.project.writeEntry(__title__, "units", json.dumps(self._units)) - if self._faults: - self.project.writeEntry(__title__, "faults", json.dumps(self._faults)) - - def onSelectedFaultChanged(self, index): - if index >= 0: - fault = self.faultSelection.currentText() - self.faultDipValue.setValue(self._faults[fault]['fault_dip']) - self.faultPitchValue.setValue(self._faults[fault]['fault_pitch']) - self.faultDisplacementValue.setValue(self._faults[fault]['displacement']) - self.faultActiveCheckBox.setChecked(self._faults[fault]['active']) - self.faultMajorAxisLength.setValue(self._faults[fault]['major_axis']) - self.faultIntermediateAxisLength.setValue(self._faults[fault]['intermediate_axis']) - self.faultMinorAxisLength.setValue(self._faults[fault]['minor_axis']) - self.faultCentreX.setValue(self._faults[fault]['fault_centre']['x']) - self.faultCentreY.setValue(self._faults[fault]['fault_centre']['y']) - # self.faultCentreZ.setValue(self._faults[fault]['centre'].z()) - self._onActiveFaultChanged(self._faults[fault]['active']) - def saveFaultsToProject(self): - if self._faults: - self.project.writeEntry(__title__, "faults", json.dumps(self._faults)) - def saveUnitsToProject(self): - if self._units: - self.project.writeEntry(__title__, "units", json.dumps(self._units)) - - def resetFaultField(self): - self.faultDipValue.setValue(0) - self.faultPitchValue.setValue(0) - self.faultDisplacementValue.setValue(0) - self.faultActiveCheckBox.setChecked(0) - self.faultMajorAxisLength.setValue(0) - self.faultIntermediateAxisLength.setValue(0) - self.faultMinorAxisLength.setValue(0) - self.faultCentreX.setValue(0) - self.faultCentreY.setValue(0) - # self.faultCentreZ.setValue(self._faults[fault]['centre'].z()) - self._onActiveFaultChanged(False) - - def _onActiveFaultChanged(self, value): - self.faultDipValue.setEnabled(value) - self.faultPitchValue.setEnabled(value) - self.faultDisplacementValue.setEnabled(value) - self.faultMajorAxisLength.setEnabled(value) - self.faultIntermediateAxisLength.setEnabled(value) - self.faultMinorAxisLength.setEnabled(value) - self.faultCentreX.setEnabled(value) - self.faultCentreY.setEnabled(value) - # self.faultCentreZ.setEnabled(value) - - def updateFaultProperty(self, prop, value): - fault = self.faultSelection.currentText() - if fault not in self._faults: - return - self._faults[fault][prop] = value - if prop == 'active': - self._onActiveFaultChanged(value) - self.saveFaultsToProject() - def drawFaultElipse(self): - fault = self.faultSelection.currentText() - if fault: - centre = self._faults[fault]['centre'] - major_axis = self._faults[fault]['major_axis'] - - minor_axis = self._faults[fault]['minor_axis'] - azimuth = self._faults[fault].get('azimuth', 0) - crs = self._faults[fault].get('crs', 'EPSG:4326') - # Create an ellipsoid centered at the fault center - ellipsoid = QgsEllipse( - QgsPoint(centre.x(), centre.y()), major_axis / 2, minor_axis / 2, azimuth - ) - - # Add the ellipsoid to the map canvas - ellipsoid_layer = QgsVectorLayer(f"Polygon?crs={crs}", f"{fault}: Ellipsoid", "memory") - ellipsoid_layer_provider = ellipsoid_layer.dataProvider() - ellipsoid_feature = QgsFeature() - ellipsoid_feature.setGeometry(ellipsoid.toPolygon()) - ellipsoid_layer_provider.addFeatures([ellipsoid_feature]) - - QgsProject.instance().addMapLayer(ellipsoid_layer) - - def _getSortedStratigraphicColumn(self): - - return sorted(self._units.items(), key=lambda x: x[1]['order']) - - def _initialiseStratigraphicColumn(self): - while self.stratigraphicColumnContainer.count(): - child = self.stratigraphicColumnContainer.takeAt(0) - if child.widget(): - child.widget().deleteLater() - - def create_lambda(i, direction): - return lambda: self.onOrderChanged(i, i + direction) - - def create_color_picker(unit): - def pick_color(): - color = QColorDialog.getColor() - if color.isValid(): - self._units[unit]['colour'] = color.name() - self._initialiseStratigraphicColumn() - - return pick_color - - for i, (unit, value) in enumerate(self._getSortedStratigraphicColumn()): - # Add stretch factor to first column - - - label = QLineEdit(unit) - label.editingFinished.connect(lambda unit=unit, label=label: self.stratigraphicColumnUnitNameChanged(unit, label.text())) - spin_box = QDoubleSpinBox(maximum=100000, minimum=0) - spin_box.setValue(value['thickness']) - order = QLabel() - order.setText(str(value['order'])) - up = QPushButton("↑") - down = QPushButton("↓") - color_picker = QPushButton("Pick Colour") - # Set background color for the row - background_color = value.get('colour', "#ffffff") - label.setStyleSheet(f"background-color: {background_color};") - spin_box.setStyleSheet(f"background-color: {background_color};") - order.setStyleSheet(f"background-color: {background_color};") - up.setStyleSheet(f"background-color: {background_color};") - down.setStyleSheet(f"background-color: {background_color};") - color_picker.setStyleSheet(f"background-color: {background_color};") - self.stratigraphicColumnContainer.addWidget(label, i, 0) - self.stratigraphicColumnContainer.addWidget(spin_box, i, 1) - self.stratigraphicColumnContainer.addWidget(up, i, 2) - self.stratigraphicColumnContainer.addWidget(down, i, 3) - self.stratigraphicColumnContainer.addWidget(color_picker, i, 4) - unconformity = QComboBox() - unconformity.addItem('Conformable') - unconformity.addItem('Erode') - unconformity.addItem('Onlap') - if 'contact' in value: - unconformity.setCurrentText(value['contact']) - - unconformity.currentTextChanged.connect( - lambda text, unit=unit: self.stratigraphicColumnChanged(text, unit) - ) - - self.stratigraphicColumnContainer.addWidget(unconformity, i, 5) - up.clicked.connect(create_lambda(i, -1)) - down.clicked.connect(create_lambda(i, 1)) - color_picker.clicked.connect(create_color_picker(unit)) - spin_box.valueChanged.connect( - lambda value, unit=unit: self.onThicknessChanged(unit, value) - ) - remove_button = QPushButton("Remove") - remove_button.setStyleSheet(f"background-color: {background_color};") - remove_button.clicked.connect( - lambda value, unit=unit: self.stratigraphicColumnRemoveClicked(unit) - ) - self.stratigraphicColumnContainer.addWidget(remove_button, i, 6) - - self.updateGroups() - def stratigraphicColumnChanged(self, text, unit): - self._units[unit]['contact'] = text - self.updateGroups() - self.saveUnitsToProject() - def stratigraphicColumnRemoveClicked(self, unit): - if unit in self._units: - del self._units[unit] - self._initialiseStratigraphicColumn() - self.saveUnitsToProject() - def addUnitToStratigraphicColumn(self): - name = 'New Unit' - if len(self._units) > 0: - name = f'New Unit {len(self._units) + 1}' - colour = random_hex_colour(n=1)[0] - self._units[name] = { - 'thickness': 10.0, - 'order': len(self._units), - 'name': name, - 'colour': colour, - 'contact': 'Conformable', - } - self._initialiseStratigraphicColumn() - self.saveUnitsToProject() - - def stratigraphicColumnUnitNameChanged(self, unit, name): - - old_name = unit - if unit == name: - return - if unit not in self._units: - return - if name in self._units and name != unit: - self.logger(message="Cannot rename, unit name already exists", log_level=2, push=True) - return - unit = self._units[unit] - unit['name'] = name - self._units[name] = unit - del self._units[old_name] - self._initialiseStratigraphicColumn() - self.saveUnitsToProject() - def updateGroups(self): - columns = self._getSortedStratigraphicColumn() - - self.groups = [] - group = [] - ii = 0 - for _i, (_unit, value) in enumerate(columns): - group.append(value) - if value['contact'] != 'Conformable': - self.groups.append({'name': f'group_{ii}', 'units': group}) - ii += 1 - group = [] - - self.groups.append({'name': f'group_{ii}', 'units': group}) - self.initFaultNetwork() - - def onOrderChanged(self, old_index, new_index): - if new_index < 0 or new_index >= len(self._units): - return - units = dict(self._units) # update a copy - for unit, value in self._units.items(): - if value['order'] == old_index: - units[unit]['order'] = new_index - elif value['order'] == new_index: - units[unit]['order'] = old_index - self._units = units # set to copy - self._initialiseStratigraphicColumn() - self.saveUnitsToProject() - def onThicknessChanged(self, unit, value): - self._units[unit]['thickness'] = value - self.saveUnitsToProject() - def onSaveModel(self): - if self.model is None: - self.logger(message="Cannot save model, model not initialised", log_level=2, push=True) - return - try: - - fileFormat = self.fileFormatCombo.currentText() - path = self.path.text() # - name = self.modelNameLineEdit.text() - if fileFormat == 'python': - fileFormat = 'pkl' - self.model.to_file(os.path.join(path, name + "." + fileFormat)) - with open(os.path.join(path, name + "." + 'py'), 'w') as f: - f.write(f"from loopstructural import GeologicalModel\n") - f.write(f"model = GeologicalModel.from_file('{name + '.' + fileFormat}')\n") - return - - self.model.save( - filename=os.path.join(path, name + "." + fileFormat), - block_model=self.blockModelCheckBox.isChecked(), - stratigraphic_surfaces=self.stratigraphicSurfacesCheckBox.isChecked(), - fault_surfaces=self.faultSurfacesCheckBox.isChecked(), - stratigraphic_data=self.stratigraphicDataCheckBox.isChecked(), - fault_data=self.faultDataCheckBox.isChecked(), - ) - self.logger(message=f"Model saved to {path}", log_level=0, push=True) - except Exception as e: - self.logger( - message=str(e), - log_level=2, - push=True, - ) - - def saveThicknessOrder(self): - pass - # if self._units is None: - # self.logger(message="No units found", log_level=2, push=True) - # return - # self.project.writeEntry( - # "LoopStructural", "units", json.dumps(self._units) - # ) - # layer = self.basalContactsLayer.currentLayer() - # layer.startEditing() - # field_names = ["_ls_th", "_ls_or", "_ls_col"] - # field_types = [QVariant.Double, QVariant.Int, QVariant.String] - # for field_name, field_type in zip(field_names, field_types): - - # if field_name not in [field.name() for field in layer.fields()]: - # layer.dataProvider().addAttributes([QgsField(field_name, field_type)]) - # layer.updateFields() - # for unit, value in self._units.items(): - # for feature in layer.getFeatures(): - # if feature.attributeMap().get(self.unitNameField.currentField()) == unit: - # feature[field_names[0]] = value['thickness'] - # feature[field_names[1]] = value['order'] - # feature[field_names[2]] = value['colour'] - # layer.updateFeature(feature) - # layer.commitChanges() - # layer.updateFields() - # self.logger( - # message=f"Thickness, colour and order saved to {layer.name()}", log_level=0, push=True - # ) - - def onPathTextChanged(self, text): - self.outputPath = text - - def onClickPath(self): - self.outputPath = QFileDialog.getExistingDirectory(None, "Select output path for model") - - self.path.setText(self.outputPath) - # if self.path: - # if os.path.exists(self.gridDirectory): - # self.output_directory = os.path.split( - # self.dlg.lineEdit_gridOutputDir.text() - # )[-1] + 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 + ) + + 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") diff --git a/loopstructural/gui/modelling/modelling_widget.ui b/loopstructural/gui/modelling/modelling_widget.ui index cd63d94..544a98e 100644 --- a/loopstructural/gui/modelling/modelling_widget.ui +++ b/loopstructural/gui/modelling/modelling_widget.ui @@ -28,1325 +28,45 @@ 0 - + Load data - - - - 0 - 10 - 591 - 701 - - - - - - - - - - - true - - - Define Model - - - - - 10 - 20 - 571 - 266 - - - - - - - ROI - - - - - - - true - - - <html><head/><body><p>Choose a layer representing the map extent of the area to be modelled. The model area will be the axis aligned extent of this layer.</p></body></html> - - - - - - - Height - - - - - - - <html><head/><body><p>Set the top of the model bounding box, above sea level. Note that the dataset need to be withing the bounding box otherwise they will not be used.</p></body></html> - - - 10000 - - - 1000 - - - - - - - Depth - - - - - - - <html><head/><body><p>Set the bottom of the model bounding box. Negative values are below sea level.</p></body></html> - - - -10000 - - - 10000 - - - -3000 - - - - - - - DTM - - - - - - - <html><head/><body><p>Choose a raster layer as a digital terrane model. If no DTM is chosen the observations are all considered to be located at 0 elevation.</p></body></html> - - - - - - - Rotation - - - - - - - false - - - <html><head/><body><p>Rotation of the map/dataset for modelling. The model bounding box is set to be aligned to the rotated map.</p></body></html> - - - - - - - false - - - - - - - CRS - - - - - - - - - - - - - - - Stratigraphy - - - - - 10 - 20 - 571 - 311 - - - - - - - Basal Contacts - - - - - - - <html><head/><body><p>A vector layer of points/lines that represents the basal (or top) contacts of the units being modelled. There must be a column identifying the unit.</p></body></html> - - - - - - - Unit Name - - - - - - - <html><head/><body><p>Identifier for the geological unit.</p></body></html> - - - - - - - Structural Data - - - - - - - <html><head/><body><p>Shape file representing orientation data assocaited with stratigraphy/lithology. </p></body></html> - - - true - - - - - - - Format - - - - - - - <html><head/><body><p>Strike and dip using right hand rule or dip direction/dip convention</p></body></html> - - - - Strike/Dip - - - - - Dip Direction/Dip - - - - - - - - Strike - - - - - - - <html><head/><body><p>Column representing the strike/dip direction angle in degrees</p></body></html> - - - - - - - Dip - - - - - - - <html><head/><body><p>Column representing the dip angle in degrees</p></body></html> - - - - - - - Unit Name - - - - - - - <html><head/><body><p>Column representing which unit the orientation measurement belongs to. This is used to differentiate between non-conformable units.</p></body></html> - - - - - - - - - - - - - - - Faults - - - - - 10 - 20 - 571 - 221 - - - - - - - Fault Traces - - - - - - - - - - Fault Name - - - - - - - - - - Dip - - - - - - - true - - - - - - - Displacement - - - - - - - true - - - - - - - Pitch - - - - - - - - - - - - - - - - - + Geological History - + - 0 - 10 - 591 - 701 + 40 + 470 + 409 + 153 - - - - - - - Stratigraphic Column - - - - - 10 - 30 - 561 - 281 - - - - - QLayout::SetMinimumSize - - - - - - - Qt::Vertical - - - - 20 - 40 - - - - - - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - <html><head/><body><p>Save the stratigraphic order and thickness as new columns in the contacts basal contacts layer. Note this will overwrite any existing data in the &quot;LS_order' and 'LS_thickness' columns</p></body></html> - - - Add Unit - - - - - - - - - - - - - - - - - Fault Properties - - - - - 10 - 20 - 679 - 540 - - - - - - - - - - - - Displacement - - - - - - - false - - - -10000000000.000000000000000 - - - 10000000000.000000000000000 - - - - - - - Dip - - - - - - - false - - - 0.000000000000000 - - - 90.000000000000000 - - - - - - - Center - - - - - - - - - - - - - x - - - - - - - false - - - -100000000000.000000000000000 - - - 100000000000.000000000000000 - - - - - - - false - - - -100000000000.000000000000000 - - - 100000000000.000000000000000 - - - - - - - y - - - - - - - z - - - - - - - false - - - -100000000000.000000000000000 - - - 0.000000000000000 - - - - - - - - - false - - - Select on Map - - - - - - - - - - - Major Axis - - - - - - - false - - - 10000000.000000000000000 - - - - - - - Minor Axis - - - - - - - false - - - 10000000.000000000000000 - - - - - - - Intermediate Axis - - - - - - - false - - - 1000000.000000000000000 - - - - - - - Active - - - - - - - false - - - - - - - Pitch - - - - - - - false - - - 0.000000000000000 - - - 180.000000000000000 - - - - - - - - - Add Elipse to Map - - - - - - - - - - - + Topology - - - - 0 - 10 - 591 - 701 - - - - - - - - - Fault-Fault - - - - - 0 - 20 - 571 - 271 - - - - - - - - - - - Fault-Stratigraphy - - - - - 0 - 20 - 571 - 311 - - - - - - - - + Model Setup - - - - 0 - 10 - 591 - 361 - - - - - - - - - Initialise Model - - - - - - - Run - - - - - - - Interpolation Parameters - - - - - -1 - 19 - 591 - 263 - - - - - - - - - - Qt::Vertical - - - - 20 - 40 - - - - - - - - - - Number of elements - - - - - - - 10000000 - - - - - - - Value point weight - - - - - - - Norm point weight - - - - - - - Regularisation - - - - - - - 50.000000000000000 - - - - - - - - - - - - - - - - - - - - + 3D Viewer - - - - 9 - 9 - 591 - 511 - - - - - - - - - - QLayout::SetMinimumSize - - - - - Add Data - - - - - - - Add Block Model - - - - - - - Add Surfaces - - - - - - - Clear - - - - - - - - + Export Model - - - - 10 - 10 - 581 - 641 - - - - - - - - - Export Model - - - - - 10 - 20 - 551 - 191 - - - - - - - - - File Format - - - - - - - - vtk - - - - - python - - - - - geoh5 - - - - - - - - Stratigraphic Surfaces - - - - - - - - - - true - - - - - - - Fault Surfaces - - - - - - - - - - true - - - - - - - Block Model - - - - - - - - - - true - - - - - - - Stratigraphy Data - - - - - - - - - - true - - - - - - - Fault Data - - - - - - - - - - true - - - - - - - Model Name - - - - - - - - - - Directory - - - - - - - - - - - - ... - - - - - - - - - - - - - 20 - 230 - 75 - 23 - - - - Save - - - - - - - - - - Interogate Model - - - - - 10 - 20 - 551 - 271 - - - - - - - <html><head/><body><p>Extract the basal contacts by intersecting the model surfaces with the DTM. </p></body></html> - - - Model contacts - - - - - - - false - - - Add to Project - - - - - - - <html><head/><body><p>Calculate the magnitude of the fault displacements and show this on the map</p></body></html> - - - Fault Displacements - - - - - - - false - - - Add to Project - - - - - - - - - - - - Add to map - - - - - - - - - <html><head/><body><p>Evaluate the stratigraphic ID on a pointset. If the points are not in the model the unit will be -1</p></body></html> - - - Evaluate Model on layer - - - - - - - Mapped Lithologies - - - - - - - Add to map - - - - - - - Add fault traces - - - - - - - <html><head/><body><p>Evaluate the scalar value or the gradient of a geological feature on a vector pointset.</p></body></html> - - - Evaluate feature on layer - - - - - - - - - - - - - - false - - - Gradient - - - - - - - - - - - - - - Add - - - - - - - - - - - false - - - Add to map - - - - - - - Add scalar field - - - - - - - - - - - - Add to Project - - - - - - - - - - - - - - - @@ -1365,30 +85,30 @@ <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0//EN" "http://www.w3.org/TR/REC-html40/strict.dtd"> <html><head><meta name="qrichtext" content="1" /><style type="text/css"> p, li { white-space: pre-wrap; } -</style></head><body style=" font-family:'MS Shell Dlg 2'; font-size:3.3pt; font-weight:400; font-style:normal;"> -<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:12pt; font-weight:600;">About LoopStructural</span></p> -<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8.25pt;">LoopStructural is an open source 3D structural geological python library developed as part of the Loop project by Lachlan Grose. This plugin provides a convenient link between QGIS datasets and the LoopStructural modelling algorithms. </span></p> -<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px; font-size:8.25pt;"><br /></p> -<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8.25pt;">Using this plugin you can:</span></p> -<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8.25pt;">- Build a model from a scratch map (fault traces, contacts, structural data and a stratigraphic column</span></p> -<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8.25pt;">- Define fault kinematics</span></p> -<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8.25pt;">- Export models to vtk, gocad, geoh5 3D formats</span></p> -<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8.25pt;">- Export the model to be loaded into a python environment</span></p> -<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px; font-size:8.25pt;"><br /></p> -<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8.25pt;">You cannot:</span></p> -<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8.25pt;">- View the model in 3D (QGIS 3D environment is not sufficient at the moment)</span></p> -<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8.25pt;">- Access all of the LoopStructural features in the Python API</span></p> -<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px; font-size:8.25pt;"><br /></p> -<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8.25pt;">To help support this project </span><a href="https://github.com/Loop3D/loopstructural"><span style=" font-size:8pt; text-decoration: underline; color:#0000ff;">please star the project on GitHub</span></a><span style=" font-size:8.25pt;"> </span></p> -<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px; font-size:8.25pt;"><br /></p> -<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8.25pt;">If you have any questions about building models using LoopStructural or have found a bug please </span><a href="https://github.com/Loop3D/LoopStructural/issues"><span style=" font-size:8pt; text-decoration: underline; color:#0000ff;">submit an issue</span></a><span style=" font-size:8.25pt; text-decoration: underline; color:#0000ff;"> on the LoopStructural github page. </span></p> -<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px; font-size:8.25pt;"><br /></p> -<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:11pt; font-weight:600;">Cite LoopStructural</span></p> -<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8.25pt;">Grose et al 2020</span></p> -<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-size:8.25pt;">Grose et al 2021</span></p> -<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px; font-size:8.25pt;"><br /></p> -<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px; font-size:8.25pt;"><br /></p> -<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px; font-size:8.25pt;"><br /></p></body></html> +</style></head><body style=" font-family:'Ubuntu Sans'; font-size:11pt; font-weight:400; font-style:normal;"> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-family:'MS Shell Dlg 2'; font-size:12pt; font-weight:600;">About LoopStructural</span></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-family:'MS Shell Dlg 2'; font-size:8.25pt;">LoopStructural is an open source 3D structural geological python library developed as part of the Loop project by Lachlan Grose. This plugin provides a convenient link between QGIS datasets and the LoopStructural modelling algorithms. </span></p> +<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px; font-family:'MS Shell Dlg 2'; font-size:8.25pt;"><br /></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-family:'MS Shell Dlg 2'; font-size:8.25pt;">Using this plugin you can:</span></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-family:'MS Shell Dlg 2'; font-size:8.25pt;">- Build a model from a scratch map (fault traces, contacts, structural data and a stratigraphic column</span></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-family:'MS Shell Dlg 2'; font-size:8.25pt;">- Define fault kinematics</span></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-family:'MS Shell Dlg 2'; font-size:8.25pt;">- Export models to vtk, gocad, geoh5 3D formats</span></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-family:'MS Shell Dlg 2'; font-size:8.25pt;">- Export the model to be loaded into a python environment</span></p> +<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px; font-family:'MS Shell Dlg 2'; font-size:8.25pt;"><br /></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-family:'MS Shell Dlg 2'; font-size:8.25pt;">You cannot:</span></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-family:'MS Shell Dlg 2'; font-size:8.25pt;">- View the model in 3D (QGIS 3D environment is not sufficient at the moment)</span></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-family:'MS Shell Dlg 2'; font-size:8.25pt;">- Access all of the LoopStructural features in the Python API</span></p> +<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px; font-family:'MS Shell Dlg 2'; font-size:8.25pt;"><br /></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-family:'MS Shell Dlg 2'; font-size:8.25pt;">To help support this project </span><a href="https://github.com/Loop3D/loopstructural"><span style=" font-family:'MS Shell Dlg 2'; font-size:8pt; text-decoration: underline; color:#0000ff;">please star the project on GitHub</span></a><span style=" font-family:'MS Shell Dlg 2'; font-size:8.25pt;"> </span></p> +<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px; font-family:'MS Shell Dlg 2'; font-size:8.25pt;"><br /></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-family:'MS Shell Dlg 2'; font-size:8.25pt;">If you have any questions about building models using LoopStructural or have found a bug please </span><a href="https://github.com/Loop3D/LoopStructural/issues"><span style=" font-family:'MS Shell Dlg 2'; font-size:8pt; text-decoration: underline; color:#0000ff;">submit an issue</span></a><span style=" font-family:'MS Shell Dlg 2'; font-size:8.25pt; text-decoration: underline; color:#0000ff;"> on the LoopStructural github page. </span></p> +<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px; font-family:'MS Shell Dlg 2'; font-size:8.25pt;"><br /></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-family:'MS Shell Dlg 2'; font-weight:600;">Cite LoopStructural</span></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-family:'MS Shell Dlg 2'; font-size:8.25pt;">Grose et al 2020</span></p> +<p style=" margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px;"><span style=" font-family:'MS Shell Dlg 2'; font-size:8.25pt;">Grose et al 2021</span></p> +<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px; font-family:'MS Shell Dlg 2'; font-size:8.25pt;"><br /></p> +<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px; font-family:'MS Shell Dlg 2'; font-size:8.25pt;"><br /></p> +<p style="-qt-paragraph-type:empty; margin-top:0px; margin-bottom:0px; margin-left:0px; margin-right:0px; -qt-block-indent:0; text-indent:0px; font-family:'MS Shell Dlg 2'; font-size:8.25pt;"><br /></p></body></html> Qt::LinksAccessibleByKeyboard|Qt::LinksAccessibleByMouse|Qt::TextBrowserInteraction|Qt::TextEditable|Qt::TextSelectableByMouse @@ -1402,19 +122,16 @@ p, li { white-space: pre-wrap; } - QgsDoubleSpinBox - QDoubleSpinBox -
qgsdoublespinbox.h
-
- - QgsFieldComboBox - QComboBox -
qgsfieldcombobox.h
+ QgsCollapsibleGroupBox + QGroupBox +
qgscollapsiblegroupbox.h
+ 1
- QgsMapLayerComboBox - QComboBox -
qgsmaplayercombobox.h
+ QgsExtentGroupBox + QgsCollapsibleGroupBox +
qgsextentgroupbox.h
+ 1
diff --git a/loopstructural/gui/modelling/modelling_widget_back.py b/loopstructural/gui/modelling/modelling_widget_back.py new file mode 100644 index 0000000..a0773fe --- /dev/null +++ b/loopstructural/gui/modelling/modelling_widget_back.py @@ -0,0 +1,1050 @@ +import json +import os +import random + +import numpy as np +from LoopStructural.utils import random_hex_colour +from pyvistaqt import QtInteractor +from qgis.core import ( + QgsEllipse, + QgsFeature, + QgsFieldProxyModel, + QgsMapLayerProxyModel, + QgsPoint, + QgsProject, + QgsVectorLayer, +) +from qgis.PyQt import uic +from qgis.PyQt.QtGui import QColor +from qgis.PyQt.QtWidgets import ( + QCheckBox, + QColorDialog, + QComboBox, + QDoubleSpinBox, + QFileDialog, + QLabel, + QLineEdit, + QListWidgetItem, + QPushButton, + QWidget, +) + +from ...main import QgsProcessInputData +from ...main.callableToLayer import callableToLayer +from ...main.geometry.calculateLineAzimuth import calculateAverageAzimuth +from ...main.rasterFromModel import callableToRaster + +# from .feature_widget import FeatureWidget +# from LoopStructural.visualisation import Loop3DView +# from loopstructural.gui.modelling.stratigraphic_column import StratigraphicColumnWidget +__title__ = "LoopStructural" + + +class ModellingWidget(QWidget): + def __init__(self, parent: QWidget = None, mapCanvas=None, logger=None): + super().__init__(parent) + uic.loadUi(os.path.join(os.path.dirname(__file__), "modelling_widget.ui"), self) + self.project = QgsProject.instance() + self.mapCanvas = mapCanvas + self.rotationDoubleSpinBox.setValue(mapCanvas.rotation()) + self._set_layer_filters() + # self.unitNameField.setLayer(self.basalContactsLayer.currentLayer()) + self.logger = logger + self._basalContacts = None + self._units = {} + self._faults = {} + self._connectSignals() + self.view = None + self.model = None + self.outputPath = "" + self.activeFeature = None + self.groups = [] + self.plotter = QtInteractor(parent) + self.plotter.add_axes() + self.pyvista_layout.addWidget(self.plotter) + self.loadFromProject() + + def setLayerComboBoxFromProject(self, comboBox: QComboBox, layerKey: str): + layerName, flag = self.project.readEntry(__title__, layerKey) + if flag: + + layers = self.project.mapLayersByName(layerName) + print(layerName, layers) + if len(layers) == 0: + self.logger( + message=f"Layer {layerName} not found in project", + log_level=2, + push=True, + ) + return + comboBox.setLayer(None) + comboBox.setLayer(layers[0]) + + def setLayerFieldComboBoxFromProject( + self, comboBox: QComboBox, fieldKey: str, layer: QgsVectorLayer + ): + if layer is None: + self.logger(message="Layer is None", log_level=2, push=True) + return + fieldName, flag = self.project.readEntry(__title__, fieldKey) + if not flag or not fieldName: + self.logger(message=f"Field {fieldKey} not found in project", log_level=0, push=True) + return + field_names = [field.name() for field in layer.fields()] + if fieldName not in field_names: + self.logger( + message=f"Field {fieldName} not found in layer {layer.name()}", + log_level=0, + push=True, + ) + return + comboBox.setField(fieldName) + + def loadFromProject(self): + # Load settings from project + self.setLayerComboBoxFromProject(self.basalContactsLayer, "basal_contacts_layer") + self.setLayerFieldComboBoxFromProject( + self.unitNameField, "unitname_field", self.basalContactsLayer.currentLayer() + ) + self.setLayerComboBoxFromProject(self.structuralDataLayer, "structural_data_layer") + self.setLayerFieldComboBoxFromProject( + self.dipField, "dip_field", self.structuralDataLayer.currentLayer() + ) + self.setLayerFieldComboBoxFromProject( + self.orientationField, "orientation_field", self.structuralDataLayer.currentLayer() + ) + self.setLayerFieldComboBoxFromProject( + self.structuralDataUnitName, + "structuraldata_unitname_field", + self.structuralDataLayer.currentLayer(), + ) + self.setLayerComboBoxFromProject(self.faultTraceLayer, "fault_trace_layer") + self.setLayerFieldComboBoxFromProject( + self.faultNameField, "faultname_field", self.faultTraceLayer.currentLayer() + ) + self.setLayerFieldComboBoxFromProject( + self.faultDipField, "fault_dip_field", self.faultTraceLayer.currentLayer() + ) + self.setLayerFieldComboBoxFromProject( + self.faultDisplacementField, + "fault_displacement_field", + self.faultTraceLayer.currentLayer(), + ) + self.setLayerFieldComboBoxFromProject( + self.faultPitchField, "fault_pitch_field", self.faultTraceLayer.currentLayer() + ) + self.setLayerComboBoxFromProject(self.DtmLayer, "dtm_layer") + self.setLayerComboBoxFromProject(self.roiLayer, "roi_layer") + label, flag = self.project.readEntry(__title__, "orientation_label", "Strike") + if flag: + self.orientationType.setCurrentText(label) + resp, flag = self.project.readEntry(__title__, "units", "") + if flag: + self._units = json.loads(resp) + if len(self._units) > 0: + self._initialiseStratigraphicColumn() + resp, flag = self.project.readEntry(__title__, "faults", "") + if flag: + # try: + self._faults = json.loads(resp) + self.initFaultNetwork() + # except: + # self.logger(message="Faults not loaded", log_level=2, push=True) + + def _set_layer_filters(self): + # Set filters for the layer selection comboboxes + # basal contacts can be line or points + self.basalContactsLayer.setFilters( + QgsMapLayerProxyModel.LineLayer | QgsMapLayerProxyModel.PointLayer + ) + self.basalContactsLayer.setAllowEmptyLayer(True) + # Structural data can only be points + self.structuralDataLayer.setFilters(QgsMapLayerProxyModel.PointLayer) + self.basalContactsLayer.setAllowEmptyLayer(True) + # fault traces can be lines or points + self.faultTraceLayer.setFilters( + QgsMapLayerProxyModel.LineLayer | QgsMapLayerProxyModel.PointLayer + ) + self.faultTraceLayer.setAllowEmptyLayer(True) + # dtm can only be a raster + self.DtmLayer.setFilters(QgsMapLayerProxyModel.RasterLayer) + self.DtmLayer.setAllowEmptyLayer(True) + + # evaluate on model layer + self.evaluateModelOnLayerSelector.setFilters(QgsMapLayerProxyModel.PointLayer) + self.evaluateModelOnLayerSelector.setAllowEmptyLayer(True) + # evaluate on feature layer + self.evaluateFeatureLayerSelector.setFilters(QgsMapLayerProxyModel.PointLayer) + self.evaluateFeatureLayerSelector.setAllowEmptyLayer(True) + # orientation field can only be double or int + self.orientationField.setFilters(QgsFieldProxyModel.Numeric) + self.dipField.setFilters(QgsFieldProxyModel.Numeric) + # fault dip field can only be double or int + self.faultDipField.setFilters(QgsFieldProxyModel.Numeric) + # fault displacement field can only be double or int + self.faultDisplacementField.setFilters(QgsFieldProxyModel.Numeric) + + def saveLayerComboBoxState(self, comboBox: QComboBox, layerKey: str): + layer = comboBox.currentLayer() + if layer is not None: + self.project.writeEntry(__title__, layerKey, layer.name()) + + def saveLayerFieldComboBoxState(self, comboBox: QComboBox, fieldKey: str): + field = comboBox.currentField() + if field is not None: + self.project.writeEntry(__title__, fieldKey, field) + + def saveSettingToProject(self, key: str, value: str): + self.project.writeEntry(__title__, key, value) + + def _connectSignals(self): + self.basalContactsLayer.layerChanged.connect(self.onBasalContactsChanged) + self.structuralDataLayer.layerChanged.connect(self.onStructuralDataLayerChanged) + self.unitNameField.fieldChanged.connect(self.onUnitFieldChanged) + self.faultTraceLayer.layerChanged.connect(self.onFaultTraceLayerChanged) + self.faultNameField.fieldChanged.connect(self.onFaultFieldChanged) + self.faultDipField.fieldChanged.connect(self.onFaultFieldChanged) + self.faultDisplacementField.fieldChanged.connect(self.onFaultFieldChanged) + self.orientationType.currentIndexChanged.connect(self.onOrientationTypeChanged) + self.orientationField.fieldChanged.connect(self.onOrientationFieldChanged) + self.initModel.clicked.connect(self.onInitialiseModel) + self.rotationDoubleSpinBox.valueChanged.connect(self.onRotationChanged) + self.runModelButton.clicked.connect(self.onRunModel) + self.pathButton.clicked.connect(self.onClickPath) + self.saveButton.clicked.connect(self.onSaveModel) + self.path.textChanged.connect(self.onPathTextChanged) + self.faultSelection.currentIndexChanged.connect(self.onSelectedFaultChanged) + + self.basalContactsLayer.layerChanged.connect( + lambda: self.saveLayerComboBoxState(self.basalContactsLayer, 'basal_contacts_layer') + ) + self.unitNameField.fieldChanged.connect( + lambda: self.saveLayerFieldComboBoxState(self.unitNameField, 'unitname_field') + ) + self.structuralDataLayer.layerChanged.connect( + lambda: self.saveLayerComboBoxState(self.structuralDataLayer, 'structural_data_layer') + ) + self.orientationField.fieldChanged.connect( + lambda: self.saveLayerFieldComboBoxState(self.orientationField, 'orientation_field') + ) + + self.dipField.fieldChanged.connect( + lambda: self.saveLayerFieldComboBoxState(self.dipField, 'dip_field') + ) + self.roiLayer.layerChanged.connect( + lambda: self.saveLayerComboBoxState(self.roiLayer, 'roi_layer') + ) + self.faultTraceLayer.layerChanged.connect( + lambda: self.saveLayerComboBoxState(self.faultTraceLayer, 'fault_trace_layer') + ) + self.faultNameField.fieldChanged.connect( + lambda: self.saveLayerFieldComboBoxState(self.faultNameField, 'faultname_field') + ) + self.faultDipField.fieldChanged.connect( + lambda: self.saveLayerFieldComboBoxState(self.faultDipField, 'fault_dip_field') + ) + self.faultDisplacementField.fieldChanged.connect( + lambda: self.saveLayerFieldComboBoxState( + self.faultDisplacementField, 'fault_displacement_field' + ) + ) + self.faultPitchField.fieldChanged.connect( + lambda: self.saveLayerFieldComboBoxState(self.faultPitchField, 'fault_pitch_field') + ) + self.faultDipValue.valueChanged.connect( + lambda value: self.updateFaultProperty('fault_dip', value) + ) + + self.structuralDataUnitName.fieldChanged.connect( + lambda: self.saveLayerFieldComboBoxState( + self.structuralDataUnitName, 'structuraldata_unitname_field' + ) + ) + self.faultPitchValue.valueChanged.connect( + lambda value: self.updateFaultProperty('fault_pitch', value) + ) + self.faultDisplacementValue.valueChanged.connect( + lambda value: self.updateFaultProperty('displacement', value) + ) + self.faultActiveCheckBox.stateChanged.connect( + lambda value: self.updateFaultProperty('active', value) + ) + self.faultMajorAxisLength.valueChanged.connect( + lambda value: self.updateFaultProperty('major_axis', value) + ) + self.faultIntermediateAxisLength.valueChanged.connect( + lambda value: self.updateFaultProperty('intermediate_axis', value) + ) + self.faultMinorAxisLength.valueChanged.connect( + lambda value: self.updateFaultProperty('minor_axis', value) + ) + + self.orientationType.currentIndexChanged.connect( + lambda value: self.saveSettingToProject( + 'orientation_type', self.orientationLabel.text() + ) + ) + + # self.faultCentreX.valueChanged.connect(lambda value: self.updateFaultProperty('centre', value)) + # self.faultCentreY.valueChanged.connect(lambda value: self.updateFaultProperty('centre', value)) + # self.faultCentreZ.valueChanged.connect(lambda value: self.updateFaultProperty('centre', value)) + self.addFaultElipseToMap.clicked.connect(self.drawFaultElipse) + self.addModelContactsToProject.clicked.connect(self.onAddModelContactsToProject) + self.addFaultDisplacementsToProject.clicked.connect(self.onAddFaultDisplacmentsToProject) + self.evaluateModelOnLayer.clicked.connect(self.onEvaluateModelOnLayer) + self.evaluateFeatureOnLayer.clicked.connect(self.onEvaluateFeatureOnLayer) + self.addMappedLithologiesToProject.clicked.connect(self.onAddModelledLithologiesToProject) + self.addFaultTracesToProject.clicked.connect(self.onAddFaultTracesToProject) + self.addScalarFieldToProject.clicked.connect(self.onAddScalarFieldToProject) + # self.saveThicknessOrderButton.clicked.connect(self.saveThicknessOrder) + self.addUnitButton.clicked.connect(self.addUnitToStratigraphicColumn) + self.addBlockModelToPyvistaButton.clicked.connect(self.addBlockModelToPyvista) + self.clearPyvistaButton.clicked.connect(self.clearPyvista) + self.addSurfacesToPyvistaButton.clicked.connect(self.addModelSurfacesToPyvista) + self.addDataToPyvistaButton.clicked.connect(self.addDataToPyvista) + QgsProject.instance().readProject.connect(self.loadFromProject) + + def onModelListItemClicked(self, feature): + self.activeFeature = self.model[feature.text()] + self.numberOfElementsSpinBox.setValue( + self.activeFeature.builder.build_arguments['nelements'] + ) + self.numberOfElementsSpinBox.valueChanged.connect( + lambda nelements: self.activeFeature.builder.update_build_arguments( + {'nelements': nelements} + ) + ) + self.regularisationSpin.setValue( + self.activeFeature.builder.build_arguments['regularisation'] + ) + self.regularisationSpin.valueChanged.connect( + lambda regularisation: self.activeFeature.builder.update_builupdate_build_argumentsd_args( + {'regularisation': regularisation} + ) + ) + self.npwSpin.setValue(self.activeFeature.builder.build_arguments['npw']) + self.npwSpin.valueChanged.connect( + lambda npw: self.activeFeature.builder.update_build_arguments({'npw': npw}) + ) + self.cpwSpin.setValue(self.activeFeature.builder.build_arguments['cpw']) + self.cpwSpin.valueChanged.connect( + lambda cpw: self.activeFeature.builder.update_build_arguments({'cpw': cpw}) + ) + # self.updateButton.clicked.connect(lambda : feature.builder.update()) + + def onInitialiseModel(self): + + columnmap = { + 'unitname': self.unitNameField.currentField(), + 'faultname': self.faultNameField.currentField(), + 'dip': self.dipField.currentField(), + 'orientation': self.orientationField.currentField(), + 'structure_unitname': self.structuralDataUnitName.currentField(), + # 'pitch': self.faultPitchField.currentField() + } + faultNetwork = np.zeros((len(self._faults), len(self._faults))) + for i in range(len(self._faults)): + for j in range(len(self._faults)): + if i != j: + item = self.faultNetworkTable.cellWidget(i, j) + if item.currentText() == 'Abuts': + faultNetwork[i, j] = 1 + elif item.currentText() == 'Cuts': + faultNetwork[i, j] = -1 + faultStratigraphy = np.zeros((len(self._faults), len(self.groups))) + for i in range(len(self._faults)): + for j in range(len(self.groups)): + item = self.faultStratigraphyTable.cellWidget(i, j) + faultStratigraphy[i, j] = item.isChecked() + + processor = QgsProcessInputData( + basal_contacts=self.basalContactsLayer.currentLayer(), + groups=self.groups, + fault_trace=self.faultTraceLayer.currentLayer(), + fault_properties=self._faults, + structural_data=self.structuralDataLayer.currentLayer(), + dtm=self.DtmLayer.currentLayer(), + columnmap=columnmap, + roi=self.roiLayer.currentLayer(), + top=self.heightSpinBox.value(), + bottom=self.depthSpinBox.value(), + dip_direction=self.orientationType.currentIndex() == 1, + rotation=self.rotationDoubleSpinBox.value(), + faultNetwork=faultNetwork, + faultStratigraphy=faultStratigraphy, + faultlist=list(self._faults.keys()), + ) + self.processor = processor + self.model = processor.get_model() + self.logger(message="Model initialised", log_level=0, push=True) + self.modelList.clear() + for feature in self.model.features: + if feature.name[0] == '_': + continue + item = QListWidgetItem() + item.setText(feature.name) + item.setBackground( + QColor(random.randint(0, 255), random.randint(0, 255), random.randint(0, 255)) + ) + + self.modelList.addItem(item) + self.modelList.itemClicked.connect(self.onModelListItemClicked) + self.plotter.add_mesh(self.model.bounding_box.vtk().outline()) + + def onOrientationTypeChanged(self, index): + if index == 0: + self.orientationLabel.setText("Strike") + else: + self.orientationLabel.setText("Dip Direction") + + def onRotationChanged(self, rotation): + self.mapCanvas.setRotation(rotation) + + def onOrientationFieldChanged(self, field): + pass + + def onStructuralDataLayerChanged(self, layer): + self.orientationField.setLayer(layer) + self.dipField.setLayer(layer) + self.structuralDataUnitName.setLayer(layer) + # self.saveLayersToProject() + # self.dipField.setValidator(QDoubleValidator(0.0, 360.0, 2)) + # self.orientationField.setValidator(QDoubleValidator(0.0, 360.0, 2)) + + def onRunModel(self): + try: + self.model.update(progressbar=False) + self._model_updated() + self.logger(message="Model run", log_level=0, push=True) + + except Exception as e: + self.logger( + message=str(e), + log_level=2, + push=True, + ) + + def _model_updated(self): + self.addScalarFieldComboBox.clear() + self.evaluateFeatureFeatureSelector.clear() + for feature in self.model.features: + ## make sure that private features are not added to the list + if feature.name[0] != "_": + self.addScalarFieldComboBox.addItem(feature.name) + self.evaluateFeatureFeatureSelector.addItem(feature.name) + self.addScalarFieldComboBox.setCurrentIndex(0) + self.evaluateFeatureFeatureSelector.setCurrentIndex(0) + + def onAddModelContactsToProject(self): + pass + + def onAddFaultDisplacmentsToProject(self): + pass + + def addBlockModelToPyvista(self): + if self.model is None: + self.logger(message="Model not initialised", log_level=2, push=True) + return + self.plotter.add_mesh(self.model.get_block_model()[0].vtk(), show_scalar_bar=False) + + def addModelSurfacesToPyvista(self): + if self.model is None: + self.logger(message="Model not initialised", log_level=2, push=True) + return + surfaces = self.model.get_stratigraphic_surfaces() + for surface in surfaces: + self.plotter.add_mesh(surface.vtk(), show_scalar_bar=False, color=surface.colour) + fault_surfaces = self.model.get_fault_surfaces() + for surface in fault_surfaces: + self.plotter.add_mesh(surface.vtk(), show_scalar_bar=False, color='black') + + def addDataToPyvista(self): + if self.model is None: + self.logger(message="Model not initialised", log_level=2, push=True) + return + for f in self.model.features: + if f.name[0] != "_": + for d in f.get_data(): + self.plotter.add_mesh(d.vtk(), show_scalar_bar=False) + + def clearPyvista(self): + self.plotter.clear() + if self.model is not None: + self.plotter.add_mesh(self.model.bounding_box.vtk().outline()) + + def onEvaluateModelOnLayer(self): + layer = self.evaluateModelOnLayerSelector.currentLayer() + + callableToLayer( + lambda xyz: self.model.evaluate_model(xyz), + layer, + self.DtmLayer.currentLayer(), + 'unit_id', + ) + + def onEvaluateFeatureOnLayer(self): + feature_name = self.evaluateFeatureFeatureSelector.currentText() + layer = self.evaluateFeatureLayerSelector.currentLayer() + callableToLayer( + lambda xyz: self.model.evaluate_feature_value(feature_name, xyz), + layer, + self.DtmLayer.currentLayer(), + feature_name, + ) + pass + + def onAddModelledLithologiesToProject(self): + if self.model is None: + self.logger(message="Model not initialised", log_level=2, push=True) + return + bounding_box = self.model.bounding_box + feature_layer = callableToRaster( + lambda xyz: self.model.evaluate_model(xyz), + dtm=self.DtmLayer.currentLayer(), + bounding_box=bounding_box, + crs=QgsProject.instance().crs(), + layer_name='modelled_lithologies', + ) + if feature_layer.isValid(): + QgsProject.instance().addMapLayer(feature_layer) + else: + self.logger(message="Failed to add scalar field to project", log_level=2, push=True) + pass + + def onAddFaultTracesToProject(self): + pass + + def onAddScalarFieldToProject(self): + feature_name = self.addScalarFieldComboBox.currentText() + if self.model is None: + self.logger(message="Model not initialised", log_level=2, push=True) + return + bounding_box = self.model.bounding_box + feature_layer = callableToRaster( + lambda xyz: self.model.evaluate_feature_value(feature_name, xyz), + dtm=self.DtmLayer.currentLayer(), + bounding_box=bounding_box, + crs=QgsProject.instance().crs(), + layer_name=f'{feature_name}_scalar_field', + ) + if feature_layer.isValid(): + QgsProject.instance().addMapLayer(feature_layer) + else: + self.logger(message="Failed to add scalar field to project", log_level=2, push=True) + + def onBasalContactsChanged(self, layer): + self.unitNameField.setLayer(layer) + # self.saveLayersToProject() + + def onFaultTraceLayerChanged(self, layer): + self.faultNameField.setLayer(layer) + self.faultDipField.setLayer(layer) + self.faultDisplacementField.setLayer(layer) + self._faults = {} # reset faults + self.onSelectedFaultChanged(-1) + self.initFaultSelector() + self.initFaultNetwork() + # self.saveLayersToProject() + + def onUnitFieldChanged(self, field): + if len(self._units) == 0: + + unique_values = set() + attributes = {} + layer = self.unitNameField.layer() + if layer: + fields = {} + fields['unitname'] = layer.fields().indexFromName(field) + if '_ls_th' in [field.name() for field in layer.fields()]: + fields['thickness'] = layer.fields().indexFromName('_ls_th') + if '_ls_or' in [field.name() for field in layer.fields()]: + fields['order'] = layer.fields().indexFromName('_ls_or') + if '_ls_col' in [field.name() for field in layer.fields()]: + fields['colour'] = layer.fields().indexFromName('_ls_col') + field_index = layer.fields().indexFromName(field) + + for feature in layer.getFeatures(): + unique_values.add(str(feature[field_index])) + attributes[str(feature[field_index])] = {} + for k in fields: + if feature[fields[k]] is not None: + attributes[str(feature[field_index])][k] = feature[fields[k]] + + colours = random_hex_colour(n=len(unique_values)) + self._units = dict( + zip( + list(unique_values), + [ + { + 'thickness': ( + attributes[u]['thickness'] if 'thickness' in attributes[u] else 10.0 + ), + 'order': int(attributes[u]['order']) if 'order' in attributes[u] else i, + 'name': u, + 'colour': ( + str(attributes[u]['colour']) + if 'colour' in attributes[u] + else colours[i] + ), + 'contact': ( + str(attributes[u]['contact']) + if 'contact' in attributes[u] + else 'Conformable' + ), + } + for i, u in enumerate(unique_values) + ], + ) + ) + self._initialiseStratigraphicColumn() + + def initFaultSelector(self): + self.faultSelection.clear() + self.resetFaultField() + if self._faults: + faults = list(self._faults.keys()) + self.faultSelection.addItems(faults) + + def initFaultNetwork(self): + # faultNetwork + self.faultNetworkTable.clear() + self.faultNetworkTable.setRowCount(0) + self.faultNetworkTable.setColumnCount(0) + self.faultStratigraphyTable.clear() + self.faultStratigraphyTable.setRowCount(0) + self.faultStratigraphyTable.setColumnCount(0) + if not self._faults: + return + + faults = list(self._faults.keys()) + self.faultNetworkTable.setRowCount(len(faults)) + self.faultNetworkTable.setColumnCount(len(faults)) + + # Set headers + self.faultNetworkTable.setHorizontalHeaderLabels(faults) + self.faultNetworkTable.setVerticalHeaderLabels(faults) + + # Fill table with empty items + for i in range(len(faults)): + for j in range(len(faults)): + if i == j: + flag = QLabel() + flag.setText('') + else: + flag = QComboBox() + flag.addItem('') + flag.addItem('Abuts') + flag.addItem('Cuts') + # item = QTableWidgetItem(flag) + self.faultNetworkTable.setCellWidget(i, j, flag) + + # Make cells more visible + self.faultNetworkTable.setShowGrid(True) + self.faultNetworkTable.resizeColumnsToContents() + self.faultNetworkTable.resizeRowsToContents() + + self.faultStratigraphyTable.clear() + + faults = list(self._faults.keys()) + groups = [g['name'] for g in self.groups] + self.faultStratigraphyTable.setRowCount(len(faults)) + self.faultStratigraphyTable.setColumnCount(len(groups)) + + # Set headers + self.faultStratigraphyTable.setHorizontalHeaderLabels(groups) + self.faultStratigraphyTable.setVerticalHeaderLabels(faults) + + # Fill table with empty items + for j in range(len(groups)): + for i in range(len(faults)): + flag = QCheckBox() + flag.setChecked(True) + self.faultStratigraphyTable.setCellWidget(i, j, flag) + + # Make cells more visible + self.faultStratigraphyTable.setShowGrid(True) + self.faultStratigraphyTable.resizeColumnsToContents() + self.faultStratigraphyTable.resizeRowsToContents() + + def onFaultFieldChanged(self, field): + name_field = self.faultNameField.currentField() + dip_field = self.faultDipField.currentField() + displacement_field = self.faultDisplacementField.currentField() + layer = self.faultNameField.layer() + + if name_field and layer: + self._faults = {} + for feature in layer.getFeatures(): + self._faults[str(feature[name_field])] = { + 'fault_dip': feature.attributeMap().get(dip_field, 90), + 'displacement': feature.attributeMap().get( + displacement_field, 0.1 * feature.geometry().length() + ), + 'fault_centre': { + 'x': feature.geometry().centroid().asPoint().x(), + 'y': feature.geometry().centroid().asPoint().y(), + }, + 'major_axis': feature.geometry().length(), + 'intermediate_axis': feature.geometry().length(), + 'minor_axis': feature.geometry().length() / 3, + 'active': True, + "azimuth": calculateAverageAzimuth(feature.geometry()), + "fault_pitch": feature.attributeMap().get('pitch', 90), + "crs": layer.crs().authid(), + } + self.initFaultSelector() + self.initFaultNetwork() + # self.saveLayersToProject() + + def saveLayersToProject(self): + if self.basalContactsLayer.currentLayer() is not None: + self.project.writeEntry( + __title__, "basal_contacts_layer", self.basalContactsLayer.currentLayer().name() + ) + if self.structuralDataLayer.currentLayer() is not None: + self.project.writeEntry( + __title__, "structural_data_layer", self.structuralDataLayer.currentLayer().name() + ) + if self.faultTraceLayer.currentLayer() is not None: + self.project.writeEntry( + __title__, "fault_trace_layer", self.faultTraceLayer.currentLayer().name() + ) + if self.DtmLayer.currentLayer() is not None: + self.project.writeEntry(__title__, "dtm_layer", self.DtmLayer.currentLayer().name()) + if self.roiLayer.currentLayer() is not None: + self.project.writeEntry(__title__, "roi_layer", self.roiLayer.currentLayer().name()) + if self.unitNameField.currentField() is not None: + self.project.writeEntry(__title__, "unitname_field", self.unitNameField.currentField()) + if self.dipField.currentField() is not None: + self.project.writeEntry(__title__, "dip_field", self.dipField.currentField()) + if self.orientationField.currentField() is not None: + self.project.writeEntry( + __title__, "orientation_field", self.orientationField.currentField() + ) + if self.faultNameField.currentField() is not None: + self.project.writeEntry( + __title__, "faultname_field", self.faultNameField.currentField() + ) + if self.faultDipField.currentField() is not None: + self.project.writeEntry(__title__, "fault_dip_field", self.faultDipField.currentField()) + if self.faultDisplacementField.currentField() is not None: + self.project.writeEntry( + __title__, "fault_displacement_field", self.faultDisplacementField.currentField() + ) + if self.faultPitchField.currentField() is not None: + self.project.writeEntry( + __title__, "fault_pitch_field", self.faultPitchField.currentField() + ) + if self._units: + self.project.writeEntry(__title__, "units", json.dumps(self._units)) + if self._faults: + self.project.writeEntry(__title__, "faults", json.dumps(self._faults)) + + def onSelectedFaultChanged(self, index): + if index >= 0: + fault = self.faultSelection.currentText() + self.faultDipValue.setValue(self._faults[fault]['fault_dip']) + self.faultPitchValue.setValue(self._faults[fault]['fault_pitch']) + self.faultDisplacementValue.setValue(self._faults[fault]['displacement']) + self.faultActiveCheckBox.setChecked(self._faults[fault]['active']) + self.faultMajorAxisLength.setValue(self._faults[fault]['major_axis']) + self.faultIntermediateAxisLength.setValue(self._faults[fault]['intermediate_axis']) + self.faultMinorAxisLength.setValue(self._faults[fault]['minor_axis']) + self.faultCentreX.setValue(self._faults[fault]['fault_centre']['x']) + self.faultCentreY.setValue(self._faults[fault]['fault_centre']['y']) + # self.faultCentreZ.setValue(self._faults[fault]['centre'].z()) + self._onActiveFaultChanged(self._faults[fault]['active']) + + def saveFaultsToProject(self): + if self._faults: + self.project.writeEntry(__title__, "faults", json.dumps(self._faults)) + + def saveUnitsToProject(self): + if self._units: + self.project.writeEntry(__title__, "units", json.dumps(self._units)) + + def resetFaultField(self): + self.faultDipValue.setValue(0) + self.faultPitchValue.setValue(0) + self.faultDisplacementValue.setValue(0) + self.faultActiveCheckBox.setChecked(0) + self.faultMajorAxisLength.setValue(0) + self.faultIntermediateAxisLength.setValue(0) + self.faultMinorAxisLength.setValue(0) + self.faultCentreX.setValue(0) + self.faultCentreY.setValue(0) + # self.faultCentreZ.setValue(self._faults[fault]['centre'].z()) + self._onActiveFaultChanged(False) + + def _onActiveFaultChanged(self, value): + self.faultDipValue.setEnabled(value) + self.faultPitchValue.setEnabled(value) + self.faultDisplacementValue.setEnabled(value) + self.faultMajorAxisLength.setEnabled(value) + self.faultIntermediateAxisLength.setEnabled(value) + self.faultMinorAxisLength.setEnabled(value) + self.faultCentreX.setEnabled(value) + self.faultCentreY.setEnabled(value) + # self.faultCentreZ.setEnabled(value) + + def updateFaultProperty(self, prop, value): + fault = self.faultSelection.currentText() + if fault not in self._faults: + return + self._faults[fault][prop] = value + if prop == 'active': + self._onActiveFaultChanged(value) + self.saveFaultsToProject() + + def drawFaultElipse(self): + fault = self.faultSelection.currentText() + if fault: + centre = self._faults[fault]['centre'] + major_axis = self._faults[fault]['major_axis'] + + minor_axis = self._faults[fault]['minor_axis'] + azimuth = self._faults[fault].get('azimuth', 0) + crs = self._faults[fault].get('crs', 'EPSG:4326') + # Create an ellipsoid centered at the fault center + ellipsoid = QgsEllipse( + QgsPoint(centre.x(), centre.y()), major_axis / 2, minor_axis / 2, azimuth + ) + + # Add the ellipsoid to the map canvas + ellipsoid_layer = QgsVectorLayer(f"Polygon?crs={crs}", f"{fault}: Ellipsoid", "memory") + ellipsoid_layer_provider = ellipsoid_layer.dataProvider() + ellipsoid_feature = QgsFeature() + ellipsoid_feature.setGeometry(ellipsoid.toPolygon()) + ellipsoid_layer_provider.addFeatures([ellipsoid_feature]) + + QgsProject.instance().addMapLayer(ellipsoid_layer) + + def _getSortedStratigraphicColumn(self): + + return sorted(self._units.items(), key=lambda x: x[1]['order']) + + def _initialiseStratigraphicColumn(self): + while self.stratigraphicColumnContainer.count(): + child = self.stratigraphicColumnContainer.takeAt(0) + if child.widget(): + child.widget().deleteLater() + + def create_lambda(i, direction): + return lambda: self.onOrderChanged(i, i + direction) + + def create_color_picker(unit): + def pick_color(): + color = QColorDialog.getColor() + if color.isValid(): + self._units[unit]['colour'] = color.name() + self._initialiseStratigraphicColumn() + + return pick_color + + for i, (unit, value) in enumerate(self._getSortedStratigraphicColumn()): + # Add stretch factor to first column + + label = QLineEdit(unit) + label.editingFinished.connect( + lambda unit=unit, label=label: self.stratigraphicColumnUnitNameChanged( + unit, label.text() + ) + ) + spin_box = QDoubleSpinBox(maximum=100000, minimum=0) + spin_box.setValue(value['thickness']) + order = QLabel() + order.setText(str(value['order'])) + up = QPushButton("↑") + down = QPushButton("↓") + color_picker = QPushButton("Pick Colour") + # Set background color for the row + background_color = value.get('colour', "#ffffff") + label.setStyleSheet(f"background-color: {background_color};") + spin_box.setStyleSheet(f"background-color: {background_color};") + order.setStyleSheet(f"background-color: {background_color};") + up.setStyleSheet(f"background-color: {background_color};") + down.setStyleSheet(f"background-color: {background_color};") + color_picker.setStyleSheet(f"background-color: {background_color};") + self.stratigraphicColumnContainer.addWidget(label, i, 0) + self.stratigraphicColumnContainer.addWidget(spin_box, i, 1) + self.stratigraphicColumnContainer.addWidget(up, i, 2) + self.stratigraphicColumnContainer.addWidget(down, i, 3) + self.stratigraphicColumnContainer.addWidget(color_picker, i, 4) + unconformity = QComboBox() + unconformity.addItem('Conformable') + unconformity.addItem('Erode') + unconformity.addItem('Onlap') + if 'contact' in value: + unconformity.setCurrentText(value['contact']) + + unconformity.currentTextChanged.connect( + lambda text, unit=unit: self.stratigraphicColumnChanged(text, unit) + ) + + self.stratigraphicColumnContainer.addWidget(unconformity, i, 5) + up.clicked.connect(create_lambda(i, -1)) + down.clicked.connect(create_lambda(i, 1)) + color_picker.clicked.connect(create_color_picker(unit)) + spin_box.valueChanged.connect( + lambda value, unit=unit: self.onThicknessChanged(unit, value) + ) + remove_button = QPushButton("Remove") + remove_button.setStyleSheet(f"background-color: {background_color};") + remove_button.clicked.connect( + lambda value, unit=unit: self.stratigraphicColumnRemoveClicked(unit) + ) + self.stratigraphicColumnContainer.addWidget(remove_button, i, 6) + + self.updateGroups() + + def stratigraphicColumnChanged(self, text, unit): + self._units[unit]['contact'] = text + self.updateGroups() + self.saveUnitsToProject() + + def stratigraphicColumnRemoveClicked(self, unit): + if unit in self._units: + del self._units[unit] + self._initialiseStratigraphicColumn() + self.saveUnitsToProject() + + def addUnitToStratigraphicColumn(self): + name = 'New Unit' + if len(self._units) > 0: + name = f'New Unit {len(self._units) + 1}' + colour = random_hex_colour(n=1)[0] + self._units[name] = { + 'thickness': 10.0, + 'order': len(self._units), + 'name': name, + 'colour': colour, + 'contact': 'Conformable', + } + self._initialiseStratigraphicColumn() + self.saveUnitsToProject() + + def stratigraphicColumnUnitNameChanged(self, unit, name): + + old_name = unit + if unit == name: + return + if unit not in self._units: + return + if name in self._units and name != unit: + self.logger(message="Cannot rename, unit name already exists", log_level=2, push=True) + return + unit = self._units[unit] + unit['name'] = name + self._units[name] = unit + del self._units[old_name] + self._initialiseStratigraphicColumn() + self.saveUnitsToProject() + + def updateGroups(self): + columns = self._getSortedStratigraphicColumn() + + self.groups = [] + group = [] + ii = 0 + for _i, (_unit, value) in enumerate(columns): + group.append(value) + if value['contact'] != 'Conformable': + self.groups.append({'name': f'group_{ii}', 'units': group}) + ii += 1 + group = [] + + self.groups.append({'name': f'group_{ii}', 'units': group}) + self.initFaultNetwork() + + def onOrderChanged(self, old_index, new_index): + if new_index < 0 or new_index >= len(self._units): + return + units = dict(self._units) # update a copy + for unit, value in self._units.items(): + if value['order'] == old_index: + units[unit]['order'] = new_index + elif value['order'] == new_index: + units[unit]['order'] = old_index + self._units = units # set to copy + self._initialiseStratigraphicColumn() + self.saveUnitsToProject() + + def onThicknessChanged(self, unit, value): + self._units[unit]['thickness'] = value + self.saveUnitsToProject() + + def onSaveModel(self): + if self.model is None: + self.logger(message="Cannot save model, model not initialised", log_level=2, push=True) + return + try: + + fileFormat = self.fileFormatCombo.currentText() + path = self.path.text() # + name = self.modelNameLineEdit.text() + if fileFormat == 'python': + fileFormat = 'pkl' + self.model.to_file(os.path.join(path, name + "." + fileFormat)) + with open(os.path.join(path, name + "." + 'py'), 'w') as f: + f.write("from loopstructural import GeologicalModel\n") + f.write(f"model = GeologicalModel.from_file('{name + '.' + fileFormat}')\n") + return + + self.model.save( + filename=os.path.join(path, name + "." + fileFormat), + block_model=self.blockModelCheckBox.isChecked(), + stratigraphic_surfaces=self.stratigraphicSurfacesCheckBox.isChecked(), + fault_surfaces=self.faultSurfacesCheckBox.isChecked(), + stratigraphic_data=self.stratigraphicDataCheckBox.isChecked(), + fault_data=self.faultDataCheckBox.isChecked(), + ) + self.logger(message=f"Model saved to {path}", log_level=0, push=True) + except Exception as e: + self.logger( + message=str(e), + log_level=2, + push=True, + ) + + def saveThicknessOrder(self): + pass + # if self._units is None: + # self.logger(message="No units found", log_level=2, push=True) + # return + # self.project.writeEntry( + # "LoopStructural", "units", json.dumps(self._units) + # ) + # layer = self.basalContactsLayer.currentLayer() + # layer.startEditing() + # field_names = ["_ls_th", "_ls_or", "_ls_col"] + # field_types = [QVariant.Double, QVariant.Int, QVariant.String] + # for field_name, field_type in zip(field_names, field_types): + + # if field_name not in [field.name() for field in layer.fields()]: + # layer.dataProvider().addAttributes([QgsField(field_name, field_type)]) + # layer.updateFields() + # for unit, value in self._units.items(): + # for feature in layer.getFeatures(): + # if feature.attributeMap().get(self.unitNameField.currentField()) == unit: + # feature[field_names[0]] = value['thickness'] + # feature[field_names[1]] = value['order'] + # feature[field_names[2]] = value['colour'] + # layer.updateFeature(feature) + # layer.commitChanges() + # layer.updateFields() + # self.logger( + # message=f"Thickness, colour and order saved to {layer.name()}", log_level=0, push=True + # ) + + def onPathTextChanged(self, text): + self.outputPath = text + + def onClickPath(self): + self.outputPath = QFileDialog.getExistingDirectory(None, "Select output path for model") + + self.path.setText(self.outputPath) + # if self.path: + # if os.path.exists(self.gridDirectory): + # self.output_directory = os.path.split( + # self.dlg.lineEdit_gridOutputDir.text() + # )[-1] diff --git a/loopstructural/gui/modelling/stratigraphic_column/__init__.py b/loopstructural/gui/modelling/stratigraphic_column/__init__.py new file mode 100644 index 0000000..50d8f5d --- /dev/null +++ b/loopstructural/gui/modelling/stratigraphic_column/__init__.py @@ -0,0 +1 @@ +from .stratigraphic_column import StratColumnWidget diff --git a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py new file mode 100644 index 0000000..2e1eb39 --- /dev/null +++ b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py @@ -0,0 +1,165 @@ +from PyQt5.QtWidgets import ( + QAbstractItemView, + QListWidget, + QListWidgetItem, + QPushButton, + QVBoxLayout, + QWidget, +) + +from loopstructural.gui.modelling.stratigraphic_column.unconformity import UnconformityWidget +from LoopStructural.modelling.core.stratigraphic_column import StratigraphicColumnElementType + +from .stratigraphic_unit import StratigraphicUnitWidget + + +class StratColumnWidget(QWidget): + """In control of building the stratigraphic column + + :param QWidget: _description_ + :type QWidget: _type_ + """ + + def __init__(self, parent=None, data_manager=None): + super().__init__() + layout = QVBoxLayout(self) + if data_manager is None: + raise ValueError("Data manager must be provided.") + self.data_manager = data_manager + # Main list widget + self.unitList = QListWidget() + self.unitList.setDragDropMode(QAbstractItemView.InternalMove) + self.unitList.model().rowsMoved.connect(self.update_order) + layout.addWidget(self.unitList) + + # Add unit button + addUnitButton = QPushButton("Add Unit") + addUnitButton.clicked.connect(self.add_unit) + layout.addWidget(addUnitButton) + + # Add unconformity button + addUnconformityButton = QPushButton("Add Unconformity") + addUnconformityButton.clicked.connect(self.add_unconformity) + layout.addWidget(addUnconformityButton) + + # add init from basal contacts button + initFromBasalContactsButton = QPushButton("Initialise from map") + initFromBasalContactsButton.clicked.connect( + self.init_stratigraphic_column_from_basal_contacts + ) + layout.addWidget(initFromBasalContactsButton) + clearButton = QPushButton("Clear Stratigraphic Column") + clearButton.clicked.connect(self.clearColumn) + layout.addWidget(clearButton) + # Update display from data manager + self.update_display() + self.data_manager.set_stratigraphic_column_callback(self.update_display) + + def clearColumn(self): + """Clear the stratigraphic column.""" + self.unitList.clear() + if self.data_manager: + self.data_manager._stratigraphic_column.clear() + else: + print("Error: Data manager is not initialized.") + + def update_display(self): + """Update the widget display based on the data manager's stratigraphic column.""" + self.unitList.clear() + if self.data_manager and self.data_manager._stratigraphic_column: + for unit in self.data_manager._stratigraphic_column.order: + if unit.element_type == StratigraphicColumnElementType.UNIT: + self.add_unit(unit_data=unit.to_dict(), create_new=False) + elif unit.element_type == StratigraphicColumnElementType.UNCONFORMITY: + + self.add_unconformity(unconformity_data=unit.to_dict(), create_new=False) + + def init_stratigraphic_column_from_basal_contacts(self): + if self.data_manager: + self.data_manager.init_stratigraphic_column_from_basal_contacts() + self.update_display() + else: + print("Error: Data manager is not initialized.") + + def add_unit(self, *, unit_data=None, create_new=True): + if unit_data is None: + unit_data = {'type': 'unit', 'name': ''} + if create_new: + unit = self.data_manager.add_to_stratigraphic_column(unit_data) + unit_data['uuid'] = unit.uuid + else: + if unit_data['uuid'] is not None or unit_data['uuid'] != '': + + unit = self.data_manager._stratigraphic_column.get_element_by_uuid( + unit_data['uuid'] + ) + unit_data.pop('type', None) # Remove type if present + for k in list(unit_data.keys()): + if unit_data[k] is None: + unit_data.pop(k) + unit_widget = StratigraphicUnitWidget(**unit_data) + unit_widget.deleteRequested.connect(self.delete_unit) # Connect delete signal + unit_widget.nameChanged.connect( + lambda: self.update_element(unit_widget) + ) # Connect name change signal + + unit_widget.thicknessChanged.connect( + lambda: self.update_element(unit_widget) + ) # Connect thickness change signal + + unit_widget.set_thickness(unit_data.get('thickness', 0.0)) # Set initial thickness + item = QListWidgetItem() + item.setSizeHint(unit_widget.sizeHint()) + self.unitList.addItem(item) + self.unitList.setItemWidget(item, unit_widget) + unit_widget.setData(unit_data) # Set data for the unit widget + # Update data manager + + def add_unconformity(self, *, unconformity_data=None, create_new=True): + if unconformity_data is None: + unconformity_data = {'type': 'unconformity', 'unconformity_type': 'erode'} + if create_new: + unconformity = self.data_manager.add_to_stratigraphic_column(unconformity_data) + else: + unconformity = self.data_manager._stratigraphic_column.get_element_by_uuid( + unconformity_data['uuid'] + ) + + unconformity_widget = UnconformityWidget(uuid=unconformity.uuid) + unconformity_widget.deleteRequested.connect(self.delete_unit) + item = QListWidgetItem() + item.setSizeHint(unconformity_widget.sizeHint()) + self.unitList.addItem(item) + self.unitList.setItemWidget(item, unconformity_widget) + + # Update data manager + + def delete_unit(self, unit_widget): + for i in range(self.unitList.count()): + item = self.unitList.item(i) + if self.unitList.itemWidget(item) == unit_widget: + self.unitList.takeItem(i) + break + + # Update data manager + if self.data_manager: + self.data_manager.remove_from_stratigraphic_column(unit_widget.uuid) + + def update_order(self, parent, start, end, destination, row): + """Update the data manager when the order of items changes.""" + if self.data_manager: + ordered_uuids = [] + for i in range(self.unitList.count()): + item = self.unitList.item(i) + widget = self.unitList.itemWidget(item) + if widget: + ordered_uuids.append(widget.uuid) + else: + print(f"Warning: Item at index {i} has no widget associated with it.") + self.data_manager.update_stratigraphic_column_order(ordered_uuids) + + def update_element(self, unit_widget): + """Update the data manager with the changes made in the unit widget.""" + if self.data_manager: + unit_data = unit_widget.getData() + self.data_manager._stratigraphic_column.update_element(unit_data) diff --git a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.py b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.py new file mode 100644 index 0000000..05f6d73 --- /dev/null +++ b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.py @@ -0,0 +1,131 @@ +import os +from typing import Optional + +from PyQt5 import uic +from PyQt5.QtCore import pyqtSignal +from PyQt5.QtWidgets import QWidget + + +class StratigraphicUnitWidget(QWidget): + deleteRequested = pyqtSignal(QWidget) # Signal to request deletion + thicknessChanged = pyqtSignal(float) # Signal for thickness changes + colourChanged = pyqtSignal(str) # Signal for colour changes + nameChanged = pyqtSignal(str) # Signal for name changes + + def __init__( + self, + uuid, + name: Optional[str] = None, + colour: Optional[str] = None, + thickness: float = 0.0, + parent=None, + ): + super().__init__(parent) + uic.loadUi(os.path.join(os.path.dirname(__file__), "stratigraphic_unit.ui"), self) + self.uuid = uuid + self._name = name if name is not None else "" + self.colour = colour if colour is not None else "" + self.thickness = thickness # Optional thickness attribute + # Add delete button + self.buttonDelete.clicked.connect(self.request_delete) + self.lineEditName.editingFinished.connect(self.onNameChanged) + self.spinBoxThickness.valueChanged.connect(self.onThicknessChanged) + + @property + def name(self): + return str(self._name) + + @name.setter + def name(self, value: str): + self._name = str(value) + + def set_thickness(self, thickness: float): + """ + Set the thickness of the stratigraphic unit. + :param thickness: The thickness value to set. + """ + self.thickness = thickness + self.spinBoxThickness.setValue(thickness) + self.validateFields() + + def onColourSelectClicked(self): + """ + Open a color dialog to select a color for the stratigraphic unit. + """ + from PyQt5.QtWidgets import QColorDialog + + color = QColorDialog.getColor() + if color.isValid(): + self.colour = color.name() + self.buttonColor.setStyleSheet(f"background-color: {self.colour};") + + def onThicknessChanged(self, thickness: float): + """ + Update the thickness of the stratigraphic unit. + :param thickness: The new thickness value. + """ + self.thickness = thickness + self.validateFields() + self.thicknessChanged.emit(thickness) + + def onNameChanged(self): + """ + Update the name of the stratigraphic unit. + :param name: The new name value. + """ + name = self.lineEditName.text().strip() + if name != self.name: + self.name = name + self.validateFields() + self.nameChanged.emit(name) + + def request_delete(self): + + self.deleteRequested.emit(self) + + def validateFields(self): + """ + Validate the fields and update the widget's appearance. + """ + # Reset all styles first + self.lineEditName.setStyleSheet("") + self.spinBoxThickness.setStyleSheet("") + self.lineEditName.setToolTip("") + self.spinBoxThickness.setToolTip("") + + if not self.name or self.name.strip() == "": + self.lineEditName.setStyleSheet("border: 2px solid red;") + self.lineEditName.setToolTip("Name cannot be empty.") + elif hasattr(self, 'thickness') and not self.thickness > 0: + self.spinBoxThickness.setStyleSheet("border: 2px solid red;") + self.spinBoxThickness.setToolTip("Thickness must be greater than zero.") + + def setData(self, data: Optional[dict] = None): + """ + Set the data for the stratigraphic unit widget. + :param data: A dictionary containing 'name' and 'colour' keys. + """ + if data: + self.name = str(data.get("name", "")) + self.colour = data.get("colour", "") + self.lineEditName.setText(self.name) + # self.lineEditColour.setText(self.colour) + else: + self.name = "" + self.colour = "" + self.lineEditName.clear() + # self.lineEditColour.clear() + + self.validateFields() + + def getData(self) -> dict: + """ + Get the data from the stratigraphic unit widget. + :return: A dictionary containing 'name', 'colour', and 'thickness'. + """ + return { + "uuid": self.uuid, + "name": self.name, + "colour": self.colour, + "thickness": self.thickness, + } diff --git a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.ui b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.ui new file mode 100644 index 0000000..9272c68 --- /dev/null +++ b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.ui @@ -0,0 +1,54 @@ + + + StratigraphicUnitWidget + + + + 0 + 0 + 756 + 62 + + + + + + + m + + + 2 + + + 0.000000000000000 + + + 10000.000000000000000 + + + + + + + + + + Thickness: + + + + + + + Delete this unit + + + 🗑️ + + + + + + + + diff --git a/loopstructural/gui/modelling/stratigraphic_column/unconformity.py b/loopstructural/gui/modelling/stratigraphic_column/unconformity.py new file mode 100644 index 0000000..519644f --- /dev/null +++ b/loopstructural/gui/modelling/stratigraphic_column/unconformity.py @@ -0,0 +1,45 @@ +import os +from typing import Optional + +from PyQt5 import uic +from PyQt5.QtCore import pyqtSignal +from PyQt5.QtWidgets import QWidget + + +class UnconformityWidget(QWidget): + deleteRequested = pyqtSignal(QWidget) # Signal to request deletion + + def __init__( + self, + uuid, + parent=None, + ): + super().__init__(parent) + uic.loadUi(os.path.join(os.path.dirname(__file__), 'unconformity.ui'), self) + # Add delete button + self.buttonDelete.clicked.connect(self.request_delete) + self.uuid = uuid + self.unconformity_type = 'erode' + self.comboBoxUnconformityType.currentIndexChanged.connect( + lambda: setattr(self, 'unconformity_type', self.comboBoxUnconformityType.currentText()) + ) + + def request_delete(self): + + self.deleteRequested.emit(self) + + def setData(self, data: Optional[dict] = None): + """ + Set the data for the unconformity widget. + :param data: A dictionary containing 'unconformity_type' key. + """ + if data: + self.unconformity_type = data.get("unconformity_type", "") + self.unconformityTypeComboBox.setCurrentIndex( + self.unconformityTypeComboBox.findText(self.unconformity_type) + ) + else: + self.unconformity_type = 'erode' + self.unconformityTypeComboBox.setCurrentIndex( + self.unconformityTypeComboBox.findText(self.unconformity_type) + ) diff --git a/loopstructural/gui/modelling/stratigraphic_column/unconformity.ui b/loopstructural/gui/modelling/stratigraphic_column/unconformity.ui new file mode 100644 index 0000000..023789d --- /dev/null +++ b/loopstructural/gui/modelling/stratigraphic_column/unconformity.ui @@ -0,0 +1,52 @@ + + + StratigraphicUnitWidget + + + + 0 + 0 + 756 + 62 + + + + + + + Delete this unit + + + 🗑️ + + + + + + + Select unconformity type + + + + erode + + + + + onlap + + + + + + + + Type + + + + + + + + diff --git a/loopstructural/gui/modelling/topology_tab.ui b/loopstructural/gui/modelling/topology_tab.ui new file mode 100644 index 0000000..e3162d4 --- /dev/null +++ b/loopstructural/gui/modelling/topology_tab.ui @@ -0,0 +1,69 @@ + + + Form + + + + 0 + 0 + 607 + 713 + + + + Form + + + + + 0 + 0 + 591 + 701 + + + + + + + + + Fault-Fault + + + + + 0 + 20 + 571 + 271 + + + + + + + + + + + Fault-Stratigraphy + + + + + 0 + 20 + 571 + 311 + + + + + + + + + + + diff --git a/loopstructural/gui/modelling/viewer_tab.ui b/loopstructural/gui/modelling/viewer_tab.ui new file mode 100644 index 0000000..ec3088d --- /dev/null +++ b/loopstructural/gui/modelling/viewer_tab.ui @@ -0,0 +1,69 @@ + + + Form + + + + 0 + 0 + 123 + 207 + + + + Form + + + + + 0 + 10 + 591 + 511 + + + + + + + + + + QLayout::SetMinimumSize + + + + + Add Data + + + + + + + Add Block Model + + + + + + + Add Surfaces + + + + + + + Clear + + + + + + + + + + + diff --git a/loopstructural/gui/visualisation/feature_list_widget.py b/loopstructural/gui/visualisation/feature_list_widget.py new file mode 100644 index 0000000..ad235c6 --- /dev/null +++ b/loopstructural/gui/visualisation/feature_list_widget.py @@ -0,0 +1,148 @@ +from typing import Optional, Union + +from PyQt5.QtWidgets import QMenu, QPushButton, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget + +from LoopStructural.datatypes import VectorPoints + + +class FeatureListWidget(QWidget): + def __init__(self, parent=None, *, model_manager=None, viewer=None): + super().__init__(parent) + self.mainLayout = QVBoxLayout(self) + self.treeWidget = QTreeWidget(self) + self.treeWidget.setHeaderHidden(True) # Hide the header + self.mainLayout.addWidget(self.treeWidget) + self.setLayout(self.mainLayout) + self.model_manager = model_manager + self.viewer = viewer + + # Add buttons + self.addBoundingBoxButton = QPushButton("Add Model Bounding Box", self) + self.addFaultSurfacesButton = QPushButton("Add Fault Surfaces", self) + self.addStratigraphicSurfacesButton = QPushButton("Add Stratigraphic Surfaces", self) + + # Connect buttons to their respective methods + self.addBoundingBoxButton.clicked.connect(self.add_model_bounding_box) + self.addFaultSurfacesButton.clicked.connect(self.add_fault_surfaces) + self.addStratigraphicSurfacesButton.clicked.connect(self.add_stratigraphic_surfaces) + + # Add buttons to the layout + self.mainLayout.addWidget(self.addBoundingBoxButton) + self.mainLayout.addWidget(self.addFaultSurfacesButton) + self.mainLayout.addWidget(self.addStratigraphicSurfacesButton) + + # Populate the feature list + self.update_feature_list() + self.model_manager.observers.append(self.update_feature_list) + + def update_feature_list(self): + if not self.model_manager: + return + + self.treeWidget.clear() + for feature in self.model_manager.features(): + if not feature.name.startswith('__'): + self.add_feature(feature) + + def _get_vector_scale(self, scale: Optional[Union[float, int]] = None) -> float: + autoscale = 1.0 + if self.model_manager.model is not None: + # automatically scale vector data to be 5% of the bounding box length + autoscale = self.model_manager.model.bounding_box.length.max() * 0.05 + if scale is None: + scale = autoscale + else: + scale = scale * autoscale + + return scale + + def add_feature(self, feature): + """Add a feature to the feature list. + + :param feature: The feature to add. + :type feature: Feature + """ + featureItem = QTreeWidgetItem(self.treeWidget) + featureItem.setText(0, feature.name) + + def contextMenuEvent(self, event): + menu = QMenu(self) + + add_scalar_action = menu.addAction("Add Scalar Field") + add_surface_action = menu.addAction("Add Surface") + add_vector_action = menu.addAction("Add Vector Field") + add_data_action = menu.addAction("Add Data") + + action = menu.exec_(self.mapToGlobal(event.pos())) + + selected_items = self.treeWidget.selectedItems() + if not selected_items: + return + + feature_name = selected_items[0].text(0) + + if action == add_scalar_action: + self.add_scalar_field(feature_name) + elif action == add_surface_action: + self.add_surface(feature_name) + elif action == add_vector_action: + self.add_vector_field(feature_name) + elif action == add_data_action: + self.add_data(feature_name) + + def add_scalar_field(self, feature_name): + scalar_field = self.model_manager.model[feature_name].scalar_field() + self.viewer.add_mesh(scalar_field.vtk(), name=f'{feature_name}_scalar_field') + print(f"Adding scalar field to feature: {feature_name}") + + def add_surface(self, feature_name): + surfaces = self.model_manager.model[feature_name].surfaces() + for surface in surfaces: + self.viewer.add_mesh(surface.vtk(), name=f'{feature_name}_surface') + print(f"Adding surface to feature: {feature_name}") + + def add_vector_field(self, feature_name): + vector_field = self.model_manager.model[feature_name].vector_field() + scale = self._get_vector_scale() + self.viewer.add_mesh(vector_field.vtk(scale=scale), name=f'{feature_name}_vector_field') + print(f"Adding vector field to feature: {feature_name}") + + def add_data(self, feature_name): + data = self.model_manager.model[feature_name].get_data() + for d in data: + if issubclass(type(d), VectorPoints): + scale = self._get_vector_scale() + # tolerance is None means all points are shown + self.viewer.add_mesh( + d.vtk(scale=scale, tolerance=None), name=f'{feature_name}_{d.name}_points' + ) + else: + self.viewer.add_mesh(d.vtk(), name=f'{feature_name}_{d.name}') + print(f"Adding data to feature: {feature_name}") + + def add_model_bounding_box(self): + if not self.model_manager: + print("Model manager is not set.") + return + bb = self.model_manager.model.bounding_box.vtk().outline() + self.viewer.add_mesh(bb, name='model_bounding_box') + # Logic for adding model bounding box + print("Adding model bounding box...") + + def add_fault_surfaces(self): + if not self.model_manager: + print("Model manager is not set.") + return + fault_surfaces = self.model_manager.model.get_fault_surfaces() + for surface in fault_surfaces: + self.viewer.add_mesh(surface.vtk(), name=f'fault_surface_{surface.name}') + print("Adding fault surfaces...") + + def add_stratigraphic_surfaces(self): + if not self.model_manager: + print("Model manager is not set.") + return + stratigraphic_surfaces = self.model_manager.model.get_stratigraphic_surfaces() + for surface in stratigraphic_surfaces: + self.viewer.add_mesh(surface.vtk(), name=surface.name) + print("Adding stratigraphic surfaces...") diff --git a/loopstructural/gui/visualisation/geometry_object.py b/loopstructural/gui/visualisation/geometry_object.py new file mode 100644 index 0000000..15953db --- /dev/null +++ b/loopstructural/gui/visualisation/geometry_object.py @@ -0,0 +1,15 @@ +class GeometryObject: + def __init__(self, name, object, options=None): + self.name = name + self.object = object + self.options = options or {} + + def export(self): + # Placeholder for export functionality + print(f"Exporting {self.name} of type {self.object}") + + def set_option(self, key, value): + self.options[key] = value + + def get_option(self, key): + return self.options.get(key, None) diff --git a/loopstructural/gui/visualisation/loop_pyvistaqt_wrapper.py b/loopstructural/gui/visualisation/loop_pyvistaqt_wrapper.py new file mode 100644 index 0000000..6c9bca4 --- /dev/null +++ b/loopstructural/gui/visualisation/loop_pyvistaqt_wrapper.py @@ -0,0 +1,76 @@ +import re + +from PyQt5.QtCore import pyqtSignal +from pyvistaqt import QtInteractor + + +class LoopPyVistaQTPlotter(QtInteractor): + objectAdded = pyqtSignal(QtInteractor) # Signal to request deletion + + def __init__(self, parent): + super().__init__(parent=parent) + self.objects = {} + self.add_axes() + + def increment_name(self, name): + parts = name.split('_') + if len(parts) == 1: + name = name + '_1' + while name in self.actors: + parts = name.split('_') + try: + parts[-1] = str(int(parts[-1]) + 1) + except ValueError: + parts.append('1') + name = '_'.join(parts) + return name + + def add_mesh(self, *args, **kwargs): + """Add a mesh to the plotter.""" + if 'name' not in kwargs or not kwargs['name']: + name = 'unnnamed_object' + kwargs['name'] = name + kwargs['name'] = kwargs['name'].replace(' ', '_') + kwargs['name'] = re.sub(r'[^a-zA-Z0-9_$]', '_', kwargs['name']) + if kwargs['name'][0].isdigit(): + kwargs['name'] = 'ls_' + kwargs['name'] + if kwargs['name'][0] == '_': + kwargs['name'] = 'ls' + kwargs['name'] + kwargs['name'] = self.increment_name(kwargs['name']) + if '__opacity' in kwargs['name']: + raise ValueError('Cannot use __opacity in name') + if '__visibility' in kwargs['name']: + raise ValueError('Cannot use __visibility in name') + if '__control_visibility' in kwargs['name']: + raise ValueError('Cannot use __control_visibility in name') + actor = super().add_mesh(*args, **kwargs) + self.objects[kwargs['name']] = args[0] + self.objectAdded.emit(self) + return actor + + def remove_object(self, name): + """Remove an object by name.""" + if name in self.actors: + self.remove_actor(self.actors[name]) + self.update() + else: + raise ValueError(f"Object '{name}' not found in the plotter.") + + def change_object_name(self, old_name, new_name): + """Change the name of an object.""" + if old_name in self.actors: + if new_name in self.objects: + raise ValueError(f"Object '{new_name}' already exists.") + self.actors[new_name] = self.actors.pop(old_name) + self.actors[new_name].name = new_name + else: + raise ValueError(f"Object '{old_name}' not found in the plotter.") + + def change_object_visibility(self, name, visibility): + """Change the visibility of an object.""" + if name in self.actors: + self.actors[name].visibility = visibility + self.actors[name].actor.visibility = visibility + self.update() + else: + raise ValueError(f"Object '{name}' not found in the plotter.") diff --git a/loopstructural/gui/visualisation/model_object_widget.py b/loopstructural/gui/visualisation/model_object_widget.py new file mode 100644 index 0000000..e69de29 diff --git a/loopstructural/gui/visualisation/object_list_widget.py b/loopstructural/gui/visualisation/object_list_widget.py new file mode 100644 index 0000000..7b4eac9 --- /dev/null +++ b/loopstructural/gui/visualisation/object_list_widget.py @@ -0,0 +1,264 @@ +import geoh5py +import pyvista as pv +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import ( + QCheckBox, + QFileDialog, + QHBoxLayout, # Add missing import + QLabel, + QMenu, + QPushButton, + QTreeWidget, + QTreeWidgetItem, + QVBoxLayout, + QWidget, +) + + +class ObjectListWidget(QWidget): + def __init__(self, parent=None, *, viewer=None): + super().__init__(parent) + self.mainLayout = QVBoxLayout(self) + self.treeWidget = QTreeWidget(self) + self.treeWidget.setHeaderHidden(True) # Hide the header + self.treeWidget.setSelectionMode(QTreeWidget.MultiSelection) # Enable multi-selection + self.mainLayout.addWidget(self.treeWidget) + addButton = QPushButton("Add Object", self) + addButton.setContextMenuPolicy(Qt.CustomContextMenu) + addButton.clicked.connect(self.show_add_object_menu) + self.mainLayout.addWidget(addButton) + + self.setLayout(self.mainLayout) + self.viewer = viewer + self.viewer.objectAdded.connect(self.update_object_list) + self.treeWidget.installEventFilter(self) + + def update_object_list(self, new_object): + + for object_name in self.viewer.actors: + # Check if object already exists in tree + exists = False + for i in range(self.treeWidget.topLevelItemCount()): + item = self.treeWidget.topLevelItem(i) + widget = self.treeWidget.itemWidget(item, 0) + if widget and widget.findChild(QLabel).text() == object_name: + exists = True + break + if not exists: + self.add_actor(object_name) + + def add_actor(self, actor_name): + # Create a tree item for the object + if not hasattr(self.viewer.actors[actor_name], 'visibility'): + return + objectItem = QTreeWidgetItem(self.treeWidget) + + # Add a checkbox for visibility toggle in front of the name + visibilityCheckbox = QCheckBox() + visibilityCheckbox.setChecked(self.viewer.actors[actor_name].visibility) + visibilityCheckbox.stateChanged.connect( + lambda state, name=self.viewer.actors[actor_name].name: self.set_object_visibility( + name, state == Qt.Checked + ) + ) + + # Create a widget to hold the checkbox and name on a single line + itemWidget = QWidget() + itemLayout = QHBoxLayout(itemWidget) # Use horizontal layout for single line + itemLayout.setContentsMargins(0, 0, 0, 0) + itemLayout.addWidget(visibilityCheckbox) + itemLayout.addWidget(QLabel(self.viewer.actors[actor_name].name)) + itemWidget.setLayout(itemLayout) + + self.treeWidget.setItemWidget(objectItem, 0, itemWidget) + objectItem.setExpanded(False) # Initially collapsed + + def set_object_visibility(self, object_name, visibility): + self.viewer.actors[object_name].visibility = visibility + + # self.object_manager.set_object_visibility(object_name, visibility) + # Logic to update visibility in the list widget + + def contextMenuEvent(self, event): + menu = QMenu(self) + + export_action = menu.addAction("Export Object") + remove_action = menu.addAction("Remove Object") + + action = menu.exec_(self.mapToGlobal(event.pos())) + + if action == export_action: + self.export_selected_object() + elif action == remove_action: + self.remove_selected_object() + + def export_selected_object(self): + selected_items = self.treeWidget.selectedItems() + if not selected_items: + return + + item_widget = self.treeWidget.itemWidget(selected_items[0], 0) + object_label = item_widget.findChild(QLabel).text() + object = self.viewer.objects.get(object_label, None) + if object is None: + return + + # Determine available formats based on object type and dependencies + formats = [] + try: + has_geoh5py = True + except ImportError: + has_geoh5py = False + + if hasattr(object, "faces"): # Likely a surface/mesh + formats = ["obj", "vtk", "ply"] + if has_geoh5py: + formats.append("geoh5") + elif hasattr(object, "points"): # Likely a point cloud + formats = ["vtp"] + if has_geoh5py: + formats.append("geoh5") + else: + formats = ["vtk"] # Default + + # Build file filter string + filter_map = { + "obj": "OBJ (*.obj)", + "vtk": "VTK (*.vtk)", + "ply": "PLY (*.ply)", + "vtp": "VTP (*.vtp)", + "geoh5": "Geoh5 (*.geoh5)", + } + filters = ";;".join([filter_map[f] for f in formats]) + + file_path, selected_filter = QFileDialog.getSaveFileName( + self, "Export Object", object_label, filters + ) + if not file_path: + return + + selected_format = None + for fmt, desc in filter_map.items(): + if desc in selected_filter: + selected_format = fmt + break + + try: + if selected_format == "obj": + ( + object.save(file_path) + if hasattr(object, "save") + else pv.save_meshio(file_path, object) + ) + elif selected_format == "vtk": + pv.save_meshio(file_path, object) + elif selected_format == "ply": + pv.save_meshio(file_path, object) + elif selected_format == "vtp": + ( + object.save(file_path) + if hasattr(object, "save") + else pv.save_meshio(file_path, object) + ) + elif selected_format == "geoh5": + with geoh5py.Geoh5(file_path, overwrite=True) as geoh5: + if hasattr(object, "faces"): + geoh5.add_surface( + name=object_label, vertices=object.points, faces=object.faces + ) + else: + geoh5.add_points(name=object_label, vertices=object.points) + print(f"Exported {object_label} to {file_path} as {selected_format}") + except Exception as e: + print(f"Failed to export object: {e}") + # Logic for exporting the object + + def remove_selected_object(self): + selected_items = self.treeWidget.selectedItems() + if not selected_items: + return + for item in selected_items: + + item_widget = self.treeWidget.itemWidget(item, 0) + object_label = item_widget.findChild(QLabel).text() + # Logic for removing the object + if self.viewer and hasattr(self.viewer, 'remove_object'): + self.viewer.remove_object(object_label) + else: + print("Error: Viewer is not initialized or does not support object removal.") + self.treeWidget.takeTopLevelItem(self.treeWidget.indexOfTopLevelItem(item)) + + def show_add_object_menu(self): + menu = QMenu(self) + + addFeatureAction = menu.addAction("Surface from model") + loadFeatureAction = menu.addAction("Load from file") + + buttonPosition = self.sender().mapToGlobal(self.sender().rect().bottomLeft()) + action = menu.exec_(buttonPosition) + + if action == addFeatureAction: + self.add_feature_from_geological_model() + elif action == loadFeatureAction: + self.load_feature_from_file() + + def add_feature_from_geological_model(self): + # Logic to add a feature from the geological model + print("Adding feature from geological model") + + def load_feature_from_file(self): + file_path, _ = QFileDialog.getOpenFileName( + self, "Select Mesh File", "", "Mesh Files (*.vtk *.vtp *.obj *.stl *.ply)" + ) + file_name = file_path.split("/")[-1] if file_path else "Unnamed Mesh" + if not file_path: + return + + try: + mesh = pv.read(file_path) + if not isinstance(mesh, pv.PolyData): + raise ValueError("The file does not contain a valid mesh.") + + # Add the mesh to the viewer + if self.viewer and hasattr(self.viewer, 'add_mesh'): + self.viewer.add_mesh(mesh, name=file_name) + else: + print("Error: Viewer is not initialized or does not support adding meshes.") + + print(f"Loaded mesh from file: {file_path}") + except Exception as e: + print(f"Failed to load mesh: {e}") + + def eventFilter(self, source, event): + if source == self.treeWidget and event.type() == event.KeyPress: + if event.key() == Qt.Key_Space: + selected_items = self.treeWidget.selectedItems() + for item in selected_items: + item_widget = self.treeWidget.itemWidget(item, 0) + if item_widget: + checkbox = item_widget.findChild(QCheckBox) + if checkbox: + checkbox.setChecked(not checkbox.isChecked()) + return True + return super().eventFilter(source, event) + + def keyPressEvent(self, event): + if event.key() == Qt.Key_Space: + selected_items = self.treeWidget.selectedItems() + for item in selected_items: + item_widget = self.treeWidget.itemWidget(item, 0) + if item_widget: + checkbox = item_widget.findChild(QCheckBox) + if checkbox: + checkbox.setChecked(not checkbox.isChecked()) + elif event.key() == Qt.Key_Delete: + selected_items = self.treeWidget.selectedItems() + for item in selected_items: + item_widget = self.treeWidget.itemWidget(item, 0) + if item_widget: + object_label = item_widget.findChild(QLabel).text() + if self.viewer and hasattr(self.viewer, 'remove_object'): + self.viewer.remove_object(object_label) + self.treeWidget.takeTopLevelItem(self.treeWidget.indexOfTopLevelItem(item)) + else: + super().keyPressEvent(event) diff --git a/loopstructural/gui/visualisation/visualisation_widget.py b/loopstructural/gui/visualisation/visualisation_widget.py new file mode 100644 index 0000000..88f9dde --- /dev/null +++ b/loopstructural/gui/visualisation/visualisation_widget.py @@ -0,0 +1,45 @@ +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import ( + QSplitter, + QVBoxLayout, + QWidget, +) + +from .loop_pyvistaqt_wrapper import LoopPyVistaQTPlotter +from .object_list_widget import ObjectListWidget +from .feature_list_widget import FeatureListWidget + + +class VisualisationWidget(QWidget): + def __init__(self, parent: QWidget = None, mapCanvas=None, logger=None, model_manager=None): + + super().__init__(parent) + # Load the UI file for Tab 1 + self.mapCanvas = mapCanvas + self.logger = logger + self.model_manager = model_manager + + mainLayout = QVBoxLayout(self) + self.setLayout(mainLayout) + + # Create a splitter to separate the viewer and the object list + splitter = QSplitter(self) + mainLayout.addWidget(splitter) + + # Create the object selection sidebar + + # Create the viewer + self.plotter = LoopPyVistaQTPlotter(parent) + # self.plotter.add_axes() + + self.objectList = ObjectListWidget(viewer=self.plotter) + + # Modify layout to stack object list and feature list vertically + sidebarSplitter = QSplitter(Qt.Vertical, self) + sidebarSplitter.addWidget(self.objectList) + + # Create the feature list widget + self.featureList = FeatureListWidget(model_manager=self.model_manager, viewer=self.plotter) + sidebarSplitter.addWidget(self.featureList) + splitter.addWidget(sidebarSplitter) + splitter.addWidget(self.plotter) diff --git a/loopstructural/main/callableToLayer.py b/loopstructural/main/callableToLayer.py index 6899c0c..19b2d4e 100644 --- a/loopstructural/main/callableToLayer.py +++ b/loopstructural/main/callableToLayer.py @@ -1,15 +1,14 @@ import numpy as np from qgis.core import ( QgsField, - QgsWkbTypes, QgsRaster, + QgsWkbTypes, ) from qgis.PyQt.QtCore import QVariant def callableToLayer(callable, layer, dtm, name: str): - """ - Convert a feature to a raster and store it in QGIS as a temporary layer. + """Convert a feature to a raster and store it in QGIS as a temporary layer. :param feature: The object that has an `evaluate_value` method for computing values. :param dtm: Digital terrain model (if needed for processing). diff --git a/loopstructural/main/data_manager.py b/loopstructural/main/data_manager.py new file mode 100644 index 0000000..c788723 --- /dev/null +++ b/loopstructural/main/data_manager.py @@ -0,0 +1,558 @@ +import json + +import numpy as np +from qgis.core import QgsPointXY, QgsProject, QgsVectorLayer + +from LoopStructural import FaultTopology, StratigraphicColumn +from LoopStructural.datatypes import BoundingBox + +from .vectorLayerWrapper import qgsLayerToGeoDataFrame + +__title__ = "LoopStructural" +default_bounding_box = { + 'xmin': 0, + 'xmax': 1000, + 'ymin': 0, + 'ymax': 1000, + 'zmin': -7000, + 'zmax': 1000, +} + + +class ModellingDataManager: + def __init__(self, *, project=None, mapCanvas=None, logger=None): + if project is None: + raise ValueError("project cannot be None") + if mapCanvas is None: + raise ValueError("mapCanvas cannot be None") + if logger is None: + raise ValueError("logger cannot be None") + self.project = project + self.project.readProject.connect(self.onLoadProject) + self.project.writeProject.connect(self.onSaveProject) + self._bounding_box = BoundingBox( + origin=[ + default_bounding_box['xmin'], + default_bounding_box['ymin'], + default_bounding_box['zmin'], + ], + maximum=[ + default_bounding_box['xmax'], + default_bounding_box['ymax'], + default_bounding_box['zmax'], + ], + ) + + self._basal_contacts = None + self._fault_traces = None + self._structural_orientations = None + self._unique_basal_units = [] + self.map_canvas = mapCanvas + self.logger = logger + self._stratigraphic_column = StratigraphicColumn() + self._fault_topology = FaultTopology(self._stratigraphic_column) + self._model_manager = None + self.bounding_box_callback = None + self.basal_contacts_callback = None + self.fault_traces_callback = None + self.structural_orientations_callback = None + self.stratigraphic_column_callback = None + self.fault_adjacency = None + self.fault_stratigraphy_adjacency = None + self.elevation = np.nan + self.dem_layer = None + self.use_dem = True + self.dem_callback = None + + def onSaveProject(self): + """Save project data.""" + self.logger(message="Saving project data...", log_level=3) + datamanager_dict = self.to_dict() + self.project.writeEntry(__title__, "data_manager", json.dumps(datamanager_dict)) + + def onLoadProject(self): + """Load project data.""" + self.logger(message="Loading project data...", log_level=3) + datamanager_json, flag = self.project.readEntry(__title__, "data_manager", "") + if datamanager_json and flag: + try: + datamanager_dict = json.loads(datamanager_json) + self.update_from_dict(datamanager_dict) + + except json.JSONDecodeError as e: + self.logger(message=f"Error loading data manager: {e}", log_level=2) + + def set_model_manager(self, model_manager): + """Set the model manager for the data manager.""" + if model_manager is None: + raise ValueError("model_manager cannot be None") + self._model_manager = model_manager + self._model_manager.set_stratigraphic_column(self._stratigraphic_column) + self._model_manager.set_fault_topology(self._fault_topology) + self._model_manager.update_bounding_box(self._bounding_box) + + def set_bounding_box(self, xmin=None, xmax=None, ymin=None, ymax=None, zmin=None, zmax=None): + """Set the bounding box for the model.""" + origin = self._bounding_box.origin + maximum = self._bounding_box.maximum + + if xmin is not None: + origin[0] = xmin + if xmax is not None: + maximum[0] = xmax + if ymin is not None: + origin[1] = ymin + if ymax is not None: + maximum[1] = ymax + if zmin is not None: + origin[2] = zmin + if zmax is not None: + maximum[2] = zmax + self._bounding_box.origin = origin + self._bounding_box.maximum = maximum + self._bounding_box.origin = origin + self._bounding_box.maximum = maximum + self._model_manager.update_bounding_box(self._bounding_box) + if self.bounding_box_callback: + self.bounding_box_callback(self._bounding_box) + + def set_bounding_box_update_callback(self, callback): + self.bounding_box_callback = callback + self.bounding_box_callback(self._bounding_box) + + def set_fault_trace_layer_callback(self, callback): + """Set the callback for when the fault trace layer is updated.""" + self.fault_traces_callback = callback + + def set_structural_orientations_callback(self, callback): + """Set the callback for when the structural orientations are updated.""" + self.structural_orientations_callback = callback + + def set_basal_contacts_callback(self, callback): + """Set the callback for when the basal contacts are updated.""" + self.basal_contacts_callback = callback + + def set_stratigraphic_column_callback(self, callback): + """Set the callback for when the stratigraphic column is updated.""" + self.stratigraphic_column_callback = callback + + def set_dem_callback(self, callback): + """Set the callback for when the DEM layer is updated.""" + self.dem_callback = callback + if self.dem_layer: + self.dem_callback(self.dem_layer) + + def get_bounding_box(self): + """Get the current bounding box.""" + return self._bounding_box + + def set_elevation(self, elevation): + """Set the elevation for the model.""" + self.elevation = elevation + self.dem_function = lambda x, y: self.elevation + self._model_manager.set_dem_function(self.dem_function) + + def set_dem_layer(self, dem_layer): + self.dem_layer = dem_layer + if dem_layer is None: + self.dem_function = lambda x, y: 0.0 + self.logger( + message="DEM layer is None, using 0.0 for elevation. Choose a valid layer or specify a constant value", + log_level=2, + ) + else: + + def dem_function(x, y): + if not self.dem_layer.isValid(): + self.logger( + message="DEM layer is not valid, using 0.0 for elevation.", + log_level=2, + ) + return 0.0 + return ( + self.dem_layer.dataProvider().sample(QgsPointXY(x, y), 1)[0] + if self.dem_layer + else np.nan + ) + + self.dem_function = dem_function + self._model_manager.set_dem_function(self.dem_function) + if self.dem_callback: + self.dem_callback(self.dem_layer) + + def set_use_dem(self, use_dem): + self.use_dem = use_dem + self._model_manager.set_dem_function(self.dem_function) + + def set_basal_contacts(self, basal_contacts, unitname_field=None, use_z_coordinate=False): + """Set the basal contacts for the model.""" + self._basal_contacts = { + 'layer': basal_contacts, + 'unitname_field': unitname_field, + 'use_z_coordinate': use_z_coordinate, + } + # self._unitname_field = unitname_field + self.calculate_unique_basal_units() + # if stratigraphic column is not empty, update contacts + if len(self._stratigraphic_column.order) > 0: + self.update_stratigraphy() + if self.basal_contacts_callback: + self.basal_contacts_callback(**self._basal_contacts) + + def calculate_unique_basal_units(self): + if ( + self._basal_contacts is not None + and self._basal_contacts['unitname_field'] is not None + and self._basal_contacts['layer'] is not None + ): + self._unique_basal_units.clear() + for feature in self._basal_contacts['layer'].getFeatures(): + unit_name = feature[self._basal_contacts['unitname_field']] + if unit_name not in self._unique_basal_units: + self._unique_basal_units.append(unit_name) + return len(self._unique_basal_units) + + def init_stratigraphic_column_from_basal_contacts(self): + if len(self._unique_basal_units) == 0: + self.logger(message="No basal contacts set, cannot initialise stratigraphic column.") + return + else: + for unit_name in self._unique_basal_units: + if not self._stratigraphic_column.get_unit_by_name(name=unit_name): + # Add the unit to the stratigraphic column if it does not already exist + self._stratigraphic_column.add_unit(name=unit_name, colour=None) + self.update_stratigraphy() + + def add_to_stratigraphic_column(self, unit_data): + """Add a unit or unconformity to the stratigraphic column.""" + stratigraphic_element = None + if isinstance(unit_data, dict): + if unit_data.get('type') == 'unit': + stratigraphic_element = self._stratigraphic_column.add_unit( + name=unit_data.get('name'), colour=unit_data.get('colour') + ) + elif unit_data.get('type') == 'unconformity': + stratigraphic_element = self._stratigraphic_column.add_unconformity( + name=unit_data.get('name') + ) + else: + raise ValueError("unit_data must be a dictionary with 'type' key.") + if stratigraphic_element is None: + self.logger(message="Failed to add unit or unconformity to the stratigraphic column.") + else: + self.logger( + message=f"Added {unit_data.get('type')} '{unit_data.get('name')}' to the stratigraphic column." + ) + self.update_stratigraphy() + return stratigraphic_element + + def remove_from_stratigraphic_column(self, unit_uuid): + """Remove a unit or unconformity from the stratigraphic column.""" + self._stratigraphic_column.remove_unit(uuid=unit_uuid) + self.update_stratigraphy() + + def update_stratigraphic_column_order(self, new_order): + """Update the order of units in the stratigraphic column.""" + if not isinstance(new_order, list): + raise ValueError("new_order must be a list of unit uuids.") + self._stratigraphic_column.update_order(new_order) + self.update_stratigraphy() + + def get_basal_contacts(self): + """Get the basal contacts.""" + return self._basal_contacts + + def get_unique_faults(self): + """Get the unique faults from the fault traces.""" + if self._fault_traces is None or self._fault_traces['layer'] is None: + return [] + unique_faults = set() + for feature in self._fault_traces['layer'].getFeatures(): + fault_name = feature[self._fault_traces['fault_name_field']] + unique_faults.add(str(fault_name)) + return list(unique_faults) + + def set_fault_trace_layer( + self, + fault_trace_layer, + *, + fault_name_field=None, + fault_dip_field=None, + fault_displacement_field=None, + use_z_coordinate=False, + ): + """Set the fault traces for the model.""" + + self._fault_traces = { + 'layer': fault_trace_layer, + 'fault_name_field': fault_name_field, + 'fault_dip_field': fault_dip_field, + 'fault_displacement_field': fault_displacement_field, + 'use_z_coordinate': use_z_coordinate, + } + self.update_faults() + if self.fault_traces_callback: + self.fault_traces_callback(**self._fault_traces) + + def get_fault_traces(self): + """Get the fault traces.""" + return self._fault_traces + + def set_structural_orientations( + self, + structural_orientations, + strike_field=None, + dip_field=None, + unitname_field=None, + orientation_type=None, + use_z_coordinate=False, + ): + """Set the structural orientations for the model.""" + self._structural_orientations = {} + self._structural_orientations['layer'] = structural_orientations + self._structural_orientations['strike_field'] = strike_field + self._structural_orientations['dip_field'] = dip_field + self._structural_orientations['unitname_field'] = unitname_field + self._structural_orientations['orientation_type'] = orientation_type + self._structural_orientations['use_z_coordinate'] = use_z_coordinate + if self.structural_orientations_callback: + self.structural_orientations_callback(**self._structural_orientations) + self.update_stratigraphy() + + def get_structural_orientations(self): + """Get the structural orientations.""" + return self._structural_orientations + + def get_stratigraphic_column(self): + """Get the stratigraphic column.""" + return self._stratigraphic_column + + def update_stratigraphy(self): + """Update the foliation features in the model manager.""" + print("Updating stratigraphy...") + if self._model_manager is not None: + if self._basal_contacts is not None: + self._model_manager.update_contact_traces( + qgsLayerToGeoDataFrame(self._basal_contacts['layer']), + unit_name_field=self._basal_contacts['unitname_field'], + ) + if self._structural_orientations is not None: + print("Updating structural orientations...") + self._model_manager.update_structural_data( + qgsLayerToGeoDataFrame(self._structural_orientations['layer']), + strike_field=self._structural_orientations['strike_field'], + dip_field=self._structural_orientations['dip_field'], + unit_name_field=self._structural_orientations['unitname_field'], + dip_direction=( + True + if self._structural_orientations['orientation_type'] == "Dip Direction" + else False + ), + ) + else: + self.logger(message="Model manager is not set, cannot update foliation features.") + + def update_faults(self): + """Update the faults in the model manager.""" + unique_faults = self.get_unique_faults() + for f in unique_faults: + print(f"Adding fault {f} to fault topology") + if f not in self._fault_topology.faults: + self._fault_topology.add_fault(f) + faults_to_remove = list(set(self._fault_topology.faults) - set(unique_faults)) + for fault in faults_to_remove: + print(f"Removing fault {fault} from fault topology") + self._fault_topology.remove_fault(fault) + self.fault_adjacency = np.zeros((len(unique_faults), len(unique_faults)), dtype=int) + if self._model_manager is not None: + self._model_manager.update_fault_points( + qgsLayerToGeoDataFrame(self._fault_traces['layer']), + fault_name_field=self._fault_traces['fault_name_field'], + fault_dip_field=self._fault_traces['fault_dip_field'], + fault_displacement_field=self._fault_traces['fault_displacement_field'], + use_z_coordinate=self._fault_traces['use_z_coordinate'], + ) + else: + self.logger(message="Model manager is not set, cannot update faults.") + + def update_stratigraphic_column(self): + """Update the stratigraphic column in the model manager.""" + if self._model_manager is not None: + self._model_manager.groups = self._stratigraphic_column.get_groups() + else: + self.logger(message="Model manager is not set, cannot update stratigraphic column.") + + def clear_data(self): + """Clear all data in the manager.""" + self._bounding_box = BoundingBox() + self._basal_contacts = None + self._fault_traces = None + self._structural_orientations = None + + def to_dict(self): + """Convert the data manager to a dictionary.""" + # Create copies of the dictionaries to avoid modifying the originals + basal_contacts = dict(self._basal_contacts) if self._basal_contacts else None + fault_traces = dict(self._fault_traces) if self._fault_traces else None + structural_orientations = ( + dict(self._structural_orientations) if self._structural_orientations else None + ) + + # Replace layer objects with layer names + if basal_contacts and 'layer' in basal_contacts and basal_contacts['layer'] is not None: + basal_contacts['layer'] = basal_contacts['layer'].name() + if fault_traces and 'layer' in fault_traces and fault_traces['layer'] is not None: + fault_traces['layer'] = fault_traces['layer'].name() + if ( + structural_orientations + and 'layer' in structural_orientations + and structural_orientations['layer'] is not None + ): + structural_orientations['layer'] = structural_orientations['layer'].name() + if self.dem_layer is not None: + dem_layer_name = self.dem_layer.name() + + return { + 'bounding_box': self._bounding_box.to_dict(), + 'basal_contacts': basal_contacts, + 'fault_traces': fault_traces, + 'structural_orientations': structural_orientations, + 'stratigraphic_column': ( + self._stratigraphic_column.to_dict() if self._stratigraphic_column else None + ), + 'dem_layer': dem_layer_name if self.dem_layer else None, + 'use_dem': self.use_dem, + 'elevation': self.elevation, + } + + def from_dict(self, data): + """Load data from a dictionary.""" + if 'bounding_box' in data: + self.set_bounding_box( + xmin=data['bounding_box']['origin'][0], + xmax=data['bounding_box']['maximum'][0], + ymin=data['bounding_box']['origin'][1], + ymax=data['bounding_box']['maximum'][1], + zmin=data['bounding_box']['origin'][2], + zmax=data['bounding_box']['maximum'][2], + ) + if 'dem_layer' in data and data['dem_layer'] is not None: + dem_layer = QgsProject.instance().mapLayersByName(data['dem_layer']) + if dem_layer: + self.set_dem_layer(dem_layer[0]) + else: + self.logger( + message=f"DEM layer '{data['dem_layer']}' not found in project.", + log_level=2, + ) + if 'use_dem' in data: + self.set_use_dem(data['use_dem']) + if 'elevation' in data: + self.set_elevation(data['elevation']) + if 'basal_contacts' in data: + self._basal_contacts = data['basal_contacts'] + if 'fault_traces' in data: + self._fault_traces = data['fault_traces'] + if 'structural_orientations' in data: + self._structural_orientations = data['structural_orientations'] + if 'stratigraphic_column' in data: + self._stratigraphic_column = StratigraphicColumn.from_dict(data['stratigraphic_column']) + self.stratigraphic_column_callback() + + def update_from_dict(self, data): + """Update the data manager from a dictionary.""" + if 'bounding_box' in data: + self.set_bounding_box( + xmin=data['bounding_box']['origin'][0], + xmax=data['bounding_box']['maximum'][0], + ymin=data['bounding_box']['origin'][1], + ymax=data['bounding_box']['maximum'][1], + zmin=data['bounding_box']['origin'][2], + zmax=data['bounding_box']['maximum'][2], + ) + else: + self.set_bounding_box(**default_bounding_box) + if 'dem_layer' in data and data['dem_layer'] is not None: + dem_layer = QgsProject.instance().mapLayersByName(data['dem_layer']) + if dem_layer: + self.set_dem_layer(dem_layer[0]) + else: + self.logger( + message=f"DEM layer '{data['dem_layer']}' not found in project.", + log_level=2, + ) + if 'use_dem' in data: + self.set_use_dem(data['use_dem']) + if 'elevation' in data: + self.set_elevation(data['elevation']) + if ( + 'basal_contacts' in data + and data['basal_contacts'] is not None + and 'layer' in data['basal_contacts'] + ): + layer = self.find_layer_by_name(data['basal_contacts']['layer']) + if layer: + self.set_basal_contacts( + layer, unitname_field=data['basal_contacts'].get('unitname_field', None) + ) + if ( + 'fault_traces' in data + and data['fault_traces'] is not None + and 'layer' in data['fault_traces'] + ): + layer = self.find_layer_by_name(data['fault_traces']['layer']) + if layer: + self.set_fault_trace_layer( + layer, + fault_name_field=data['fault_traces'].get('fault_name_field', None), + fault_dip_field=data['fault_traces'].get('fault_dip_field', None), + fault_displacement_field=data['fault_traces'].get( + 'fault_displacement_field', None + ), + ) + if ( + 'structural_orientations' in data + and data['structural_orientations'] is not None + and 'layer' in data['structural_orientations'] + ): + layer = self.find_layer_by_name(data['structural_orientations']['layer']) + if layer: + self.set_structural_orientations( + layer, + strike_field=data['structural_orientations'].get('strike_field', None), + dip_field=data['structural_orientations'].get('dip_field', None), + unitname_field=data['structural_orientations'].get('unitname_field', None), + orientation_type=data['structural_orientations'].get('orientation_type', None), + ) + if 'stratigraphic_column' in data: + self._stratigraphic_column.update_from_dict(data['stratigraphic_column']) + else: + self._stratigraphic_column = StratigraphicColumn() + + if self.stratigraphic_column_callback: + self.stratigraphic_column_callback() + + def find_layer_by_name(self, layer_name): + """Find a layer by name in the project.""" + if layer_name is None: + return None + if issubclass(type(layer_name), str): + layers = self.project.mapLayersByName(layer_name) + else: + layers = [layer_name] + if layers: + if len(layers) > 1: + self.logger( + message=f"Multiple layers found with name '{layer_name}', returning the first one.", + log_level=2, + ) + i = 0 + while i < len(layers) and not issubclass(type(layers[i]), QgsVectorLayer): + + i += 1 + + if issubclass(type(layers[i]), QgsVectorLayer): + return layers[i] + else: + self.logger(message=f"Layer '{layer_name}' is not a vector layer.", log_level=2) + return None diff --git a/loopstructural/main/geometry/calculateLineAzimuth.py b/loopstructural/main/geometry/calculateLineAzimuth.py index 166c3a2..3ad7eea 100644 --- a/loopstructural/main/geometry/calculateLineAzimuth.py +++ b/loopstructural/main/geometry/calculateLineAzimuth.py @@ -2,7 +2,6 @@ def calculateAverageAzimuth(line: QgsGeometry): - """Calculate the average azimuth of a line geometry. Args: @@ -10,6 +9,7 @@ def calculateAverageAzimuth(line: QgsGeometry): Returns: float: The average azimuth of the line. + """ if line.isMultipart(): lines = line.asMultiPolyline() diff --git a/loopstructural/main/geometry/line2point.py b/loopstructural/main/geometry/line2point.py index a759072..17dbe3a 100644 --- a/loopstructural/main/geometry/line2point.py +++ b/loopstructural/main/geometry/line2point.py @@ -1,12 +1,11 @@ from PyQt5.QtCore import QVariant - from qgis.core import ( - QgsVectorLayer, QgsFeature, - QgsGeometry, - QgsPoint, QgsField, QgsFields, + QgsGeometry, + QgsPoint, + QgsVectorLayer, ) diff --git a/loopstructural/main/loopstructuralwrapper.py b/loopstructural/main/loopstructuralwrapper.py index 6c67c0f..75b742a 100644 --- a/loopstructural/main/loopstructuralwrapper.py +++ b/loopstructural/main/loopstructuralwrapper.py @@ -1,9 +1,11 @@ +from typing import List + +import numpy as np +import pandas as pd from LoopStructural import GeologicalModel from LoopStructural.modelling.input import ProcessInputData + from .vectorLayerWrapper import qgsLayerToDataFrame -import pandas as pd -import numpy as np -from typing import List class QgsProcessInputData(ProcessInputData): @@ -85,7 +87,7 @@ def __init__( columnmap['structure_unitname']: 'name', columnmap['dip']: 'dip', columnmap['orientation']: 'strike', - } + } )[['X', 'Y', 'Z', 'dip', 'strike', 'name']] contact_orientations['dip'] = contact_orientations['dip'].astype(float) contact_orientations['strike'] = contact_orientations['strike'].astype(float) @@ -122,7 +124,7 @@ def __init__( ) fault_properties = None if len(faults) > 0: - + fault_properties = pd.DataFrame(faults) fault_properties['fault_name'] = fault_properties['fault_name'].astype(str) fault_properties = fault_properties.set_index('fault_name') diff --git a/loopstructural/main/model_manager.py b/loopstructural/main/model_manager.py new file mode 100644 index 0000000..75b42bc --- /dev/null +++ b/loopstructural/main/model_manager.py @@ -0,0 +1,342 @@ +from collections import defaultdict +from typing import Callable + +import geopandas as gpd +import pandas as pd + +from LoopStructural import GeologicalModel +from LoopStructural.datatypes import BoundingBox +from LoopStructural.modelling.core.fault_topology import FaultRelationshipType +from LoopStructural.modelling.core.stratigraphic_column import StratigraphicColumn +from loopstructural.toolbelt.preferences import PlgSettingsStructure + + +class AllSampler: + """This is a simple sampler that just returns all the points, or all of the vertices + of a line. It will also copy the elevation from the DEM or the elevation set in the data manager. + """ + + def __call__(self, line: gpd.GeoDataFrame, dem: Callable, use_z: bool) -> pd.DataFrame: + """Sample the line and return a DataFrame with X, Y, Z coordinates and attributes.""" + points = [] + feature_id = 0 + if line is None: + return pd.DataFrame(points, columns=['X', 'Y', 'Z', 'feature_id']) + for geom in line.geometry: + attributes = line.iloc[feature_id].to_dict() + attributes.pop('geometry', None) # Remove geometry from attributes + if geom.geom_type == 'LineString': + coords = list(geom.coords) + for coord in coords: + x, y = coord[0], coord[1] + # Use Z from geometry if available, otherwise use DEM + if use_z and len(coord) > 2: + z = coord[2] + else: + z = dem(x, y) + points.append({'X': x, 'Y': y, 'Z': z, 'feature_id': feature_id, **attributes}) + elif geom.geom_type == 'MultiLineString': + for l in geom.geoms: + coords = list(l.coords) + for coord in coords: + x, y = coord[0], coord[1] + # Use Z from geometry if available, otherwise use DEM + if use_z and len(coord) > 2: + z = coord[2] + else: + z = dem(x, y) + points.append( + {'X': x, 'Y': y, 'Z': z, 'feature_id': feature_id, **attributes} + ) + elif geom.geom_type == 'Point': + x, y = geom.x, geom.y + # Use Z from geometry if available, otherwise use DEM + if use_z and hasattr(geom, 'z'): + z = geom.z + else: + z = dem(x, y) + points.append({'X': x, 'Y': y, 'Z': z, 'feature_id': feature_id, **attributes}) + feature_id += 1 + df = pd.DataFrame(points) + return df + + +class GeologicalModelManager: + """This class manages the geological model and assembles it from the data provided by the data manager. + It is responsible for updating the model with faults, stratigraphy, and other geological features. + """ + + def __init__(self): + """Initialize the geological model manager.""" + self.model = GeologicalModel([0, 0, 0], [1, 1, 1]) + self.stratigraphy = {} + self.groups = [] + self.faults = defaultdict(dict) + self.stratigraphy = defaultdict(dict) + self.stratigraphic_column = None + self.fault_topology = None + self.observers = [] + self.dem_function = lambda x, y: 0 + + def set_stratigraphic_column(self, stratigraphic_column: StratigraphicColumn): + """Set the stratigraphic column for the geological model manager.""" + self.stratigraphic_column = stratigraphic_column + + def set_fault_topology(self, fault_topology): + """Set the fault topology for the geological model manager.""" + self.fault_topology = fault_topology + + def update_bounding_box(self, bounding_box: BoundingBox): + """Update the bounding box of the geological model. + + :param bounding_box: The new bounding box. + :type bounding_box: BoundingBox + """ + self.model.bounding_box = bounding_box + + def set_dem_function(self, dem_function: Callable): + """Set the function to get the elevation at a point. + :param dem_function: A function that takes x and y coordinates and returns the elevation. + """ + self.dem_function = dem_function + + def update_fault_points( + self, + fault_trace: gpd.GeoDataFrame, + *, + fault_name_field=None, + fault_dip_field=None, + fault_displacement_field=None, + sampler=AllSampler(), + use_z_coordinate=False, + ): + """Add fault trace data to the geological model. + :param fault_trace: A GeoDataFrame containing the fault trace data. + :param fault_name_field: The field name for the fault name. + :param fault_dip_field: The field name for the fault dip. + :param fault_displacement_field: The field name for the fault displacement. + :param sampler: A callable that samples the fault trace and returns a DataFrame with X, Y, Z coordinates. + """ + # sample fault trace + self.faults.clear() # Clear existing faults + fault_points = sampler(fault_trace, self.dem_function, use_z_coordinate) + cols = ['X', 'Y', 'Z'] + if fault_name_field is not None and fault_name_field in fault_points.columns: + fault_points['fault_name'] = fault_points[fault_name_field].astype(str) + else: + fault_points['fault_name'] = fault_points['feature_id'].astype(str) + if fault_dip_field is not None and fault_dip_field in fault_points.columns: + fault_points['dip'] = fault_points[fault_dip_field] + cols.append('dip') + if ( + fault_displacement_field is not None + and fault_displacement_field in fault_points.columns + ): + fault_points['displacement'] = fault_points[fault_displacement_field] + cols.append('displacement') + existing_faults = set(self.fault_topology.faults) + for fault_name in fault_points['fault_name'].unique(): + self.faults[fault_name]['data'] = fault_points.loc[ + fault_points['fault_name'] == fault_name, cols + ] + if fault_name not in existing_faults: + self.fault_topology.add_fault(fault_name) + else: + existing_faults.remove(fault_name) + + for fault_name in existing_faults: + self.fault_topology.remove_fault(fault_name) + + def update_contact_traces( + self, + basal_contacts: gpd.GeoDataFrame, + *, + sampler=AllSampler(), + unit_name_field=None, + use_z_coordinate=False, + ): + self.stratigraphy.clear() # Clear existing stratigraphy + unit_points = sampler(basal_contacts, self.dem_function, use_z_coordinate) + if len(unit_points) == 0 or unit_points.empty: + print("No basal contacts found or empty GeoDataFrame.") + return + if unit_name_field is not None: + unit_points['unit_name'] = unit_points[unit_name_field].astype(str) + else: + return + for unit_name in unit_points['unit_name'].unique(): + self.stratigraphy[unit_name]['contact'] = unit_points.loc[ + unit_points['unit_name'] == unit_name, ['X', 'Y', 'Z'] + ] + + def update_structural_data( + self, + structural_orientations: gpd.GeoDataFrame, + *, + strike_field=None, + dip_field=None, + unit_name_field=None, + dip_direction=False, + sampler=AllSampler(), + use_z_coordinate=False, + ): + """Add structural orientation data to the geological model.""" + if structural_orientations is None or structural_orientations.empty: + return + if ( + strike_field is None + or strike_field not in structural_orientations.columns + or dip_field is None + or dip_field not in structural_orientations.columns + ): + return + if unit_name_field is None or unit_name_field not in structural_orientations.columns: + return + structural_orientations = sampler( + structural_orientations, self.dem_function, use_z_coordinate + ) + + structural_orientations['unit_name'] = structural_orientations[unit_name_field].astype(str) + + structural_orientations['dip'] = structural_orientations[dip_field] + structural_orientations['strike'] = structural_orientations[strike_field] + structural_orientations = structural_orientations[ + ['X', 'Y', 'Z', 'dip', 'strike', 'unit_name'] + ] + if dip_direction: + structural_orientations['dip'] = structural_orientations[dip_field] + structural_orientations['strike'] = structural_orientations[strike_field] - 90 + for unit_name in structural_orientations['unit_name'].unique(): + orientations = structural_orientations.loc[ + structural_orientations['unit_name'] == unit_name, ['X', 'Y', 'Z', 'dip', 'strike'] + ] + self.stratigraphy[unit_name]['orientations'] = orientations + + def update_stratigraphic_column(self, stratigraphic_column: StratigraphicColumn): + """Update the stratigraphic column with a new stratigraphic column""" + self.stratigraphic_column = stratigraphic_column + self.update_foliation_features() + + # def update_stratigraphic_unit(self, unit_data): + # self.data + + def update_foliation_features(self): + """Builds the stratigraphic feature from the stratigraphic column data + and the basal contacts and structural orientations data. + This method will automatically add unconformities based on the stratigraphic column. + """ + stratigraphic_column = {} + for _i, group in enumerate(reversed(self.stratigraphic_column.get_groups())): + val = 0 + data = [] + groupname = group.name + stratigraphic_column[groupname] = {} + for u in group.units: + unit_data = self.stratigraphy.get(u.name, None) + if unit_data is None: + continue + else: + if 'contact' in unit_data: + contact = unit_data['contact'] + if not contact.empty: + contact['val'] = val + contact['feature_name'] = groupname + data.append(contact) + if 'orientations' in unit_data: + orientations = unit_data['orientations'] + if not orientations.empty: + orientations['val'] = val + orientations['feature_name'] = groupname + data.append(orientations) + + val += u.thickness + if len(data) == 0: + print(f"No data found for group {groupname}, skipping.") + continue + data = pd.concat(data, ignore_index=True) + foliation = self.model.create_and_add_foliation( + groupname, + series_surface_data=data, + force_constrained=True, + nelements=PlgSettingsStructure.interpolator_nelements, + npw=PlgSettingsStructure.interpolator_npw, + cpw=PlgSettingsStructure.interpolator_cpw, + regularisation=PlgSettingsStructure.interpolator_regularisation, + ) + self.model.add_unconformity(foliation, 0) + self.model.stratigraphic_column = self.stratigraphic_column + + def update_fault_features(self): + """Update the fault features in the geological model.""" + for fault_name, fault_data in self.faults.items(): + if 'data' in fault_data and not fault_data['data'].empty: + data = fault_data['data'].copy() + data['feature_name'] = fault_name + data['val'] = 0 + # need to have a way of specifying the displacement from the trace + # or maybe the model should calculate it + if 'displacement' in fault_data['data']: + displacement = fault_data['data']['displacement'].mean() + else: + displacement = 10 + if 'dip' in fault_data['data']: + dip = fault_data['data']['dip'].mean() + else: + dip = 90 + print(f"Fault {fault_name} dip: {dip}") + + if 'pitch' in fault_data['data']: + pitch = fault_data['data']['pitch'].mean() + else: + pitch = 0 + + self.model.create_and_add_fault( + fault_name, + displacement=displacement, + fault_dip=dip, + fault_pitch=pitch, + fault_data=data, + nelements=PlgSettingsStructure.interpolator_nelements, + npw=PlgSettingsStructure.interpolator_npw, + cpw=PlgSettingsStructure.interpolator_cpw, + regularisation=PlgSettingsStructure.interpolator_regularisation, + ) + for f in self.fault_topology.faults: + for f2 in self.fault_topology.faults: + + if f != f2: + relationship = self.fault_topology.get_fault_relationship(f, f2) + + if relationship is FaultRelationshipType.ABUTTING: + self.model[f].add_abutting_fault(self.model[f2]) + + @property + def valid(self): + valid = True + if len(self.groups) == 0: + valid = False + if len(self.stratigraphy) == 0: + valid = False + if len(self.faults) > 0: + for _fault_name, fault_data in self.faults.items(): + if 'data' in fault_data and not fault_data['data'].empty: + valid = True + else: + valid = False + return valid + + def update_model(self): + """Update the geological model with the current stratigraphy and faults.""" + + self.model.features = [] + self.model.feature_name_index = {} + + # Update the model with stratigraphy + self.update_fault_features() + self.update_foliation_features() + + for observer in self.observers: + observer() + + def features(self): + return self.model.features diff --git a/loopstructural/main/projectmanager.py b/loopstructural/main/projectmanager.py new file mode 100644 index 0000000..955003a --- /dev/null +++ b/loopstructural/main/projectmanager.py @@ -0,0 +1,13 @@ +class ProjectManager: + def __init__(self, project, data_manager=None): + self.project = project + self.data_manager = data_manager + + def load_from_project(self): + pass + + def save_to_project(self): + pass + + def clear_project(self): + pass diff --git a/loopstructural/main/rasterFromModel.py b/loopstructural/main/rasterFromModel.py index a10dab6..1731885 100644 --- a/loopstructural/main/rasterFromModel.py +++ b/loopstructural/main/rasterFromModel.py @@ -1,15 +1,16 @@ +import os +import tempfile +import uuid + import numpy as np from osgeo import gdal, osr from qgis.core import QgsRasterLayer + from .geometry.mapGrid import createGrid -import uuid -import tempfile -import os def callableToRaster(callable, dtm, bounding_box, crs, layer_name): - """ - Convert a feature to a raster and store it in QGIS as a temporary layer. + """Convert a feature to a raster and store it in QGIS as a temporary layer. :param feature: The object that has an `evaluate_value` method for computing values. :param dtm: Digital terrain model (if needed for processing). diff --git a/loopstructural/main/stratigraphic_column.py b/loopstructural/main/stratigraphic_column.py new file mode 100644 index 0000000..903c785 --- /dev/null +++ b/loopstructural/main/stratigraphic_column.py @@ -0,0 +1,304 @@ +import enum +from typing import Dict + + +class UnconformityType(enum.Enum): + """ + An enumeration for different types of unconformities in a stratigraphic column. + """ + + ERODE = 'erode' + ONLAP = 'onlap' + + +class StratigraphicColumnElementType(enum.Enum): + """ + An enumeration for different types of elements in a stratigraphic column. + """ + + UNIT = 'unit' + UNCONFORMITY = 'unconformity' + + +class StratigraphicColumnElement: + """ + A class to represent an element in a stratigraphic column, which can be a unit or a topological object + for example unconformity. + """ + + def __init__(self, uuid=None): + """ + Initializes the StratigraphicColumnElement with a name and an optional description. + """ + if uuid is None: + import uuid as uuid_module + + uuid = str(uuid_module.uuid4()) + self.uuid = uuid + + +class StratigraphicUnit(StratigraphicColumnElement): + """ + A class to represent a stratigraphic unit, which is a distinct layer of rock with specific characteristics. + """ + + def __init__(self, *, uuid=None, name=None, colour=None, thickness=None): + """ + Initializes the StratigraphicUnit with a name and an optional description. + """ + super().__init__(uuid) + self.name = name + self.colour = colour + self.thickness = thickness + self.element_type = StratigraphicColumnElementType.UNIT + + def to_dict(self): + """ + Converts the stratigraphic unit to a dictionary representation. + """ + return {"name": self.name, "colour": self.colour, "thickness": self.thickness} + + @classmethod + def from_dict(cls, data): + """ + Creates a StratigraphicUnit from a dictionary representation. + """ + if not isinstance(data, dict): + raise TypeError("Data must be a dictionary") + name = data.get("name") + colour = data.get("colour") + thickness = data.get("thickness", None) + uuid = data.get("uuid", None) + return cls(uuid=uuid, name=name, colour=colour, thickness=thickness) + + def __str__(self): + """ + Returns a string representation of the stratigraphic unit. + """ + return ( + f"StratigraphicUnit(name={self.name}, colour={self.colour}, thickness={self.thickness})" + ) + + +class StratigraphicUnconformity(StratigraphicColumnElement): + """ + A class to represent a stratigraphic unconformity, which is a surface of discontinuity in the stratigraphic record. + """ + + def __init__( + self, *, uuid=None, name=None, unconformity_type: UnconformityType = UnconformityType.ERODE + ): + """ + Initializes the StratigraphicUnconformity with a name and an optional description. + """ + super().__init__(uuid) + self.name = name + if unconformity_type not in [UnconformityType.ERODE, UnconformityType.ONLAP]: + raise ValueError("Invalid unconformity type") + self.unconformity_type = unconformity_type + self.element_type = StratigraphicColumnElementType.UNCONFORMITY + + def to_dict(self): + """ + Converts the stratigraphic unconformity to a dictionary representation. + """ + return { + "uuid": self.uuid, + "name": self.name, + "unconformity_type": self.unconformity_type.value, + } + + def __str__(self): + """ + Returns a string representation of the stratigraphic unconformity. + """ + return ( + f"StratigraphicUnconformity(name={self.name}, " + f"unconformity_type={self.unconformity_type.value})" + ) + + @classmethod + def from_dict(cls, data): + """ + Creates a StratigraphicUnconformity from a dictionary representation. + """ + if not isinstance(data, dict): + raise TypeError("Data must be a dictionary") + name = data.get("name") + unconformity_type = UnconformityType( + data.get("unconformity_type", UnconformityType.ERODE.value) + ) + uuid = data.get("uuid", None) + return cls(uuid=uuid, name=name, unconformity_type=unconformity_type) + + +class StratigraphicColumn: + """ + A class to represent a stratigraphic column, which is a vertical section of the Earth's crust + showing the sequence of rock layers and their relationships. + """ + + def __init__(self): + """ + Initializes the StratigraphicColumn with a name and a list of layers. + """ + self.order = [] + + def add_unit(self, name, colour, thickness=None): + unit = StratigraphicUnit(name=name, colour=colour, thickness=thickness) + + self.order.append(unit) + return unit + + def remove_unit(self, uuid): + """ + Removes a unit or unconformity from the stratigraphic column by its uuid. + """ + for i, element in enumerate(self.order): + if element.uuid == uuid: + del self.order[i] + return True + return False + + def add_unconformity(self, name, unconformity_type=UnconformityType.ERODE): + unconformity = StratigraphicUnconformity( + uuid=None, name=name, unconformity_type=unconformity_type + ) + + self.order.append(unconformity) + return unconformity + + def get_element_by_index(self, index): + """ + Retrieves an element by its index from the stratigraphic column. + """ + if index < 0 or index >= len(self.order): + raise IndexError("Index out of range") + return self.order[index] + + def get_unit_by_name(self, name): + """ + Retrieves a unit by its name from the stratigraphic column. + """ + for unit in self.order: + if isinstance(unit, StratigraphicUnit) and unit.name == name: + return unit + + return None + + def add_element(self, element): + """ + Adds a StratigraphicColumnElement to the stratigraphic column. + """ + if isinstance(element, StratigraphicColumnElement): + self.order.append(element) + else: + raise TypeError("Element must be an instance of StratigraphicColumnElement") + + def get_elements(self): + """ + Returns a list of all elements in the stratigraphic column. + """ + return self.order + + def get_groups(self): + groups = [] + group = [] + for e in self.order: + if isinstance(e, StratigraphicUnit): + group.append(e) + else: + if group: + groups.append(group) + group = [] + if group: + groups.append(group) + return groups + + def get_unitname_groups(self): + groups = [] + group = [] + for e in self.order: + if isinstance(e, StratigraphicUnit): + group.append(e.name) + else: + if group: + groups.append(group) + group = [] + if group: + groups.append(group) + return groups + + def __getitem__(self, uuid): + """ + Retrieves an element by its uuid from the stratigraphic column. + """ + for element in self.order: + if element.uuid == uuid: + return element + raise KeyError(f"No element found with uuid: {uuid}") + + def update_order(self, new_order): + """ + Updates the order of elements in the stratigraphic column based on a new order list. + """ + if not isinstance(new_order, list): + raise TypeError("New order must be a list") + self.order = [ + self.__getitem__(uuid) for uuid in new_order if self.__getitem__(uuid) is not None + ] + + def update_element(self, unit_data: Dict): + """ + Updates an existing element in the stratigraphic column with new data. + :param unit_data: A dictionary containing the updated data for the element. + """ + if not isinstance(unit_data, dict): + raise TypeError("unit_data must be a dictionary") + element = self.__getitem__(unit_data['uuid']) + if isinstance(element, StratigraphicUnit): + element.name = unit_data.get('name', element.name) + element.colour = unit_data.get('colour', element.colour) + element.thickness = unit_data.get('thickness', element.thickness) + elif isinstance(element, StratigraphicUnconformity): + element.name = unit_data.get('name', element.name) + element.unconformity_type = UnconformityType( + unit_data.get('unconformity_type', element.unconformity_type.value) + ) + + def clear(self): + """ + Clears the stratigraphic column, removing all elements. + """ + self.order.clear() + + def __str__(self): + """ + Returns a string representation of the stratigraphic column, listing all elements. + """ + return "\n".join([f"{i+1}. {element}" for i, element in enumerate(self.order)]) + + def to_dict(self): + """ + Converts the stratigraphic column to a dictionary representation. + """ + return { + "elements": [element.to_dict() for element in self.order], + } + + @classmethod + def from_dict(cls, data): + """ + Creates a StratigraphicColumn from a dictionary representation. + """ + if not isinstance(data, dict): + raise TypeError("Data must be a dictionary") + column = cls() + elements_data = data.get("elements", []) + for element_data in elements_data: + if "unconformity_type" in element_data: + element = StratigraphicUnconformity.from_dict(element_data) + else: + element = StratigraphicUnit.from_dict(element_data) + column.add_element(element) + return column diff --git a/loopstructural/main/vectorLayerWrapper.py b/loopstructural/main/vectorLayerWrapper.py index 7cca5cd..7e146db 100644 --- a/loopstructural/main/vectorLayerWrapper.py +++ b/loopstructural/main/vectorLayerWrapper.py @@ -1,5 +1,24 @@ import pandas as pd -from qgis.core import QgsWkbTypes, QgsRaster +import geopandas as gpd +from qgis.core import QgsRaster, QgsWkbTypes + + +def qgsLayerToGeoDataFrame(layer) -> gpd.GeoDataFrame: + if layer is None: + return None + features = layer.getFeatures() + fields = layer.fields() + data = {'geometry': []} + for f in fields: + data[f.name()] = [] + for feature in features: + geom = feature.geometry() + if geom.isEmpty(): + continue + data['geometry'].append(geom) + for f in fields: + data[f.name()].append(feature[f.name()]) + return gpd.GeoDataFrame(data, crs=layer.crs().authid()) def qgsLayerToDataFrame(layer, dtm) -> pd.DataFrame: @@ -34,7 +53,7 @@ def qgsLayerToDataFrame(layer, dtm) -> pd.DataFrame: points.extend(line) # points = geom.asMultiPolyline()[0] else: - if geom.type() == QgsWkbTypes.PointGeometry: + if geom.type() == QgsWkbTypes.PointGeometry: points = [geom.asPoint()] elif geom.type() == QgsWkbTypes.LineGeometry: points = geom.asPolyline() diff --git a/loopstructural/metadata.txt b/loopstructural/metadata.txt index 7f1665f..ca67a04 100644 --- a/loopstructural/metadata.txt +++ b/loopstructural/metadata.txt @@ -23,5 +23,6 @@ qgisMaximumVersion=3.99 # versioning version=0.1.0 changelog= - + +# python deps plugin_dependencies=qpip diff --git a/loopstructural/plugin_main.py b/loopstructural/plugin_main.py index 95267e1..ddf69cf 100644 --- a/loopstructural/plugin_main.py +++ b/loopstructural/plugin_main.py @@ -1,20 +1,19 @@ #! python3 -""" - Main plugin module. -""" +"""Main plugin module.""" # standard +import importlib.util +import os from functools import partial from pathlib import Path -import os # PyQGIS -from qgis.core import QgsApplication, QgsSettings +from qgis.core import QgsApplication, QgsProject, QgsSettings from qgis.gui import QgisInterface -from qgis.PyQt.QtCore import QCoreApplication, QLocale, QTranslator, QUrl, Qt +from qgis.PyQt.QtCore import QCoreApplication, QLocale, Qt, QTranslator, QUrl from qgis.PyQt.QtGui import QDesktopServices, QIcon -from qgis.PyQt.QtWidgets import QAction, QMenu, QDockWidget +from qgis.PyQt.QtWidgets import QAction, QDockWidget # project from loopstructural.__about__ import ( @@ -23,20 +22,19 @@ __title__, __uri_homepage__, ) -try: - import LoopStructural -except ImportError: + +if importlib.util.find_spec("pyvistaqt") is None: raise ImportError( - "LoopStructural is not installed. Please install it using the requirements.txt file in the plugin directory." + "pyvistaqt is not installed. Please install it using the requirements.txt file in the plugin directory." ) -try: - import pyvistaqt -except ImportError: +if importlib.util.find_spec("LoopStructural") is None: raise ImportError( - "pyvistaqt is not installed. Please install it using the requirements.txt file in the plugin directory." + "LoopStructural is not installed. Please install it using the requirements.txt file in the plugin directory." ) from loopstructural.gui.dlg_settings import PlgOptionsFactory -from loopstructural.gui.modelling.modelling_widget import ModellingWidget as Modelling +from loopstructural.gui.loop_widget import LoopWidget +from loopstructural.main.data_manager import ModellingDataManager +from loopstructural.main.model_manager import GeologicalModelManager from loopstructural.toolbelt import PlgLogger # ############################################################################ @@ -66,16 +64,32 @@ def __init__(self, iface: QgisInterface): self.translator = QTranslator() self.translator.load(str(locale_path.resolve())) QCoreApplication.installTranslator(self.translator) + self.data_manager = ModellingDataManager( + mapCanvas=self.iface.mapCanvas(), logger=self.log, project=QgsProject.instance() + ) + self.model_manager = GeologicalModelManager() + self.data_manager.set_model_manager(self.model_manager) + + def injectLogHandler(self): + import logging + + import LoopStructural + from loopstructural.toolbelt.log_handler import PlgLoggerHandler + + handler = PlgLoggerHandler(plg_logger_class=PlgLogger, push=True) + handler.setFormatter(logging.Formatter('%(name)s - %(levelname)s - %(message)s')) + + LoopStructural.setLogging(level="warning", handler=handler) def initGui(self): """Set up plugin UI elements.""" + self.injectLogHandler() self.toolbar = self.iface.addToolBar(u'LoopStructural') self.toolbar.setObjectName(u'LoopStructural') # settings page within the QGIS preferences menu self.options_factory = PlgOptionsFactory() self.iface.registerOptionsWidgetFactory(self.options_factory) - # -- Actions self.action_help = QAction( QgsApplication.getThemeIcon("mActionHelpContents.svg"), @@ -95,7 +109,7 @@ def initGui(self): lambda: self.iface.showOptionsDialog(currentPage="mOptionsPage{}".format(__title__)) ) self.action_modelling = QAction( - QIcon(os.path.dirname(__file__)+"/icon.png"), + QIcon(os.path.dirname(__file__) + "/icon.png"), self.tr("LoopStructural Modelling"), self.iface.mainWindow(), ) @@ -122,35 +136,36 @@ def initGui(self): self.iface.pluginHelpMenu().addAction(self.action_help_plugin_menu_documentation) ## --- dock widget - self.modelling_dockwidget = QDockWidget(self.tr("Modelling"), self.iface.mainWindow()) - self.model_setup_widget = Modelling( - self.iface.mainWindow(), mapCanvas=self.iface.mapCanvas(), logger=self.log + self.loop_dockwidget = QDockWidget(self.tr("Loop"), self.iface.mainWindow()) + self.loop_widget = LoopWidget( + self.iface.mainWindow(), + mapCanvas=self.iface.mapCanvas(), + logger=self.log, + data_manager=self.data_manager, + model_manager=self.model_manager, ) - self.modelling_dockwidget.setWidget(self.model_setup_widget) - self.iface.addDockWidget(Qt.RightDockWidgetArea, self.modelling_dockwidget) + + self.loop_dockwidget.setWidget(self.loop_widget) + self.iface.addDockWidget(Qt.RightDockWidgetArea, self.loop_dockwidget) right_docks = [ - d - for d in self.iface.mainWindow().findChildren(QDockWidget) - if self.iface.mainWindow().dockWidgetArea(d) == Qt.RightDockWidgetArea - ] - # If there are other dock widgets, tab this one with the first one found + d + for d in self.iface.mainWindow().findChildren(QDockWidget) + if self.iface.mainWindow().dockWidgetArea(d) == Qt.RightDockWidgetArea + ] + # If there are other dock widgets, tab this one with the first one found if right_docks: for dock in right_docks: - if dock != self.modelling_dockwidget: - self.iface.mainWindow().tabifyDockWidget(dock, self.modelling_dockwidget) + if dock != self.loop_dockwidget: + self.iface.mainWindow().tabifyDockWidget(dock, self.loop_dockwidget) # Optionally, bring your plugin tab to the front - self.modelling_dockwidget.raise_() + self.loop_dockwidget.raise_() break - self.modelling_dockwidget.show() - - self.modelling_dockwidget.close() - self.action_modelling.triggered.connect( - self.modelling_dockwidget.toggleViewAction().trigger - ) - - + self.loop_dockwidget.show() + self.loop_dockwidget.close() + # -- Connect actions + self.action_modelling.triggered.connect(self.loop_dockwidget.toggleViewAction().trigger) def tr(self, message: str) -> str: """Get the translation for a string using Qt translation API. diff --git a/loopstructural/processing/provider.py b/loopstructural/processing/provider.py index b9ad0c1..c7b1608 100644 --- a/loopstructural/processing/provider.py +++ b/loopstructural/processing/provider.py @@ -1,7 +1,6 @@ #! python3 -""" - Processing provider module. +"""Processing provider module. """ # PyQGIS @@ -18,9 +17,7 @@ class LoopstructuralProvider(QgsProcessingProvider): - """ - Processing provider class. - """ + """Processing provider class.""" def loadAlgorithms(self): """Loads all algorithms belonging to this provider.""" diff --git a/loopstructural/requirements.txt b/loopstructural/requirements.txt index 6edfe10..1c14ece 100644 --- a/loopstructural/requirements.txt +++ b/loopstructural/requirements.txt @@ -1,2 +1,5 @@ pyvistaqt -LoopStructural[all] \ No newline at end of file +pyvista +LoopStructural==1.6.17 +geoh5py +meshio diff --git a/loopstructural/resources/images/infinity_loop_icon.svg b/loopstructural/resources/images/infinity_loop_icon.svg new file mode 100644 index 0000000..7d423b3 --- /dev/null +++ b/loopstructural/resources/images/infinity_loop_icon.svg @@ -0,0 +1,4 @@ + + diff --git a/loopstructural/toolbelt/log_handler.py b/loopstructural/toolbelt/log_handler.py index 017704f..af0b6e2 100644 --- a/loopstructural/toolbelt/log_handler.py +++ b/loopstructural/toolbelt/log_handler.py @@ -11,9 +11,10 @@ from qgis.PyQt.QtWidgets import QPushButton, QWidget from qgis.utils import iface +import loopstructural.toolbelt.preferences as plg_prefs_hdlr + # project package from loopstructural.__about__ import __title__ -import loopstructural.toolbelt.preferences as plg_prefs_hdlr # ############################################################################ # ########## Classes ############### @@ -145,3 +146,54 @@ def log( level=log_level, duration=duration, ) + + +class PlgLoggerHandler(logging.Handler): + """ + Standard logging.Handler that forwards logs to PlgLogger.log(). + """ + + def __init__(self, plg_logger_class, level=logging.NOTSET, push=False, duration=None): + """ + Parameters + ---------- + plg_logger_class : class + Class providing a static `log()` method (like your PlgLogger). + level : int + The logging level to handle. + push : bool + Whether to push messages to the QGIS message bar. + duration : int + Optional fixed duration for messages. + """ + super().__init__(level) + self.plg_logger_class = plg_logger_class + self.push = push + self.duration = duration + + def emit(self, record): + try: + msg = self.format(record) + qgis_level = self._map_log_level(record.levelno) + self.plg_logger_class.log( + message=msg, + log_level=qgis_level, + push=self.push, + duration=self.duration, + application='LoopStructural', + ) + except Exception: + self.handleError(record) + + @staticmethod + def _map_log_level(py_level): + if py_level >= logging.CRITICAL: + return 2 + elif py_level >= logging.ERROR: + return 2 + elif py_level >= logging.WARNING: + return 1 + elif py_level >= logging.INFO: + return 0 + else: + return 4 # "none" / debug / custom diff --git a/loopstructural/toolbelt/preferences.py b/loopstructural/toolbelt/preferences.py index 7f810d5..1d442d2 100644 --- a/loopstructural/toolbelt/preferences.py +++ b/loopstructural/toolbelt/preferences.py @@ -1,8 +1,6 @@ #! python3 -""" - Plugin settings. -""" +"""Plugin settings.""" # standard from dataclasses import asdict, dataclass, fields @@ -26,6 +24,11 @@ class PlgSettingsStructure: # global debug_mode: bool = False version: str = __version__ + interpolator_type: str = 'FDI' + interpolator_nelements: int = 10000 + interpolator_regularisation: float = 1.0 + interpolator_cpw: float = 1.0 + interpolator_npw: float = 1.0 class PlgOptionsManager: diff --git a/tests/qgis/test_plg_preferences.py b/tests/qgis/test_plg_preferences.py index 4d64c26..c76ab46 100644 --- a/tests/qgis/test_plg_preferences.py +++ b/tests/qgis/test_plg_preferences.py @@ -1,14 +1,13 @@ -#! python3 # noqa E265 +#! python3 -""" - Usage from the repo root folder: +"""Usage from the repo root folder: - .. code-block:: bash +.. code-block:: bash - # for whole tests - python -m unittest tests.qgis.test_plg_preferences - # for specific test - python -m unittest tests.qgis.test_plg_preferences.TestPlgPreferences.test_plg_preferences_structure +# for whole tests +python -m unittest tests.qgis.test_plg_preferences +# for specific test +python -m unittest tests.qgis.test_plg_preferences.TestPlgPreferences.test_plg_preferences_structure """ # standard library diff --git a/tests/unit/test_plg_metadata.py b/tests/unit/test_plg_metadata.py index ffc49fc..bf178da 100644 --- a/tests/unit/test_plg_metadata.py +++ b/tests/unit/test_plg_metadata.py @@ -1,13 +1,12 @@ -#! python3 # noqa E265 +#! python3 -""" - Usage from the repo root folder: +"""Usage from the repo root folder: - .. code-block:: bash - # for whole tests - python -m unittest tests.unit.test_plg_metadata - # for specific test - python -m unittest tests.unit.test_plg_metadata.TestPluginMetadata.test_version_semver +.. code-block:: bash +# for whole tests +python -m unittest tests.unit.test_plg_metadata +# for specific test +python -m unittest tests.unit.test_plg_metadata.TestPluginMetadata.test_version_semver """ # standard library @@ -26,7 +25,6 @@ class TestPluginMetadata(unittest.TestCase): - """Test about module""" def test_metadata_types(self):