From 9e744cbb1774267c7f793f02d19b1775096a9f53 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Tue, 20 May 2025 16:42:09 +1000 Subject: [PATCH 001/111] Decluttering UI stage 1 --- loopstructural/gui/modelling/base_tab.py | 32 + loopstructural/gui/modelling/data_manager.py | 3 + loopstructural/gui/modelling/export_tab.ui | 400 +++++ .../gui/modelling/fault_graph/fault.svg | 258 ++++ .../gui/modelling/fault_graph/fault_graph.py | 285 ++++ .../modelling/fault_graph/stratigraphy.svg | 255 +++ .../modelling/fault_graph/unconformity.svg | 250 +++ .../gui/modelling/geological_history_tab.py | 17 + .../gui/modelling/geological_history_tab.ui | 359 +++++ .../gui/modelling/model_definition_tab.py | 13 + .../gui/modelling/model_definition_tab.ui | 389 +++++ .../gui/modelling/model_setup_tab.py | 0 .../gui/modelling/model_setup_tab.ui | 136 ++ .../gui/modelling/modelling_widget.py | 1000 +----------- .../gui/modelling/modelling_widget.ui | 1369 +---------------- .../gui/modelling/modelling_widget_back.py | 981 ++++++++++++ .../stratigraphic_column.py | 34 + .../stratigraphic_unit.py | 10 + .../stratigraphic_unit.ui | 75 + loopstructural/gui/modelling/topology_tab.ui | 69 + loopstructural/gui/modelling/viewer_tab.ui | 69 + loopstructural/loopstructural | 1 + loopstructural/metadata.txt | 1 + loopstructural/requirements.txt | 5 + 24 files changed, 3715 insertions(+), 2296 deletions(-) create mode 100644 loopstructural/gui/modelling/base_tab.py create mode 100644 loopstructural/gui/modelling/data_manager.py create mode 100644 loopstructural/gui/modelling/export_tab.ui create mode 100644 loopstructural/gui/modelling/fault_graph/fault.svg create mode 100644 loopstructural/gui/modelling/fault_graph/fault_graph.py create mode 100644 loopstructural/gui/modelling/fault_graph/stratigraphy.svg create mode 100644 loopstructural/gui/modelling/fault_graph/unconformity.svg create mode 100644 loopstructural/gui/modelling/geological_history_tab.py create mode 100644 loopstructural/gui/modelling/geological_history_tab.ui create mode 100644 loopstructural/gui/modelling/model_definition_tab.py create mode 100644 loopstructural/gui/modelling/model_definition_tab.ui create mode 100644 loopstructural/gui/modelling/model_setup_tab.py create mode 100644 loopstructural/gui/modelling/model_setup_tab.ui create mode 100644 loopstructural/gui/modelling/modelling_widget_back.py create mode 100644 loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py create mode 100644 loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.py create mode 100644 loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.ui create mode 100644 loopstructural/gui/modelling/topology_tab.ui create mode 100644 loopstructural/gui/modelling/viewer_tab.ui create mode 120000 loopstructural/loopstructural create mode 100644 loopstructural/requirements.txt diff --git a/loopstructural/gui/modelling/base_tab.py b/loopstructural/gui/modelling/base_tab.py new file mode 100644 index 0000000..c593e7e --- /dev/null +++ b/loopstructural/gui/modelling/base_tab.py @@ -0,0 +1,32 @@ +from PyQt5.QtWidgets import QScrollArea, QVBoxLayout, QWidget + + +class BaseTab(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + self.data_manager = None + # Initialize a default layout for all tabs + self.scroll_area = QScrollArea(self) + self.scroll_area.setWidgetResizable(True) + + # Create a container widget for the scroll area + self.container_widget = QWidget() + self.scroll_area.setWidget(self.container_widget) + + # 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.main_layout.addWidget(self.scroll_area) + + # self.layout = QVBoxLayout(self) + # self.setLayout(self.layout) # Set the layout for the tab + + 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/data_manager.py b/loopstructural/gui/modelling/data_manager.py new file mode 100644 index 0000000..4506cd8 --- /dev/null +++ b/loopstructural/gui/modelling/data_manager.py @@ -0,0 +1,3 @@ +class ModellingDataManager: + def __init__(self): + pass 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_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..a8150a7 --- /dev/null +++ b/loopstructural/gui/modelling/fault_graph/fault_graph.py @@ -0,0 +1,285 @@ +import os +import sys + +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): + 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/geological_history_tab.py b/loopstructural/gui/modelling/geological_history_tab.py new file mode 100644 index 0000000..43485fd --- /dev/null +++ b/loopstructural/gui/modelling/geological_history_tab.py @@ -0,0 +1,17 @@ +import os + +from PyQt5.QtWidgets import QWidget +from qgis.PyQt import uic + +from loopstructural.gui.modelling.base_tab import BaseTab + + +class GeologialHistoryTab(BaseTab): + def __init__(self, parent=None): + super().__init__(parent) + # Load the UI file for Tab 1 + ui_widget = QWidget() + uic.loadUi(os.path.join(os.path.dirname(__file__), "geological_history_tab.ui"), ui_widget) + + # Add the loaded UI widget to the container layout + self.container_layout.addWidget(ui_widget) 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/model_definition_tab.py b/loopstructural/gui/modelling/model_definition_tab.py new file mode 100644 index 0000000..b1b20a8 --- /dev/null +++ b/loopstructural/gui/modelling/model_definition_tab.py @@ -0,0 +1,13 @@ +import os + +from PyQt5.QtWidgets import QWidget +from qgis.PyQt import uic + +from loopstructural.gui.modelling.base_tab import BaseTab + + +class ModelDefinitionTab(BaseTab): + def __init__(self, parent=None): + super().__init__(parent) + # Load the UI file for Tab 1 + uic.loadUi(os.path.join(os.path.dirname(__file__), "model_definition_tab.ui"), self) diff --git a/loopstructural/gui/modelling/model_definition_tab.ui b/loopstructural/gui/modelling/model_definition_tab.ui new file mode 100644 index 0000000..2ee7f35 --- /dev/null +++ b/loopstructural/gui/modelling/model_definition_tab.ui @@ -0,0 +1,389 @@ + + + Form + + + + 0 + 0 + 593 + 721 + + + + Form + + + + + 10 + 0 + 561 + 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 + + + + + + + + + + + + + + + + + + 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..b404a80 100644 --- a/loopstructural/gui/modelling/modelling_widget.py +++ b/loopstructural/gui/modelling/modelling_widget.py @@ -1,981 +1,41 @@ -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 pyvistaqt import QtInteractor -import pyvista as pv -from LoopStructural.utils import random_hex_colour +from PyQt5.QtWidgets import QVBoxLayout, QWidget +from qgis.PyQt import uic -from ...main import QgsProcessInputData -from ...main.geometry.calculateLineAzimuth import calculateAverageAzimuth -from ...main.rasterFromModel import callableToRaster -from ...main.callableToLayer import callableToLayer +from loopstructural.gui.modelling.fault_graph.fault_graph import FaultGraph +from loopstructural.gui.modelling.geological_history_tab import GeologialHistoryTab +from loopstructural.gui.modelling.model_definition_tab import ModelDefinitionTab +from loopstructural.gui.modelling.stratigraphic_column.stratigraphic_column import StratColumnWidget -# 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) + # Load the UI file for Tab 1 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 = None + self.model_definition_tab_widget = None + self.geological_history_tab_widget = None + self.stratigraphic_column_tab_widget = None + self.fault_graph_tab_widget = None + + # Ensure the tabs have layouts + if not self.load_data_tab.layout(): + self.load_data_tab.setLayout(QVBoxLayout()) + if not self.geological_history_tab.layout(): + self.geological_history_tab.setLayout(QVBoxLayout()) + if not self.model_setup_tab.layout(): + self.model_setup_tab.setLayout(QVBoxLayout()) + if not self.topology_tab.layout(): + self.topology_tab.setLayout(QVBoxLayout()) + self.model_definition_tab_widget = ModelDefinitionTab(self) + self.geological_history_tab_widget = GeologialHistoryTab(self) + self.stratigraphic_column_tab_widget = StratColumnWidget(self) + self.fault_graph_tab_widget = FaultGraph(self) + self.load_data_tab.layout().addWidget(self.model_definition_tab_widget) + self.geological_history_tab.layout().addWidget(self.geological_history_tab_widget) + self.model_setup_tab.layout().addWidget(self.stratigraphic_column_tab_widget) + self.topology_tab.layout().addWidget(self.fault_graph_tab_widget) 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..dd2fd5f --- /dev/null +++ b/loopstructural/gui/modelling/modelling_widget_back.py @@ -0,0 +1,981 @@ +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 pyvistaqt import QtInteractor +import pyvista as pv +from LoopStructural.utils import random_hex_colour + +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): + 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] 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..aa52e8b --- /dev/null +++ b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py @@ -0,0 +1,34 @@ +from PyQt5 import uic +from PyQt5.QtWidgets import ( + QAbstractItemView, + QListWidget, + QListWidgetItem, + QPushButton, + QVBoxLayout, + QWidget, +) + +from .stratigraphic_unit import StratigraphicUnitWidget + + +class StratColumnWidget(QWidget): + def __init__(self, parent=None): + super().__init__() + layout = QVBoxLayout(self) + + # Main list widget + self.unitList = QListWidget() + self.unitList.setDragDropMode(QAbstractItemView.InternalMove) + layout.addWidget(self.unitList) + + # Add unit button + addButton = QPushButton("Add Unit") + addButton.clicked.connect(self.add_unit) + layout.addWidget(addButton) + + def add_unit(self): + unit_widget = StratigraphicUnitWidget() + item = QListWidgetItem() + item.setSizeHint(unit_widget.sizeHint()) + self.unitList.addItem(item) + self.unitList.setItemWidget(item, unit_widget) 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..32403ad --- /dev/null +++ b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.py @@ -0,0 +1,10 @@ +import os + +from PyQt5 import uic +from PyQt5.QtWidgets import QWidget + + +class StratigraphicUnitWidget(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + uic.loadUi(os.path.join(os.path.dirname(__file__), "stratigraphic_unit.ui"), self) 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..25223a7 --- /dev/null +++ b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.ui @@ -0,0 +1,75 @@ + + + StratigraphicUnitWidget + + + + 0 + 0 + 756 + 53 + + + + + + + Thickness: + + + + + + + Color: + + + + + + + Delete this unit + + + 🗑️ + + + + + + + m + + + 2 + + + 0.000000000000000 + + + 10000.000000000000000 + + + + + + + Name: + + + + + + + Select + + + + + + + + + + + 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/loopstructural b/loopstructural/loopstructural new file mode 120000 index 0000000..4bb4558 --- /dev/null +++ b/loopstructural/loopstructural @@ -0,0 +1 @@ +/home/lgrose/dev/plugin_loopstructural/loopstructural \ No newline at end of file diff --git a/loopstructural/metadata.txt b/loopstructural/metadata.txt index c862dc7..adc24a6 100644 --- a/loopstructural/metadata.txt +++ b/loopstructural/metadata.txt @@ -24,3 +24,4 @@ qgisMaximumVersion=3.99 version=0.1.0 changelog= +# python diff --git a/loopstructural/requirements.txt b/loopstructural/requirements.txt new file mode 100644 index 0000000..8163f13 --- /dev/null +++ b/loopstructural/requirements.txt @@ -0,0 +1,5 @@ +pyvistaqt +pyvista +LoopStructural +loopstructural-visualisation +geoh5py \ No newline at end of file From 7a922121f7899c893a74e18ffc4d2ff8c3a9474b Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Wed, 21 May 2025 10:37:55 +1000 Subject: [PATCH 002/111] modular widgets for layer selection --- .../modelling/model_definition/__init__.py | 1 + .../model_definition/bounding_box.py | 11 + .../model_definition/bounding_box.ui | 147 +++++++ .../model_definition/fault_layers.py | 11 + .../model_definition/fault_layers.ui | 105 +++++ .../model_definition/model_definition_tab.py | 34 ++ .../model_definition/model_definition_tab.ui | 34 ++ .../model_definition/stratigraphic_layers.py | 11 + .../model_definition/stratigraphic_layers.ui | 158 +++++++ .../gui/modelling/model_definition_tab.py | 13 - .../gui/modelling/model_definition_tab.ui | 389 ------------------ .../gui/modelling/modelling_widget.py | 2 +- .../stratigraphic_column.py | 13 +- .../stratigraphic_unit.py | 18 +- 14 files changed, 541 insertions(+), 406 deletions(-) create mode 100644 loopstructural/gui/modelling/model_definition/__init__.py create mode 100644 loopstructural/gui/modelling/model_definition/bounding_box.py create mode 100644 loopstructural/gui/modelling/model_definition/bounding_box.ui create mode 100644 loopstructural/gui/modelling/model_definition/fault_layers.py create mode 100644 loopstructural/gui/modelling/model_definition/fault_layers.ui create mode 100644 loopstructural/gui/modelling/model_definition/model_definition_tab.py create mode 100644 loopstructural/gui/modelling/model_definition/model_definition_tab.ui create mode 100644 loopstructural/gui/modelling/model_definition/stratigraphic_layers.py create mode 100644 loopstructural/gui/modelling/model_definition/stratigraphic_layers.ui delete mode 100644 loopstructural/gui/modelling/model_definition_tab.py delete mode 100644 loopstructural/gui/modelling/model_definition_tab.ui 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..8b23164 --- /dev/null +++ b/loopstructural/gui/modelling/model_definition/bounding_box.py @@ -0,0 +1,11 @@ +import os + +from PyQt5.QtWidgets import QWidget +from qgis.PyQt import uic + + +class BoundingBoxWidget(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + ui_path = os.path.join(os.path.dirname(__file__), "bounding_box.ui") + uic.loadUi(ui_path, self) 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..269a7c8 --- /dev/null +++ b/loopstructural/gui/modelling/model_definition/bounding_box.ui @@ -0,0 +1,147 @@ + + + Form + + + + 0 + 0 + 750 + 423 + + + + Form + + + + + + + 10 + 10 + 730 + 211 + + + + + + + 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 + + + + + + + + + + + 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..b20e65a --- /dev/null +++ b/loopstructural/gui/modelling/model_definition/fault_layers.py @@ -0,0 +1,11 @@ +import os + +from PyQt5.QtWidgets import QWidget +from qgis.PyQt import uic + + +class FaultLayersWidget(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + ui_path = os.path.join(os.path.dirname(__file__), "fault_layers.ui") + uic.loadUi(ui_path, self) 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..d0b48cd --- /dev/null +++ b/loopstructural/gui/modelling/model_definition/fault_layers.ui @@ -0,0 +1,105 @@ + + + Form + + + + 0 + 0 + 750 + 300 + + + + Form + + + + + + + 10 + 10 + 730 + 181 + + + + + + + Fault Traces + + + + + + + + + + Fault Name + + + + + + + + + + Dip + + + + + + + true + + + + + + + Displacement + + + + + + + true + + + + + + + Pitch + + + + + + + + + + + + + + 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..26e3da9 --- /dev/null +++ b/loopstructural/gui/modelling/model_definition/model_definition_tab.py @@ -0,0 +1,34 @@ +import os + +from PyQt5.QtWidgets import QSizePolicy, QToolBox +from qgis.PyQt import uic + +from loopstructural.gui.modelling.base_tab import BaseTab + +from .bounding_box import BoundingBoxWidget +from .fault_layers import FaultLayersWidget +from .stratigraphic_layers import StratigraphicLayersWidget + + +class ModelDefinitionTab(BaseTab): + def __init__(self, parent=None): + super().__init__(parent) + # Load the UI file for Tab 1 + uic.loadUi(os.path.join(os.path.dirname(__file__), "model_definition_tab.ui"), self) + + # Create a QToolBox for collapsible sections + self.toolBox = QToolBox(self) + self.mainLayout.addWidget(self.toolBox) + + # Add widgets to the QToolBox + bounding_box = BoundingBoxWidget(self) + fault_layers = FaultLayersWidget(self) + stratigraphy_layers = StratigraphicLayersWidget(self) + + # Set uniform size policy for all widgets + for widget in [bounding_box, fault_layers, stratigraphy_layers]: + widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + + self.toolBox.addItem(bounding_box, "Bounding Box") + self.toolBox.addItem(fault_layers, "Fault Layers") + self.toolBox.addItem(stratigraphy_layers, "Stratigraphic Layers") diff --git a/loopstructural/gui/modelling/model_definition/model_definition_tab.ui b/loopstructural/gui/modelling/model_definition/model_definition_tab.ui new file mode 100644 index 0000000..41a67ff --- /dev/null +++ b/loopstructural/gui/modelling/model_definition/model_definition_tab.ui @@ -0,0 +1,34 @@ + + + Form + + + + 0 + 0 + 593 + 721 + + + + Form + + + + + 10 + 0 + 561 + 701 + + + + + + + + + + + + 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..5242a0d --- /dev/null +++ b/loopstructural/gui/modelling/model_definition/stratigraphic_layers.py @@ -0,0 +1,11 @@ +import os + +from PyQt5.QtWidgets import QWidget +from qgis.PyQt import uic + + +class StratigraphicLayersWidget(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + ui_path = os.path.join(os.path.dirname(__file__), "stratigraphic_layers.ui") + uic.loadUi(ui_path, self) 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..9a8ced9 --- /dev/null +++ b/loopstructural/gui/modelling/model_definition/stratigraphic_layers.ui @@ -0,0 +1,158 @@ + + + Form + + + + 0 + 0 + 750 + 391 + + + + Form + + + + + + + 10 + 10 + 730 + 231 + + + + + + + <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 + + + + + + + + + + + QgsFieldComboBox + QComboBox +
qgsfieldcombobox.h
+
+ + QgsMapLayerComboBox + QComboBox +
qgsmaplayercombobox.h
+
+
+ + +
diff --git a/loopstructural/gui/modelling/model_definition_tab.py b/loopstructural/gui/modelling/model_definition_tab.py deleted file mode 100644 index b1b20a8..0000000 --- a/loopstructural/gui/modelling/model_definition_tab.py +++ /dev/null @@ -1,13 +0,0 @@ -import os - -from PyQt5.QtWidgets import QWidget -from qgis.PyQt import uic - -from loopstructural.gui.modelling.base_tab import BaseTab - - -class ModelDefinitionTab(BaseTab): - def __init__(self, parent=None): - super().__init__(parent) - # Load the UI file for Tab 1 - uic.loadUi(os.path.join(os.path.dirname(__file__), "model_definition_tab.ui"), self) diff --git a/loopstructural/gui/modelling/model_definition_tab.ui b/loopstructural/gui/modelling/model_definition_tab.ui deleted file mode 100644 index 2ee7f35..0000000 --- a/loopstructural/gui/modelling/model_definition_tab.ui +++ /dev/null @@ -1,389 +0,0 @@ - - - Form - - - - 0 - 0 - 593 - 721 - - - - Form - - - - - 10 - 0 - 561 - 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 - - - - - - - - - - - - - - - - - - QgsFieldComboBox - QComboBox -
qgsfieldcombobox.h
-
- - QgsMapLayerComboBox - QComboBox -
qgsmaplayercombobox.h
-
-
- - -
diff --git a/loopstructural/gui/modelling/modelling_widget.py b/loopstructural/gui/modelling/modelling_widget.py index b404a80..c286bcc 100644 --- a/loopstructural/gui/modelling/modelling_widget.py +++ b/loopstructural/gui/modelling/modelling_widget.py @@ -5,7 +5,7 @@ from loopstructural.gui.modelling.fault_graph.fault_graph import FaultGraph from loopstructural.gui.modelling.geological_history_tab import GeologialHistoryTab -from loopstructural.gui.modelling.model_definition_tab import ModelDefinitionTab +from loopstructural.gui.modelling.model_definition import ModelDefinitionTab from loopstructural.gui.modelling.stratigraphic_column.stratigraphic_column import StratColumnWidget diff --git a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py index aa52e8b..d312adc 100644 --- a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py +++ b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py @@ -1,4 +1,3 @@ -from PyQt5 import uic from PyQt5.QtWidgets import ( QAbstractItemView, QListWidget, @@ -28,7 +27,19 @@ def __init__(self, parent=None): def add_unit(self): unit_widget = StratigraphicUnitWidget() + unit_widget.deleteRequested.connect(self.delete_unit) # Connect delete signal + print("Unit added and delete signal connected") # Debug print item = QListWidgetItem() item.setSizeHint(unit_widget.sizeHint()) self.unitList.addItem(item) self.unitList.setItemWidget(item, unit_widget) + + def delete_unit(self, unit_widget): + print("delete_unit method triggered") # Debug print + print("Delete unit requested") # Debug print + for i in range(self.unitList.count()): + item = self.unitList.item(i) + if self.unitList.itemWidget(item) == unit_widget: + print(f"Deleting unit at index {i}") # Debug print + self.unitList.takeItem(i) + break diff --git a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.py b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.py index 32403ad..e8a2d2a 100644 --- a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.py +++ b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.py @@ -1,10 +1,24 @@ import os +from typing import Optional from PyQt5 import uic -from PyQt5.QtWidgets import QWidget +from PyQt5.QtCore import pyqtSignal +from PyQt5.QtWidgets import QHBoxLayout, QWidget class StratigraphicUnitWidget(QWidget): - def __init__(self, parent=None): + deleteRequested = pyqtSignal(QWidget) # Signal to request deletion + + def __init__(self, name: Optional[str] = None, colour: Optional[str] = None, parent=None): super().__init__(parent) uic.loadUi(os.path.join(os.path.dirname(__file__), "stratigraphic_unit.ui"), self) + + # Add delete button + layout = QHBoxLayout(self) + self.buttonDelete.clicked.connect(self.request_delete) + self.setLayout(layout) + + def request_delete(self): + print("Delete button clicked in StratigraphicUnitWidget") # Debug print + print("Emitting deleteRequested signal") # Debug print + self.deleteRequested.emit(self) From 7399bdd440347bda20e1baacd054c82b8bf63804 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Wed, 21 May 2025 10:38:04 +1000 Subject: [PATCH 003/111] ruff fix --- docs/conf.py | 4 +- loopstructural/__about__.py | 8 ++-- loopstructural/gui/dlg_settings.py | 6 +-- .../gui/modelling/fault_graph/fault_graph.py | 1 - .../gui/modelling/modelling_widget_back.py | 37 ++++++++----------- loopstructural/main/callableToLayer.py | 5 +-- .../main/geometry/calculateLineAzimuth.py | 2 +- loopstructural/main/geometry/line2point.py | 7 ++-- loopstructural/main/loopstructuralwrapper.py | 8 ++-- loopstructural/main/rasterFromModel.py | 11 +++--- loopstructural/main/vectorLayerWrapper.py | 2 +- loopstructural/plugin_main.py | 9 ++--- loopstructural/processing/provider.py | 6 +-- loopstructural/toolbelt/log_handler.py | 3 +- loopstructural/toolbelt/preferences.py | 3 +- tests/qgis/test_plg_preferences.py | 15 ++++---- tests/unit/test_plg_metadata.py | 16 ++++---- 17 files changed, 65 insertions(+), 78 deletions(-) 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/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/gui/dlg_settings.py b/loopstructural/gui/dlg_settings.py index 660e69b..50a6433 100644 --- a/loopstructural/gui/dlg_settings.py +++ b/loopstructural/gui/dlg_settings.py @@ -1,7 +1,6 @@ #! python3 -""" - Plugin settings form integrated into QGIS 'Options' menu. +"""Plugin settings form integrated into QGIS 'Options' menu. """ # standard @@ -84,7 +83,8 @@ 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 diff --git a/loopstructural/gui/modelling/fault_graph/fault_graph.py b/loopstructural/gui/modelling/fault_graph/fault_graph.py index a8150a7..0a86ffe 100644 --- a/loopstructural/gui/modelling/fault_graph/fault_graph.py +++ b/loopstructural/gui/modelling/fault_graph/fault_graph.py @@ -1,5 +1,4 @@ import os -import sys from PyQt5 import QtCore, QtGui, QtWidgets from PyQt5.QtSvg import QGraphicsSvgItem diff --git a/loopstructural/gui/modelling/modelling_widget_back.py b/loopstructural/gui/modelling/modelling_widget_back.py index dd2fd5f..0f1cb4d 100644 --- a/loopstructural/gui/modelling/modelling_widget_back.py +++ b/loopstructural/gui/modelling/modelling_widget_back.py @@ -1,9 +1,19 @@ -from calendar import c +import json import os import random -import json -from PyQt5.QtCore import QVariant + 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 ( @@ -13,31 +23,16 @@ QDoubleSpinBox, QFileDialog, QLabel, + QLineEdit, QListWidgetItem, QPushButton, QWidget, - QLineEdit, ) -from qgis.core import ( - QgsEllipse, - QgsFeature, - QgsField, - QgsMapLayerProxyModel, - QgsFieldProxyModel, - QgsPoint, - QgsProject, - QgsVectorLayer, -) - -from pyvistaqt import QtInteractor -import pyvista as pv -from LoopStructural.utils import random_hex_colour from ...main import QgsProcessInputData +from ...main.callableToLayer import callableToLayer 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 @@ -917,7 +912,7 @@ def onSaveModel(self): 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("from loopstructural import GeologicalModel\n") f.write(f"model = GeologicalModel.from_file('{name + '.' + fileFormat}')\n") return 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/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..ea27cde 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): 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/vectorLayerWrapper.py b/loopstructural/main/vectorLayerWrapper.py index 7cca5cd..e79f02c 100644 --- a/loopstructural/main/vectorLayerWrapper.py +++ b/loopstructural/main/vectorLayerWrapper.py @@ -1,5 +1,5 @@ import pandas as pd -from qgis.core import QgsWkbTypes, QgsRaster +from qgis.core import QgsRaster, QgsWkbTypes def qgsLayerToDataFrame(layer, dtm) -> pd.DataFrame: diff --git a/loopstructural/plugin_main.py b/loopstructural/plugin_main.py index e48439d..d36d4f4 100644 --- a/loopstructural/plugin_main.py +++ b/loopstructural/plugin_main.py @@ -1,20 +1,19 @@ #! python3 -""" - Main plugin module. +"""Main plugin module. """ # standard +import os from functools import partial from pathlib import Path -import os # PyQGIS from qgis.core import QgsApplication, 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 ( diff --git a/loopstructural/processing/provider.py b/loopstructural/processing/provider.py index b9ad0c1..fcb3402 100644 --- a/loopstructural/processing/provider.py +++ b/loopstructural/processing/provider.py @@ -1,7 +1,6 @@ #! python3 -""" - Processing provider module. +"""Processing provider module. """ # PyQGIS @@ -18,8 +17,7 @@ class LoopstructuralProvider(QgsProcessingProvider): - """ - Processing provider class. + """Processing provider class. """ def loadAlgorithms(self): diff --git a/loopstructural/toolbelt/log_handler.py b/loopstructural/toolbelt/log_handler.py index 017704f..41c8f73 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 ############### diff --git a/loopstructural/toolbelt/preferences.py b/loopstructural/toolbelt/preferences.py index 7f810d5..402304a 100644 --- a/loopstructural/toolbelt/preferences.py +++ b/loopstructural/toolbelt/preferences.py @@ -1,7 +1,6 @@ #! python3 -""" - Plugin settings. +"""Plugin settings. """ # standard 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): From bc7845b7bad807d01a4071f4f3a2f5814525035f Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Wed, 21 May 2025 11:50:55 +1000 Subject: [PATCH 004/111] make tabs scrollable if required --- loopstructural/gui/modelling/base_tab.py | 66 ++++++++++++++----- .../model_definition/model_definition_tab.py | 13 ++-- .../model_definition/model_definition_tab.ui | 34 ---------- 3 files changed, 54 insertions(+), 59 deletions(-) delete mode 100644 loopstructural/gui/modelling/model_definition/model_definition_tab.ui diff --git a/loopstructural/gui/modelling/base_tab.py b/loopstructural/gui/modelling/base_tab.py index c593e7e..8896cdb 100644 --- a/loopstructural/gui/modelling/base_tab.py +++ b/loopstructural/gui/modelling/base_tab.py @@ -1,27 +1,57 @@ -from PyQt5.QtWidgets import QScrollArea, QVBoxLayout, QWidget +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): + def __init__(self, parent=None, scrollable=False): super().__init__(parent) self.data_manager = None # Initialize a default layout for all tabs - self.scroll_area = QScrollArea(self) - self.scroll_area.setWidgetResizable(True) - - # Create a container widget for the scroll area - self.container_widget = QWidget() - self.scroll_area.setWidget(self.container_widget) - - # 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.main_layout.addWidget(self.scroll_area) - - # self.layout = QVBoxLayout(self) - # self.setLayout(self.layout) # Set the layout for the tab + 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.""" diff --git a/loopstructural/gui/modelling/model_definition/model_definition_tab.py b/loopstructural/gui/modelling/model_definition/model_definition_tab.py index 26e3da9..08a2a02 100644 --- a/loopstructural/gui/modelling/model_definition/model_definition_tab.py +++ b/loopstructural/gui/modelling/model_definition/model_definition_tab.py @@ -12,13 +12,12 @@ class ModelDefinitionTab(BaseTab): def __init__(self, parent=None): - super().__init__(parent) + super().__init__(parent, scrollable=True) # Load the UI file for Tab 1 - uic.loadUi(os.path.join(os.path.dirname(__file__), "model_definition_tab.ui"), self) # Create a QToolBox for collapsible sections - self.toolBox = QToolBox(self) - self.mainLayout.addWidget(self.toolBox) + # self.toolBox = QToolBox(self) + # self.add_widget(self.toolBox) # Add widgets to the QToolBox bounding_box = BoundingBoxWidget(self) @@ -29,6 +28,6 @@ def __init__(self, parent=None): for widget in [bounding_box, fault_layers, stratigraphy_layers]: widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) - self.toolBox.addItem(bounding_box, "Bounding Box") - self.toolBox.addItem(fault_layers, "Fault Layers") - self.toolBox.addItem(stratigraphy_layers, "Stratigraphic Layers") + self.add_widget(bounding_box, 'Bounding Box') # , "Bounding Box") + self.add_widget(fault_layers, 'Fault Layers') # , "Fault Layers") + self.add_widget(stratigraphy_layers, 'Stratigraphic Layers') # , "Stratigraphic Layers") diff --git a/loopstructural/gui/modelling/model_definition/model_definition_tab.ui b/loopstructural/gui/modelling/model_definition/model_definition_tab.ui deleted file mode 100644 index 41a67ff..0000000 --- a/loopstructural/gui/modelling/model_definition/model_definition_tab.ui +++ /dev/null @@ -1,34 +0,0 @@ - - - Form - - - - 0 - 0 - 593 - 721 - - - - Form - - - - - 10 - 0 - 561 - 701 - - - - - - - - - - - - From 4fadd68bc7666e52e4cf12af4798f5b1ae061270 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Wed, 21 May 2025 11:51:15 +1000 Subject: [PATCH 005/111] add unconformity to strat column --- .../gui/modelling/geological_history_tab.py | 8 +-- .../stratigraphic_column/__init__.py | 1 + .../stratigraphic_column.py | 26 +++++++--- .../stratigraphic_column/unconformity.py | 18 +++++++ .../stratigraphic_column/unconformity.ui | 52 +++++++++++++++++++ 5 files changed, 95 insertions(+), 10 deletions(-) create mode 100644 loopstructural/gui/modelling/stratigraphic_column/__init__.py create mode 100644 loopstructural/gui/modelling/stratigraphic_column/unconformity.py create mode 100644 loopstructural/gui/modelling/stratigraphic_column/unconformity.ui diff --git a/loopstructural/gui/modelling/geological_history_tab.py b/loopstructural/gui/modelling/geological_history_tab.py index 43485fd..f3930b1 100644 --- a/loopstructural/gui/modelling/geological_history_tab.py +++ b/loopstructural/gui/modelling/geological_history_tab.py @@ -1,17 +1,19 @@ import os +from tokenize import group from PyQt5.QtWidgets import QWidget from qgis.PyQt import uic 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): super().__init__(parent) # Load the UI file for Tab 1 - ui_widget = QWidget() - uic.loadUi(os.path.join(os.path.dirname(__file__), "geological_history_tab.ui"), ui_widget) + stratigraphic_column_widget = StratColumnWidget(self) # Add the loaded UI widget to the container layout - self.container_layout.addWidget(ui_widget) + self.add_widget(stratigraphic_column_widget, group_box=False) + 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 index d312adc..9690b1d 100644 --- a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py +++ b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py @@ -7,6 +7,8 @@ QWidget, ) +from loopstructural.gui.modelling.stratigraphic_column.unconformity import UnconformityWidget + from .stratigraphic_unit import StratigraphicUnitWidget @@ -21,25 +23,35 @@ def __init__(self, parent=None): layout.addWidget(self.unitList) # Add unit button - addButton = QPushButton("Add Unit") - addButton.clicked.connect(self.add_unit) - layout.addWidget(addButton) + 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) def add_unit(self): unit_widget = StratigraphicUnitWidget() unit_widget.deleteRequested.connect(self.delete_unit) # Connect delete signal - print("Unit added and delete signal connected") # Debug print item = QListWidgetItem() item.setSizeHint(unit_widget.sizeHint()) self.unitList.addItem(item) self.unitList.setItemWidget(item, unit_widget) + def add_unconformity(self): + unconformity_widget = UnconformityWidget() + unconformity_widget.deleteRequested.connect(self.delete_unit) + item = QListWidgetItem() + item.setSizeHint(unconformity_widget.sizeHint()) + self.unitList.addItem(item) + self.unitList.setItemWidget(item, unconformity_widget) + def delete_unit(self, unit_widget): - print("delete_unit method triggered") # Debug print - print("Delete unit requested") # Debug print + for i in range(self.unitList.count()): item = self.unitList.item(i) if self.unitList.itemWidget(item) == unit_widget: - print(f"Deleting unit at index {i}") # Debug print self.unitList.takeItem(i) break diff --git a/loopstructural/gui/modelling/stratigraphic_column/unconformity.py b/loopstructural/gui/modelling/stratigraphic_column/unconformity.py new file mode 100644 index 0000000..f5e7b4e --- /dev/null +++ b/loopstructural/gui/modelling/stratigraphic_column/unconformity.py @@ -0,0 +1,18 @@ +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, parent=None): + super().__init__(parent) + uic.loadUi(os.path.join(os.path.dirname(__file__), 'unconformity.ui'), self) + + def request_delete(self): + + self.deleteRequested.emit(self) diff --git a/loopstructural/gui/modelling/stratigraphic_column/unconformity.ui b/loopstructural/gui/modelling/stratigraphic_column/unconformity.ui new file mode 100644 index 0000000..4acd466 --- /dev/null +++ b/loopstructural/gui/modelling/stratigraphic_column/unconformity.ui @@ -0,0 +1,52 @@ + + + StratigraphicUnitWidget + + + + 0 + 0 + 756 + 53 + + + + + + + Type + + + + + + + Delete this unit + + + 🗑️ + + + + + + + Select unconformity type + + + + erode + + + + + onlap + + + + + + + + + From a952692ffd93c6ad30d3eb02c123369fd8ecfd91 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Wed, 21 May 2025 14:18:03 +1000 Subject: [PATCH 006/111] add extents tab to bounding box widget --- .../gui/modelling/geological_history_tab.py | 4 +- .../model_definition/bounding_box.ui | 313 +++++++++++------- 2 files changed, 200 insertions(+), 117 deletions(-) diff --git a/loopstructural/gui/modelling/geological_history_tab.py b/loopstructural/gui/modelling/geological_history_tab.py index f3930b1..6b80aa5 100644 --- a/loopstructural/gui/modelling/geological_history_tab.py +++ b/loopstructural/gui/modelling/geological_history_tab.py @@ -13,7 +13,7 @@ def __init__(self, parent=None): super().__init__(parent) # Load the UI file for Tab 1 stratigraphic_column_widget = StratColumnWidget(self) - + # Add the loaded UI widget to the container layout self.add_widget(stratigraphic_column_widget, group_box=False) - + diff --git a/loopstructural/gui/modelling/model_definition/bounding_box.ui b/loopstructural/gui/modelling/model_definition/bounding_box.ui index 269a7c8..fd16061 100644 --- a/loopstructural/gui/modelling/model_definition/bounding_box.ui +++ b/loopstructural/gui/modelling/model_definition/bounding_box.ui @@ -15,127 +15,210 @@ - - - - 10 - 10 - 730 - 211 - + + + 1 - - - - - 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 - - - - + + + Current Layout + + + + + + + + + + + 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 + + + + + + + + + + Extent + + + + + + + + + <html><head/><body><p>Define the extent of the model on the map.</p></body></html> + + + + + + + + + Top + + + + + + + <html><head/><body><p>Set the top extent of the model.</p></body></html> + + + 10000 + + + 1000 + + + + + + + Bottom + + + + + + + <html><head/><body><p>Set the bottom extent of the model.</p></body></html> + + + -10000 + + + 10000 + + + -3000 + + + + + + +
+ + QgsCollapsibleGroupBox + QGroupBox +
qgscollapsiblegroupbox.h
+ 1 +
+ + QgsExtentGroupBox + QgsCollapsibleGroupBox +
qgsextentgroupbox.h
+ 1 +
QgsMapLayerComboBox QComboBox From 3c197bac79e7bee1eb99b47604bc20ea57c3d352 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Mon, 26 May 2025 10:12:11 +1000 Subject: [PATCH 007/111] move data manager into main from gui --- loopstructural/gui/modelling/data_manager.py | 3 - loopstructural/loopstructural | 1 - loopstructural/main/data_manager.py | 68 ++++++++++++++++++++ 3 files changed, 68 insertions(+), 4 deletions(-) delete mode 100644 loopstructural/gui/modelling/data_manager.py delete mode 120000 loopstructural/loopstructural create mode 100644 loopstructural/main/data_manager.py diff --git a/loopstructural/gui/modelling/data_manager.py b/loopstructural/gui/modelling/data_manager.py deleted file mode 100644 index 4506cd8..0000000 --- a/loopstructural/gui/modelling/data_manager.py +++ /dev/null @@ -1,3 +0,0 @@ -class ModellingDataManager: - def __init__(self): - pass diff --git a/loopstructural/loopstructural b/loopstructural/loopstructural deleted file mode 120000 index 4bb4558..0000000 --- a/loopstructural/loopstructural +++ /dev/null @@ -1 +0,0 @@ -/home/lgrose/dev/plugin_loopstructural/loopstructural \ No newline at end of file diff --git a/loopstructural/main/data_manager.py b/loopstructural/main/data_manager.py new file mode 100644 index 0000000..cc27c97 --- /dev/null +++ b/loopstructural/main/data_manager.py @@ -0,0 +1,68 @@ +from LoopStructural.datatypes import BoundingBox + + +class ModellingDataManager: + def __init__(self): + self._bounding_box = BoundingBox() + self._basal_contacts = None + self._fault_traces = None + self._structural_orientations = None + self._unique_basal_units = [] + + def set_bounding_box(self, east, west, north, south, top, bottom): + """Set the bounding box for the model.""" + self._bounding_box.update([west, south, bottom], [east, north, top]) + + def get_bounding_box(self): + """Get the current bounding box.""" + return self._bounding_box + + def set_basal_contacts(self, basal_contacts, unitname_field=None): + """Set the basal contacts for the model.""" + self._basal_contacts = basal_contacts + self._unitname_field = unitname_field + self.calculate_unique_basal_units() + + def calculate_unique_basal_units(self): + if self._basal_contacts is not None and self._unitname_field is not None: + self._unique_basal_units.clear() + for feature in self._basal_contacts.getFeatures(): + unit_name = feature[self._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 get_basal_contacts(self): + """Get the basal contacts.""" + return self._basal_contacts + + def set_fault_traces(self, fault_traces): + """Set the fault traces for the model.""" + self._fault_traces = fault_traces + + def get_fault_traces(self): + """Get the fault traces.""" + return self._fault_traces + + def set_structural_orientations(self, structural_orientations): + """Set the structural orientations for the model.""" + self._structural_orientations = structural_orientations + + def get_structural_orientations(self): + """Get the structural orientations.""" + return self._structural_orientations + + def updatestratigraphic_column(self, stratigraphic_column): + """Set the stratigraphic column for the model.""" + self._stratigraphic_column = stratigraphic_column + + def get_stratigraphic_column(self): + """Get the stratigraphic column.""" + return self._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 From 055d5f14770cbf4343ffbd90c4e4b5e2c4cf2406 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Mon, 26 May 2025 10:12:22 +1000 Subject: [PATCH 008/111] add project manager class to manage interaction with qgs project --- loopstructural/main/projectmanager.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 loopstructural/main/projectmanager.py 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 From bcf5fe97da34108120be09daa5327882f8869e33 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Mon, 26 May 2025 10:12:36 +1000 Subject: [PATCH 009/111] connect ui elements for stratigraphic layers --- .../model_definition/stratigraphic_layers.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/loopstructural/gui/modelling/model_definition/stratigraphic_layers.py b/loopstructural/gui/modelling/model_definition/stratigraphic_layers.py index 5242a0d..e0e4000 100644 --- a/loopstructural/gui/modelling/model_definition/stratigraphic_layers.py +++ b/loopstructural/gui/modelling/model_definition/stratigraphic_layers.py @@ -1,6 +1,7 @@ import os from PyQt5.QtWidgets import QWidget +from qgis.core import QgsMapLayerProxyModel from qgis.PyQt import uic @@ -9,3 +10,25 @@ def __init__(self, parent=None): 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) + + def onBasalContactsChanged(self, layer): + self.unitNameField.setLayer(layer) + + def onStructuralDataLayerChanged(self, layer): + self.orientationField.setLayer(layer) + self.dipField.setLayer(layer) + self.structuralDataUnitName.setLayer(layer) + + def onUnitFieldChanged(self, field): + pass + # self.updateDataManager() From 73940013d8d59392dc8a74c8e2d4759529c6e33a Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Mon, 26 May 2025 10:12:45 +1000 Subject: [PATCH 010/111] refactor bb ui --- .../model_definition/bounding_box.ui | 372 ++++++++---------- 1 file changed, 166 insertions(+), 206 deletions(-) diff --git a/loopstructural/gui/modelling/model_definition/bounding_box.ui b/loopstructural/gui/modelling/model_definition/bounding_box.ui index fd16061..8d94401 100644 --- a/loopstructural/gui/modelling/model_definition/bounding_box.ui +++ b/loopstructural/gui/modelling/model_definition/bounding_box.ui @@ -7,7 +7,7 @@ 0 0 750 - 423 + 309 @@ -15,216 +15,176 @@ - - - 1 + + + Define the bounding box for the regular grid. + + + + + X + + + + + + + Y + + + + + + + Z + + + + + + + Origin + + + + + + + -10000.000000000000000 + + + 10000.000000000000000 + + + + + + + -10000.000000000000000 + + + 10000.000000000000000 + + + + + + + -10000.000000000000000 + + + 10000.000000000000000 + + + + + + + Maximum + + + + + + + -10000.000000000000000 + + + 10000.000000000000000 + + + + + + + -10000.000000000000000 + + + 10000.000000000000000 + + + + + + + -10000.000000000000000 + + + 10000.000000000000000 + + + + + + + Steps + + + + + + + 1 + + + 1000 + + + 50 + + + + + + + 1 + + + 1000 + + + 50 + + + + + + + 1 + + + 1000 + + + 25 + + + + + + + + + Select from layer current + + + + + + + Use current view extent + + + + + + + Draw on map - - - Current Layout - - - - - - - - - - - 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 - - - - - - - - - - Extent - - - - - - - - - <html><head/><body><p>Define the extent of the model on the map.</p></body></html> - - - - - - - - - Top - - - - - - - <html><head/><body><p>Set the top extent of the model.</p></body></html> - - - 10000 - - - 1000 - - - - - - - Bottom - - - - - - - <html><head/><body><p>Set the bottom extent of the model.</p></body></html> - - - -10000 - - - 10000 - - - -3000 - - - - - - -
- - - QgsCollapsibleGroupBox - QGroupBox -
qgscollapsiblegroupbox.h
- 1 -
- - QgsExtentGroupBox - QgsCollapsibleGroupBox -
qgsextentgroupbox.h
- 1 -
- - QgsMapLayerComboBox - QComboBox -
qgsmaplayercombobox.h
-
-
From 0b79f5c083b75952c1e595445837bed5d4cf432e Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Mon, 26 May 2025 10:31:51 +1000 Subject: [PATCH 011/111] adding data manager to the tabs/widgets --- loopstructural/gui/modelling/base_tab.py | 4 ++-- .../gui/modelling/geological_history_tab.py | 8 ++++---- .../gui/modelling/model_definition/bounding_box.py | 3 ++- .../gui/modelling/model_definition/fault_layers.py | 3 ++- .../model_definition/model_definition_tab.py | 10 +++++----- .../model_definition/stratigraphic_layers.py | 9 +++++++-- loopstructural/gui/modelling/modelling_widget.py | 11 ++++++----- 7 files changed, 28 insertions(+), 20 deletions(-) diff --git a/loopstructural/gui/modelling/base_tab.py b/loopstructural/gui/modelling/base_tab.py index 8896cdb..f930a0a 100644 --- a/loopstructural/gui/modelling/base_tab.py +++ b/loopstructural/gui/modelling/base_tab.py @@ -4,9 +4,9 @@ class BaseTab(QWidget): - def __init__(self, parent=None, scrollable=False): + def __init__(self, parent=None, data_manager=None, scrollable=False): super().__init__(parent) - self.data_manager = None + self.data_manager = data_manager # Initialize a default layout for all tabs if scrollable: self.setAttribute(Qt.WA_TransparentForMouseEvents, True) diff --git a/loopstructural/gui/modelling/geological_history_tab.py b/loopstructural/gui/modelling/geological_history_tab.py index 6b80aa5..f51a979 100644 --- a/loopstructural/gui/modelling/geological_history_tab.py +++ b/loopstructural/gui/modelling/geological_history_tab.py @@ -9,11 +9,11 @@ class GeologialHistoryTab(BaseTab): - def __init__(self, parent=None): - super().__init__(parent) + 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) - + # Add the loaded UI widget to the container layout self.add_widget(stratigraphic_column_widget, group_box=False) - diff --git a/loopstructural/gui/modelling/model_definition/bounding_box.py b/loopstructural/gui/modelling/model_definition/bounding_box.py index 8b23164..12f7dd6 100644 --- a/loopstructural/gui/modelling/model_definition/bounding_box.py +++ b/loopstructural/gui/modelling/model_definition/bounding_box.py @@ -5,7 +5,8 @@ class BoundingBoxWidget(QWidget): - def __init__(self, parent=None): + 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) diff --git a/loopstructural/gui/modelling/model_definition/fault_layers.py b/loopstructural/gui/modelling/model_definition/fault_layers.py index b20e65a..e727f9f 100644 --- a/loopstructural/gui/modelling/model_definition/fault_layers.py +++ b/loopstructural/gui/modelling/model_definition/fault_layers.py @@ -5,7 +5,8 @@ class FaultLayersWidget(QWidget): - def __init__(self, parent=None): + 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) diff --git a/loopstructural/gui/modelling/model_definition/model_definition_tab.py b/loopstructural/gui/modelling/model_definition/model_definition_tab.py index 08a2a02..6b9ee02 100644 --- a/loopstructural/gui/modelling/model_definition/model_definition_tab.py +++ b/loopstructural/gui/modelling/model_definition/model_definition_tab.py @@ -11,8 +11,8 @@ class ModelDefinitionTab(BaseTab): - def __init__(self, parent=None): - super().__init__(parent, scrollable=True) + def __init__(self, parent=None, data_manager=None): + super().__init__(parent, data_manager, scrollable=True) # Load the UI file for Tab 1 # Create a QToolBox for collapsible sections @@ -20,9 +20,9 @@ def __init__(self, parent=None): # self.add_widget(self.toolBox) # Add widgets to the QToolBox - bounding_box = BoundingBoxWidget(self) - fault_layers = FaultLayersWidget(self) - stratigraphy_layers = StratigraphicLayersWidget(self) + bounding_box = BoundingBoxWidget(self, data_manager) + fault_layers = FaultLayersWidget(self, data_manager) + stratigraphy_layers = StratigraphicLayersWidget(self, data_manager) # Set uniform size policy for all widgets for widget in [bounding_box, fault_layers, stratigraphy_layers]: diff --git a/loopstructural/gui/modelling/model_definition/stratigraphic_layers.py b/loopstructural/gui/modelling/model_definition/stratigraphic_layers.py index e0e4000..38f34df 100644 --- a/loopstructural/gui/modelling/model_definition/stratigraphic_layers.py +++ b/loopstructural/gui/modelling/model_definition/stratigraphic_layers.py @@ -6,7 +6,10 @@ class StratigraphicLayersWidget(QWidget): - def __init__(self, parent=None): + 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) @@ -23,6 +26,7 @@ def __init__(self, parent=None): def onBasalContactsChanged(self, layer): self.unitNameField.setLayer(layer) + self.data_manager.set_basal_contacts(layer, self.unitNameField.fieldName()) def onStructuralDataLayerChanged(self, layer): self.orientationField.setLayer(layer) @@ -30,5 +34,6 @@ def onStructuralDataLayerChanged(self, layer): self.structuralDataUnitName.setLayer(layer) def onUnitFieldChanged(self, field): - pass + self.data_manager.set_basal_contacts(self.basalContactsLayer.layer(), field) + # self.updateDataManager() diff --git a/loopstructural/gui/modelling/modelling_widget.py b/loopstructural/gui/modelling/modelling_widget.py index c286bcc..d145c4c 100644 --- a/loopstructural/gui/modelling/modelling_widget.py +++ b/loopstructural/gui/modelling/modelling_widget.py @@ -7,6 +7,7 @@ from loopstructural.gui.modelling.geological_history_tab import GeologialHistoryTab from loopstructural.gui.modelling.model_definition import ModelDefinitionTab from loopstructural.gui.modelling.stratigraphic_column.stratigraphic_column import StratColumnWidget +from loopstructural.main.data_manager import ModellingDataManager class ModellingWidget(QWidget): @@ -16,7 +17,7 @@ def __init__(self, parent: QWidget = None, mapCanvas=None, logger=None): uic.loadUi(os.path.join(os.path.dirname(__file__), "modelling_widget.ui"), self) self.mapCanvas = mapCanvas self.logger = logger - self.data_manager = None + self.data_manager = ModellingDataManager() self.model_definition_tab_widget = None self.geological_history_tab_widget = None self.stratigraphic_column_tab_widget = None @@ -31,10 +32,10 @@ def __init__(self, parent: QWidget = None, mapCanvas=None, logger=None): self.model_setup_tab.setLayout(QVBoxLayout()) if not self.topology_tab.layout(): self.topology_tab.setLayout(QVBoxLayout()) - self.model_definition_tab_widget = ModelDefinitionTab(self) - self.geological_history_tab_widget = GeologialHistoryTab(self) - self.stratigraphic_column_tab_widget = StratColumnWidget(self) - self.fault_graph_tab_widget = FaultGraph(self) + self.model_definition_tab_widget = ModelDefinitionTab(self, self.data_manager) + self.geological_history_tab_widget = GeologialHistoryTab(self, self.data_manager) + self.stratigraphic_column_tab_widget = StratColumnWidget(self, self.data_manager) + self.fault_graph_tab_widget = FaultGraph(self, self.data_manager) self.load_data_tab.layout().addWidget(self.model_definition_tab_widget) self.geological_history_tab.layout().addWidget(self.geological_history_tab_widget) self.model_setup_tab.layout().addWidget(self.stratigraphic_column_tab_widget) From 76f763b8836a6576e227dd3361a1fd4df131130e Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Tue, 27 May 2025 19:00:53 +1000 Subject: [PATCH 012/111] conecting widgets to data manager --- .../gui/modelling/fault_graph/fault_graph.py | 2 +- .../model_definition/bounding_box.py | 40 ++++++++++++++++ .../model_definition/bounding_box.ui | 26 +++++------ .../model_definition/fault_layers.py | 46 +++++++++++++++++++ .../model_definition/stratigraphic_layers.py | 4 +- .../gui/modelling/modelling_widget.py | 15 +++--- .../stratigraphic_column.py | 4 +- loopstructural/main/data_manager.py | 30 ++++++++++-- 8 files changed, 138 insertions(+), 29 deletions(-) diff --git a/loopstructural/gui/modelling/fault_graph/fault_graph.py b/loopstructural/gui/modelling/fault_graph/fault_graph.py index 0a86ffe..7d1810d 100644 --- a/loopstructural/gui/modelling/fault_graph/fault_graph.py +++ b/loopstructural/gui/modelling/fault_graph/fault_graph.py @@ -235,7 +235,7 @@ def mouseDoubleClickEvent(self, event): class FaultGraph(QtWidgets.QWidget): - def __init__(self, parent=None): + def __init__(self, parent=None, data_manager=None): super().__init__() layout = QtWidgets.QVBoxLayout(self) self.view = QtWidgets.QGraphicsView() diff --git a/loopstructural/gui/modelling/model_definition/bounding_box.py b/loopstructural/gui/modelling/model_definition/bounding_box.py index 12f7dd6..df0adf8 100644 --- a/loopstructural/gui/modelling/model_definition/bounding_box.py +++ b/loopstructural/gui/modelling/model_definition/bounding_box.py @@ -10,3 +10,43 @@ def __init__(self, parent=None, data_manager=None): 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) + + 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.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 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 index 8d94401..13ea995 100644 --- a/loopstructural/gui/modelling/model_definition/bounding_box.ui +++ b/loopstructural/gui/modelling/model_definition/bounding_box.ui @@ -50,30 +50,30 @@ - -10000.000000000000000 + 0.000000000000000 - 10000.000000000000000 + 1000000.000000000000000 - -10000.000000000000000 + 0.000000000000000 - 10000.000000000000000 + 1000000.000000000000000 - -10000.000000000000000 + 0.000000000000000 - 10000.000000000000000 + 1000000.000000000000000 @@ -87,20 +87,20 @@ - -10000.000000000000000 + 0.000000000000000 - 10000.000000000000000 + 1000000.000000000000000 - -10000.000000000000000 + 0.000000000000000 - 10000.000000000000000 + 1000000.000000000000000 @@ -163,21 +163,21 @@ - + Select from layer current - + Use current view extent - + Draw on map diff --git a/loopstructural/gui/modelling/model_definition/fault_layers.py b/loopstructural/gui/modelling/model_definition/fault_layers.py index e727f9f..aa2eb47 100644 --- a/loopstructural/gui/modelling/model_definition/fault_layers.py +++ b/loopstructural/gui/modelling/model_definition/fault_layers.py @@ -1,8 +1,11 @@ import os from PyQt5.QtWidgets import QWidget +from qgis.core import QgsFieldProxyModel, QgsMapLayerProxyModel from qgis.PyQt import uic +from ....main.geometry.calculateLineAzimuth import calculateAverageAzimuth + class FaultLayersWidget(QWidget): def __init__(self, parent=None, data_manager=None): @@ -10,3 +13,46 @@ def __init__(self, parent=None, data_manager=None): 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) + + def onFaultTraceLayerChanged(self, layer): + self.faultNameField.setLayer(layer) + self.faultDipField.setLayer(layer) + self.faultDisplacementField.setLayer(layer) + + 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(), + } diff --git a/loopstructural/gui/modelling/model_definition/stratigraphic_layers.py b/loopstructural/gui/modelling/model_definition/stratigraphic_layers.py index 38f34df..88232fd 100644 --- a/loopstructural/gui/modelling/model_definition/stratigraphic_layers.py +++ b/loopstructural/gui/modelling/model_definition/stratigraphic_layers.py @@ -26,7 +26,7 @@ def __init__(self, parent=None, data_manager=None): def onBasalContactsChanged(self, layer): self.unitNameField.setLayer(layer) - self.data_manager.set_basal_contacts(layer, self.unitNameField.fieldName()) + self.data_manager.set_basal_contacts(layer, self.unitNameField.currentField()) def onStructuralDataLayerChanged(self, layer): self.orientationField.setLayer(layer) @@ -34,6 +34,6 @@ def onStructuralDataLayerChanged(self, layer): self.structuralDataUnitName.setLayer(layer) def onUnitFieldChanged(self, field): - self.data_manager.set_basal_contacts(self.basalContactsLayer.layer(), field) + self.data_manager.set_basal_contacts(self.basalContactsLayer.currentLayer(), field) # self.updateDataManager() diff --git a/loopstructural/gui/modelling/modelling_widget.py b/loopstructural/gui/modelling/modelling_widget.py index d145c4c..c3f6445 100644 --- a/loopstructural/gui/modelling/modelling_widget.py +++ b/loopstructural/gui/modelling/modelling_widget.py @@ -17,8 +17,7 @@ def __init__(self, parent: QWidget = None, mapCanvas=None, logger=None): uic.loadUi(os.path.join(os.path.dirname(__file__), "modelling_widget.ui"), self) self.mapCanvas = mapCanvas self.logger = logger - self.data_manager = ModellingDataManager() - self.model_definition_tab_widget = None + self.data_manager = ModellingDataManager(mapCanvas=mapCanvas, logger=logger) self.geological_history_tab_widget = None self.stratigraphic_column_tab_widget = None self.fault_graph_tab_widget = None @@ -32,10 +31,14 @@ def __init__(self, parent: QWidget = None, mapCanvas=None, logger=None): self.model_setup_tab.setLayout(QVBoxLayout()) if not self.topology_tab.layout(): self.topology_tab.setLayout(QVBoxLayout()) - self.model_definition_tab_widget = ModelDefinitionTab(self, self.data_manager) - self.geological_history_tab_widget = GeologialHistoryTab(self, self.data_manager) - self.stratigraphic_column_tab_widget = StratColumnWidget(self, self.data_manager) - self.fault_graph_tab_widget = FaultGraph(self, self.data_manager) + 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.stratigraphic_column_tab_widget = StratColumnWidget( + self, data_manager=self.data_manager + ) + self.fault_graph_tab_widget = FaultGraph(self, data_manager=self.data_manager) self.load_data_tab.layout().addWidget(self.model_definition_tab_widget) self.geological_history_tab.layout().addWidget(self.geological_history_tab_widget) self.model_setup_tab.layout().addWidget(self.stratigraphic_column_tab_widget) diff --git a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py index 9690b1d..4db99df 100644 --- a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py +++ b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py @@ -13,10 +13,10 @@ class StratColumnWidget(QWidget): - def __init__(self, parent=None): + def __init__(self, parent=None, data_manager=None): super().__init__() layout = QVBoxLayout(self) - + self.data_manager = data_manager # Main list widget self.unitList = QListWidget() self.unitList.setDragDropMode(QAbstractItemView.InternalMove) diff --git a/loopstructural/main/data_manager.py b/loopstructural/main/data_manager.py index cc27c97..e8e6162 100644 --- a/loopstructural/main/data_manager.py +++ b/loopstructural/main/data_manager.py @@ -2,16 +2,37 @@ class ModellingDataManager: - def __init__(self): - self._bounding_box = BoundingBox() + def __init__(self, mapCanvas=None, logger=None): + self._bounding_box = BoundingBox(origin=[0, 0, 0], maximum=[1000, 1000, 1000]) self._basal_contacts = None self._fault_traces = None self._structural_orientations = None self._unique_basal_units = [] + self.map_canvas = mapCanvas + self.logger = logger - def set_bounding_box(self, east, west, north, south, top, bottom): + def set_bounding_box(self, xmin=None, xmax=None, ymin=None, ymax=None, zmin=None, zmax=None): """Set the bounding box for the model.""" - self._bounding_box.update([west, south, bottom], [east, north, top]) + 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._bounding_box.update([west, south, bottom], [east, north, top]) def get_bounding_box(self): """Get the current bounding box.""" @@ -21,7 +42,6 @@ def set_basal_contacts(self, basal_contacts, unitname_field=None): """Set the basal contacts for the model.""" self._basal_contacts = basal_contacts self._unitname_field = unitname_field - self.calculate_unique_basal_units() def calculate_unique_basal_units(self): if self._basal_contacts is not None and self._unitname_field is not None: From 91880b0a55e303873293152352a64eb527297c56 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Mon, 2 Jun 2025 15:16:31 +1000 Subject: [PATCH 013/111] updating stratigraphic column widget/manager --- .../gui/modelling/geological_history_tab.py | 4 +- .../gui/modelling/modelling_widget.py | 6 +- .../stratigraphic_column.py | 67 ++++- .../stratigraphic_unit.py | 21 +- .../stratigraphic_column/unconformity.py | 2 + loopstructural/main/data_manager.py | 38 ++- loopstructural/main/stratigraphic_column.py | 230 ++++++++++++++++++ 7 files changed, 354 insertions(+), 14 deletions(-) create mode 100644 loopstructural/main/stratigraphic_column.py diff --git a/loopstructural/gui/modelling/geological_history_tab.py b/loopstructural/gui/modelling/geological_history_tab.py index f51a979..8dc2723 100644 --- a/loopstructural/gui/modelling/geological_history_tab.py +++ b/loopstructural/gui/modelling/geological_history_tab.py @@ -11,9 +11,7 @@ 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) - + 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/modelling_widget.py b/loopstructural/gui/modelling/modelling_widget.py index c3f6445..58479c3 100644 --- a/loopstructural/gui/modelling/modelling_widget.py +++ b/loopstructural/gui/modelling/modelling_widget.py @@ -36,10 +36,10 @@ def __init__(self, parent: QWidget = None, mapCanvas=None, logger=None): self, data_manager=self.data_manager ) self.stratigraphic_column_tab_widget = StratColumnWidget( - self, data_manager=self.data_manager - ) + # self, data_manager=self.data_manager + # ) self.fault_graph_tab_widget = FaultGraph(self, data_manager=self.data_manager) self.load_data_tab.layout().addWidget(self.model_definition_tab_widget) self.geological_history_tab.layout().addWidget(self.geological_history_tab_widget) - self.model_setup_tab.layout().addWidget(self.stratigraphic_column_tab_widget) + # self.model_setup_tab.layout().addWidget(self.stratigraphic_column_tab_widget) self.topology_tab.layout().addWidget(self.fault_graph_tab_widget) diff --git a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py index 4db99df..a0f3ab7 100644 --- a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py +++ b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py @@ -8,11 +8,18 @@ ) from loopstructural.gui.modelling.stratigraphic_column.unconformity import UnconformityWidget +from loopstructural.main.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) @@ -20,6 +27,7 @@ def __init__(self, parent=None, data_manager=None): # 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 @@ -32,15 +40,48 @@ def __init__(self, parent=None, data_manager=None): addUnconformityButton.clicked.connect(self.add_unconformity) layout.addWidget(addUnconformityButton) - def add_unit(self): + # add init from basal contacts button + initFromBasalContactsButton = QPushButton("Initialise from map") + initFromBasalContactsButton.clicked.connect( + self.init_stratigraphic_column_from_basal_contacts + ) + layout.addWidget(initFromBasalContactsButton) + + # Update display from data manager + self.update_display() + + 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()) + elif unit.element_type == StratigraphicColumnElementType.UNCONFORMITY: + self.add_unconformity(unconformity_data=unit.to_dict()) + + 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): unit_widget = StratigraphicUnitWidget() unit_widget.deleteRequested.connect(self.delete_unit) # Connect delete signal 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 + if self.data_manager: + if unit_data is None: + unit_data = {'type': 'unit', 'name': unit_widget.name} + self.data_manager.add_to_stratigraphic_column(unit_data) - def add_unconformity(self): + def add_unconformity(self, *, unconformity_data=None): unconformity_widget = UnconformityWidget() unconformity_widget.deleteRequested.connect(self.delete_unit) item = QListWidgetItem() @@ -48,10 +89,30 @@ def add_unconformity(self): self.unitList.addItem(item) self.unitList.setItemWidget(item, unconformity_widget) - def delete_unit(self, unit_widget): + # Update data manager + if self.data_manager: + if unconformity_data is None: + unconformity_data = {'type': 'unconformity', 'name': unconformity_widget.name} + self.data_manager.add_to_stratigraphic_column(unconformity_data) + 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.name) + + def update_order(self, parent, start, end, destination, row): + """Update the data manager when the order of items changes.""" + if self.data_manager: + ordered_names = [] + for i in range(self.unitList.count()): + item = self.unitList.item(i) + widget = self.unitList.itemWidget(item) + if widget: + ordered_names.append(widget.name) + self.data_manager.update_stratigraphic_column_order(ordered_names) diff --git a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.py b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.py index e8a2d2a..607dc50 100644 --- a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.py +++ b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.py @@ -14,11 +14,24 @@ def __init__(self, name: Optional[str] = None, colour: Optional[str] = None, par uic.loadUi(os.path.join(os.path.dirname(__file__), "stratigraphic_unit.ui"), self) # Add delete button - layout = QHBoxLayout(self) self.buttonDelete.clicked.connect(self.request_delete) - self.setLayout(layout) def request_delete(self): - print("Delete button clicked in StratigraphicUnitWidget") # Debug print - print("Emitting deleteRequested signal") # Debug print + self.deleteRequested.emit(self) + + 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 = 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() diff --git a/loopstructural/gui/modelling/stratigraphic_column/unconformity.py b/loopstructural/gui/modelling/stratigraphic_column/unconformity.py index f5e7b4e..3a87272 100644 --- a/loopstructural/gui/modelling/stratigraphic_column/unconformity.py +++ b/loopstructural/gui/modelling/stratigraphic_column/unconformity.py @@ -12,6 +12,8 @@ class UnconformityWidget(QWidget): def __init__(self, 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) def request_delete(self): diff --git a/loopstructural/main/data_manager.py b/loopstructural/main/data_manager.py index e8e6162..9965c01 100644 --- a/loopstructural/main/data_manager.py +++ b/loopstructural/main/data_manager.py @@ -1,5 +1,7 @@ from LoopStructural.datatypes import BoundingBox +from .stratigraphic_column import StratigraphicColumn + class ModellingDataManager: def __init__(self, mapCanvas=None, logger=None): @@ -10,6 +12,7 @@ def __init__(self, mapCanvas=None, logger=None): self._unique_basal_units = [] self.map_canvas = mapCanvas self.logger = logger + self._stratigraphic_column = StratigraphicColumn() def set_bounding_box(self, xmin=None, xmax=None, ymin=None, ymax=None, zmin=None, zmax=None): """Set the bounding box for the model.""" @@ -42,6 +45,7 @@ def set_basal_contacts(self, basal_contacts, unitname_field=None): """Set the basal contacts for the model.""" self._basal_contacts = basal_contacts self._unitname_field = unitname_field + self.calculate_unique_basal_units() def calculate_unique_basal_units(self): if self._basal_contacts is not None and self._unitname_field is not None: @@ -52,6 +56,38 @@ def calculate_unique_basal_units(self): 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: + self._stratigraphic_column.add_unit(name=unit_name, colour=None) + + def add_to_stratigraphic_column(self, unit_data): + """Add a unit or unconformity to the stratigraphic column.""" + + if isinstance(unit_data, dict): + if unit_data.get('type') == 'unit': + self._stratigraphic_column.add_unit( + name=unit_data.get('name'), colour=unit_data.get('colour') + ) + elif unit_data.get('type') == 'unconformity': + self._stratigraphic_column.add_unconformity(name=unit_data.get('name')) + else: + print('unit_data', unit_data) + raise ValueError("unit_data must be a dictionary with 'type' key.") + + def remove_from_stratigraphic_column(self, unit_name): + """Remove a unit or unconformity from the stratigraphic column.""" + self._stratigraphic_column.remove_unit(name=unit_name) + + 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 names.") + self._stratigraphic_column.update_order(new_order) + def get_basal_contacts(self): """Get the basal contacts.""" return self._basal_contacts @@ -72,7 +108,7 @@ def get_structural_orientations(self): """Get the structural orientations.""" return self._structural_orientations - def updatestratigraphic_column(self, stratigraphic_column): + def update_stratigraphic_column(self, stratigraphic_column): """Set the stratigraphic column for the model.""" self._stratigraphic_column = stratigraphic_column diff --git a/loopstructural/main/stratigraphic_column.py b/loopstructural/main/stratigraphic_column.py new file mode 100644 index 0000000..c89e373 --- /dev/null +++ b/loopstructural/main/stratigraphic_column.py @@ -0,0 +1,230 @@ +import enum + + +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, name): + """ + Initializes the StratigraphicColumnElement with a name and an optional description. + """ + self.name = name + + +class StratigraphicUnit(StratigraphicColumnElement): + """ + A class to represent a stratigraphic unit, which is a distinct layer of rock with specific characteristics. + """ + + def __init__(self, name, colour, thickness=None): + """ + Initializes the StratigraphicUnit with a name and an optional description. + """ + super().__init__(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) + return cls(name, colour, thickness) + + +class StratigraphicUnconformity(StratigraphicColumnElement): + """ + A class to represent a stratigraphic unconformity, which is a surface of discontinuity in the stratigraphic record. + """ + + def __init__(self, name, unconformity_type: UnconformityType = UnconformityType.ERODE): + """ + Initializes the StratigraphicUnconformity with a name and an optional description. + """ + super().__init__(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 {"name": self.name, "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) + ) + return cls(name, 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, colour, thickness) + + self.order.append(unit) + return unit + + def remove_unit(self, name): + """ + Removes a unit or unconformity from the stratigraphic column by its name. + """ + for i, element in enumerate(self.order): + if element.name == name: + del self.order[i] + return True + return False + + def add_unconformity(self, name, unconformity_type=UnconformityType.ERODE): + unconformity = StratigraphicUnconformity(name, 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 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 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.get_unit_by_name(name) + for name in new_order + if self.get_unit_by_name(name) is not None + ] + + 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.name}" 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 From 1aaa609402f62d990a23452e3a857b00e000647c Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Tue, 3 Jun 2025 10:14:49 +1000 Subject: [PATCH 014/111] update stratigraphic column to use uuid instead of name --- .../gui/modelling/modelling_widget.py | 5 +- .../stratigraphic_column.py | 28 +++---- .../stratigraphic_unit.py | 5 +- .../stratigraphic_column/unconformity.py | 27 ++++++- loopstructural/main/data_manager.py | 13 +-- loopstructural/main/stratigraphic_column.py | 79 ++++++++++++++----- 6 files changed, 111 insertions(+), 46 deletions(-) diff --git a/loopstructural/gui/modelling/modelling_widget.py b/loopstructural/gui/modelling/modelling_widget.py index 58479c3..f4d5999 100644 --- a/loopstructural/gui/modelling/modelling_widget.py +++ b/loopstructural/gui/modelling/modelling_widget.py @@ -35,11 +35,8 @@ def __init__(self, parent: QWidget = None, mapCanvas=None, logger=None): self.geological_history_tab_widget = GeologialHistoryTab( self, data_manager=self.data_manager ) - self.stratigraphic_column_tab_widget = StratColumnWidget( - # self, data_manager=self.data_manager - # ) + # self.fault_graph_tab_widget = FaultGraph(self, data_manager=self.data_manager) self.load_data_tab.layout().addWidget(self.model_definition_tab_widget) self.geological_history_tab.layout().addWidget(self.geological_history_tab_widget) - # self.model_setup_tab.layout().addWidget(self.stratigraphic_column_tab_widget) self.topology_tab.layout().addWidget(self.fault_graph_tab_widget) diff --git a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py index a0f3ab7..54d0f75 100644 --- a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py +++ b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py @@ -23,6 +23,8 @@ class StratColumnWidget(QWidget): 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() @@ -68,7 +70,10 @@ def init_stratigraphic_column_from_basal_contacts(self): print("Error: Data manager is not initialized.") def add_unit(self, *, unit_data=None): - unit_widget = StratigraphicUnitWidget() + if unit_data is None: + unit_data = {'type': 'unit', 'name': ''} + unit = self.data_manager.add_to_stratigraphic_column(unit_data) + unit_widget = StratigraphicUnitWidget(uuid=unit.uuid) unit_widget.deleteRequested.connect(self.delete_unit) # Connect delete signal item = QListWidgetItem() item.setSizeHint(unit_widget.sizeHint()) @@ -76,13 +81,12 @@ def add_unit(self, *, unit_data=None): self.unitList.setItemWidget(item, unit_widget) unit_widget.setData(unit_data) # Set data for the unit widget # Update data manager - if self.data_manager: - if unit_data is None: - unit_data = {'type': 'unit', 'name': unit_widget.name} - self.data_manager.add_to_stratigraphic_column(unit_data) def add_unconformity(self, *, unconformity_data=None): - unconformity_widget = UnconformityWidget() + if unconformity_data is None: + unconformity_data = {'type': 'unconformity', 'unconformity_type': 'erode'} + unconformity = self.data_manager.add_to_stratigraphic_column(unconformity_data) + unconformity_widget = UnconformityWidget(uuid=unconformity.uuid) unconformity_widget.deleteRequested.connect(self.delete_unit) item = QListWidgetItem() item.setSizeHint(unconformity_widget.sizeHint()) @@ -90,10 +94,6 @@ def add_unconformity(self, *, unconformity_data=None): self.unitList.setItemWidget(item, unconformity_widget) # Update data manager - if self.data_manager: - if unconformity_data is None: - unconformity_data = {'type': 'unconformity', 'name': unconformity_widget.name} - self.data_manager.add_to_stratigraphic_column(unconformity_data) def delete_unit(self, unit_widget): for i in range(self.unitList.count()): @@ -104,15 +104,15 @@ def delete_unit(self, unit_widget): # Update data manager if self.data_manager: - self.data_manager.remove_from_stratigraphic_column(unit_widget.name) + 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_names = [] + ordered_uuids = [] for i in range(self.unitList.count()): item = self.unitList.item(i) widget = self.unitList.itemWidget(item) if widget: - ordered_names.append(widget.name) - self.data_manager.update_stratigraphic_column_order(ordered_names) + ordered_uuids.append(widget.uuid) + self.data_manager.update_stratigraphic_column_order(ordered_uuids) diff --git a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.py b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.py index 607dc50..27693d6 100644 --- a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.py +++ b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.py @@ -3,15 +3,16 @@ from PyQt5 import uic from PyQt5.QtCore import pyqtSignal -from PyQt5.QtWidgets import QHBoxLayout, QWidget +from PyQt5.QtWidgets import QWidget class StratigraphicUnitWidget(QWidget): deleteRequested = pyqtSignal(QWidget) # Signal to request deletion - def __init__(self, name: Optional[str] = None, colour: Optional[str] = None, parent=None): + def __init__(self, uuid, name: Optional[str] = None, colour: Optional[str] = None, parent=None): super().__init__(parent) uic.loadUi(os.path.join(os.path.dirname(__file__), "stratigraphic_unit.ui"), self) + self.uuid = uuid # Add delete button self.buttonDelete.clicked.connect(self.request_delete) diff --git a/loopstructural/gui/modelling/stratigraphic_column/unconformity.py b/loopstructural/gui/modelling/stratigraphic_column/unconformity.py index 3a87272..519644f 100644 --- a/loopstructural/gui/modelling/stratigraphic_column/unconformity.py +++ b/loopstructural/gui/modelling/stratigraphic_column/unconformity.py @@ -9,12 +9,37 @@ class UnconformityWidget(QWidget): deleteRequested = pyqtSignal(QWidget) # Signal to request deletion - def __init__(self, parent=None): + 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/main/data_manager.py b/loopstructural/main/data_manager.py index 9965c01..1f99346 100644 --- a/loopstructural/main/data_manager.py +++ b/loopstructural/main/data_manager.py @@ -1,5 +1,7 @@ from LoopStructural.datatypes import BoundingBox +from tests import unit + from .stratigraphic_column import StratigraphicColumn @@ -69,23 +71,22 @@ def add_to_stratigraphic_column(self, unit_data): if isinstance(unit_data, dict): if unit_data.get('type') == 'unit': - self._stratigraphic_column.add_unit( + return self._stratigraphic_column.add_unit( name=unit_data.get('name'), colour=unit_data.get('colour') ) elif unit_data.get('type') == 'unconformity': - self._stratigraphic_column.add_unconformity(name=unit_data.get('name')) + return self._stratigraphic_column.add_unconformity(name=unit_data.get('name')) else: - print('unit_data', unit_data) raise ValueError("unit_data must be a dictionary with 'type' key.") - def remove_from_stratigraphic_column(self, unit_name): + def remove_from_stratigraphic_column(self, unit_uuid): """Remove a unit or unconformity from the stratigraphic column.""" - self._stratigraphic_column.remove_unit(name=unit_name) + self._stratigraphic_column.remove_unit(uuid=unit_uuid) 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 names.") + raise ValueError("new_order must be a list of unit uuids.") self._stratigraphic_column.update_order(new_order) def get_basal_contacts(self): diff --git a/loopstructural/main/stratigraphic_column.py b/loopstructural/main/stratigraphic_column.py index c89e373..74d7370 100644 --- a/loopstructural/main/stratigraphic_column.py +++ b/loopstructural/main/stratigraphic_column.py @@ -25,11 +25,15 @@ class StratigraphicColumnElement: for example unconformity. """ - def __init__(self, name): + def __init__(self, uuid=None): """ Initializes the StratigraphicColumnElement with a name and an optional description. """ - self.name = name + if uuid is None: + import uuid as uuid_module + + uuid = str(uuid_module.uuid4()) + self.uuid = uuid class StratigraphicUnit(StratigraphicColumnElement): @@ -37,11 +41,12 @@ class StratigraphicUnit(StratigraphicColumnElement): A class to represent a stratigraphic unit, which is a distinct layer of rock with specific characteristics. """ - def __init__(self, name, colour, thickness=None): + def __init__(self, *, uuid=None, name=None, colour=None, thickness=None): """ Initializes the StratigraphicUnit with a name and an optional description. """ - super().__init__(name) + super().__init__(uuid) + self.name = name self.colour = colour self.thickness = thickness self.element_type = StratigraphicColumnElementType.UNIT @@ -62,7 +67,16 @@ def from_dict(cls, data): name = data.get("name") colour = data.get("colour") thickness = data.get("thickness", None) - return cls(name, colour, thickness) + 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): @@ -70,11 +84,14 @@ class StratigraphicUnconformity(StratigraphicColumnElement): A class to represent a stratigraphic unconformity, which is a surface of discontinuity in the stratigraphic record. """ - def __init__(self, name, unconformity_type: UnconformityType = UnconformityType.ERODE): + def __init__( + self, *, uuid=None, name=None, unconformity_type: UnconformityType = UnconformityType.ERODE + ): """ Initializes the StratigraphicUnconformity with a name and an optional description. """ - super().__init__(name) + 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 @@ -84,7 +101,20 @@ def to_dict(self): """ Converts the stratigraphic unconformity to a dictionary representation. """ - return {"name": self.name, "unconformity_type": self.unconformity_type.value} + 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): @@ -97,7 +127,8 @@ def from_dict(cls, data): unconformity_type = UnconformityType( data.get("unconformity_type", UnconformityType.ERODE.value) ) - return cls(name, unconformity_type) + uuid = data.get("uuid", None) + return cls(uuid=uuid, name=name, unconformity_type=unconformity_type) class StratigraphicColumn: @@ -113,23 +144,25 @@ def __init__(self): self.order = [] def add_unit(self, name, colour, thickness=None): - unit = StratigraphicUnit(name, colour, thickness) + unit = StratigraphicUnit(name=name, colour=colour, thickness=thickness) self.order.append(unit) return unit - def remove_unit(self, name): + def remove_unit(self, uuid): """ - Removes a unit or unconformity from the stratigraphic column by its name. + Removes a unit or unconformity from the stratigraphic column by its uuid. """ for i, element in enumerate(self.order): - if element.name == name: + if element.uuid == uuid: del self.order[i] return True return False def add_unconformity(self, name, unconformity_type=UnconformityType.ERODE): - unconformity = StratigraphicUnconformity(name, unconformity_type) + unconformity = StratigraphicUnconformity( + uuid=None, name=name, unconformity_type=unconformity_type + ) self.order.append(unconformity) return unconformity @@ -147,8 +180,9 @@ def get_unit_by_name(self, name): Retrieves a unit by its name from the stratigraphic column. """ for unit in self.order: - if unit.name == name: + if isinstance(unit, StratigraphicUnit) and unit.name == name: return unit + return None def add_element(self, element): @@ -180,6 +214,15 @@ def get_groups(self): 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. @@ -187,9 +230,7 @@ def update_order(self, new_order): if not isinstance(new_order, list): raise TypeError("New order must be a list") self.order = [ - self.get_unit_by_name(name) - for name in new_order - if self.get_unit_by_name(name) is not None + self.__getitem__(uuid) for uuid in new_order if self.__getitem__(uuid) is not None ] def clear(self): @@ -202,7 +243,7 @@ def __str__(self): """ Returns a string representation of the stratigraphic column, listing all elements. """ - return "\n".join([f"{i+1}. {element.name}" for i, element in enumerate(self.order)]) + return "\n".join([f"{i+1}. {element}" for i, element in enumerate(self.order)]) def to_dict(self): """ From b7a6d4edfafeba1aeb5bccc250fdb59eb3315988 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Tue, 3 Jun 2025 13:09:50 +1000 Subject: [PATCH 015/111] fix: adding new dock widget for 3D viewer --- .../gui/modelling/feature_details_panel.py | 20 +++++ .../gui/modelling/geological_model_tab.py | 51 +++++++++++ .../gui/modelling/modelling_widget.py | 31 +++---- .../stratigraphic_unit.py | 53 ++++++++++- .../gui/visualisation/geometry_object.py | 18 ++++ .../visualisation/loop_pyvistaqt_wrapper.py | 73 +++++++++++++++ .../gui/visualisation/object_list_widget.py | 90 +++++++++++++++++++ .../gui/visualisation/visualisation_widget.py | 43 +++++++++ loopstructural/plugin_main.py | 58 +++++++++--- 9 files changed, 402 insertions(+), 35 deletions(-) create mode 100644 loopstructural/gui/modelling/feature_details_panel.py create mode 100644 loopstructural/gui/modelling/geological_model_tab.py create mode 100644 loopstructural/gui/visualisation/geometry_object.py create mode 100644 loopstructural/gui/visualisation/loop_pyvistaqt_wrapper.py create mode 100644 loopstructural/gui/visualisation/object_list_widget.py create mode 100644 loopstructural/gui/visualisation/visualisation_widget.py diff --git a/loopstructural/gui/modelling/feature_details_panel.py b/loopstructural/gui/modelling/feature_details_panel.py new file mode 100644 index 0000000..74199cf --- /dev/null +++ b/loopstructural/gui/modelling/feature_details_panel.py @@ -0,0 +1,20 @@ +from PyQt5.QtWidgets import QFormLayout, QLineEdit, QWidget + + +class FeatureDetailsPanel(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + + # Layout for feature details + self.featureDetailsLayout = QFormLayout(self) + + # Example fields for parameters and settings + self.parameterInput = QLineEdit() + self.settingInput = QLineEdit() + self.featureDetailsLayout.addRow("Parameter:", self.parameterInput) + self.featureDetailsLayout.addRow("Setting:", self.settingInput) + + def update_feature_details(self, feature): + """Update the panel based on the selected feature.""" + # Logic to update fields based on the feature + pass diff --git a/loopstructural/gui/modelling/geological_model_tab.py b/loopstructural/gui/modelling/geological_model_tab.py new file mode 100644 index 0000000..4cbd53f --- /dev/null +++ b/loopstructural/gui/modelling/geological_model_tab.py @@ -0,0 +1,51 @@ +from PyQt5.QtWidgets import ( + QFormLayout, + QLineEdit, + QPushButton, + QSplitter, + QTreeWidget, + QTreeWidgetItem, + QVBoxLayout, + QWidget, +) + +from loopstructural.gui.modelling.feature_details_panel import FeatureDetailsPanel + + +class GeologicalModelTab(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + + # 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 = FeatureDetailsPanel() + splitter.addWidget(self.featureDetailsPanel) + + # Action buttons + self.saveButton = QPushButton("Save Changes") + self.resetButton = QPushButton("Reset Parameters") + mainLayout.addWidget(self.saveButton) + mainLayout.addWidget(self.resetButton) + + # Connect signals + self.saveButton.clicked.connect(self.save_changes) + self.resetButton.clicked.connect(self.reset_parameters) + + def save_changes(self): + # Logic to save changes + pass + + def reset_parameters(self): + # Logic to reset parameters + pass diff --git a/loopstructural/gui/modelling/modelling_widget.py b/loopstructural/gui/modelling/modelling_widget.py index f4d5999..0a3222e 100644 --- a/loopstructural/gui/modelling/modelling_widget.py +++ b/loopstructural/gui/modelling/modelling_widget.py @@ -1,10 +1,8 @@ -import os - -from PyQt5.QtWidgets import QVBoxLayout, QWidget -from qgis.PyQt import uic +from PyQt5.QtWidgets import QTabWidget, QVBoxLayout, QWidget from loopstructural.gui.modelling.fault_graph.fault_graph import FaultGraph from loopstructural.gui.modelling.geological_history_tab import GeologialHistoryTab +from loopstructural.gui.modelling.geological_model_tab import GeologicalModelTab from loopstructural.gui.modelling.model_definition import ModelDefinitionTab from loopstructural.gui.modelling.stratigraphic_column.stratigraphic_column import StratColumnWidget from loopstructural.main.data_manager import ModellingDataManager @@ -14,29 +12,22 @@ class ModellingWidget(QWidget): def __init__(self, parent: QWidget = None, mapCanvas=None, logger=None): super().__init__(parent) # Load the UI file for Tab 1 - uic.loadUi(os.path.join(os.path.dirname(__file__), "modelling_widget.ui"), self) + # uic.loadUi(os.path.join(os.path.dirname(__file__), "modelling_widget.ui"), self) self.mapCanvas = mapCanvas self.logger = logger self.data_manager = ModellingDataManager(mapCanvas=mapCanvas, logger=logger) self.geological_history_tab_widget = None self.stratigraphic_column_tab_widget = None self.fault_graph_tab_widget = None - - # Ensure the tabs have layouts - if not self.load_data_tab.layout(): - self.load_data_tab.setLayout(QVBoxLayout()) - if not self.geological_history_tab.layout(): - self.geological_history_tab.setLayout(QVBoxLayout()) - if not self.model_setup_tab.layout(): - self.model_setup_tab.setLayout(QVBoxLayout()) - if not self.topology_tab.layout(): - self.topology_tab.setLayout(QVBoxLayout()) 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_graph_tab_widget = FaultGraph(self, data_manager=self.data_manager) - self.load_data_tab.layout().addWidget(self.model_definition_tab_widget) - self.geological_history_tab.layout().addWidget(self.geological_history_tab_widget) - self.topology_tab.layout().addWidget(self.fault_graph_tab_widget) + self.geological_model_tab_widget = GeologicalModelTab(self) + 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.geological_model_tab_widget, "Geological Model") diff --git a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.py b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.py index 27693d6..ea8b3ed 100644 --- a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.py +++ b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.py @@ -13,14 +13,63 @@ def __init__(self, uuid, name: Optional[str] = None, colour: Optional[str] = Non 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 = 0.0 # Optional thickness attribute # Add delete button self.buttonDelete.clicked.connect(self.request_delete) + self.lineEditName.textChanged.connect(self.onNameChanged) + self.spinBoxThickness.valueChanged.connect(self.onThicknessChanged) + self.buttonColor.clicked.connect(self.onColourSelectClicked) + + 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() + + def onNameChanged(self, name: str): + """ + Update the name of the stratigraphic unit. + :param name: The new name value. + """ + self.name = name + self.validateFields() 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. @@ -36,3 +85,5 @@ def setData(self, data: Optional[dict] = None): self.colour = "" self.lineEditName.clear() # self.lineEditColour.clear() + + self.validateFields() diff --git a/loopstructural/gui/visualisation/geometry_object.py b/loopstructural/gui/visualisation/geometry_object.py new file mode 100644 index 0000000..0ee4624 --- /dev/null +++ b/loopstructural/gui/visualisation/geometry_object.py @@ -0,0 +1,18 @@ +import pyvista as pv + + +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..8bf1b7d --- /dev/null +++ b/loopstructural/gui/visualisation/loop_pyvistaqt_wrapper.py @@ -0,0 +1,73 @@ +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) + + 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.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/object_list_widget.py b/loopstructural/gui/visualisation/object_list_widget.py new file mode 100644 index 0000000..dd689f2 --- /dev/null +++ b/loopstructural/gui/visualisation/object_list_widget.py @@ -0,0 +1,90 @@ +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import ( + QCheckBox, + QHBoxLayout, + QLabel, + QMenu, + QTreeWidget, + QTreeWidgetItem, + QWidget, +) + + +class ObjectListWidget(QTreeWidget): + def __init__(self, parent=None, *, viewer=None): + super().__init__(parent) + self.viewer = viewer + self.viewer.objectAdded.connect(self.update_object_list) + + # Set header labels for the tree widget + self.setHeaderLabels(["Object Name", "Visibility"]) + + def update_object_list(self, new_object): + for object_name in self.viewer.actors: + print(f"Adding object: {object_name}") + + self.add_actor(object_name) + + def add_actor(self, actor_name): + # Create a tree item for the object + objectItem = QTreeWidgetItem(self) + objectItem.setText(0, self.viewer.actors[actor_name].name) + + # Add a checkbox for visibility toggle + 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 + ) + ) + self.setItemWidget(objectItem, 1, visibilityCheckbox) + + # # Add child items for properties + # properties = self.viewer.actors[ + # actor_name + # ].properties # Assuming `properties` is a dictionary + # for prop_name, prop_value in properties.items(): + # propertyItem = QTreeWidgetItem(objectItem) + # propertyItem.setText(0, f"{prop_name}: {prop_value}") + + 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.selectedItems() + if not selected_items: + return + + object_name = selected_items[0].text(0) + # Logic for exporting the object + print(f"Exporting object: {object_name}") + + def remove_selected_object(self): + selected_items = self.selectedItems() + if not selected_items: + return + + object_name = selected_items[0].text(0) + # Logic for removing the object + self.viewer.remove_object(object_name) + self.takeTopLevelItem(self.indexOfTopLevelItem(selected_items[0])) + print(f"Removing object: {object_name}") diff --git a/loopstructural/gui/visualisation/visualisation_widget.py b/loopstructural/gui/visualisation/visualisation_widget.py new file mode 100644 index 0000000..3597314 --- /dev/null +++ b/loopstructural/gui/visualisation/visualisation_widget.py @@ -0,0 +1,43 @@ +import pyvista as pv +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import ( + QCheckBox, + QHBoxLayout, + QLabel, + QListWidgetItem, + QMenu, + QSplitter, + QVBoxLayout, + QWidget, +) +from pyvistaqt import QtInteractor + +from .geometry_object import GeometryObject +from .loop_pyvistaqt_wrapper import LoopPyVistaQTPlotter +from .object_list_widget import ObjectListWidget + + +class VisualisationWidget(QWidget): + def __init__(self, parent: QWidget = None, mapCanvas=None, logger=None): + super().__init__(parent) + # Load the UI file for Tab 1 + # uic.loadUi(os.path.join(os.path.dirname(__file__), "modelling_widget.ui"), self) + self.mapCanvas = mapCanvas + self.logger = logger + + 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) + splitter.addWidget(self.objectList) + splitter.addWidget(self.plotter) diff --git a/loopstructural/plugin_main.py b/loopstructural/plugin_main.py index d36d4f4..ebf8835 100644 --- a/loopstructural/plugin_main.py +++ b/loopstructural/plugin_main.py @@ -1,7 +1,6 @@ #! python3 -"""Main plugin module. -""" +"""Main plugin module.""" # standard import os @@ -24,6 +23,7 @@ ) from loopstructural.gui.dlg_settings import PlgOptionsFactory from loopstructural.gui.modelling.modelling_widget import ModellingWidget as Modelling +from loopstructural.gui.visualisation.visualisation_widget import VisualisationWidget from loopstructural.toolbelt import PlgLogger # ############################################################################ @@ -62,7 +62,6 @@ def initGui(self): self.options_factory = PlgOptionsFactory() self.iface.registerOptionsWidgetFactory(self.options_factory) - # -- Actions self.action_help = QAction( QgsApplication.getThemeIcon("mActionHelpContents.svg"), @@ -82,12 +81,17 @@ 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(), ) - + self.action_visualisation = QAction( + QIcon(os.path.dirname(__file__) + "/icon.png"), + self.tr("LoopStructural Visualisation"), + self.iface.mainWindow(), + ) self.toolbar.addAction(self.action_modelling) + self.toolbar.addAction(self.action_visualisation) # -- Menu self.iface.addPluginToMenu(__title__, self.action_settings) @@ -116,11 +120,11 @@ def initGui(self): self.modelling_dockwidget.setWidget(self.model_setup_widget) self.iface.addDockWidget(Qt.RightDockWidgetArea, self.modelling_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: @@ -131,13 +135,39 @@ def initGui(self): self.modelling_dockwidget.show() self.modelling_dockwidget.close() + + ## -- visualisation dock widget + self.visualisation_dockwidget = QDockWidget( + self.tr("Visualisation"), self.iface.mainWindow() + ) + self.visualisation_widget = VisualisationWidget( + self.iface.mainWindow(), mapCanvas=self.iface.mapCanvas(), logger=self.log + ) + self.visualisation_dockwidget.setWidget(self.visualisation_widget) + self.iface.addDockWidget(Qt.RightDockWidgetArea, self.visualisation_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 + if right_docks: + for dock in right_docks: + if dock != self.visualisation_dockwidget: + self.iface.mainWindow().tabifyDockWidget(dock, self.visualisation_dockwidget) + # Optionally, bring your plugin tab to the front + self.visualisation_dockwidget.raise_() + break + self.visualisation_dockwidget.show() + self.visualisation_dockwidget.close() + + # -- Connect actions self.action_modelling.triggered.connect( self.modelling_dockwidget.toggleViewAction().trigger ) - - - - + self.action_visualisation.triggered.connect( + self.visualisation_dockwidget.toggleViewAction().trigger + ) def tr(self, message: str) -> str: """Get the translation for a string using Qt translation API. From f6c5da072b51887594cf81e32d0acb11a40455db Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Tue, 17 Jun 2025 09:18:14 +1000 Subject: [PATCH 016/111] adding model manager --- .../gui/modelling/modelling_widget.py | 3 + .../gui/visualisation/visualisation_widget.py | 10 -- loopstructural/main/data_manager.py | 33 ++++- loopstructural/main/model_manager.py | 121 ++++++++++++++++++ 4 files changed, 154 insertions(+), 13 deletions(-) create mode 100644 loopstructural/main/model_manager.py diff --git a/loopstructural/gui/modelling/modelling_widget.py b/loopstructural/gui/modelling/modelling_widget.py index 0a3222e..221dbfc 100644 --- a/loopstructural/gui/modelling/modelling_widget.py +++ b/loopstructural/gui/modelling/modelling_widget.py @@ -1,3 +1,4 @@ +from LoopStructural import GeologicalModel from PyQt5.QtWidgets import QTabWidget, QVBoxLayout, QWidget from loopstructural.gui.modelling.fault_graph.fault_graph import FaultGraph @@ -6,6 +7,7 @@ from loopstructural.gui.modelling.model_definition import ModelDefinitionTab from loopstructural.gui.modelling.stratigraphic_column.stratigraphic_column import StratColumnWidget from loopstructural.main.data_manager import ModellingDataManager +from loopstructural.main.model_manager import GeologicalModelManager class ModellingWidget(QWidget): @@ -16,6 +18,7 @@ def __init__(self, parent: QWidget = None, mapCanvas=None, logger=None): self.mapCanvas = mapCanvas self.logger = logger self.data_manager = ModellingDataManager(mapCanvas=mapCanvas, logger=logger) + self.model_manager = GeologicalModelManager() self.geological_history_tab_widget = None self.stratigraphic_column_tab_widget = None self.fault_graph_tab_widget = None diff --git a/loopstructural/gui/visualisation/visualisation_widget.py b/loopstructural/gui/visualisation/visualisation_widget.py index 3597314..606a631 100644 --- a/loopstructural/gui/visualisation/visualisation_widget.py +++ b/loopstructural/gui/visualisation/visualisation_widget.py @@ -1,18 +1,9 @@ -import pyvista as pv -from PyQt5.QtCore import Qt from PyQt5.QtWidgets import ( - QCheckBox, - QHBoxLayout, - QLabel, - QListWidgetItem, - QMenu, QSplitter, QVBoxLayout, QWidget, ) -from pyvistaqt import QtInteractor -from .geometry_object import GeometryObject from .loop_pyvistaqt_wrapper import LoopPyVistaQTPlotter from .object_list_widget import ObjectListWidget @@ -21,7 +12,6 @@ class VisualisationWidget(QWidget): def __init__(self, parent: QWidget = None, mapCanvas=None, logger=None): super().__init__(parent) # Load the UI file for Tab 1 - # uic.loadUi(os.path.join(os.path.dirname(__file__), "modelling_widget.ui"), self) self.mapCanvas = mapCanvas self.logger = logger diff --git a/loopstructural/main/data_manager.py b/loopstructural/main/data_manager.py index 1f99346..ed72072 100644 --- a/loopstructural/main/data_manager.py +++ b/loopstructural/main/data_manager.py @@ -1,12 +1,14 @@ from LoopStructural.datatypes import BoundingBox -from tests import unit - from .stratigraphic_column import StratigraphicColumn class ModellingDataManager: - def __init__(self, mapCanvas=None, logger=None): + def __init__(self, *, mapCanvas=None, logger=None): + if mapCanvas is None: + raise ValueError("mapCanvas cannot be None") + if logger is None: + raise ValueError("logger cannot be None") self._bounding_box = BoundingBox(origin=[0, 0, 0], maximum=[1000, 1000, 1000]) self._basal_contacts = None self._fault_traces = None @@ -15,6 +17,15 @@ def __init__(self, mapCanvas=None, logger=None): self.map_canvas = mapCanvas self.logger = logger self._stratigraphic_column = StratigraphicColumn() + self._model_manager = None + + 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.update_bounding_box(self._bounding_box) + self._model_manager.update_stratigraphic_column(self._stratigraphic_column) def set_bounding_box(self, xmin=None, xmax=None, ymin=None, ymax=None, zmin=None, zmax=None): """Set the bounding box for the model.""" @@ -38,6 +49,7 @@ def set_bounding_box(self, xmin=None, xmax=None, ymin=None, ymax=None, zmin=None self._bounding_box.origin = origin self._bounding_box.maximum = maximum # self._bounding_box.update([west, south, bottom], [east, north, top]) + self._model_manager.update_bounding_box(self._bounding_box) def get_bounding_box(self): """Get the current bounding box.""" @@ -96,6 +108,7 @@ def get_basal_contacts(self): def set_fault_traces(self, fault_traces): """Set the fault traces for the model.""" self._fault_traces = fault_traces + self.update_faults() def get_fault_traces(self): """Get the fault traces.""" @@ -117,6 +130,20 @@ def get_stratigraphic_column(self): """Get the stratigraphic column.""" return self._stratigraphic_column + def update_stratigraphys(self): + """Update the foliation features in the model manager.""" + if self._model_manager is not None: + self._model_manager.update_foliation_features() + else: + self.logger(message="Model manager is not set, cannot update foliation features.") + + def update_faults(self): + """Update the faults in the model manager.""" + if self._model_manager is not None: + self._model_manager.update_fault_points(self._fault_traces) + else: + self.logger(message="Model manager is not set, cannot update faults.") + def clear_data(self): """Clear all data in the manager.""" self._bounding_box = BoundingBox() diff --git a/loopstructural/main/model_manager.py b/loopstructural/main/model_manager.py new file mode 100644 index 0000000..994ce1f --- /dev/null +++ b/loopstructural/main/model_manager.py @@ -0,0 +1,121 @@ +from collections import defaultdict + +import geopandas as gpd +import pandas as pd +from LoopStructural import GeologicalModel +from LoopStructural.datatypes import BoundingBox + +from loopstructural.main.stratigraphic_column import StratigraphicColumn + + +class AllSampler: + def __call__(self, line: gpd.GeoDataFrame) -> pd.DataFrame: + return + + +class GeologicalModelManager: + def __init__(self): + self.model = GeologicalModel([0, 0, 0], [1, 1, 1]) + self.stratigraphy = {} + self.groups = [] + self.faults = defaultdict(dict) + + def update_bounding_box(self, bounding_box: BoundingBox): + self.model.bounding_box = bounding_box + + def update_fault_points(self, fault_trace: gpd.GeoDataFrame, *, sampler=AllSampler): + """Add fault trace data to the geological model.""" + # sample fault trace + fault_points = sampler(fault_trace) + + for fault_name in fault_points['fault_name'].unique(): + self.faults[fault_name]['data'] = fault_points.loc[ + fault_points['fault_name'] == fault_name, ['X', 'Y', 'Z'] + ] + + def update_contact_traces(self, basal_contacts: gpd.GeoDataFrame, *, sampler=AllSampler): + unit_points = sampler(basal_contacts) + for unit_name in unit_points['unit_name'].unique(): + self.stratigraphy[unit_name] = unit_points.loc[ + unit_points['unit_name'] == unit_name, [['X', 'Y', 'Z']] + ] + + def update_structural_data(self, structural_orientations: gpd.GeoDataFrame): + """Add structural orientation data to the geological model.""" + 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): + # new_groups = stratigraphic_column.get_groups() + # old_groups = self.groups.copy() + # would be nice to check if the groups have changed + # and if the contents of the group have changed + # but for now just update groups when the stratigraphic column is updated + # # Update the model with the new stratigraphic column + self.groups = stratigraphic_column.get_groups() + self.update_foliation_features() + + def update_foliation_features(self): + for i, units in enumerate(self.groups): + val = 0 + data = [] + groupname = f"Group_{i + 1}" + for u in reversed(units): + unit_data = self.stratigraphy.get(u, None) + if unit_data is None: + continue + else: + unit_data = unit_data.copy() + unit_data['val'] = val + unit_data['feature_name'] = groupname + data.append(unit_data) + val += u.thickness + + data = pd.concat(data, ignore_index=True) + self.model.create_and_add_foliation(groupname, series_surface_data=data) + + 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 + self.model.create_and_add_fault(fault_name, displacement=10, fault_data=data) + + @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.""" + if not self.valid: + raise ValueError("Model is not valid. Please check the data.") + + # Update the model with stratigraphy + for unit_name, unit_data in self.stratigraphy.items(): + self.model.add_stratigraphic_unit(unit_name, unit_data) + + # Update the model with faults + for fault_name, fault_data in self.faults.items(): + if 'data' in fault_data and not fault_data['data'].empty: + self.model.add_fault(fault_name, fault_data['data']) + + # Finalize the model + self.model.finalize() From 58e89cececd71db3bb452314e313b481261f05e0 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Mon, 23 Jun 2025 17:10:54 +1000 Subject: [PATCH 017/111] fix: update bb nelements --- loopstructural/gui/modelling/model_definition/bounding_box.ui | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/loopstructural/gui/modelling/model_definition/bounding_box.ui b/loopstructural/gui/modelling/model_definition/bounding_box.ui index 13ea995..947dd03 100644 --- a/loopstructural/gui/modelling/model_definition/bounding_box.ui +++ b/loopstructural/gui/modelling/model_definition/bounding_box.ui @@ -63,7 +63,7 @@ 0.000000000000000 - 1000000.000000000000000 + 1000000000.000000000000000 @@ -100,7 +100,7 @@ 0.000000000000000 - 1000000.000000000000000 + 1000000001.000000000000000
From 5d6949be4d2bff4d3e23fd85a643d795d7b91edc Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Mon, 23 Jun 2025 17:11:08 +1000 Subject: [PATCH 018/111] fix: add qgistogeodataframe --- loopstructural/main/vectorLayerWrapper.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/loopstructural/main/vectorLayerWrapper.py b/loopstructural/main/vectorLayerWrapper.py index e79f02c..61faa95 100644 --- a/loopstructural/main/vectorLayerWrapper.py +++ b/loopstructural/main/vectorLayerWrapper.py @@ -1,6 +1,25 @@ import pandas as pd +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: """Convert a vector layer to a pandas DataFrame From 64f0a298707141afb84243dd47ff6b632b1d746c Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Mon, 23 Jun 2025 17:13:05 +1000 Subject: [PATCH 019/111] fix: update data manager when fault layer changes --- .../model_definition/fault_layers.py | 33 ++++--------------- 1 file changed, 7 insertions(+), 26 deletions(-) diff --git a/loopstructural/gui/modelling/model_definition/fault_layers.py b/loopstructural/gui/modelling/model_definition/fault_layers.py index aa2eb47..e7513ae 100644 --- a/loopstructural/gui/modelling/model_definition/fault_layers.py +++ b/loopstructural/gui/modelling/model_definition/fault_layers.py @@ -30,29 +30,10 @@ def onFaultTraceLayerChanged(self, layer): self.faultDipField.setLayer(layer) self.faultDisplacementField.setLayer(layer) - 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(), - } + 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(), + ) \ No newline at end of file From 5a00032ece8995f1fa0de93ad047b17894a1ccea Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Mon, 23 Jun 2025 17:13:31 +1000 Subject: [PATCH 020/111] fix: model manager implementation for faults --- loopstructural/main/model_manager.py | 46 ++++++++++++++++++++++------ 1 file changed, 36 insertions(+), 10 deletions(-) diff --git a/loopstructural/main/model_manager.py b/loopstructural/main/model_manager.py index 994ce1f..8f55a63 100644 --- a/loopstructural/main/model_manager.py +++ b/loopstructural/main/model_manager.py @@ -10,7 +10,25 @@ class AllSampler: def __call__(self, line: gpd.GeoDataFrame) -> pd.DataFrame: - return + points = [] + feature_id = 0 + 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 x, y in coords: + points.append({'X': x, 'Y': y, 'Z': 0, 'feature_id': feature_id, **attributes }) + elif geom.geom_type == 'MultiLineString': + for line in geom.geoms: + coords = list(line.coords) + for x, y in coords: + points.append({'X': x, 'Y': y, 'Z': 0, 'feature_id': feature_id, **attributes}) + + elif geom.geom_type == 'Point': + points.append({'X': geom.x, 'Y': geom.y, 'Z': 0, 'feature_id': feature_id, **attributes}) + feature_id += 1 + return pd.DataFrame(points) class GeologicalModelManager: @@ -19,19 +37,24 @@ def __init__(self): self.stratigraphy = {} self.groups = [] self.faults = defaultdict(dict) - + self.stratigraphy = defaultdict(dict) def update_bounding_box(self, bounding_box: BoundingBox): self.model.bounding_box = bounding_box - def update_fault_points(self, fault_trace: gpd.GeoDataFrame, *, sampler=AllSampler): + def update_fault_points(self, fault_trace: gpd.GeoDataFrame, *, fault_name_field=None, fault_dip_field=None, fault_displacement_field=None, sampler=AllSampler()): """Add fault trace data to the geological model.""" # sample fault trace + self.faults.clear() # Clear existing faults fault_points = sampler(fault_trace) - + if fault_name_field is not None: + fault_points['fault_name'] = fault_points[fault_name_field] + else: + fault_points['fault_name'] = fault_points['feature_id'].astype(str) for fault_name in fault_points['fault_name'].unique(): self.faults[fault_name]['data'] = fault_points.loc[ fault_points['fault_name'] == fault_name, ['X', 'Y', 'Z'] ] + def update_contact_traces(self, basal_contacts: gpd.GeoDataFrame, *, sampler=AllSampler): unit_points = sampler(basal_contacts) @@ -105,17 +128,20 @@ def valid(self): def update_model(self): """Update the geological model with the current stratigraphy and faults.""" - if not self.valid: - raise ValueError("Model is not valid. Please check the data.") + # if not self.valid: + # raise ValueError("Model is not valid. Please check the data.") # Update the model with stratigraphy for unit_name, unit_data in self.stratigraphy.items(): - self.model.add_stratigraphic_unit(unit_name, unit_data) + self.model.create_and_add_foliation(unit_name, series_surface_data=unit_data) # Update the model with faults for fault_name, fault_data in self.faults.items(): if 'data' in fault_data and not fault_data['data'].empty: - self.model.add_fault(fault_name, fault_data['data']) + data = fault_data['data'].copy() + data['feature_name'] = fault_name + data['val'] = 0 + self.model.create_and_add_fault(fault_name, 10,fault_data=data) - # Finalize the model - self.model.finalize() + def features(self): + return self.model.features From 00c4d314312e9592e5985c4b9c3ad1da663e80a1 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Mon, 23 Jun 2025 17:14:20 +1000 Subject: [PATCH 021/111] fix: add unit to gui without creating a new one --- .../stratigraphic_column.py | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py index 54d0f75..739eab0 100644 --- a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py +++ b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py @@ -58,9 +58,9 @@ def update_display(self): 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()) + 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()) + self.add_unconformity(unconformity_data=unit.to_dict(),create_new=False) def init_stratigraphic_column_from_basal_contacts(self): if self.data_manager: @@ -69,10 +69,18 @@ def init_stratigraphic_column_from_basal_contacts(self): else: print("Error: Data manager is not initialized.") - def add_unit(self, *, unit_data=None): + def add_unit(self, *, unit_data=None, create_new=True): if unit_data is None: unit_data = {'type': 'unit', 'name': ''} - unit = self.data_manager.add_to_stratigraphic_column(unit_data) + if create_new: + unit = self.data_manager.add_to_stratigraphic_column(unit_data) + else: + if unit_data['name'] is not None or unit_data['name'] != '': + + unit = self.data_manager._stratigraphic_column.get_unit_by_name( + unit_data['name'] + ) + unit_widget = StratigraphicUnitWidget(uuid=unit.uuid) unit_widget.deleteRequested.connect(self.delete_unit) # Connect delete signal item = QListWidgetItem() @@ -82,10 +90,15 @@ def add_unit(self, *, unit_data=None): unit_widget.setData(unit_data) # Set data for the unit widget # Update data manager - def add_unconformity(self, *, unconformity_data=None): + def add_unconformity(self, *, unconformity_data=None, create_new=True): if unconformity_data is None: unconformity_data = {'type': 'unconformity', 'unconformity_type': 'erode'} - unconformity = self.data_manager.add_to_stratigraphic_column(unconformity_data) + if create_new: + unconformity = self.data_manager.add_to_stratigraphic_column(unconformity_data) + else: + unconformity = self.data_manager._stratigraphic_column.get_unconformity_by_type( + unconformity_data['unconformity_type'] + ) unconformity_widget = UnconformityWidget(uuid=unconformity.uuid) unconformity_widget.deleteRequested.connect(self.delete_unit) item = QListWidgetItem() From ac7070ecc7aafe98d2802903257c3500a5ecd6d3 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Mon, 23 Jun 2025 17:14:36 +1000 Subject: [PATCH 022/111] fix: add feature detail panels --- .../gui/modelling/feature_details_panel.py | 144 ++++++++++++++++-- .../gui/modelling/geological_model_tab.py | 46 +++++- 2 files changed, 173 insertions(+), 17 deletions(-) diff --git a/loopstructural/gui/modelling/feature_details_panel.py b/loopstructural/gui/modelling/feature_details_panel.py index 74199cf..c6458e0 100644 --- a/loopstructural/gui/modelling/feature_details_panel.py +++ b/loopstructural/gui/modelling/feature_details_panel.py @@ -1,20 +1,136 @@ -from PyQt5.QtWidgets import QFormLayout, QLineEdit, QWidget +from PyQt5.QtWidgets import ( + QWidget, QVBoxLayout, QSlider, QLabel, QDoubleSpinBox, QCheckBox, QFormLayout, QLineEdit +) +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import QScrollArea +from LoopStructural.utils import normal_vector_to_strike_and_dip +class BaseFeatureDetailsPanel(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + # Create a scroll area for horizontal scrolling + scroll = QScrollArea(self) + scroll.setWidgetResizable(True) + scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) + scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) -class FeatureDetailsPanel(QWidget): - def __init__(self, parent=None): + # 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) + +class FaultFeatureDetailsPanel(BaseFeatureDetailsPanel): + def __init__(self, parent=None,*, fault=None): + super().__init__(parent) + 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] + + 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, + # 'enabled': fault.fault_enabled + } + + # Fault displacement slider + self.displacement_slider = QSlider(Qt.Horizontal) + self.displacement_slider.setRange(0, 1000) # Example range + self.displacement_slider.setValue(self.fault.displacement) + self.displacement_label = QLabel("Fault Displacement: 0") + self.displacement_slider.valueChanged.connect( + lambda value: self.displacement_label.setText(f"Fault Displacement: {value}") + ) + self.displacement_slider.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) + ) + # Enabled field + # self.enabled_checkbox = QCheckBox("Enabled") + # self.enabled_checkbox.setChecked(False) + + # Form layout for better organization + form_layout = QFormLayout() + form_layout.addRow(self.displacement_label, self.displacement_slider) + 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) - # Layout for feature details - self.featureDetailsLayout = QFormLayout(self) + # Foliation thickness slider + self.thickness_slider = QSlider(Qt.Horizontal) + self.thickness_slider.setRange(0, 100) # Example range + self.thickness_slider.setValue(0) + self.thickness_label = QLabel("Foliation Thickness: 0") + self.thickness_slider.valueChanged.connect( + lambda value: self.thickness_label.setText(f"Foliation Thickness: {value}") + ) + + # Foliation orientation + self.orientation_spinbox = QDoubleSpinBox() + self.orientation_spinbox.setRange(0, 360) # Orientation angle range + self.orientation_spinbox.setValue(0) + self.orientation_spinbox.setPrefix("Orientation: ") + + # Enabled field + self.enabled_checkbox = QCheckBox("Enabled") + self.enabled_checkbox.setChecked(False) - # Example fields for parameters and settings - self.parameterInput = QLineEdit() - self.settingInput = QLineEdit() - self.featureDetailsLayout.addRow("Parameter:", self.parameterInput) - self.featureDetailsLayout.addRow("Setting:", self.settingInput) + # Form layout for better organization + form_layout = QFormLayout() + form_layout.addRow(self.thickness_label, self.thickness_slider) + form_layout.addRow("Orientation:", self.orientation_spinbox) + form_layout.addRow("Enabled:", self.enabled_checkbox) - def update_feature_details(self, feature): - """Update the panel based on the selected feature.""" - # Logic to update fields based on the feature - pass + self.layout.addLayout(form_layout) + # Remove redundant layout setting + # self.setLayout(self.layout) \ No newline at end of file diff --git a/loopstructural/gui/modelling/geological_model_tab.py b/loopstructural/gui/modelling/geological_model_tab.py index 4cbd53f..ad18ac7 100644 --- a/loopstructural/gui/modelling/geological_model_tab.py +++ b/loopstructural/gui/modelling/geological_model_tab.py @@ -1,3 +1,4 @@ +from PyQt5.QtCore import Qt from PyQt5.QtWidgets import ( QFormLayout, QLineEdit, @@ -9,12 +10,14 @@ QWidget, ) -from loopstructural.gui.modelling.feature_details_panel import FeatureDetailsPanel +from loopstructural.gui.modelling.feature_details_panel import FaultFeatureDetailsPanel, FoliationFeatureDetailsPanel +from LoopStructural.modelling.features import FeatureType class GeologicalModelTab(QWidget): - def __init__(self, parent=None): + def __init__(self, parent=None,*, model_manager=None): super().__init__(parent) + self.model_manager = model_manager # Main layout mainLayout = QVBoxLayout(self) @@ -29,9 +32,18 @@ def __init__(self, parent=None): splitter.addWidget(self.featureList) # Feature details panel - self.featureDetailsPanel = FeatureDetailsPanel() + 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.saveButton = QPushButton("Save Changes") self.resetButton = QPushButton("Reset Parameters") @@ -41,6 +53,10 @@ def __init__(self, parent=None): # Connect signals self.saveButton.clicked.connect(self.save_changes) self.resetButton.clicked.connect(self.reset_parameters) + 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 @@ -49,3 +65,27 @@ def save_changes(self): def reset_parameters(self): # Logic to reset parameters pass + + def initialize_model(self): + self.model_manager.update_model() + for feature in self.model_manager.features(): + 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.FOLIATION: + 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 From 863f9700c90499ae5e3687231275a5a51c8dbcc4 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Mon, 23 Jun 2025 17:14:57 +1000 Subject: [PATCH 023/111] fix: link model manager and data manager --- .../gui/modelling/modelling_widget.py | 5 ++--- loopstructural/main/data_manager.py | 18 ++++++++++++++---- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/loopstructural/gui/modelling/modelling_widget.py b/loopstructural/gui/modelling/modelling_widget.py index 221dbfc..ae603a7 100644 --- a/loopstructural/gui/modelling/modelling_widget.py +++ b/loopstructural/gui/modelling/modelling_widget.py @@ -13,12 +13,11 @@ class ModellingWidget(QWidget): def __init__(self, parent: QWidget = None, mapCanvas=None, logger=None): super().__init__(parent) - # Load the UI file for Tab 1 - # uic.loadUi(os.path.join(os.path.dirname(__file__), "modelling_widget.ui"), self) self.mapCanvas = mapCanvas self.logger = logger self.data_manager = ModellingDataManager(mapCanvas=mapCanvas, logger=logger) self.model_manager = GeologicalModelManager() + self.data_manager.set_model_manager(self.model_manager) self.geological_history_tab_widget = None self.stratigraphic_column_tab_widget = None self.fault_graph_tab_widget = None @@ -26,7 +25,7 @@ def __init__(self, parent: QWidget = None, mapCanvas=None, logger=None): self.geological_history_tab_widget = GeologialHistoryTab( self, data_manager=self.data_manager ) - self.geological_model_tab_widget = GeologicalModelTab(self) + self.geological_model_tab_widget = GeologicalModelTab(self, model_manager = self.model_manager) mainLayout = QVBoxLayout(self) self.setLayout(mainLayout) tabWidget = QTabWidget(self) diff --git a/loopstructural/main/data_manager.py b/loopstructural/main/data_manager.py index ed72072..10bf265 100644 --- a/loopstructural/main/data_manager.py +++ b/loopstructural/main/data_manager.py @@ -1,7 +1,7 @@ from LoopStructural.datatypes import BoundingBox from .stratigraphic_column import StratigraphicColumn - +from .vectorLayerWrapper import qgsLayerToGeoDataFrame class ModellingDataManager: def __init__(self, *, mapCanvas=None, logger=None): @@ -105,9 +105,18 @@ def get_basal_contacts(self): """Get the basal contacts.""" return self._basal_contacts - def set_fault_traces(self, fault_traces): + def set_fault_trace_layer(self, fault_trace_layer, fault_name_field=None, fault_dip_field=None, fault_displacement_field=None): """Set the fault traces for the model.""" - self._fault_traces = fault_traces + if fault_trace_layer is None: + print("Fault trace layer is None, cannot set fault traces.") + return + if fault_trace_layer.featureCount()==0: + self.logger(message="Fault trace layer is empty, cannot set fault traces.") + return + + 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} self.update_faults() def get_fault_traces(self): @@ -140,7 +149,8 @@ def update_stratigraphys(self): def update_faults(self): """Update the faults in the model manager.""" if self._model_manager is not None: - self._model_manager.update_fault_points(self._fault_traces) + 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']) else: self.logger(message="Model manager is not set, cannot update faults.") From a6fea129ffdce7ec7f98c692ef3a704b699df12a Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Tue, 24 Jun 2025 08:17:37 +1000 Subject: [PATCH 024/111] fix: visualisation load external meshes --- .../gui/modelling/feature_details_panel.py | 10 +- .../gui/visualisation/object_list_widget.py | 92 ++++++++++++++----- .../gui/visualisation/visualisation_widget.py | 2 +- 3 files changed, 76 insertions(+), 28 deletions(-) diff --git a/loopstructural/gui/modelling/feature_details_panel.py b/loopstructural/gui/modelling/feature_details_panel.py index c6458e0..8543e13 100644 --- a/loopstructural/gui/modelling/feature_details_panel.py +++ b/loopstructural/gui/modelling/feature_details_panel.py @@ -47,7 +47,7 @@ def __init__(self, parent=None,*, fault=None): self.displacement_slider = QSlider(Qt.Horizontal) self.displacement_slider.setRange(0, 1000) # Example range self.displacement_slider.setValue(self.fault.displacement) - self.displacement_label = QLabel("Fault Displacement: 0") + self.displacement_label = QLabel(f"Fault Displacement: {self.fault.displacement}") self.displacement_slider.valueChanged.connect( lambda value: self.displacement_label.setText(f"Fault Displacement: {value}") ) @@ -59,14 +59,14 @@ def __init__(self, parent=None,*, fault=None): 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.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.setPrefix("Minor Axis Length: ") self.minor_axis_spinbox.valueChanged.connect( lambda value: self.fault_parameters.__setitem__('minor_axis_length', value) ) @@ -76,13 +76,13 @@ def __init__(self, parent=None,*, fault=None): self.intermediate_axis_spinbox.valueChanged.connect( lambda value: self.fault_parameters.__setitem__('intermediate_axis_length', value) ) - self.intermediate_axis_spinbox.setPrefix("Intermediate Axis Length: ") + # 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.setPrefix("Fault Dip: ") self.dip_spinbox.valueChanged.connect( lambda value: self.fault_parameters.__setitem__('dip', value) ) diff --git a/loopstructural/gui/visualisation/object_list_widget.py b/loopstructural/gui/visualisation/object_list_widget.py index dd689f2..0bcea68 100644 --- a/loopstructural/gui/visualisation/object_list_widget.py +++ b/loopstructural/gui/visualisation/object_list_widget.py @@ -1,36 +1,49 @@ from PyQt5.QtCore import Qt from PyQt5.QtWidgets import ( QCheckBox, - QHBoxLayout, + QVBoxLayout, QLabel, QMenu, QTreeWidget, QTreeWidgetItem, QWidget, + QPushButton, + QDialog, + QRadioButton, + QButtonGroup, + QDialogButtonBox, + QFileDialog, + QHBoxLayout, # Add missing import ) +import pyvista as pv -class ObjectListWidget(QTreeWidget): +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.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) - # Set header labels for the tree widget - self.setHeaderLabels(["Object Name", "Visibility"]) - def update_object_list(self, new_object): + for object_name in self.viewer.actors: - print(f"Adding object: {object_name}") - - self.add_actor(object_name) + if not self.treeWidget.findItems(object_name, Qt.MatchExactly): + self.add_actor(object_name ) def add_actor(self, actor_name): # Create a tree item for the object - objectItem = QTreeWidgetItem(self) - objectItem.setText(0, self.viewer.actors[actor_name].name) + objectItem = QTreeWidgetItem(self.treeWidget) - # Add a checkbox for visibility toggle + # Add a checkbox for visibility toggle in front of the name visibilityCheckbox = QCheckBox() visibilityCheckbox.setChecked(self.viewer.actors[actor_name].visibility) visibilityCheckbox.stateChanged.connect( @@ -38,16 +51,16 @@ def add_actor(self, actor_name): name, state == Qt.Checked ) ) - self.setItemWidget(objectItem, 1, visibilityCheckbox) - # # Add child items for properties - # properties = self.viewer.actors[ - # actor_name - # ].properties # Assuming `properties` is a dictionary - # for prop_name, prop_value in properties.items(): - # propertyItem = QTreeWidgetItem(objectItem) - # propertyItem.setText(0, f"{prop_name}: {prop_value}") + # 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): @@ -70,7 +83,7 @@ def contextMenuEvent(self, event): self.remove_selected_object() def export_selected_object(self): - selected_items = self.selectedItems() + selected_items = self.treeWidget.selectedItems() if not selected_items: return @@ -79,12 +92,47 @@ def export_selected_object(self): print(f"Exporting object: {object_name}") def remove_selected_object(self): - selected_items = self.selectedItems() + selected_items = self.treeWidget.selectedItems() if not selected_items: return object_name = selected_items[0].text(0) # Logic for removing the object self.viewer.remove_object(object_name) - self.takeTopLevelItem(self.indexOfTopLevelItem(selected_items[0])) + self.treeWidget.takeTopLevelItem(self.treeWidget.indexOfTopLevelItem(selected_items[0])) print(f"Removing object: {object_name}") + + 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 + self.viewer.add_mesh(mesh,name=file_name) + print(f"Loaded mesh from file: {file_path}") + except Exception as e: + print(f"Failed to load mesh: {e}") diff --git a/loopstructural/gui/visualisation/visualisation_widget.py b/loopstructural/gui/visualisation/visualisation_widget.py index 606a631..325bcc7 100644 --- a/loopstructural/gui/visualisation/visualisation_widget.py +++ b/loopstructural/gui/visualisation/visualisation_widget.py @@ -27,7 +27,7 @@ def __init__(self, parent: QWidget = None, mapCanvas=None, logger=None): # Create the viewer self.plotter = LoopPyVistaQTPlotter(parent) # self.plotter.add_axes() - + self.objectList = ObjectListWidget(viewer=self.plotter) splitter.addWidget(self.objectList) splitter.addWidget(self.plotter) From 12edfea2dcc1bc651745b0f9ee579b103b556263 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Tue, 24 Jun 2025 10:47:19 +1000 Subject: [PATCH 025/111] fix: allow updating of stratigraphic column element --- loopstructural/main/stratigraphic_column.py | 34 +++++++++++++++++++-- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/loopstructural/main/stratigraphic_column.py b/loopstructural/main/stratigraphic_column.py index 74d7370..985b50f 100644 --- a/loopstructural/main/stratigraphic_column.py +++ b/loopstructural/main/stratigraphic_column.py @@ -1,5 +1,5 @@ import enum - +from typing import Dict, List class UnconformityType(enum.Enum): """ @@ -213,7 +213,19 @@ def get_groups(self): 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. @@ -232,7 +244,23 @@ def update_order(self, new_order): 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. From fc9685abc16e2bb7c14317fd0047671e89393ab7 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Tue, 24 Jun 2025 10:47:40 +1000 Subject: [PATCH 026/111] fix: don't duplicate features in featurelist --- loopstructural/gui/modelling/geological_model_tab.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/loopstructural/gui/modelling/geological_model_tab.py b/loopstructural/gui/modelling/geological_model_tab.py index ad18ac7..3599875 100644 --- a/loopstructural/gui/modelling/geological_model_tab.py +++ b/loopstructural/gui/modelling/geological_model_tab.py @@ -69,6 +69,10 @@ def reset_parameters(self): def initialize_model(self): self.model_manager.update_model() for feature in self.model_manager.features(): + 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) @@ -80,7 +84,7 @@ def on_feature_selected(self, item): if feature.type == FeatureType.FAULT: print("Fault feature selected") self.featureDetailsPanel = FaultFeatureDetailsPanel(fault=feature) - elif feature.type == FeatureType.FOLIATION: + elif feature.type == FeatureType.INTERPOLATED: self.featureDetailsPanel = FoliationFeatureDetailsPanel(feature=feature ) else: self.featureDetailsPanel = QWidget() # Default empty panel From e180e9b77a9ea528703d526d20984267890110df Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Tue, 24 Jun 2025 10:48:16 +1000 Subject: [PATCH 027/111] fix: update stratigraphic column units when thickness/name changes --- .../stratigraphic_column.py | 8 +++++ .../stratigraphic_unit.py | 32 +++++++++++++++---- 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py index 739eab0..145922c 100644 --- a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py +++ b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py @@ -83,6 +83,8 @@ def add_unit(self, *, unit_data=None, create_new=True): unit_widget = StratigraphicUnitWidget(uuid=unit.uuid) 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 item = QListWidgetItem() item.setSizeHint(unit_widget.sizeHint()) self.unitList.addItem(item) @@ -129,3 +131,9 @@ def update_order(self, parent, start, end, destination, row): if widget: ordered_uuids.append(widget.uuid) 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 index ea8b3ed..21ec4ee 100644 --- a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.py +++ b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.py @@ -8,7 +8,9 @@ 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, parent=None): super().__init__(parent) uic.loadUi(os.path.join(os.path.dirname(__file__), "stratigraphic_unit.ui"), self) @@ -18,7 +20,7 @@ def __init__(self, uuid, name: Optional[str] = None, colour: Optional[str] = Non self.thickness = 0.0 # Optional thickness attribute # Add delete button self.buttonDelete.clicked.connect(self.request_delete) - self.lineEditName.textChanged.connect(self.onNameChanged) + self.lineEditName.editingFinished.connect(self.onNameChanged) self.spinBoxThickness.valueChanged.connect(self.onThicknessChanged) self.buttonColor.clicked.connect(self.onColourSelectClicked) @@ -40,19 +42,23 @@ def onThicknessChanged(self, thickness: float): """ self.thickness = thickness self.validateFields() - - def onNameChanged(self, name: str): + self.thicknessChanged.emit(thickness) + def onNameChanged(self): """ Update the name of the stratigraphic unit. :param name: The new name value. """ - self.name = name - self.validateFields() - + 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. @@ -87,3 +93,15 @@ def setData(self, data: Optional[dict] = None): # 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 + } \ No newline at end of file From 54c3b165f3146cbb68c018b8add723f0da3ecde9 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Tue, 24 Jun 2025 10:48:44 +1000 Subject: [PATCH 028/111] fix: build stratigraphy objects --- loopstructural/main/data_manager.py | 47 +++++++++++++++++++++------- loopstructural/main/model_manager.py | 37 +++++++++++++++++----- 2 files changed, 65 insertions(+), 19 deletions(-) diff --git a/loopstructural/main/data_manager.py b/loopstructural/main/data_manager.py index 10bf265..5cf8382 100644 --- a/loopstructural/main/data_manager.py +++ b/loopstructural/main/data_manager.py @@ -57,15 +57,19 @@ def get_bounding_box(self): def set_basal_contacts(self, basal_contacts, unitname_field=None): """Set the basal contacts for the model.""" - self._basal_contacts = basal_contacts - self._unitname_field = unitname_field + self._basal_contacts = {'layer':basal_contacts, 'unitname_field': unitname_field} + # 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() + def calculate_unique_basal_units(self): - if self._basal_contacts is not None and self._unitname_field is not None: + if self._basal_contacts is not None and self._basal_contacts['unitname_field'] is not None: self._unique_basal_units.clear() - for feature in self._basal_contacts.getFeatures(): - unit_name = feature[self._unitname_field] + 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) @@ -80,17 +84,22 @@ def init_stratigraphic_column_from_basal_contacts(self): 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': - return self._stratigraphic_column.add_unit( + stratigraphic_element = self._stratigraphic_column.add_unit( name=unit_data.get('name'), colour=unit_data.get('colour') ) elif unit_data.get('type') == 'unconformity': - return self._stratigraphic_column.add_unconformity(name=unit_data.get('name')) + 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) @@ -139,10 +148,19 @@ def get_stratigraphic_column(self): """Get the stratigraphic column.""" return self._stratigraphic_column - def update_stratigraphys(self): + def update_stratigraphy(self): """Update the foliation features in the model manager.""" + print("Updating stratigraphy...") + self.update_stratigraphic_column() if self._model_manager is not None: - self._model_manager.update_foliation_features() + 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: + 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) else: self.logger(message="Model manager is not set, cannot update foliation features.") @@ -153,7 +171,12 @@ def update_faults(self): 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']) 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() diff --git a/loopstructural/main/model_manager.py b/loopstructural/main/model_manager.py index 8f55a63..54ac66f 100644 --- a/loopstructural/main/model_manager.py +++ b/loopstructural/main/model_manager.py @@ -56,15 +56,35 @@ def update_fault_points(self, fault_trace: gpd.GeoDataFrame, *, fault_name_field ] - def update_contact_traces(self, basal_contacts: gpd.GeoDataFrame, *, sampler=AllSampler): + def update_contact_traces(self, basal_contacts: gpd.GeoDataFrame, *, sampler=AllSampler(), unit_name_field=None): unit_points = sampler(basal_contacts) + if unit_name_field is not None: + unit_points['unit_name'] = unit_points[unit_name_field] + else: + return for unit_name in unit_points['unit_name'].unique(): self.stratigraphy[unit_name] = unit_points.loc[ - unit_points['unit_name'] == unit_name, [['X', 'Y', 'Z']] + unit_points['unit_name'] == unit_name, ['X', 'Y', 'Z'] ] - def update_structural_data(self, structural_orientations: gpd.GeoDataFrame): + def update_structural_data(self, structural_orientations: gpd.GeoDataFrame, *, strike_field=None, dip_field=None, unit_name_field=None,dip_direction=False): """Add structural orientation data to the geological model.""" + if strike_field is None or dip_field is None: + return + if unit_name_field is not None: + return + structural_orientations = structural_orientations.copy() + structural_orientations['unit_name'] = structural_orientations[unit_name_field] + structural_orientations['X'] = structural_orientations.geometry.x + structural_orientations['Y'] = structural_orientations.geometry.y + structural_orientations['Z'] = structural_orientations.geometry.z + 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'] @@ -81,13 +101,16 @@ def update_stratigraphic_column(self, stratigraphic_column: StratigraphicColumn) self.groups = stratigraphic_column.get_groups() self.update_foliation_features() + def update_stratigraphic_unit(self, unit_data): + self.data + def update_foliation_features(self): for i, units in enumerate(self.groups): val = 0 data = [] groupname = f"Group_{i + 1}" for u in reversed(units): - unit_data = self.stratigraphy.get(u, None) + unit_data = self.stratigraphy.get(u.name, None) if unit_data is None: continue else: @@ -96,7 +119,8 @@ def update_foliation_features(self): unit_data['feature_name'] = groupname data.append(unit_data) val += u.thickness - + if len(data) == 0: + continue data = pd.concat(data, ignore_index=True) self.model.create_and_add_foliation(groupname, series_surface_data=data) @@ -132,8 +156,7 @@ def update_model(self): # raise ValueError("Model is not valid. Please check the data.") # Update the model with stratigraphy - for unit_name, unit_data in self.stratigraphy.items(): - self.model.create_and_add_foliation(unit_name, series_surface_data=unit_data) + self.update_foliation_features() # Update the model with faults for fault_name, fault_data in self.faults.items(): From c3c2f7196cce72d17656185b8aae865a49ce60d6 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Wed, 25 Jun 2025 15:16:07 +1000 Subject: [PATCH 029/111] setting up pre-commit --- .gitignore | 2 +- .pre-commit-config.yaml | 26 -------------------------- 2 files changed, 1 insertion(+), 27 deletions(-) diff --git a/.gitignore b/.gitignore index efcc2c9..8755903 100644 --- a/.gitignore +++ b/.gitignore @@ -129,4 +129,4 @@ dmypy.json *.zip *.qm -loopstructural/embedded_external_libs/* \ No newline at end of file +loopstructural/embedded_external_libs/* 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 From 48647ec0aa9be91b7595aec7895a590eedad313e Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Wed, 25 Jun 2025 15:16:32 +1000 Subject: [PATCH 030/111] precommit on non python files --- .github/labeler.yml | 2 +- .github/workflows/auto-labeler.yml | 1 - .github/workflows/documentation.yml | 5 ++--- .github/workflows/linter.yml | 1 - .github/workflows/packager.yml | 3 +-- .github/workflows/release-please.yml | 4 ++-- .github/workflows/releaser.yml | 1 - .github/workflows/tester.yml | 1 - .vscode/extensions.json | 4 ++-- CHANGELOG.md | 2 +- CONTRIBUTING.md | 1 - 11 files changed, 9 insertions(+), 16 deletions(-) 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 8947b63..c477465 100644 --- a/.github/workflows/auto-labeler.yml +++ b/.github/workflows/auto-labeler.yml @@ -12,4 +12,3 @@ jobs: - uses: actions/labeler@v5 with: repo-token: "${{ secrets.GITHUB_TOKEN }}" - 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 38d315b..fbe546b 100644 --- a/.github/workflows/linter.yml +++ b/.github/workflows/linter.yml @@ -47,4 +47,3 @@ jobs: - uses: stefanzweifel/git-auto-commit-action@v5 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..7a80ab6 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,4 @@ 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..8046c7b 100644 --- a/.github/workflows/releaser.yml +++ b/.github/workflows/releaser.yml @@ -56,5 +56,4 @@ 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/.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 - From 0e3aa8bf473ecc3443f420c343b2ae8b6b2096cc Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Wed, 25 Jun 2025 15:19:36 +1000 Subject: [PATCH 031/111] precommit --- loopstructural/__init__.py | 2 +- .../gui/modelling/feature_details_panel.py | 4 +- .../gui/modelling/geological_history_tab.py | 4 -- .../gui/modelling/geological_model_tab.py | 2 - .../model_definition/fault_layers.py | 3 +- .../model_definition/model_definition_tab.py | 4 +- .../gui/modelling/modelling_widget_back.py | 48 +++++++++---------- .../gui/visualisation/geometry_object.py | 1 - .../gui/visualisation/object_list_widget.py | 19 +++++--- loopstructural/main/stratigraphic_column.py | 2 +- loopstructural/main/vectorLayerWrapper.py | 2 +- loopstructural/metadata.txt | 2 +- 12 files changed, 44 insertions(+), 49 deletions(-) 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/modelling/feature_details_panel.py b/loopstructural/gui/modelling/feature_details_panel.py index 8543e13..361e408 100644 --- a/loopstructural/gui/modelling/feature_details_panel.py +++ b/loopstructural/gui/modelling/feature_details_panel.py @@ -1,5 +1,5 @@ from PyQt5.QtWidgets import ( - QWidget, QVBoxLayout, QSlider, QLabel, QDoubleSpinBox, QCheckBox, QFormLayout, QLineEdit + QWidget, QVBoxLayout, QSlider, QLabel, QDoubleSpinBox, QCheckBox, QFormLayout ) from PyQt5.QtCore import Qt from PyQt5.QtWidgets import QScrollArea @@ -133,4 +133,4 @@ def __init__(self, parent=None,*, feature=None): self.layout.addLayout(form_layout) # Remove redundant layout setting - # self.setLayout(self.layout) \ No newline at end of file + # self.setLayout(self.layout) diff --git a/loopstructural/gui/modelling/geological_history_tab.py b/loopstructural/gui/modelling/geological_history_tab.py index 8dc2723..c5a6bdb 100644 --- a/loopstructural/gui/modelling/geological_history_tab.py +++ b/loopstructural/gui/modelling/geological_history_tab.py @@ -1,8 +1,4 @@ -import os -from tokenize import group -from PyQt5.QtWidgets import QWidget -from qgis.PyQt import uic from loopstructural.gui.modelling.base_tab import BaseTab from loopstructural.gui.modelling.stratigraphic_column.stratigraphic_column import StratColumnWidget diff --git a/loopstructural/gui/modelling/geological_model_tab.py b/loopstructural/gui/modelling/geological_model_tab.py index 3599875..31e7547 100644 --- a/loopstructural/gui/modelling/geological_model_tab.py +++ b/loopstructural/gui/modelling/geological_model_tab.py @@ -1,7 +1,5 @@ from PyQt5.QtCore import Qt from PyQt5.QtWidgets import ( - QFormLayout, - QLineEdit, QPushButton, QSplitter, QTreeWidget, diff --git a/loopstructural/gui/modelling/model_definition/fault_layers.py b/loopstructural/gui/modelling/model_definition/fault_layers.py index e7513ae..90b4086 100644 --- a/loopstructural/gui/modelling/model_definition/fault_layers.py +++ b/loopstructural/gui/modelling/model_definition/fault_layers.py @@ -4,7 +4,6 @@ from qgis.core import QgsFieldProxyModel, QgsMapLayerProxyModel from qgis.PyQt import uic -from ....main.geometry.calculateLineAzimuth import calculateAverageAzimuth class FaultLayersWidget(QWidget): @@ -36,4 +35,4 @@ def onFaultFieldChanged(self): fault_name_field = self.faultNameField.currentField(), fault_dip_field = self.faultDipField.currentField(), fault_displacement_field = self.faultDisplacementField.currentField(), - ) \ No newline at end of file + ) diff --git a/loopstructural/gui/modelling/model_definition/model_definition_tab.py b/loopstructural/gui/modelling/model_definition/model_definition_tab.py index 6b9ee02..391792e 100644 --- a/loopstructural/gui/modelling/model_definition/model_definition_tab.py +++ b/loopstructural/gui/modelling/model_definition/model_definition_tab.py @@ -1,7 +1,5 @@ -import os -from PyQt5.QtWidgets import QSizePolicy, QToolBox -from qgis.PyQt import uic +from PyQt5.QtWidgets import QSizePolicy from loopstructural.gui.modelling.base_tab import BaseTab diff --git a/loopstructural/gui/modelling/modelling_widget_back.py b/loopstructural/gui/modelling/modelling_widget_back.py index 0f1cb4d..4511c7e 100644 --- a/loopstructural/gui/modelling/modelling_widget_back.py +++ b/loopstructural/gui/modelling/modelling_widget_back.py @@ -93,11 +93,11 @@ def setLayerFieldComboBoxFromProject(self, comboBox: QComboBox, fieldKey: str, l ) return comboBox.setField(fieldName) - - - + + + def loadFromProject(self): - # Load settings from project + # 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") @@ -128,7 +128,7 @@ def loadFromProject(self): # 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 @@ -165,14 +165,14 @@ 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) @@ -190,7 +190,7 @@ def _connectSignals(self): 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')) @@ -206,8 +206,8 @@ def _connectSignals(self): self.faultDipValue.valueChanged.connect( lambda value: self.updateFaultProperty('fault_dip', value) ) - - + + self.structuralDataUnitName.fieldChanged.connect( lambda: self.saveLayerFieldComboBoxState(self.structuralDataUnitName,'structuraldata_unitname_field') ) @@ -229,9 +229,9 @@ def _connectSignals(self): 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)) @@ -249,7 +249,7 @@ def _connectSignals(self): self.clearPyvistaButton.clicked.connect(self.clearPyvista) self.addSurfacesToPyvistaButton.clicked.connect(self.addModelSurfacesToPyvista) self.addDataToPyvistaButton.clicked.connect(self.addDataToPyvista) - QgsProject.instance().readProject.connect(self.loadFromProject) + QgsProject.instance().readProject.connect(self.loadFromProject) def onModelListItemClicked(self, feature): self.activeFeature = self.model[feature.text()] self.numberOfElementsSpinBox.setValue( @@ -396,7 +396,7 @@ def addModelSurfacesToPyvista(self): return surfaces = self.model.get_stratigraphic_surfaces() for surface in surfaces: - self.plotter.add_mesh(surface.vtk(),show_scalar_bar=False,color=surface.colour) + 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') @@ -508,7 +508,7 @@ def onUnitFieldChanged(self, field): 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( @@ -629,7 +629,7 @@ def onFaultFieldChanged(self, field): self.initFaultSelector() self.initFaultNetwork() # self.saveLayersToProject() - + def saveLayersToProject(self): if self.basalContactsLayer.currentLayer() is not None: self.project.writeEntry( @@ -677,7 +677,7 @@ def saveLayersToProject(self): 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() @@ -698,7 +698,7 @@ def saveFaultsToProject(self): 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) @@ -763,7 +763,7 @@ def _initialiseStratigraphicColumn(self): child = self.stratigraphicColumnContainer.takeAt(0) if child.widget(): child.widget().deleteLater() - + def create_lambda(i, direction): return lambda: self.onOrderChanged(i, i + direction) @@ -778,7 +778,7 @@ def 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())) @@ -826,7 +826,7 @@ def pick_color(): 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 @@ -851,12 +851,12 @@ def addUnitToStratigraphicColumn(self): } self._initialiseStratigraphicColumn() self.saveUnitsToProject() - + def stratigraphicColumnUnitNameChanged(self, unit, name): - + old_name = unit if unit == name: - return + return if unit not in self._units: return if name in self._units and name != unit: diff --git a/loopstructural/gui/visualisation/geometry_object.py b/loopstructural/gui/visualisation/geometry_object.py index 0ee4624..e2bfc1c 100644 --- a/loopstructural/gui/visualisation/geometry_object.py +++ b/loopstructural/gui/visualisation/geometry_object.py @@ -1,4 +1,3 @@ -import pyvista as pv class GeometryObject: diff --git a/loopstructural/gui/visualisation/object_list_widget.py b/loopstructural/gui/visualisation/object_list_widget.py index 0bcea68..04d0119 100644 --- a/loopstructural/gui/visualisation/object_list_widget.py +++ b/loopstructural/gui/visualisation/object_list_widget.py @@ -8,10 +8,6 @@ QTreeWidgetItem, QWidget, QPushButton, - QDialog, - QRadioButton, - QButtonGroup, - QDialogButtonBox, QFileDialog, QHBoxLayout, # Add missing import ) @@ -29,15 +25,24 @@ def __init__(self, parent=None, *, viewer=None): 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) def update_object_list(self, new_object): - + for object_name in self.viewer.actors: - if not self.treeWidget.findItems(object_name, Qt.MatchExactly): - self.add_actor(object_name ) + # 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 diff --git a/loopstructural/main/stratigraphic_column.py b/loopstructural/main/stratigraphic_column.py index 985b50f..7f8cedb 100644 --- a/loopstructural/main/stratigraphic_column.py +++ b/loopstructural/main/stratigraphic_column.py @@ -1,5 +1,5 @@ import enum -from typing import Dict, List +from typing import Dict class UnconformityType(enum.Enum): """ diff --git a/loopstructural/main/vectorLayerWrapper.py b/loopstructural/main/vectorLayerWrapper.py index 61faa95..c4f2e6c 100644 --- a/loopstructural/main/vectorLayerWrapper.py +++ b/loopstructural/main/vectorLayerWrapper.py @@ -53,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 adc24a6..0b4d4f6 100644 --- a/loopstructural/metadata.txt +++ b/loopstructural/metadata.txt @@ -23,5 +23,5 @@ qgisMaximumVersion=3.99 # versioning version=0.1.0 changelog= - + # python From 7a73327f33ecd53447c3d43f03e321189253ad58 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Wed, 25 Jun 2025 15:20:00 +1000 Subject: [PATCH 032/111] fix: move data/model manager to plugin main --- loopstructural/gui/modelling/modelling_widget.py | 13 ++++--------- loopstructural/plugin_main.py | 15 ++++++++++++--- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/loopstructural/gui/modelling/modelling_widget.py b/loopstructural/gui/modelling/modelling_widget.py index ae603a7..2c42b29 100644 --- a/loopstructural/gui/modelling/modelling_widget.py +++ b/loopstructural/gui/modelling/modelling_widget.py @@ -1,23 +1,18 @@ -from LoopStructural import GeologicalModel from PyQt5.QtWidgets import QTabWidget, QVBoxLayout, QWidget -from loopstructural.gui.modelling.fault_graph.fault_graph import FaultGraph from loopstructural.gui.modelling.geological_history_tab import GeologialHistoryTab from loopstructural.gui.modelling.geological_model_tab import GeologicalModelTab from loopstructural.gui.modelling.model_definition import ModelDefinitionTab -from loopstructural.gui.modelling.stratigraphic_column.stratigraphic_column import StratColumnWidget -from loopstructural.main.data_manager import ModellingDataManager -from loopstructural.main.model_manager import GeologicalModelManager 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) self.mapCanvas = mapCanvas self.logger = logger - self.data_manager = ModellingDataManager(mapCanvas=mapCanvas, logger=logger) - self.model_manager = GeologicalModelManager() - self.data_manager.set_model_manager(self.model_manager) + 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 diff --git a/loopstructural/plugin_main.py b/loopstructural/plugin_main.py index ebf8835..ab458e9 100644 --- a/loopstructural/plugin_main.py +++ b/loopstructural/plugin_main.py @@ -23,6 +23,8 @@ ) from loopstructural.gui.dlg_settings import PlgOptionsFactory from loopstructural.gui.modelling.modelling_widget import ModellingWidget as Modelling +from loopstructural.main.data_manager import ModellingDataManager +from loopstructural.main.model_manager import GeologicalModelManager from loopstructural.gui.visualisation.visualisation_widget import VisualisationWidget from loopstructural.toolbelt import PlgLogger @@ -53,6 +55,12 @@ 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 + ) + self.model_manager = GeologicalModelManager( + ) + self.data_manager.set_model_manager(self.model_manager) def initGui(self): """Set up plugin UI elements.""" @@ -115,7 +123,8 @@ def initGui(self): ## --- 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.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) @@ -138,10 +147,10 @@ def initGui(self): ## -- visualisation dock widget self.visualisation_dockwidget = QDockWidget( - self.tr("Visualisation"), self.iface.mainWindow() + self.tr("Visualisation"), self.iface.mainWindow(), ) self.visualisation_widget = VisualisationWidget( - self.iface.mainWindow(), mapCanvas=self.iface.mapCanvas(), logger=self.log + self.iface.mainWindow(), mapCanvas=self.iface.mapCanvas(), logger=self.log,model_manager=self.model_manager ) self.visualisation_dockwidget.setWidget(self.visualisation_widget) self.iface.addDockWidget(Qt.RightDockWidgetArea, self.visualisation_dockwidget) From 6ee278be9a21185eda443f3681f749f9c0341b27 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Wed, 25 Jun 2025 15:20:23 +1000 Subject: [PATCH 033/111] fix: add clear stratigraphic column --- .../stratigraphic_column/stratigraphic_column.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py index 145922c..d6c2abc 100644 --- a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py +++ b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py @@ -1,3 +1,4 @@ + from PyQt5.QtWidgets import ( QAbstractItemView, QListWidget, @@ -48,10 +49,19 @@ def __init__(self, parent=None, data_manager=None): 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() + 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() @@ -130,6 +140,8 @@ def update_order(self, parent, start, end, destination, row): 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): From 606e3f4d905544852a6edc1d5b5853adf13fa717 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Wed, 25 Jun 2025 15:20:41 +1000 Subject: [PATCH 034/111] fix: remove name string and colour button from stratigraphic unit --- .../stratigraphic_unit.py | 7 ++- .../stratigraphic_unit.ui | 43 +++++-------------- 2 files changed, 14 insertions(+), 36 deletions(-) diff --git a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.py b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.py index 21ec4ee..0268935 100644 --- a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.py +++ b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.py @@ -22,7 +22,6 @@ def __init__(self, uuid, name: Optional[str] = None, colour: Optional[str] = Non self.buttonDelete.clicked.connect(self.request_delete) self.lineEditName.editingFinished.connect(self.onNameChanged) self.spinBoxThickness.valueChanged.connect(self.onThicknessChanged) - self.buttonColor.clicked.connect(self.onColourSelectClicked) def onColourSelectClicked(self): """ @@ -57,8 +56,8 @@ def request_delete(self): self.deleteRequested.emit(self) - - + + def validateFields(self): """ Validate the fields and update the widget's appearance. @@ -104,4 +103,4 @@ def getData(self) -> dict: "name": self.name, "colour": self.colour, "thickness": self.thickness - } \ No newline at end of file + } diff --git a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.ui b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.ui index 25223a7..9272c68 100644 --- a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.ui +++ b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.ui @@ -7,35 +7,11 @@ 0 0 756 - 53 + 62 - - - - Thickness: - - - - - - Color: - - - - - - - Delete this unit - - - 🗑️ - - - - m @@ -52,22 +28,25 @@ - + + + + - Name: + Thickness: - + + + Delete this unit + - Select + 🗑️ - - -
From d4a031281ddb82c3bb4b76a363d4f7cc44e0c0bc Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Wed, 25 Jun 2025 15:21:16 +1000 Subject: [PATCH 035/111] fix: add feature list widget. Shows all features in model for the visualiser --- .../gui/visualisation/feature_list_widget.py | 124 ++++++++++++++++++ .../gui/visualisation/visualisation_widget.py | 18 ++- 2 files changed, 139 insertions(+), 3 deletions(-) create mode 100644 loopstructural/gui/visualisation/feature_list_widget.py diff --git a/loopstructural/gui/visualisation/feature_list_widget.py b/loopstructural/gui/visualisation/feature_list_widget.py new file mode 100644 index 0000000..e58f9d6 --- /dev/null +++ b/loopstructural/gui/visualisation/feature_list_widget.py @@ -0,0 +1,124 @@ +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import ( + QVBoxLayout, + QMenu, + QTreeWidget, + QTreeWidgetItem, + QWidget, + QPushButton +) + +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(): + self.add_feature(feature) + + def add_feature(self, 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() + self.viewer.add_mesh(vector_field.vtk(), 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: + 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() + 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=f'stratigraphic_surface_{surface.name}') + print("Adding stratigraphic surfaces...") diff --git a/loopstructural/gui/visualisation/visualisation_widget.py b/loopstructural/gui/visualisation/visualisation_widget.py index 325bcc7..88f9dde 100644 --- a/loopstructural/gui/visualisation/visualisation_widget.py +++ b/loopstructural/gui/visualisation/visualisation_widget.py @@ -1,3 +1,4 @@ +from PyQt5.QtCore import Qt from PyQt5.QtWidgets import ( QSplitter, QVBoxLayout, @@ -6,14 +7,17 @@ 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): + 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) @@ -27,7 +31,15 @@ def __init__(self, parent: QWidget = None, mapCanvas=None, logger=None): # Create the viewer self.plotter = LoopPyVistaQTPlotter(parent) # self.plotter.add_axes() - + self.objectList = ObjectListWidget(viewer=self.plotter) - splitter.addWidget(self.objectList) + + # 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) From 393e343439cfb53aaf3679831d38ccae621a64f2 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Wed, 25 Jun 2025 15:21:38 +1000 Subject: [PATCH 036/111] fix: add stratgiraphic column to model when building stratigraphy --- loopstructural/main/model_manager.py | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/loopstructural/main/model_manager.py b/loopstructural/main/model_manager.py index 54ac66f..8c25fa1 100644 --- a/loopstructural/main/model_manager.py +++ b/loopstructural/main/model_manager.py @@ -24,7 +24,7 @@ def __call__(self, line: gpd.GeoDataFrame) -> pd.DataFrame: coords = list(line.coords) for x, y in coords: points.append({'X': x, 'Y': y, 'Z': 0, 'feature_id': feature_id, **attributes}) - + elif geom.geom_type == 'Point': points.append({'X': geom.x, 'Y': geom.y, 'Z': 0, 'feature_id': feature_id, **attributes}) feature_id += 1 @@ -38,6 +38,7 @@ def __init__(self): self.groups = [] self.faults = defaultdict(dict) self.stratigraphy = defaultdict(dict) + self.observers = [] def update_bounding_box(self, bounding_box: BoundingBox): self.model.bounding_box = bounding_box @@ -54,14 +55,14 @@ def update_fault_points(self, fault_trace: gpd.GeoDataFrame, *, fault_name_field self.faults[fault_name]['data'] = fault_points.loc[ fault_points['fault_name'] == fault_name, ['X', 'Y', 'Z'] ] - + def update_contact_traces(self, basal_contacts: gpd.GeoDataFrame, *, sampler=AllSampler(), unit_name_field=None): unit_points = sampler(basal_contacts) if unit_name_field is not None: unit_points['unit_name'] = unit_points[unit_name_field] else: - return + return for unit_name in unit_points['unit_name'].unique(): self.stratigraphy[unit_name] = unit_points.loc[ unit_points['unit_name'] == unit_name, ['X', 'Y', 'Z'] @@ -74,7 +75,7 @@ def update_structural_data(self, structural_orientations: gpd.GeoDataFrame, *, s if unit_name_field is not None: return structural_orientations = structural_orientations.copy() - structural_orientations['unit_name'] = structural_orientations[unit_name_field] + structural_orientations['unit_name'] = structural_orientations[unit_name_field] structural_orientations['X'] = structural_orientations.geometry.x structural_orientations['Y'] = structural_orientations.geometry.y structural_orientations['Z'] = structural_orientations.geometry.z @@ -105,24 +106,36 @@ def update_stratigraphic_unit(self, unit_data): self.data def update_foliation_features(self): + stratigraphic_column = {} + unit_id = 0 for i, units in enumerate(self.groups): val = 0 data = [] groupname = f"Group_{i + 1}" + stratigraphic_column[groupname] = {} for u in reversed(units): unit_data = self.stratigraphy.get(u.name, None) if unit_data is None: continue else: + stratigraphic_column[groupname][u.name] = { + "max": val + u.thickness, + "min": val, + "id": unit_id, + "colour": u.colour, + } unit_data = unit_data.copy() unit_data['val'] = val unit_data['feature_name'] = groupname data.append(unit_data) + unit_id += 1 val += u.thickness if len(data) == 0: continue data = pd.concat(data, ignore_index=True) - self.model.create_and_add_foliation(groupname, series_surface_data=data) + foliation = self.model.create_and_add_foliation(groupname, series_surface_data=data) + self.model.add_unconformity(foliation,0) + self.model.set_stratigraphic_column(stratigraphic_column) def update_fault_features(self): """Update the fault features in the geological model.""" @@ -166,5 +179,7 @@ def update_model(self): data['val'] = 0 self.model.create_and_add_fault(fault_name, 10,fault_data=data) + for observer in self.observers: + observer() def features(self): return self.model.features From 83effed25c80bccfc122620cf39bdf07df053385 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Wed, 25 Jun 2025 15:22:11 +1000 Subject: [PATCH 037/111] fix: call update stratirgaphy whenever column changes --- loopstructural/main/data_manager.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/loopstructural/main/data_manager.py b/loopstructural/main/data_manager.py index 5cf8382..9204c4d 100644 --- a/loopstructural/main/data_manager.py +++ b/loopstructural/main/data_manager.py @@ -80,8 +80,10 @@ def init_stratigraphic_column_from_basal_contacts(self): return else: for unit_name in self._unique_basal_units: - self._stratigraphic_column.add_unit(name=unit_name, colour=None) - + 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 @@ -103,12 +105,15 @@ def add_to_stratigraphic_column(self, unit_data): 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.""" @@ -154,12 +159,12 @@ def update_stratigraphy(self): self.update_stratigraphic_column() 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']), + 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: - 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'], + 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) else: self.logger(message="Model manager is not set, cannot update foliation features.") @@ -167,7 +172,7 @@ def update_stratigraphy(self): def update_faults(self): """Update the faults in the model manager.""" if self._model_manager is not None: - self._model_manager.update_fault_points(qgsLayerToGeoDataFrame(self._fault_traces['layer']), + 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']) else: self.logger(message="Model manager is not set, cannot update faults.") From e695b5f5abe628e32b765bacbbe5692aafede03a Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Wed, 25 Jun 2025 15:22:25 +1000 Subject: [PATCH 038/111] misc --- loopstructural/gui/visualisation/model_object_widget.py | 0 loopstructural/main/loopstructuralwrapper.py | 4 ++-- loopstructural/requirements.txt | 2 +- loopstructural/resources/images/infinity_loop_icon.svg | 4 ++++ 4 files changed, 7 insertions(+), 3 deletions(-) create mode 100644 loopstructural/gui/visualisation/model_object_widget.py create mode 100644 loopstructural/resources/images/infinity_loop_icon.svg 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/main/loopstructuralwrapper.py b/loopstructural/main/loopstructuralwrapper.py index ea27cde..75b742a 100644 --- a/loopstructural/main/loopstructuralwrapper.py +++ b/loopstructural/main/loopstructuralwrapper.py @@ -87,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) @@ -124,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/requirements.txt b/loopstructural/requirements.txt index 8163f13..8d52603 100644 --- a/loopstructural/requirements.txt +++ b/loopstructural/requirements.txt @@ -2,4 +2,4 @@ pyvistaqt pyvista LoopStructural loopstructural-visualisation -geoh5py \ No newline at end of file +geoh5py diff --git a/loopstructural/resources/images/infinity_loop_icon.svg b/loopstructural/resources/images/infinity_loop_icon.svg new file mode 100644 index 0000000..a69ecd1 --- /dev/null +++ b/loopstructural/resources/images/infinity_loop_icon.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file From 472ff09873144f1685827d6a0fda3801c9c55258 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Wed, 25 Jun 2025 15:31:43 +1000 Subject: [PATCH 039/111] fix: don't try and add scalarbar to the object list --- loopstructural/gui/visualisation/object_list_widget.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/loopstructural/gui/visualisation/object_list_widget.py b/loopstructural/gui/visualisation/object_list_widget.py index 04d0119..057d253 100644 --- a/loopstructural/gui/visualisation/object_list_widget.py +++ b/loopstructural/gui/visualisation/object_list_widget.py @@ -46,6 +46,8 @@ def update_object_list(self, new_object): 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 From 5bf2e66ba73f59479d1c217a104bb0538783d840 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Wed, 25 Jun 2025 16:05:10 +1000 Subject: [PATCH 040/111] fix: connect orientation type toggle --- .../model_definition/stratigraphic_layers.py | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/loopstructural/gui/modelling/model_definition/stratigraphic_layers.py b/loopstructural/gui/modelling/model_definition/stratigraphic_layers.py index 88232fd..9561a1f 100644 --- a/loopstructural/gui/modelling/model_definition/stratigraphic_layers.py +++ b/loopstructural/gui/modelling/model_definition/stratigraphic_layers.py @@ -23,16 +23,40 @@ def __init__(self, parent=None, data_manager=None): 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.orientationType.currentIndexChanged.connect(self.onOrientationTypeChanged) 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) - + self.data_manager.set_structural_orientations( + layer, + self.orientationField.currentField(), + self.dipField.currentField(), + self.structuralDataUnitName.currentField(), + ) + def onStructuralDataFieldChanged(self, field): + self.data_manager.set_structural_orientations( + self.structuralDataLayer.currentLayer(), + self.orientationField.currentField(), + self.dipField.currentField(), + self.structuralDataUnitName.currentField(), + self.orientationType.currentText() + ) + # self.updateDataManager() def onUnitFieldChanged(self, field): self.data_manager.set_basal_contacts(self.basalContactsLayer.currentLayer(), field) From fd6f4d750e7212c42ddeab6630777878eca20f1c Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Wed, 25 Jun 2025 16:05:21 +1000 Subject: [PATCH 041/111] fix: add structural data to unit --- loopstructural/main/data_manager.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/loopstructural/main/data_manager.py b/loopstructural/main/data_manager.py index 9204c4d..8b957a2 100644 --- a/loopstructural/main/data_manager.py +++ b/loopstructural/main/data_manager.py @@ -137,9 +137,14 @@ def get_fault_traces(self): """Get the fault traces.""" return self._fault_traces - def set_structural_orientations(self, structural_orientations): + def set_structural_orientations(self, structural_orientations, strike_field=None, dip_field=None, unitname_field=None, orientation_type=None): """Set the structural orientations for the model.""" - self._structural_orientations = structural_orientations + 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 def get_structural_orientations(self): """Get the structural orientations.""" @@ -165,7 +170,7 @@ def update_stratigraphy(self): 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) + 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.") From 94b101dc7ea5f14dc2da5a2f3abbfaf3787436a7 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Mon, 14 Jul 2025 20:45:53 +1000 Subject: [PATCH 042/111] fixing object removal and allowing multi select --- .../gui/visualisation/object_list_widget.py | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/loopstructural/gui/visualisation/object_list_widget.py b/loopstructural/gui/visualisation/object_list_widget.py index 057d253..d71409e 100644 --- a/loopstructural/gui/visualisation/object_list_widget.py +++ b/loopstructural/gui/visualisation/object_list_widget.py @@ -20,6 +20,7 @@ def __init__(self, parent=None, *, viewer=None): 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) @@ -94,20 +95,23 @@ def export_selected_object(self): if not selected_items: return - object_name = selected_items[0].text(0) + item_widget = self.treeWidget.itemWidget(selected_items[0], 0) + object_label = item_widget.findChild(QLabel).text() # Logic for exporting the object - print(f"Exporting object: {object_name}") + print(f"Exporting object: {object_label}") def remove_selected_object(self): selected_items = self.treeWidget.selectedItems() if not selected_items: return - - object_name = selected_items[0].text(0) - # Logic for removing the object - self.viewer.remove_object(object_name) - self.treeWidget.takeTopLevelItem(self.treeWidget.indexOfTopLevelItem(selected_items[0])) - print(f"Removing object: {object_name}") + for item in selected_items: + + item_widget = self.treeWidget.itemWidget(item, 0) + object_label = item_widget.findChild(QLabel).text() + # Logic for removing the object + self.viewer.remove_object(object_label) + self.treeWidget.takeTopLevelItem(self.treeWidget.indexOfTopLevelItem(item)) + print(f"Removing object: {object_label}") def show_add_object_menu(self): menu = QMenu(self) From 74a2e836342432822cbb20c6412c755988a8f118 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Tue, 15 Jul 2025 10:18:22 +1000 Subject: [PATCH 043/111] allow bigger range for bb --- .../model_definition/bounding_box.ui | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/loopstructural/gui/modelling/model_definition/bounding_box.ui b/loopstructural/gui/modelling/model_definition/bounding_box.ui index 947dd03..54e6d89 100644 --- a/loopstructural/gui/modelling/model_definition/bounding_box.ui +++ b/loopstructural/gui/modelling/model_definition/bounding_box.ui @@ -7,7 +7,7 @@ 0 0 750 - 309 + 326 @@ -50,17 +50,17 @@ - 0.000000000000000 + -1000000000.000000000000000 - 1000000.000000000000000 + 1000000000.000000000000000 - 0.000000000000000 + -1000000000.000000000000000 1000000000.000000000000000 @@ -70,10 +70,10 @@ - 0.000000000000000 + -1000000000.000000000000000 - 1000000.000000000000000 + 1000000000.000000000000000 @@ -87,30 +87,30 @@ - 0.000000000000000 + -1000000000.000000000000000 - 1000000.000000000000000 + 1000000000.000000000000000 - 0.000000000000000 + -1000000000.000000000000000 - 1000000001.000000000000000 + 1000000000.000000000000000 - -10000.000000000000000 + -1000000000.000000000000000 - 10000.000000000000000 + 1000000000.000000000000000 From cdf2f64f97afc188594e6a33fdf00f4ff0fb2857 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Tue, 15 Jul 2025 10:18:37 +1000 Subject: [PATCH 044/111] check if layer is none if it is don't try to access it --- loopstructural/main/data_manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/loopstructural/main/data_manager.py b/loopstructural/main/data_manager.py index 8b957a2..9414491 100644 --- a/loopstructural/main/data_manager.py +++ b/loopstructural/main/data_manager.py @@ -66,7 +66,7 @@ def set_basal_contacts(self, basal_contacts, unitname_field=None): def calculate_unique_basal_units(self): - if self._basal_contacts is not None and self._basal_contacts['unitname_field'] is not None: + 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']] From 517da6b75fc3f8b77abb2ae51d5d45d041ce6d10 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Tue, 15 Jul 2025 12:42:31 +1000 Subject: [PATCH 045/111] load bb from prohject --- loopstructural/main/data_manager.py | 67 ++++++++++++++++++++++++++++- loopstructural/plugin_main.py | 29 +++++++++++-- 2 files changed, 91 insertions(+), 5 deletions(-) diff --git a/loopstructural/main/data_manager.py b/loopstructural/main/data_manager.py index 9414491..8b81170 100644 --- a/loopstructural/main/data_manager.py +++ b/loopstructural/main/data_manager.py @@ -18,7 +18,7 @@ def __init__(self, *, mapCanvas=None, logger=None): self.logger = logger self._stratigraphic_column = StratigraphicColumn() self._model_manager = None - + self.bounding_box_callback = None def set_model_manager(self, model_manager): """Set the model manager for the data manager.""" if model_manager is None: @@ -50,6 +50,10 @@ def set_bounding_box(self, xmin=None, xmax=None, ymin=None, ymax=None, zmin=None self._bounding_box.maximum = maximum # self._bounding_box.update([west, south, bottom], [east, north, top]) 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 def get_bounding_box(self): """Get the current bounding box.""" @@ -193,3 +197,64 @@ def clear_data(self): 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: + basal_contacts['layer'] = basal_contacts['layer'].name() + if fault_traces and 'layer' in fault_traces: + fault_traces['layer'] = fault_traces['layer'].name() + if structural_orientations and 'layer' in structural_orientations: + structural_orientations['layer'] = structural_orientations['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 + } + + 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 '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']) + 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]) + + 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']) \ No newline at end of file diff --git a/loopstructural/plugin_main.py b/loopstructural/plugin_main.py index ab458e9..28a7380 100644 --- a/loopstructural/plugin_main.py +++ b/loopstructural/plugin_main.py @@ -8,7 +8,7 @@ from pathlib import Path # PyQGIS -from qgis.core import QgsApplication, QgsSettings +from qgis.core import QgsApplication, QgsSettings, QgsProject from qgis.gui import QgisInterface from qgis.PyQt.QtCore import QCoreApplication, QLocale, Qt, QTranslator, QUrl from qgis.PyQt.QtGui import QDesktopServices, QIcon @@ -28,10 +28,11 @@ from loopstructural.gui.visualisation.visualisation_widget import VisualisationWidget from loopstructural.toolbelt import PlgLogger +import json # ############################################################################ # ########## Classes ############### # ################################## - +__title__ = "LoopStructural" class LoopstructuralPlugin: def __init__(self, iface: QgisInterface): @@ -61,7 +62,26 @@ def __init__(self, iface: QgisInterface): self.model_manager = GeologicalModelManager( ) self.data_manager.set_model_manager(self.model_manager) - + self.project = QgsProject.instance() + self.project.readProject.connect(self.onLoadProject) + self.project.writeProject.connect(self.onSaveProject) + def onSaveProject(self): + """Save project data.""" + self.log(message="Saving project data...", log_level=3) + datamanager_dict = self.data_manager.to_dict() + self.project.writeEntry(__title__, "data_manager", json.dumps(datamanager_dict)) + + def onLoadProject(self): + """Load project data.""" + self.log(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.data_manager.update_from_dict(datamanager_dict) + + except json.JSONDecodeError as e: + self.log(message=f"Error loading data manager: {e}", log_level=2) def initGui(self): """Set up plugin UI elements.""" self.toolbar = self.iface.addToolBar(u'LoopStructural') @@ -154,11 +174,12 @@ def initGui(self): ) self.visualisation_dockwidget.setWidget(self.visualisation_widget) self.iface.addDockWidget(Qt.RightDockWidgetArea, self.visualisation_dockwidget) - right_docks = [ + right_docks = [self.modelling_dockwidget]+[ 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: From 3062df02298f91b0a5b34b1c021a9a7d31ea8833 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Tue, 15 Jul 2025 12:43:03 +1000 Subject: [PATCH 046/111] add faults before features. this is not a final fix, need some fault order data structure --- loopstructural/main/model_manager.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/loopstructural/main/model_manager.py b/loopstructural/main/model_manager.py index 8c25fa1..314942b 100644 --- a/loopstructural/main/model_manager.py +++ b/loopstructural/main/model_manager.py @@ -167,17 +167,17 @@ def update_model(self): """Update the geological model with the current stratigraphy and faults.""" # if not self.valid: # raise ValueError("Model is not valid. Please check the data.") - - # Update the model with stratigraphy - self.update_foliation_features() - # Update the model with faults 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 - self.model.create_and_add_fault(fault_name, 10,fault_data=data) + self.model.create_and_add_fault(fault_name, 10,fault_data=data) + # Update the model with stratigraphy + self.update_foliation_features() + + for observer in self.observers: observer() From 4a9c7759bef5f39c7d24f69e554220879349c59b Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Tue, 15 Jul 2025 12:43:55 +1000 Subject: [PATCH 047/111] set bounding box widget when data manager bounding box changes --- .../gui/modelling/model_definition/bounding_box.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/loopstructural/gui/modelling/model_definition/bounding_box.py b/loopstructural/gui/modelling/model_definition/bounding_box.py index df0adf8..c49487d 100644 --- a/loopstructural/gui/modelling/model_definition/bounding_box.py +++ b/loopstructural/gui/modelling/model_definition/bounding_box.py @@ -18,6 +18,18 @@ def __init__(self, parent=None, data_manager=None): 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): """ From 433eabbc717ebb231dd6c75724efc7620b9a86b3 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Tue, 15 Jul 2025 13:36:54 +1000 Subject: [PATCH 048/111] add regularisation/npw/cpw --- .../gui/modelling/feature_details_panel.py | 109 ++++++++++++------ 1 file changed, 71 insertions(+), 38 deletions(-) diff --git a/loopstructural/gui/modelling/feature_details_panel.py b/loopstructural/gui/modelling/feature_details_panel.py index 361e408..1a44f16 100644 --- a/loopstructural/gui/modelling/feature_details_panel.py +++ b/loopstructural/gui/modelling/feature_details_panel.py @@ -1,11 +1,11 @@ from PyQt5.QtWidgets import ( - QWidget, QVBoxLayout, QSlider, QLabel, QDoubleSpinBox, QCheckBox, QFormLayout + QWidget, QVBoxLayout, QSlider, QLabel, QDoubleSpinBox, QCheckBox, QFormLayout, QComboBox ) from PyQt5.QtCore import Qt from PyQt5.QtWidgets import QScrollArea from LoopStructural.utils import normal_vector_to_strike_and_dip class BaseFeatureDetailsPanel(QWidget): - def __init__(self, parent=None): + def __init__(self, parent=None,*, feature=None): super().__init__(parent) # Create a scroll area for horizontal scrolling @@ -27,9 +27,67 @@ def __init__(self, parent=None): # Set the main layout self.setLayout(mainLayout) + ## define interpolator parameters + # Regularisation slider + self.regularisation_slider = QSlider(Qt.Horizontal) + self.regularisation_slider.setRange(0, 100) + self.regularisation_slider.setValue(1) + self.regularisation_label = QLabel("Regularisation: 1") + self.regularisation_slider.valueChanged.connect( + lambda value: self.regularisation_label.setText(f"Regularisation: {value}") + ) + # self.regularisation_slider.valueChanged.connect( + # lambda value: feature.builder.foliation_parameters.__setitem__('regularisation', value) + # ) + self.regularisation_slider.valueChanged.connect( + lambda value: feature.builder.update_build_arguments({'regularisation': value}) + ) + self.cpw_slider = QSlider(Qt.Horizontal) + self.cpw_slider.setRange(0, 100) + self.cpw_slider.setValue(1) + self.cpw_label = QLabel("Value point weight: 1") + self.cpw_slider.valueChanged.connect( + lambda value: self.cpw_label.setText(f"Value point weight: {value}") + ) + self.cpw_slider.valueChanged.connect( + lambda value: feature.builder.update_build_arguments({'cpw':value}) + ) + self.npw_slider = QSlider(Qt.Horizontal) + self.npw_slider.setRange(0, 100) + self.npw_slider.setValue(1) + self.npw_label = QLabel("Normal vector weight: 1") + self.npw_slider.valueChanged.connect( + lambda value: self.npw_label.setText(f"Normal vector weight: {value}") + ) + self.npw_slider.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(5000) + self.n_elements_spinbox.setPrefix("Number of Elements: ") + + self.n_elements_spinbox.valueChanged.connect(lambda value: feature.builder.update_build_arguments({'nelements ': value})) + + # 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(self.regularisation_label, self.regularisation_slider) + form_layout.addRow(self.cpw_label, self.cpw_slider) + form_layout.addRow(self.npw_label, self.npw_slider) + + + + self.layout.addLayout(form_layout) + class FaultFeatureDetailsPanel(BaseFeatureDetailsPanel): def __init__(self, parent=None,*, fault=None): - super().__init__(parent) + super().__init__(parent,feature=fault) if fault is None: raise ValueError("Fault must be provided.") self.fault = fault @@ -40,17 +98,15 @@ def __init__(self, parent=None,*, fault=None): 'minor_axis_length': fault.fault_minor_axis, 'intermediate_axis_length': fault.fault_intermediate_axis, 'dip': dip, + # 'pitch' # 'enabled': fault.fault_enabled } # Fault displacement slider - self.displacement_slider = QSlider(Qt.Horizontal) - self.displacement_slider.setRange(0, 1000) # Example range - self.displacement_slider.setValue(self.fault.displacement) - self.displacement_label = QLabel(f"Fault Displacement: {self.fault.displacement}") - self.displacement_slider.valueChanged.connect( - lambda value: self.displacement_label.setText(f"Fault Displacement: {value}") - ) + self.displacement_spinbox = QDoubleSpinBox() + self.displacement_spinbox.setRange(0, 1000000) # Example range + self.displacement_spinbox.setValue(self.fault.displacement) + self.displacement_label = QLabel(f"Fault Displacement:") self.displacement_slider.valueChanged.connect( lambda value: self.fault_parameters.__setitem__('displacement', value) ) @@ -86,13 +142,15 @@ def __init__(self, parent=None,*, fault=None): self.dip_spinbox.valueChanged.connect( lambda value: self.fault_parameters.__setitem__('dip', 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(self.displacement_label, self.displacement_slider) + form_layout.addRow(self.displacement_label, 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) @@ -104,33 +162,8 @@ def __init__(self, parent=None,*, fault=None): class FoliationFeatureDetailsPanel(BaseFeatureDetailsPanel): def __init__(self, parent=None,*, feature=None): - super().__init__(parent) - - # Foliation thickness slider - self.thickness_slider = QSlider(Qt.Horizontal) - self.thickness_slider.setRange(0, 100) # Example range - self.thickness_slider.setValue(0) - self.thickness_label = QLabel("Foliation Thickness: 0") - self.thickness_slider.valueChanged.connect( - lambda value: self.thickness_label.setText(f"Foliation Thickness: {value}") - ) + super().__init__(parent, feature=feature) - # Foliation orientation - self.orientation_spinbox = QDoubleSpinBox() - self.orientation_spinbox.setRange(0, 360) # Orientation angle range - self.orientation_spinbox.setValue(0) - self.orientation_spinbox.setPrefix("Orientation: ") - - # Enabled field - self.enabled_checkbox = QCheckBox("Enabled") - self.enabled_checkbox.setChecked(False) - - # Form layout for better organization - form_layout = QFormLayout() - form_layout.addRow(self.thickness_label, self.thickness_slider) - form_layout.addRow("Orientation:", self.orientation_spinbox) - form_layout.addRow("Enabled:", self.enabled_checkbox) - - self.layout.addLayout(form_layout) + # Remove redundant layout setting # self.setLayout(self.layout) From 74e034cf18968ea29b2b62b9489d250d8fca7497 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Tue, 15 Jul 2025 13:38:06 +1000 Subject: [PATCH 049/111] move project save to the data manager --- loopstructural/plugin_main.py | 26 +++----------------------- 1 file changed, 3 insertions(+), 23 deletions(-) diff --git a/loopstructural/plugin_main.py b/loopstructural/plugin_main.py index 28a7380..d340951 100644 --- a/loopstructural/plugin_main.py +++ b/loopstructural/plugin_main.py @@ -28,11 +28,9 @@ from loopstructural.gui.visualisation.visualisation_widget import VisualisationWidget from loopstructural.toolbelt import PlgLogger -import json # ############################################################################ # ########## Classes ############### # ################################## -__title__ = "LoopStructural" class LoopstructuralPlugin: def __init__(self, iface: QgisInterface): @@ -57,31 +55,13 @@ def __init__(self, iface: QgisInterface): self.translator.load(str(locale_path.resolve())) QCoreApplication.installTranslator(self.translator) self.data_manager = ModellingDataManager( - mapCanvas=self.iface.mapCanvas(), logger=self.log + mapCanvas=self.iface.mapCanvas(), logger=self.log, project=QgsProject.instance() ) self.model_manager = GeologicalModelManager( ) self.data_manager.set_model_manager(self.model_manager) - self.project = QgsProject.instance() - self.project.readProject.connect(self.onLoadProject) - self.project.writeProject.connect(self.onSaveProject) - def onSaveProject(self): - """Save project data.""" - self.log(message="Saving project data...", log_level=3) - datamanager_dict = self.data_manager.to_dict() - self.project.writeEntry(__title__, "data_manager", json.dumps(datamanager_dict)) - - def onLoadProject(self): - """Load project data.""" - self.log(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.data_manager.update_from_dict(datamanager_dict) - - except json.JSONDecodeError as e: - self.log(message=f"Error loading data manager: {e}", log_level=2) + + def initGui(self): """Set up plugin UI elements.""" self.toolbar = self.iface.addToolBar(u'LoopStructural') From 4c5870a5e401cd1e8cdd76699ec4d8a516235646 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Tue, 15 Jul 2025 13:38:45 +1000 Subject: [PATCH 050/111] adding fault trace and stratigraphic layers callbacks to set the widgets from the data manager. --- .../model_definition/fault_layers.py | 10 +- .../model_definition/stratigraphic_layers.py | 21 +++- loopstructural/main/data_manager.py | 111 ++++++++++++++++-- 3 files changed, 126 insertions(+), 16 deletions(-) diff --git a/loopstructural/gui/modelling/model_definition/fault_layers.py b/loopstructural/gui/modelling/model_definition/fault_layers.py index 90b4086..60b3f0f 100644 --- a/loopstructural/gui/modelling/model_definition/fault_layers.py +++ b/loopstructural/gui/modelling/model_definition/fault_layers.py @@ -23,7 +23,15 @@ def __init__(self, parent=None, data_manager=None): 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) + def set_fault_trace_layer(self, layer, fault_name_field=None, fault_dip_field=None, fault_displacement_field=None): + 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) def onFaultTraceLayerChanged(self, layer): self.faultNameField.setLayer(layer) self.faultDipField.setLayer(layer) diff --git a/loopstructural/gui/modelling/model_definition/stratigraphic_layers.py b/loopstructural/gui/modelling/model_definition/stratigraphic_layers.py index 9561a1f..4e1af89 100644 --- a/loopstructural/gui/modelling/model_definition/stratigraphic_layers.py +++ b/loopstructural/gui/modelling/model_definition/stratigraphic_layers.py @@ -3,7 +3,7 @@ from PyQt5.QtWidgets import QWidget from qgis.core import QgsMapLayerProxyModel from qgis.PyQt import uic - +from PyQt5.QtCore import Qt class StratigraphicLayersWidget(QWidget): def __init__(self, parent=None, data_manager=None): @@ -28,7 +28,24 @@ def __init__(self, parent=None, data_manager=None): self.orientationField.fieldChanged.connect(self.onStructuralDataFieldChanged) self.structuralDataUnitName.setLayer(self.structuralDataLayer.currentLayer()) 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) + def set_basal_contacts(self, layer, unitname_field=None): + self.basalContactsLayer.setLayer(layer) + if unitname_field: + self.unitNameField.setField(unitname_field) + def set_orientations_layer(self, layer, strike_field=None, dip_field=None, unitname_field=None, orientation_type=None): + self.structuralDataLayer.setLayer(layer) + 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) def onBasalContactsChanged(self, layer): self.unitNameField.setLayer(layer) self.data_manager.set_basal_contacts(layer, self.unitNameField.currentField()) diff --git a/loopstructural/main/data_manager.py b/loopstructural/main/data_manager.py index 8b81170..8a07061 100644 --- a/loopstructural/main/data_manager.py +++ b/loopstructural/main/data_manager.py @@ -2,13 +2,21 @@ from .stratigraphic_column import StratigraphicColumn from .vectorLayerWrapper import qgsLayerToGeoDataFrame +from qgis.core import QgsProject, QgsVectorLayer +import json +__title__ = "LoopStructural" class ModellingDataManager: - def __init__(self, *, mapCanvas=None, logger=None): + 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=[0, 0, 0], maximum=[1000, 1000, 1000]) self._basal_contacts = None self._fault_traces = None @@ -19,6 +27,28 @@ def __init__(self, *, mapCanvas=None, logger=None): self._stratigraphic_column = StratigraphicColumn() self._model_manager = None self.bounding_box_callback = None + self.basal_contacts_callback = None + self.fault_traces_callback = None + self.structural_orientations_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: @@ -52,9 +82,24 @@ def set_bounding_box(self, xmin=None, xmax=None, ymin=None, ymax=None, zmin=None self._model_manager.update_bounding_box(self._bounding_box) if self.bounding_box_callback: self.bounding_box_callback(self._bounding_box) + # if self.basal_contacts_callback: + # self.basal_contacts_callback(self._basal_contacts) + # if self.fault_traces_callback: + # self.fault_traces_callback(self._fault_traces) + # if self.structural_orientations_callback: + # self.structural_orientations_callback(self._structural_orientations) + def set_bounding_box_update_callback(self, callback): self.bounding_box_callback = callback - + 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 get_bounding_box(self): """Get the current bounding box.""" return self._bounding_box @@ -67,6 +112,8 @@ def set_basal_contacts(self, basal_contacts, unitname_field=None): # 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): @@ -136,6 +183,8 @@ def set_fault_trace_layer(self, fault_trace_layer, fault_name_field=None, fault 'fault_dip_field': fault_dip_field, 'fault_displacement_field': fault_displacement_field} self.update_faults() + if self.fault_traces_callback: + self.fault_traces_callback(**self._fault_traces) def get_fault_traces(self): """Get the fault traces.""" @@ -149,7 +198,8 @@ def set_structural_orientations(self, structural_orientations, strike_field=None self._structural_orientations['dip_field'] = dip_field self._structural_orientations['unitname_field'] = unitname_field self._structural_orientations['orientation_type'] = orientation_type - + if self.structural_orientations_callback: + self.structural_orientations_callback(**self._structural_orientations) def get_structural_orientations(self): """Get the structural orientations.""" return self._structural_orientations @@ -206,11 +256,11 @@ def to_dict(self): 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: + 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: + 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: + if structural_orientations and 'layer' in structural_orientations and structural_orientations['layer'] is not None: structural_orientations['layer'] = structural_orientations['layer'].name() return { @@ -250,11 +300,46 @@ def update_from_dict(self, data): zmin=data['bounding_box']['origin'][2], zmax=data['bounding_box']['maximum'][2]) - 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 '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 = StratigraphicColumn.from_dict(data['stratigraphic_column']) \ No newline at end of file + self._stratigraphic_column = StratigraphicColumn.from_dict(data['stratigraphic_column']) + def find_layer_by_name(self, layer_name): + """Find a layer by name in the project.""" + if layer_name is None: + self.logger(message="Layer name is None, cannot find layer.", log_level=2) + 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 + From b10cfd9afa54b0c2fa68e57a79db0621e15474cd Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Tue, 15 Jul 2025 14:02:28 +1000 Subject: [PATCH 051/111] load stratigraphic column from project --- .../stratigraphic_column.py | 4 +++- .../stratigraphic_column/stratigraphic_unit.py | 9 +++++++++ loopstructural/main/data_manager.py | 17 ++++++++--------- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py index d6c2abc..112e731 100644 --- a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py +++ b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py @@ -54,7 +54,8 @@ def __init__(self, parent=None, data_manager=None): 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() @@ -95,6 +96,7 @@ def add_unit(self, *, unit_data=None, create_new=True): 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) diff --git a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.py b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.py index 0268935..7088edf 100644 --- a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.py +++ b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.py @@ -23,6 +23,15 @@ def __init__(self, uuid, name: Optional[str] = None, colour: Optional[str] = Non self.lineEditName.editingFinished.connect(self.onNameChanged) self.spinBoxThickness.valueChanged.connect(self.onThicknessChanged) + 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. diff --git a/loopstructural/main/data_manager.py b/loopstructural/main/data_manager.py index 8a07061..00d2eee 100644 --- a/loopstructural/main/data_manager.py +++ b/loopstructural/main/data_manager.py @@ -30,7 +30,7 @@ def __init__(self, *, project=None, mapCanvas=None, logger=None): self.basal_contacts_callback = None self.fault_traces_callback = None self.structural_orientations_callback = None - + self.stratigraphic_column_callback = None def onSaveProject(self): """Save project data.""" self.logger(message="Saving project data...", log_level=3) @@ -78,16 +78,10 @@ def set_bounding_box(self, xmin=None, xmax=None, ymin=None, ymax=None, zmin=None self._bounding_box.maximum = maximum self._bounding_box.origin = origin self._bounding_box.maximum = maximum - # self._bounding_box.update([west, south, bottom], [east, north, top]) self._model_manager.update_bounding_box(self._bounding_box) if self.bounding_box_callback: self.bounding_box_callback(self._bounding_box) - # if self.basal_contacts_callback: - # self.basal_contacts_callback(self._basal_contacts) - # if self.fault_traces_callback: - # self.fault_traces_callback(self._fault_traces) - # if self.structural_orientations_callback: - # self.structural_orientations_callback(self._structural_orientations) + def set_bounding_box_update_callback(self, callback): self.bounding_box_callback = callback @@ -100,6 +94,9 @@ def set_structural_orientations_callback(self, 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 get_bounding_box(self): """Get the current bounding box.""" return self._bounding_box @@ -290,6 +287,7 @@ def from_dict(self, 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: @@ -320,10 +318,11 @@ def update_from_dict(self, data): orientation_type=data['structural_orientations'].get('orientation_type',None)) if 'stratigraphic_column' in data: self._stratigraphic_column = StratigraphicColumn.from_dict(data['stratigraphic_column']) + 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: - self.logger(message="Layer name is None, cannot find layer.", log_level=2) return None if issubclass(type(layer_name), str): layers = self.project.mapLayersByName(layer_name) From 16885e194db8cd225bf9da7d1c22d9c4cfc27141 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Tue, 15 Jul 2025 14:12:39 +1000 Subject: [PATCH 052/111] remove null items from dictionary before passing to widget --- .../modelling/stratigraphic_column/stratigraphic_column.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py index 112e731..772fcad 100644 --- a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py +++ b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py @@ -91,11 +91,15 @@ def add_unit(self, *, unit_data=None, create_new=True): unit = self.data_manager._stratigraphic_column.get_unit_by_name( unit_data['name'] ) - + for k in list(unit_data.keys()): + if unit_data[k] is None: + unit_data.pop(k) unit_widget = StratigraphicUnitWidget(uuid=unit.uuid) 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()) From 05c39759728311c0f216536fdce7237c06b973d4 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Tue, 15 Jul 2025 14:13:05 +1000 Subject: [PATCH 053/111] if line is none don't try to sample it --- loopstructural/main/model_manager.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/loopstructural/main/model_manager.py b/loopstructural/main/model_manager.py index 314942b..148a4f8 100644 --- a/loopstructural/main/model_manager.py +++ b/loopstructural/main/model_manager.py @@ -12,6 +12,8 @@ class AllSampler: def __call__(self, line: gpd.GeoDataFrame) -> pd.DataFrame: points = [] feature_id = 0 + if line is None: + return pd.DataFrame(points) for geom in line.geometry: attributes = line.iloc[feature_id].to_dict() attributes.pop('geometry', None) # Remove geometry from attributes From f30f0642f4a0ad6abe68db725660b7bf55807e14 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Wed, 16 Jul 2025 13:26:02 +1000 Subject: [PATCH 054/111] move both widgets into the same widget but separate with bottom level tabs --- loopstructural/gui/loop_widget.py | 26 ++++++++++++ loopstructural/plugin_main.py | 67 +++++++++---------------------- 2 files changed, 45 insertions(+), 48 deletions(-) create mode 100644 loopstructural/gui/loop_widget.py diff --git a/loopstructural/gui/loop_widget.py b/loopstructural/gui/loop_widget.py new file mode 100644 index 0000000..b8b3a51 --- /dev/null +++ b/loopstructural/gui/loop_widget.py @@ -0,0 +1,26 @@ +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) + modelling_widget = ModellingWidget( + self, mapCanvas=self.mapCanvas, logger=self.logger, data_manager=self.data_manager, model_manager=self.model_manager + ) + + visualisation_widget = VisualisationWidget( + self, mapCanvas=self.mapCanvas, logger=self.logger, model_manager=self.model_manager + ) + tabWidget.addTab(modelling_widget, "Modelling") + tabWidget.addTab(visualisation_widget, "Visualisation") + \ No newline at end of file diff --git a/loopstructural/plugin_main.py b/loopstructural/plugin_main.py index d340951..53cbbb8 100644 --- a/loopstructural/plugin_main.py +++ b/loopstructural/plugin_main.py @@ -22,10 +22,9 @@ __uri_homepage__, ) from loopstructural.gui.dlg_settings import PlgOptionsFactory -from loopstructural.gui.modelling.modelling_widget import ModellingWidget as Modelling from loopstructural.main.data_manager import ModellingDataManager from loopstructural.main.model_manager import GeologicalModelManager -from loopstructural.gui.visualisation.visualisation_widget import VisualisationWidget +from loopstructural.gui.loop_widget import LoopWidget from loopstructural.toolbelt import PlgLogger # ############################################################################ @@ -93,13 +92,8 @@ def initGui(self): self.tr("LoopStructural Modelling"), self.iface.mainWindow(), ) - self.action_visualisation = QAction( - QIcon(os.path.dirname(__file__) + "/icon.png"), - self.tr("LoopStructural Visualisation"), - self.iface.mainWindow(), - ) + self.toolbar.addAction(self.action_modelling) - self.toolbar.addAction(self.action_visualisation) # -- Menu self.iface.addPluginToMenu(__title__, self.action_settings) @@ -121,13 +115,17 @@ 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, - data_manager=self.data_manager, model_manager=self.model_manager + 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) @@ -136,48 +134,21 @@ def initGui(self): # 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.loop_dockwidget.show() - self.modelling_dockwidget.close() + self.loop_dockwidget.close() - ## -- visualisation dock widget - self.visualisation_dockwidget = QDockWidget( - self.tr("Visualisation"), self.iface.mainWindow(), - ) - self.visualisation_widget = VisualisationWidget( - self.iface.mainWindow(), mapCanvas=self.iface.mapCanvas(), logger=self.log,model_manager=self.model_manager - ) - self.visualisation_dockwidget.setWidget(self.visualisation_widget) - self.iface.addDockWidget(Qt.RightDockWidgetArea, self.visualisation_dockwidget) - right_docks = [self.modelling_dockwidget]+[ - 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.visualisation_dockwidget: - self.iface.mainWindow().tabifyDockWidget(dock, self.visualisation_dockwidget) - # Optionally, bring your plugin tab to the front - self.visualisation_dockwidget.raise_() - break - self.visualisation_dockwidget.show() - self.visualisation_dockwidget.close() - # -- Connect actions self.action_modelling.triggered.connect( - self.modelling_dockwidget.toggleViewAction().trigger - ) - self.action_visualisation.triggered.connect( - self.visualisation_dockwidget.toggleViewAction().trigger + self.loop_dockwidget.toggleViewAction().trigger ) + def tr(self, message: str) -> str: """Get the translation for a string using Qt translation API. From b80d526a695c8333aa39035fa010ead39c161dc4 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Wed, 16 Jul 2025 13:26:13 +1000 Subject: [PATCH 055/111] add fault adjacency tab, not connected yet --- .../gui/modelling/fault_adjacency_tab.py | 78 +++++++++++++++++++ .../gui/modelling/modelling_widget.py | 7 +- loopstructural/main/data_manager.py | 16 +++- 3 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 loopstructural/gui/modelling/fault_adjacency_tab.py diff --git a/loopstructural/gui/modelling/fault_adjacency_tab.py b/loopstructural/gui/modelling/fault_adjacency_tab.py new file mode 100644 index 0000000..da68e24 --- /dev/null +++ b/loopstructural/gui/modelling/fault_adjacency_tab.py @@ -0,0 +1,78 @@ +from PyQt5.QtWidgets import QTabWidget, QVBoxLayout, QWidget, QLabel, QTableWidget, QTableWidgetItem, QPushButton, QGroupBox +from PyQt5.QtCore import Qt +# from qgis.PyQt.QtWidgets import QgsCollapsibleGroupBox + +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.stratigraphic_table_group = QGroupBox("Stratigraphic Units Table", self) + fault_table_layout = QVBoxLayout(self.fault_table_group) + # Create the fault adjacency table + self.create_fault_adjacency_table() + fault_table_layout.addWidget(self.table) + self.layout().addWidget(self.fault_table_group) + + # Create the stratigraphic units table + self.create_stratigraphic_units_table() + stratigraphic_table_layout = QVBoxLayout(self.stratigraphic_table_group) + stratigraphic_table_layout.addWidget(self.stratigraphic_table) + self.layout().addWidget(self.stratigraphic_table_group) + + def create_fault_adjacency_table(self): + """Create a table with QPushButtons for fault adjacency.""" + faults = ['Fault A', 'Fault B', 'Fault C'] # Example fault names, replace with actual data + + self.table = QTableWidget(len(faults), len(faults), self) + self.table.setHorizontalHeaderLabels(faults) + self.table.setVerticalHeaderLabels(faults) + + for row in range(len(faults)): + for col in range(len(faults)): + button = QPushButton() + button.setStyleSheet("background-color: white;") + button.clicked.connect(lambda _, b=button: self.change_button_color(b)) + self.table.setCellWidget(row, col, button) + + def create_stratigraphic_units_table(self): + """Create a table with QPushButtons for stratigraphic units.""" + units = ['unit1', 'unit2', 'unit3'] + faults = ['Fault A', 'Fault B', 'Fault C'] # Example fault names, replace with actual data + + self.stratigraphic_table = QTableWidget(len(units), len(faults), self) + self.stratigraphic_table.setHorizontalHeaderLabels(units) + self.stratigraphic_table.setVerticalHeaderLabels(faults) + + for row in range(len(units)): + for col in range(len(faults)): + button = QPushButton() + button.setStyleSheet("background-color: white;") + button.clicked.connect(lambda _, b=button: self.change_button_colour_binary(b)) + self.stratigraphic_table.setCellWidget(row, col, button) + def change_button_colour_binary(self, button): + """Cycle the button color between red, green, and black.""" + current_color = button.styleSheet() + if "red" in current_color: + button.setStyleSheet("background-color: white;") + else: + button.setStyleSheet("background-color: red;") + def change_button_color(self, button): + """Cycle the button color between red, green, and black.""" + current_color = button.styleSheet() + if "red" in current_color: + button.setStyleSheet("background-color: green;") + elif "green" in current_color: + button.setStyleSheet("background-color: white;") + else: + button.setStyleSheet("background-color: red;") \ No newline at end of file diff --git a/loopstructural/gui/modelling/modelling_widget.py b/loopstructural/gui/modelling/modelling_widget.py index 2c42b29..5c7b70a 100644 --- a/loopstructural/gui/modelling/modelling_widget.py +++ b/loopstructural/gui/modelling/modelling_widget.py @@ -1,8 +1,10 @@ +from xmlrpc.client import Fault from PyQt5.QtWidgets import QTabWidget, QVBoxLayout, QWidget from loopstructural.gui.modelling.geological_history_tab import GeologialHistoryTab from loopstructural.gui.modelling.geological_model_tab import GeologicalModelTab from loopstructural.gui.modelling.model_definition import ModelDefinitionTab +from loopstructural.gui.modelling.fault_adjacency_tab import FaultAdjacencyTab class ModellingWidget(QWidget): @@ -20,11 +22,14 @@ def __init__(self, parent: QWidget = None, *, mapCanvas=None, logger=None, data_ self.geological_history_tab_widget = GeologialHistoryTab( self, data_manager=self.data_manager ) - self.geological_model_tab_widget = GeologicalModelTab(self, model_manager = self.model_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/main/data_manager.py b/loopstructural/main/data_manager.py index 00d2eee..2534ea7 100644 --- a/loopstructural/main/data_manager.py +++ b/loopstructural/main/data_manager.py @@ -4,6 +4,7 @@ from .vectorLayerWrapper import qgsLayerToGeoDataFrame from qgis.core import QgsProject, QgsVectorLayer import json +import numpy as np __title__ = "LoopStructural" class ModellingDataManager: @@ -31,6 +32,8 @@ def __init__(self, *, project=None, mapCanvas=None, logger=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 def onSaveProject(self): """Save project data.""" self.logger(message="Saving project data...", log_level=3) @@ -166,7 +169,15 @@ def update_stratigraphic_column_order(self, new_order): 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(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): """Set the fault traces for the model.""" if fault_trace_layer is None: @@ -227,11 +238,14 @@ def update_stratigraphy(self): def update_faults(self): """Update the faults in the model manager.""" + unique_faults = self.get_unique_faults() + 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']) 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: From bd93d9542605d24f0baa71b48e3a959ded51c1f5 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Wed, 16 Jul 2025 14:37:36 +1000 Subject: [PATCH 056/111] adding dem/elevation for model elevation callback works but dem is failing, wrong coordinates? --- .../gui/modelling/model_definition/dem.py | 46 +++++++++++ .../gui/modelling/model_definition/dem.ui | 81 +++++++++++++++++++ .../model_definition/model_definition_tab.py | 4 +- loopstructural/main/data_manager.py | 25 +++++- loopstructural/main/model_manager.py | 28 ++++--- 5 files changed, 171 insertions(+), 13 deletions(-) create mode 100644 loopstructural/gui/modelling/model_definition/dem.py create mode 100644 loopstructural/gui/modelling/model_definition/dem.ui diff --git a/loopstructural/gui/modelling/model_definition/dem.py b/loopstructural/gui/modelling/model_definition/dem.py new file mode 100644 index 0000000..4d3b540 --- /dev/null +++ b/loopstructural/gui/modelling/model_definition/dem.py @@ -0,0 +1,46 @@ +import os + +from PyQt5.QtWidgets import QWidget +from qgis.PyQt import uic +from qgis.PyQt.QtWidgets import QSizePolicy +from qgis.core import QgsMapLayerProxyModel + +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) + 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) + + \ No newline at end of file diff --git a/loopstructural/gui/modelling/model_definition/dem.ui b/loopstructural/gui/modelling/model_definition/dem.ui new file mode 100644 index 0000000..f723244 --- /dev/null +++ b/loopstructural/gui/modelling/model_definition/dem.ui @@ -0,0 +1,81 @@ + + + Form + + + + 0 + 0 + 825 + 158 + + + + Form + + + + + + + + DEM Layer + + + + + + + + + + Use DEM + + + + + + + true + + + + + + + Elevation + + + + + + + false + + + -100000000.000000000000000 + + + 1000000000.000000000000000 + + + + + + + + + + QgsDoubleSpinBox + QDoubleSpinBox +
qgsdoublespinbox.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 index 391792e..3ed7874 100644 --- a/loopstructural/gui/modelling/model_definition/model_definition_tab.py +++ b/loopstructural/gui/modelling/model_definition/model_definition_tab.py @@ -6,7 +6,7 @@ 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): @@ -19,6 +19,7 @@ def __init__(self, parent=None, data_manager=None): # Add widgets to the QToolBox bounding_box = BoundingBoxWidget(self, data_manager) + dem = DEMWidget(self, data_manager) fault_layers = FaultLayersWidget(self, data_manager) stratigraphy_layers = StratigraphicLayersWidget(self, data_manager) @@ -27,5 +28,6 @@ def __init__(self, parent=None, data_manager=None): widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self.add_widget(bounding_box, 'Bounding Box') # , "Bounding Box") + self.add_widget(dem, 'DEM') self.add_widget(fault_layers, 'Fault Layers') # , "Fault Layers") self.add_widget(stratigraphy_layers, 'Stratigraphic Layers') # , "Stratigraphic Layers") diff --git a/loopstructural/main/data_manager.py b/loopstructural/main/data_manager.py index 2534ea7..3e9c342 100644 --- a/loopstructural/main/data_manager.py +++ b/loopstructural/main/data_manager.py @@ -2,7 +2,7 @@ from .stratigraphic_column import StratigraphicColumn from .vectorLayerWrapper import qgsLayerToGeoDataFrame -from qgis.core import QgsProject, QgsVectorLayer +from qgis.core import QgsProject, QgsVectorLayer, QgsPointXY import json import numpy as np __title__ = "LoopStructural" @@ -34,6 +34,10 @@ def __init__(self, *, project=None, mapCanvas=None, logger=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 = False + self.dem_callback = None def onSaveProject(self): """Save project data.""" self.logger(message="Saving project data...", log_level=3) @@ -104,6 +108,25 @@ 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: + self.dem_function = lambda x, y: self.dem_layer.dataProvider().sample(QgsPointXY(x, y), 1)[0] if self.dem_layer else np.nan + self._model_manager.set_dem_function(self.dem_function) + + 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): """Set the basal contacts for the model.""" self._basal_contacts = {'layer':basal_contacts, 'unitname_field': unitname_field} diff --git a/loopstructural/main/model_manager.py b/loopstructural/main/model_manager.py index 148a4f8..64b9b6a 100644 --- a/loopstructural/main/model_manager.py +++ b/loopstructural/main/model_manager.py @@ -1,5 +1,6 @@ from collections import defaultdict - +from collections.abc import Callable +from typing import Callable import geopandas as gpd import pandas as pd from LoopStructural import GeologicalModel @@ -9,7 +10,7 @@ class AllSampler: - def __call__(self, line: gpd.GeoDataFrame) -> pd.DataFrame: + def __call__(self, line: gpd.GeoDataFrame, dem:Callable) -> pd.DataFrame: points = [] feature_id = 0 if line is None: @@ -20,15 +21,15 @@ def __call__(self, line: gpd.GeoDataFrame) -> pd.DataFrame: if geom.geom_type == 'LineString': coords = list(geom.coords) for x, y in coords: - points.append({'X': x, 'Y': y, 'Z': 0, 'feature_id': feature_id, **attributes }) + points.append({'X': x, 'Y': y, 'Z': dem(x, y), 'feature_id': feature_id, **attributes}) elif geom.geom_type == 'MultiLineString': - for line in geom.geoms: - coords = list(line.coords) + for l in geom.geoms: + coords = list(l.coords) for x, y in coords: - points.append({'X': x, 'Y': y, 'Z': 0, 'feature_id': feature_id, **attributes}) + points.append({'X': x, 'Y': y, 'Z': dem(x, y), 'feature_id': feature_id, **attributes}) elif geom.geom_type == 'Point': - points.append({'X': geom.x, 'Y': geom.y, 'Z': 0, 'feature_id': feature_id, **attributes}) + points.append({'X': geom.x, 'Y': geom.y, 'Z': dem(geom.x, geom.y), 'feature_id': feature_id, **attributes}) feature_id += 1 return pd.DataFrame(points) @@ -41,14 +42,17 @@ def __init__(self): self.faults = defaultdict(dict) self.stratigraphy = defaultdict(dict) self.observers = [] + self.dem_function = lambda x,y: 0 def update_bounding_box(self, 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.""" + 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()): """Add fault trace data to the geological model.""" # sample fault trace self.faults.clear() # Clear existing faults - fault_points = sampler(fault_trace) + fault_points = sampler(fault_trace, self.dem_function) if fault_name_field is not None: fault_points['fault_name'] = fault_points[fault_name_field] else: @@ -60,7 +64,7 @@ def update_fault_points(self, fault_trace: gpd.GeoDataFrame, *, fault_name_field def update_contact_traces(self, basal_contacts: gpd.GeoDataFrame, *, sampler=AllSampler(), unit_name_field=None): - unit_points = sampler(basal_contacts) + unit_points = sampler(basal_contacts,self.dem_function) if unit_name_field is not None: unit_points['unit_name'] = unit_points[unit_name_field] else: @@ -80,7 +84,9 @@ def update_structural_data(self, structural_orientations: gpd.GeoDataFrame, *, s structural_orientations['unit_name'] = structural_orientations[unit_name_field] structural_orientations['X'] = structural_orientations.geometry.x structural_orientations['Y'] = structural_orientations.geometry.y - structural_orientations['Z'] = structural_orientations.geometry.z + structural_orientations['Z'] = structural_orientations.apply( + lambda row: self.dem_function(row.geometry.x, row.geometry.y), axis=1 + ) 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']] From c33cdece9080331afe6bc0c1d0b65a0f4098ca1b Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Mon, 21 Jul 2025 11:27:49 +1000 Subject: [PATCH 057/111] fix: add loopstructural log messages to qgis plugin --- loopstructural/plugin_main.py | 10 +++++- loopstructural/toolbelt/log_handler.py | 49 ++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/loopstructural/plugin_main.py b/loopstructural/plugin_main.py index 53cbbb8..2af51c3 100644 --- a/loopstructural/plugin_main.py +++ b/loopstructural/plugin_main.py @@ -60,9 +60,17 @@ def __init__(self, iface: QgisInterface): ) self.data_manager.set_model_manager(self.model_manager) - + def injectLogHandler(self): + import LoopStructural + import logging + 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="debug", 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 diff --git a/loopstructural/toolbelt/log_handler.py b/loopstructural/toolbelt/log_handler.py index 41c8f73..afb9192 100644 --- a/loopstructural/toolbelt/log_handler.py +++ b/loopstructural/toolbelt/log_handler.py @@ -146,3 +146,52 @@ 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=record.name + ) + 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 \ No newline at end of file From a4689d6227c2ce13663a25aef324c86444c92a45 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Mon, 21 Jul 2025 11:29:50 +1000 Subject: [PATCH 058/111] fix: port to using stratigraphic column object from loopstructural --- .../stratigraphic_column.py | 16 ++-- .../stratigraphic_unit.py | 4 +- loopstructural/main/data_manager.py | 21 ++--- loopstructural/main/model_manager.py | 80 +++++++++++++------ 4 files changed, 77 insertions(+), 44 deletions(-) diff --git a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py index 772fcad..346fbfe 100644 --- a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py +++ b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py @@ -9,7 +9,7 @@ ) from loopstructural.gui.modelling.stratigraphic_column.unconformity import UnconformityWidget -from loopstructural.main.stratigraphic_column import StratigraphicColumnElementType +from LoopStructural.modelling.core.stratigraphic_column import StratigraphicColumnElementType from .stratigraphic_unit import StratigraphicUnitWidget @@ -71,6 +71,7 @@ def update_display(self): 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): @@ -86,15 +87,15 @@ def add_unit(self, *, unit_data=None, create_new=True): if create_new: unit = self.data_manager.add_to_stratigraphic_column(unit_data) else: - if unit_data['name'] is not None or unit_data['name'] != '': + if unit_data['uuid'] is not None or unit_data['uuid'] != '': - unit = self.data_manager._stratigraphic_column.get_unit_by_name( - unit_data['name'] + unit = self.data_manager._stratigraphic_column.get_element_by_uuid( + unit_data['uuid'] ) for k in list(unit_data.keys()): if unit_data[k] is None: unit_data.pop(k) - unit_widget = StratigraphicUnitWidget(uuid=unit.uuid) + 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 @@ -114,9 +115,10 @@ def add_unconformity(self, *, unconformity_data=None, create_new=True): if create_new: unconformity = self.data_manager.add_to_stratigraphic_column(unconformity_data) else: - unconformity = self.data_manager._stratigraphic_column.get_unconformity_by_type( - unconformity_data['unconformity_type'] + 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() diff --git a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.py b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.py index 7088edf..8a68204 100644 --- a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.py +++ b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.py @@ -11,13 +11,13 @@ class StratigraphicUnitWidget(QWidget): 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, parent=None): + 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 = 0.0 # Optional thickness attribute + self.thickness = thickness # Optional thickness attribute # Add delete button self.buttonDelete.clicked.connect(self.request_delete) self.lineEditName.editingFinished.connect(self.onNameChanged) diff --git a/loopstructural/main/data_manager.py b/loopstructural/main/data_manager.py index 3e9c342..c4379a7 100644 --- a/loopstructural/main/data_manager.py +++ b/loopstructural/main/data_manager.py @@ -1,6 +1,6 @@ from LoopStructural.datatypes import BoundingBox -from .stratigraphic_column import StratigraphicColumn +from LoopStructural import StratigraphicColumn from .vectorLayerWrapper import qgsLayerToGeoDataFrame from qgis.core import QgsProject, QgsVectorLayer, QgsPointXY import json @@ -36,8 +36,10 @@ def __init__(self, *, project=None, mapCanvas=None, logger=None): self.fault_stratigraphy_adjacency = None self.elevation = np.nan self.dem_layer = None - self.use_dem = False + self.use_dem = True self.dem_callback = None + + def onSaveProject(self): """Save project data.""" self.logger(message="Saving project data...", log_level=3) @@ -61,9 +63,9 @@ def set_model_manager(self, model_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.update_bounding_box(self._bounding_box) - self._model_manager.update_stratigraphic_column(self._stratigraphic_column) - + 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 @@ -235,9 +237,9 @@ def get_structural_orientations(self): """Get the structural orientations.""" return self._structural_orientations - def update_stratigraphic_column(self, stratigraphic_column): - """Set the stratigraphic column for the model.""" - self._stratigraphic_column = stratigraphic_column + # def update_stratigraphic_column(self, stratigraphic_column): + # """Set the stratigraphic column for the model.""" + # self._stratigraphic_column = stratigraphic_column def get_stratigraphic_column(self): """Get the stratigraphic column.""" @@ -354,8 +356,9 @@ def update_from_dict(self, data): 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 = StratigraphicColumn.from_dict(data['stratigraphic_column']) - self.stratigraphic_column_callback() + self._stratigraphic_column.update_from_dict(data['stratigraphic_column']) + 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.""" diff --git a/loopstructural/main/model_manager.py b/loopstructural/main/model_manager.py index 64b9b6a..097efd9 100644 --- a/loopstructural/main/model_manager.py +++ b/loopstructural/main/model_manager.py @@ -1,16 +1,20 @@ from collections import defaultdict from collections.abc import Callable +from tracemalloc import start from typing import Callable import geopandas as gpd import pandas as pd from LoopStructural import GeologicalModel from LoopStructural.datatypes import BoundingBox - from loopstructural.main.stratigraphic_column import StratigraphicColumn 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) -> pd.DataFrame: + """Sample the line and return a DataFrame with X, Y, Z coordinates and attributes.""" points = [] feature_id = 0 if line is None: @@ -35,21 +39,43 @@ def __call__(self, line: gpd.GeoDataFrame, dem:Callable) -> pd.DataFrame: 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.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 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.""" + """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()): - """Add fault trace data to the geological model.""" + """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) @@ -57,6 +83,10 @@ def update_fault_points(self, fault_trace: gpd.GeoDataFrame, *, fault_name_field fault_points['fault_name'] = fault_points[fault_name_field] else: fault_points['fault_name'] = fault_points['feature_id'].astype(str) + if fault_dip_field is not None: + fault_points['dip'] = fault_points[fault_dip_field] + if fault_displacement_field is not None: + fault_points['displacement'] = fault_points[fault_displacement_field] for fault_name in fault_points['fault_name'].unique(): self.faults[fault_name]['data'] = fault_points.loc[ fault_points['fault_name'] == fault_name, ['X', 'Y', 'Z'] @@ -64,7 +94,11 @@ def update_fault_points(self, fault_trace: gpd.GeoDataFrame, *, fault_name_field def update_contact_traces(self, basal_contacts: gpd.GeoDataFrame, *, sampler=AllSampler(), unit_name_field=None): + unit_points = sampler(basal_contacts,self.dem_function) + 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] else: @@ -101,49 +135,43 @@ def update_structural_data(self, structural_orientations: gpd.GeoDataFrame, *, s self.stratigraphy[unit_name]['orientations'] = orientations def update_stratigraphic_column(self, stratigraphic_column: StratigraphicColumn): - # new_groups = stratigraphic_column.get_groups() - # old_groups = self.groups.copy() - # would be nice to check if the groups have changed - # and if the contents of the group have changed - # but for now just update groups when the stratigraphic column is updated - # # Update the model with the new stratigraphic column - self.groups = stratigraphic_column.get_groups() + """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_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 = {} unit_id = 0 - for i, units in enumerate(self.groups): + for i, group in enumerate(self.stratigraphic_column.get_groups()): val = 0 data = [] - groupname = f"Group_{i + 1}" + groupname = group.name stratigraphic_column[groupname] = {} - for u in reversed(units): + for u in reversed(group.units): unit_data = self.stratigraphy.get(u.name, None) if unit_data is None: continue else: - stratigraphic_column[groupname][u.name] = { - "max": val + u.thickness, - "min": val, - "id": unit_id, - "colour": u.colour, - } + unit_data = unit_data.copy() unit_data['val'] = val unit_data['feature_name'] = groupname data.append(unit_data) - unit_id += 1 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) self.model.add_unconformity(foliation,0) - self.model.set_stratigraphic_column(stratigraphic_column) + self.model.stratigraphic_column = self.stratigraphic_column def update_fault_features(self): """Update the fault features in the geological model.""" @@ -173,9 +201,9 @@ def valid(self): def update_model(self): """Update the geological model with the current stratigraphy and faults.""" - # if not self.valid: - # raise ValueError("Model is not valid. Please check the data.") - # Update the model with faults + + self.model.features = [] + self.model.feature_name_index={} 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() From f40aa17ddbfc135b86fe1b5d63bedd165d98fe2b Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Mon, 21 Jul 2025 11:30:13 +1000 Subject: [PATCH 059/111] fix: add dem --- loopstructural/gui/loop_widget.py | 8 +++--- .../gui/modelling/model_definition/dem.py | 2 ++ .../gui/modelling/model_definition/dem.ui | 10 ++++--- .../model_definition/model_definition_tab.py | 26 +++++++------------ 4 files changed, 23 insertions(+), 23 deletions(-) diff --git a/loopstructural/gui/loop_widget.py b/loopstructural/gui/loop_widget.py index b8b3a51..fe60650 100644 --- a/loopstructural/gui/loop_widget.py +++ b/loopstructural/gui/loop_widget.py @@ -14,13 +14,13 @@ def __init__(self, parent=None, *, mapCanvas=None, logger=None, data_manager=Non tabWidget = QTabWidget(self) tabWidget.setTabPosition(QTabWidget.South) mainLayout.addWidget(tabWidget) - modelling_widget = ModellingWidget( + self.modelling_widget = ModellingWidget( self, mapCanvas=self.mapCanvas, logger=self.logger, data_manager=self.data_manager, model_manager=self.model_manager ) - visualisation_widget = VisualisationWidget( + self.visualisation_widget = VisualisationWidget( self, mapCanvas=self.mapCanvas, logger=self.logger, model_manager=self.model_manager ) - tabWidget.addTab(modelling_widget, "Modelling") - tabWidget.addTab(visualisation_widget, "Visualisation") + tabWidget.addTab(self.modelling_widget, "Modelling") + tabWidget.addTab(self.visualisation_widget, "Visualisation") \ No newline at end of file diff --git a/loopstructural/gui/modelling/model_definition/dem.py b/loopstructural/gui/modelling/model_definition/dem.py index 4d3b540..d4dc0b3 100644 --- a/loopstructural/gui/modelling/model_definition/dem.py +++ b/loopstructural/gui/modelling/model_definition/dem.py @@ -15,6 +15,8 @@ def __init__(self, parent=None, data_manager=None): QgsMapLayerProxyModel.RasterLayer) self.useDEMCheckBox.stateChanged.connect(self.onUseDEMClicked) self.elevationQgsDoubleSpinBox.valueChanged.connect(self.onElevationChanged) + self.onElevationChanged() + def onUseDEMClicked(self): if self.useDEMCheckBox.isChecked(): self.demLayerQgsMapLayerComboBox.setEnabled(True) diff --git a/loopstructural/gui/modelling/model_definition/dem.ui b/loopstructural/gui/modelling/model_definition/dem.ui index f723244..2d1bbaa 100644 --- a/loopstructural/gui/modelling/model_definition/dem.ui +++ b/loopstructural/gui/modelling/model_definition/dem.ui @@ -24,7 +24,11 @@
- + + + false + + @@ -36,7 +40,7 @@ - true + false @@ -50,7 +54,7 @@ - false + true -100000000.000000000000000 diff --git a/loopstructural/gui/modelling/model_definition/model_definition_tab.py b/loopstructural/gui/modelling/model_definition/model_definition_tab.py index 3ed7874..f898f75 100644 --- a/loopstructural/gui/modelling/model_definition/model_definition_tab.py +++ b/loopstructural/gui/modelling/model_definition/model_definition_tab.py @@ -10,24 +10,18 @@ class ModelDefinitionTab(BaseTab): def __init__(self, parent=None, data_manager=None): - super().__init__(parent, data_manager, scrollable=True) - # Load the UI file for Tab 1 - - # Create a QToolBox for collapsible sections - # self.toolBox = QToolBox(self) - # self.add_widget(self.toolBox) - + super().__init__(parent, data_manager, scrollable=True) # Add widgets to the QToolBox - bounding_box = BoundingBoxWidget(self, data_manager) - dem = DEMWidget(self, data_manager) - fault_layers = FaultLayersWidget(self, data_manager) - stratigraphy_layers = StratigraphicLayersWidget(self, data_manager) + 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 [bounding_box, fault_layers, stratigraphy_layers]: + for widget in [self.bounding_box, self.fault_layers, self.dem,self.stratigraphy_layers]: widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) - self.add_widget(bounding_box, 'Bounding Box') # , "Bounding Box") - self.add_widget(dem, 'DEM') - self.add_widget(fault_layers, 'Fault Layers') # , "Fault Layers") - self.add_widget(stratigraphy_layers, 'Stratigraphic Layers') # , "Stratigraphic Layers") + 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") From b64a2d6236f3161403cc984d51214548e7ccbe4a Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Mon, 21 Jul 2025 11:30:28 +1000 Subject: [PATCH 060/111] fix: change interpolation weights to qdoublespinbox --- .../gui/modelling/feature_details_panel.py | 56 ++++++++----------- .../stratigraphic_column/unconformity.ui | 16 +++--- 2 files changed, 31 insertions(+), 41 deletions(-) diff --git a/loopstructural/gui/modelling/feature_details_panel.py b/loopstructural/gui/modelling/feature_details_panel.py index 1a44f16..6e7857b 100644 --- a/loopstructural/gui/modelling/feature_details_panel.py +++ b/loopstructural/gui/modelling/feature_details_panel.py @@ -28,39 +28,29 @@ def __init__(self, parent=None,*, feature=None): self.setLayout(mainLayout) ## define interpolator parameters - # Regularisation slider - self.regularisation_slider = QSlider(Qt.Horizontal) - self.regularisation_slider.setRange(0, 100) - self.regularisation_slider.setValue(1) - self.regularisation_label = QLabel("Regularisation: 1") - self.regularisation_slider.valueChanged.connect( - lambda value: self.regularisation_label.setText(f"Regularisation: {value}") - ) + # 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_slider.valueChanged.connect( # lambda value: feature.builder.foliation_parameters.__setitem__('regularisation', value) # ) - self.regularisation_slider.valueChanged.connect( + self.regularisation_spin_box.valueChanged.connect( lambda value: feature.builder.update_build_arguments({'regularisation': value}) ) - self.cpw_slider = QSlider(Qt.Horizontal) - self.cpw_slider.setRange(0, 100) - self.cpw_slider.setValue(1) - self.cpw_label = QLabel("Value point weight: 1") - self.cpw_slider.valueChanged.connect( - lambda value: self.cpw_label.setText(f"Value point weight: {value}") - ) - self.cpw_slider.valueChanged.connect( - lambda value: feature.builder.update_build_arguments({'cpw':value}) - ) - self.npw_slider = QSlider(Qt.Horizontal) - self.npw_slider.setRange(0, 100) - self.npw_slider.setValue(1) - self.npw_label = QLabel("Normal vector weight: 1") - self.npw_slider.valueChanged.connect( - lambda value: self.npw_label.setText(f"Normal vector weight: {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_slider.valueChanged.connect( - lambda value: feature.builder.update_build_arguments({'npw':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() @@ -68,18 +58,18 @@ def __init__(self, parent=None,*, feature=None): self.n_elements_spinbox = QDoubleSpinBox() self.n_elements_spinbox.setRange(100, 1000000) - self.n_elements_spinbox.setValue(5000) + self.n_elements_spinbox.setValue(feature.interpolator.n_elements) self.n_elements_spinbox.setPrefix("Number of Elements: ") - - self.n_elements_spinbox.valueChanged.connect(lambda value: feature.builder.update_build_arguments({'nelements ': value})) + + self.n_elements_spinbox.valueChanged.connect(lambda value: feature.builder.update_build_arguments({'nelements': value})) # 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(self.regularisation_label, self.regularisation_slider) - form_layout.addRow(self.cpw_label, self.cpw_slider) - form_layout.addRow(self.npw_label, self.npw_slider) + 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) diff --git a/loopstructural/gui/modelling/stratigraphic_column/unconformity.ui b/loopstructural/gui/modelling/stratigraphic_column/unconformity.ui index 4acd466..023789d 100644 --- a/loopstructural/gui/modelling/stratigraphic_column/unconformity.ui +++ b/loopstructural/gui/modelling/stratigraphic_column/unconformity.ui @@ -7,17 +7,10 @@ 0 0 756 - 53 + 62 - - - - Type - - - @@ -45,6 +38,13 @@ + + + + Type + + + From 82708ddd799456b386d9ea4278164b4c77f4605e Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Mon, 21 Jul 2025 12:14:09 +1000 Subject: [PATCH 061/111] fix: don't add fields that aren't in the fault layer --- loopstructural/main/model_manager.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/loopstructural/main/model_manager.py b/loopstructural/main/model_manager.py index 097efd9..36b9c61 100644 --- a/loopstructural/main/model_manager.py +++ b/loopstructural/main/model_manager.py @@ -79,13 +79,13 @@ def update_fault_points(self, fault_trace: gpd.GeoDataFrame, *, fault_name_field # sample fault trace self.faults.clear() # Clear existing faults fault_points = sampler(fault_trace, self.dem_function) - if fault_name_field is not None: + if fault_name_field is not None and fault_name_field in fault_points.columns: fault_points['fault_name'] = fault_points[fault_name_field] else: fault_points['fault_name'] = fault_points['feature_id'].astype(str) - if fault_dip_field is not None: + if fault_dip_field is not None and fault_dip_field in fault_points.columns: fault_points['dip'] = fault_points[fault_dip_field] - if fault_displacement_field is not None: + if fault_displacement_field is not None and fault_displacement_field in fault_points.columns: fault_points['displacement'] = fault_points[fault_displacement_field] for fault_name in fault_points['fault_name'].unique(): self.faults[fault_name]['data'] = fault_points.loc[ From 6705655d401d0fa3540ad911498ebc0ad6aa7f6f Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Mon, 21 Jul 2025 12:14:28 +1000 Subject: [PATCH 062/111] fix: ignore unconformities for feature settings... --- loopstructural/gui/modelling/geological_model_tab.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/loopstructural/gui/modelling/geological_model_tab.py b/loopstructural/gui/modelling/geological_model_tab.py index 31e7547..7d0ce75 100644 --- a/loopstructural/gui/modelling/geological_model_tab.py +++ b/loopstructural/gui/modelling/geological_model_tab.py @@ -66,7 +66,10 @@ def reset_parameters(self): 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 From 224a55e8592e8b287310b4a13042e364af59d75b Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Mon, 21 Jul 2025 13:35:40 +1000 Subject: [PATCH 063/111] fix: put all loopstructural logs in 'LoopStructural' heading --- loopstructural/toolbelt/log_handler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/loopstructural/toolbelt/log_handler.py b/loopstructural/toolbelt/log_handler.py index afb9192..ccea28c 100644 --- a/loopstructural/toolbelt/log_handler.py +++ b/loopstructural/toolbelt/log_handler.py @@ -178,7 +178,7 @@ def emit(self, record): log_level=qgis_level, push=self.push, duration=self.duration, - application=record.name + application='LoopStructural', ) except Exception: self.handleError(record) From d91a90c247ad270cfbf74effade2e00fcf3cd2f1 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Mon, 21 Jul 2025 13:36:00 +1000 Subject: [PATCH 064/111] fix: add uuid to dictionary for created units --- .../gui/modelling/stratigraphic_column/stratigraphic_column.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py index 346fbfe..ec47280 100644 --- a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py +++ b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py @@ -86,12 +86,14 @@ def add_unit(self, *, unit_data=None, create_new=True): 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) From 41953bac1744cde03ca621c8765069a321822501 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Mon, 21 Jul 2025 13:36:28 +1000 Subject: [PATCH 065/111] fix: update dependencies/add qpip --- loopstructural/metadata.txt | 3 ++- loopstructural/requirements.txt | 1 - 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/loopstructural/metadata.txt b/loopstructural/metadata.txt index 0b4d4f6..6598b88 100644 --- a/loopstructural/metadata.txt +++ b/loopstructural/metadata.txt @@ -24,4 +24,5 @@ qgisMaximumVersion=3.99 version=0.1.0 changelog= -# python +# python deps +plugin_dependencies=qpip \ No newline at end of file diff --git a/loopstructural/requirements.txt b/loopstructural/requirements.txt index 8d52603..663b0d1 100644 --- a/loopstructural/requirements.txt +++ b/loopstructural/requirements.txt @@ -1,5 +1,4 @@ pyvistaqt pyvista LoopStructural -loopstructural-visualisation geoh5py From ca87466299b204eab617e8d493f4611285ffbcdc Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Mon, 21 Jul 2025 13:36:40 +1000 Subject: [PATCH 066/111] fix: only show loopstructural warning messages --- loopstructural/plugin_main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/loopstructural/plugin_main.py b/loopstructural/plugin_main.py index 2af51c3..b46d1a8 100644 --- a/loopstructural/plugin_main.py +++ b/loopstructural/plugin_main.py @@ -67,7 +67,7 @@ def injectLogHandler(self): handler = PlgLoggerHandler(plg_logger_class=PlgLogger, push=True) handler.setFormatter(logging.Formatter('%(name)s - %(levelname)s - %(message)s')) - LoopStructural.setLogging(level="debug", handler=handler) + LoopStructural.setLogging(level="warning", handler=handler) def initGui(self): """Set up plugin UI elements.""" self.injectLogHandler() From bf631a9dcb75632c3ea8eb860de85261f67c6f66 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Mon, 21 Jul 2025 15:21:13 +1000 Subject: [PATCH 067/111] fix: adding placeholder for default boundingbox. this will eventually be a user defined setting --- loopstructural/main/data_manager.py | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/loopstructural/main/data_manager.py b/loopstructural/main/data_manager.py index c4379a7..023aa49 100644 --- a/loopstructural/main/data_manager.py +++ b/loopstructural/main/data_manager.py @@ -6,7 +6,14 @@ import json import numpy as np __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: @@ -18,7 +25,8 @@ def __init__(self, *, project=None, mapCanvas=None, logger=None): self.project = project self.project.readProject.connect(self.onLoadProject) self.project.writeProject.connect(self.onSaveProject) - self._bounding_box = BoundingBox(origin=[0, 0, 0], maximum=[1000, 1000, 1000]) + 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 @@ -94,6 +102,7 @@ def set_bounding_box(self, xmin=None, xmax=None, ymin=None, ymax=None, zmin=None 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 @@ -248,7 +257,6 @@ def get_stratigraphic_column(self): def update_stratigraphy(self): """Update the foliation features in the model manager.""" print("Updating stratigraphy...") - self.update_stratigraphic_column() 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']), @@ -336,7 +344,8 @@ def update_from_dict(self, data): 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 '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: @@ -357,8 +366,11 @@ def update_from_dict(self, data): orientation_type=data['structural_orientations'].get('orientation_type',None)) if 'stratigraphic_column' in data: self._stratigraphic_column.update_from_dict(data['stratigraphic_column']) - if self.stratigraphic_column_callback: - self.stratigraphic_column_callback() + 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.""" From 604ca008ea4923b6280b8e2683d4c39336778e26 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Mon, 21 Jul 2025 15:21:37 +1000 Subject: [PATCH 068/111] fix: stratigraphic column was reversed. --- loopstructural/main/model_manager.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/loopstructural/main/model_manager.py b/loopstructural/main/model_manager.py index 36b9c61..08deffd 100644 --- a/loopstructural/main/model_manager.py +++ b/loopstructural/main/model_manager.py @@ -149,12 +149,12 @@ def update_foliation_features(self): """ stratigraphic_column = {} unit_id = 0 - for i, group in enumerate(self.stratigraphic_column.get_groups()): + for i, group in enumerate(reversed(self.stratigraphic_column.get_groups())): val = 0 data = [] groupname = group.name stratigraphic_column[groupname] = {} - for u in reversed(group.units): + for u in group.units: unit_data = self.stratigraphy.get(u.name, None) if unit_data is None: continue From cea7d794523cc9a08cc5d0b2786a112edb1edfaf Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Tue, 22 Jul 2025 09:31:22 +1000 Subject: [PATCH 069/111] fix: allow z coordinate from shapefile to be used for elevation --- .../model_definition/fault_layers.py | 23 +- .../model_definition/fault_layers.ui | 147 +++++----- .../model_definition/stratigraphic_layers.py | 45 ++- .../model_definition/stratigraphic_layers.ui | 262 ++++++++++-------- loopstructural/main/data_manager.py | 14 +- loopstructural/main/model_manager.py | 77 +++-- 6 files changed, 342 insertions(+), 226 deletions(-) diff --git a/loopstructural/gui/modelling/model_definition/fault_layers.py b/loopstructural/gui/modelling/model_definition/fault_layers.py index 60b3f0f..b427505 100644 --- a/loopstructural/gui/modelling/model_definition/fault_layers.py +++ b/loopstructural/gui/modelling/model_definition/fault_layers.py @@ -1,7 +1,7 @@ import os from PyQt5.QtWidgets import QWidget -from qgis.core import QgsFieldProxyModel, QgsMapLayerProxyModel +from qgis.core import QgsFieldProxyModel, QgsMapLayerProxyModel, QgsWkbTypes from qgis.PyQt import uic @@ -24,6 +24,19 @@ def __init__(self, parent=None, data_manager=None): 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): self.faultTraceLayer.setLayer(layer) if fault_name_field: @@ -36,11 +49,17 @@ 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()) + print(f"Layer {layer.name()} has Z coordinate: {has_z}") + self.enableZCheckbox(has_z) 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 index d0b48cd..999bb4f 100644 --- a/loopstructural/gui/modelling/model_definition/fault_layers.ui +++ b/loopstructural/gui/modelling/model_definition/fault_layers.ui @@ -15,76 +15,83 @@
- - - - 10 - 10 - 730 - 181 - - - - - - - Fault Traces - - - - - - - - - - Fault Name - - - - - - - - - - Dip - - - - - - - true - - - - - - - Displacement - - - - - - - true - - - - - - - Pitch - - - - - - - - + + + + + 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> + + + +
diff --git a/loopstructural/gui/modelling/model_definition/stratigraphic_layers.py b/loopstructural/gui/modelling/model_definition/stratigraphic_layers.py index 4e1af89..375001b 100644 --- a/loopstructural/gui/modelling/model_definition/stratigraphic_layers.py +++ b/loopstructural/gui/modelling/model_definition/stratigraphic_layers.py @@ -1,7 +1,7 @@ import os from PyQt5.QtWidgets import QWidget -from qgis.core import QgsMapLayerProxyModel +from qgis.core import QgsMapLayerProxyModel, QgsWkbTypes from qgis.PyQt import uic from PyQt5.QtCore import Qt @@ -30,12 +30,49 @@ def __init__(self, parent=None, data_manager=None): 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): self.basalContactsLayer.setLayer(layer) + if layer is not None and layer.isValid(): + if layer.wkbType() != QgsWkbTypes.Unknown: + has_z = QgsWkbTypes.hasZ(layer.wkbType()) + self.data_manager.logger(message=f"Layer {layer.name()} has Z coordinate: {has_z}",log_level=2) + 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) def set_orientations_layer(self, layer, strike_field=None, dip_field=None, unitname_field=None, orientation_type=None): self.structuralDataLayer.setLayer(layer) + if layer is not None and layer.isValid(): + if layer.wkbType() != QgsWkbTypes.Unknown: + has_z = QgsWkbTypes.hasZ(layer.wkbType()) + self.data_manager.logger(message=f"Layer {layer.name()} has Z coordinate: {has_z}",level=2) + 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: @@ -64,6 +101,7 @@ def onStructuralDataLayerChanged(self, layer): self.orientationField.currentField(), self.dipField.currentField(), self.structuralDataUnitName.currentField(), + use_z_coordinate=self.structural_points_use_z, ) def onStructuralDataFieldChanged(self, field): self.data_manager.set_structural_orientations( @@ -71,10 +109,11 @@ def onStructuralDataFieldChanged(self, field): self.orientationField.currentField(), self.dipField.currentField(), self.structuralDataUnitName.currentField(), - self.orientationType.currentText() + 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) + 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 index 9a8ced9..1b5278d 100644 --- a/loopstructural/gui/modelling/model_definition/stratigraphic_layers.ui +++ b/loopstructural/gui/modelling/model_definition/stratigraphic_layers.ui @@ -15,129 +15,153 @@ - - - - 10 - 10 - 730 - 231 - - - - - - - <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> - - - - - + + + + + <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> + + - 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> + Strike/Dip - - - - + + - Basal Contacts + 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> + + + +
diff --git a/loopstructural/main/data_manager.py b/loopstructural/main/data_manager.py index 023aa49..0616eb6 100644 --- a/loopstructural/main/data_manager.py +++ b/loopstructural/main/data_manager.py @@ -138,9 +138,9 @@ 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): + def set_basal_contacts(self, basal_contacts, unitname_field=None, basal_contacts_use_z=False): """Set the basal contacts for the model.""" - self._basal_contacts = {'layer':basal_contacts, 'unitname_field': unitname_field} + self._basal_contacts = {'layer':basal_contacts, 'unitname_field': unitname_field, 'use_z_coordinate': basal_contacts_use_z} # self._unitname_field = unitname_field self.calculate_unique_basal_units() # if stratigraphic column is not empty, update contacts @@ -212,7 +212,7 @@ def get_unique_faults(self): fault_name = feature[self._fault_traces['fault_name_field']] unique_faults.add(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): + 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.""" if fault_trace_layer is None: print("Fault trace layer is None, cannot set fault traces.") @@ -223,7 +223,8 @@ def set_fault_trace_layer(self, fault_trace_layer, fault_name_field=None, fault 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} + '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) @@ -232,7 +233,7 @@ 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): + 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 @@ -240,6 +241,7 @@ def set_structural_orientations(self, structural_orientations, strike_field=None 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) def get_structural_orientations(self): @@ -275,7 +277,7 @@ def update_faults(self): 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']) + 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.") diff --git a/loopstructural/main/model_manager.py b/loopstructural/main/model_manager.py index 08deffd..6e37fdf 100644 --- a/loopstructural/main/model_manager.py +++ b/loopstructural/main/model_manager.py @@ -1,5 +1,6 @@ from collections import defaultdict from collections.abc import Callable +from re import A from tracemalloc import start from typing import Callable import geopandas as gpd @@ -13,7 +14,7 @@ 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) -> pd.DataFrame: + 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 @@ -24,18 +25,36 @@ def __call__(self, line: gpd.GeoDataFrame, dem:Callable) -> pd.DataFrame: attributes.pop('geometry', None) # Remove geometry from attributes if geom.geom_type == 'LineString': coords = list(geom.coords) - for x, y in coords: - points.append({'X': x, 'Y': y, 'Z': dem(x, y), 'feature_id': feature_id, **attributes}) + 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 x, y in coords: - points.append({'X': x, 'Y': y, 'Z': dem(x, y), 'feature_id': feature_id, **attributes}) - + 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': - points.append({'X': geom.x, 'Y': geom.y, 'Z': dem(geom.x, geom.y), 'feature_id': feature_id, **attributes}) + 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 - return pd.DataFrame(points) + df = pd.DataFrame(points) + return df class GeologicalModelManager: @@ -68,7 +87,7 @@ def set_dem_function(self, dem_function: Callable): :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()): + 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. @@ -78,7 +97,7 @@ def update_fault_points(self, fault_trace: gpd.GeoDataFrame, *, fault_name_field """ # sample fault trace self.faults.clear() # Clear existing faults - fault_points = sampler(fault_trace, self.dem_function) + fault_points = sampler(fault_trace, self.dem_function, use_z_coordinate) if fault_name_field is not None and fault_name_field in fault_points.columns: fault_points['fault_name'] = fault_points[fault_name_field] else: @@ -93,9 +112,9 @@ def update_fault_points(self, fault_trace: gpd.GeoDataFrame, *, fault_name_field ] - def update_contact_traces(self, basal_contacts: gpd.GeoDataFrame, *, sampler=AllSampler(), unit_name_field=None): + def update_contact_traces(self, basal_contacts: gpd.GeoDataFrame, *, sampler=AllSampler(), unit_name_field=None, use_z_coordinate=False): - unit_points = sampler(basal_contacts,self.dem_function) + 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 @@ -104,30 +123,28 @@ def update_contact_traces(self, basal_contacts: gpd.GeoDataFrame, *, sampler=All else: return for unit_name in unit_points['unit_name'].unique(): - self.stratigraphy[unit_name] = unit_points.loc[ + 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): + 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 strike_field is None or dip_field is None: return if unit_name_field is not None: return - structural_orientations = structural_orientations.copy() + + structural_orientations = sampler(structural_orientations, self.dem_function, use_z_coordinate) + structural_orientations['unit_name'] = structural_orientations[unit_name_field] - structural_orientations['X'] = structural_orientations.geometry.x - structural_orientations['Y'] = structural_orientations.geometry.y - structural_orientations['Z'] = structural_orientations.apply( - lambda row: self.dem_function(row.geometry.x, row.geometry.y), axis=1 - ) + 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'] @@ -159,11 +176,19 @@ def update_foliation_features(self): if unit_data is None: continue else: - - unit_data = unit_data.copy() - unit_data['val'] = val - unit_data['feature_name'] = groupname - data.append(unit_data) + 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.") From 67c8c2c04a2f518bfdf080dae644c9e89de829af Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Tue, 22 Jul 2025 13:24:47 +1000 Subject: [PATCH 070/111] Update auto-labeler.yml --- .github/workflows/auto-labeler.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/auto-labeler.yml b/.github/workflows/auto-labeler.yml index c477465..baa4bf2 100644 --- a/.github/workflows/auto-labeler.yml +++ b/.github/workflows/auto-labeler.yml @@ -11,4 +11,4 @@ jobs: steps: - uses: actions/labeler@v5 with: - repo-token: "${{ secrets.GITHUB_TOKEN }}" + repo-token: "${{ secrets.GH_PAT }}" From 68067bcbccdfba2fc27bc89d73b4403388b9f5d0 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Tue, 22 Jul 2025 13:29:41 +1000 Subject: [PATCH 071/111] Update auto-labeler.yml --- .github/workflows/auto-labeler.yml | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/.github/workflows/auto-labeler.yml b/.github/workflows/auto-labeler.yml index baa4bf2..e57cd86 100644 --- a/.github/workflows/auto-labeler.yml +++ b/.github/workflows/auto-labeler.yml @@ -1,14 +1,12 @@ -name: "🏷 PR Labeler" +name: "Pull Request Labeler" on: - - pull_request_target +- pull_request_target jobs: - triage: + labeler: permissions: contents: read pull-requests: write runs-on: ubuntu-latest steps: - - uses: actions/labeler@v5 - with: - repo-token: "${{ secrets.GH_PAT }}" + - uses: actions/labeler@v5 From 0e8db1502b91608f0693c5f931c8efc28cd49bbb Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Tue, 22 Jul 2025 13:33:04 +1000 Subject: [PATCH 072/111] fix: different settings for structural frames --- .../gui/modelling/feature_details_panel.py | 59 ++++++++++++++----- 1 file changed, 43 insertions(+), 16 deletions(-) diff --git a/loopstructural/gui/modelling/feature_details_panel.py b/loopstructural/gui/modelling/feature_details_panel.py index 6e7857b..111103d 100644 --- a/loopstructural/gui/modelling/feature_details_panel.py +++ b/loopstructural/gui/modelling/feature_details_panel.py @@ -4,10 +4,10 @@ from PyQt5.QtCore import Qt from PyQt5.QtWidgets import QScrollArea from LoopStructural.utils import normal_vector_to_strike_and_dip +from LoopStructural.modelling.features import StructuralFrame class BaseFeatureDetailsPanel(QWidget): def __init__(self, parent=None,*, feature=None): super().__init__(parent) - # Create a scroll area for horizontal scrolling scroll = QScrollArea(self) scroll.setWidgetResizable(True) @@ -32,10 +32,6 @@ def __init__(self, parent=None,*, feature=None): 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_slider.valueChanged.connect( - # lambda value: feature.builder.foliation_parameters.__setitem__('regularisation', value) - # ) self.regularisation_spin_box.valueChanged.connect( lambda value: feature.builder.update_build_arguments({'regularisation': value}) ) @@ -58,10 +54,10 @@ def __init__(self, parent=None,*, feature=None): self.n_elements_spinbox = QDoubleSpinBox() self.n_elements_spinbox.setRange(100, 1000000) - self.n_elements_spinbox.setValue(feature.interpolator.n_elements) + self.n_elements_spinbox.setValue(self.getNelements(feature)) self.n_elements_spinbox.setPrefix("Number of Elements: ") - self.n_elements_spinbox.valueChanged.connect(lambda value: feature.builder.update_build_arguments({'nelements': value})) + self.n_elements_spinbox.valueChanged.connect(self.updateNelements) # Form layout for better organization form_layout = QFormLayout() @@ -71,10 +67,36 @@ def __init__(self, parent=None,*, feature=None): 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) - + # 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.n_elements = value + self.feature[i].builder.update_build_arguments({'n_elements': value}) + self.feature[i].builder.build() + elif self.feature.interpolator is not None: + + self.feature.interpolator.n_elements = value + self.feature.builder.update_build_arguments({'n_elements': 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) @@ -96,8 +118,7 @@ def __init__(self, parent=None,*, fault=None): self.displacement_spinbox = QDoubleSpinBox() self.displacement_spinbox.setRange(0, 1000000) # Example range self.displacement_spinbox.setValue(self.fault.displacement) - self.displacement_label = QLabel(f"Fault Displacement:") - self.displacement_slider.valueChanged.connect( + self.displacement_spinbox.valueChanged.connect( lambda value: self.fault_parameters.__setitem__('displacement', value) ) @@ -132,6 +153,12 @@ def __init__(self, parent=None,*, fault=None): 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 @@ -140,11 +167,11 @@ def __init__(self, parent=None,*, fault=None): # Form layout for better organization form_layout = QFormLayout() - form_layout.addRow(self.displacement_label, 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("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) From aca081cbf0d08c3fd2200bdffa2d7d024b24c365 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Tue, 22 Jul 2025 13:33:20 +1000 Subject: [PATCH 073/111] fix: add instructions labels to fault adjacency and stratigraphic units tables --- .../gui/modelling/fault_adjacency_tab.py | 48 +++++++++++++++---- 1 file changed, 38 insertions(+), 10 deletions(-) diff --git a/loopstructural/gui/modelling/fault_adjacency_tab.py b/loopstructural/gui/modelling/fault_adjacency_tab.py index da68e24..bc9ded4 100644 --- a/loopstructural/gui/modelling/fault_adjacency_tab.py +++ b/loopstructural/gui/modelling/fault_adjacency_tab.py @@ -33,29 +33,57 @@ def init_ui(self): def create_fault_adjacency_table(self): """Create a table with QPushButtons for fault adjacency.""" faults = ['Fault A', 'Fault B', 'Fault C'] # Example fault names, replace with actual data - + # Add instructions label + 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" + ) + instructions_label = QLabel(instructions) + instructions_label.setWordWrap(True) + self.layout().addWidget(instructions_label) self.table = QTableWidget(len(faults), len(faults), self) self.table.setHorizontalHeaderLabels(faults) self.table.setVerticalHeaderLabels(faults) for row in range(len(faults)): for col in range(len(faults)): - button = QPushButton() - button.setStyleSheet("background-color: white;") - button.clicked.connect(lambda _, b=button: self.change_button_color(b)) - self.table.setCellWidget(row, col, button) + 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() + button.setStyleSheet("background-color: white;") + button.clicked.connect(lambda _, b=button: self.change_button_color(b)) + self.table.setCellWidget(row, col, button) def create_stratigraphic_units_table(self): """Create a table with QPushButtons for stratigraphic units.""" + # Add instructions label + 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" + ) + instructions_label = QLabel(instructions) + instructions_label.setWordWrap(True) + self.layout().addWidget(instructions_label) units = ['unit1', 'unit2', 'unit3'] faults = ['Fault A', 'Fault B', 'Fault C'] # Example fault names, replace with actual data - self.stratigraphic_table = QTableWidget(len(units), len(faults), self) - self.stratigraphic_table.setHorizontalHeaderLabels(units) - self.stratigraphic_table.setVerticalHeaderLabels(faults) + self.stratigraphic_table = QTableWidget(len(faults), len(units), self) + self.stratigraphic_table.setHorizontalHeaderLabels(faults) + self.stratigraphic_table.setVerticalHeaderLabels(units) - for row in range(len(units)): - for col in range(len(faults)): + for row in range(len(faults)): + for col in range(len(units)): button = QPushButton() button.setStyleSheet("background-color: white;") button.clicked.connect(lambda _, b=button: self.change_button_colour_binary(b)) From b00450d75569e417993b0197607db8ebe9b661a9 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Tue, 22 Jul 2025 13:37:09 +1000 Subject: [PATCH 074/111] fix: remove unnecessary blank line in LoopstructuralPlugin class --- loopstructural/plugin_main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/loopstructural/plugin_main.py b/loopstructural/plugin_main.py index b46d1a8..3c4a8a7 100644 --- a/loopstructural/plugin_main.py +++ b/loopstructural/plugin_main.py @@ -59,7 +59,7 @@ def __init__(self, iface: QgisInterface): self.model_manager = GeologicalModelManager( ) self.data_manager.set_model_manager(self.model_manager) - + def injectLogHandler(self): import LoopStructural import logging From 377b39a9866f5675e8af8cec6d02557c450f6109 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Wed, 23 Jul 2025 13:30:36 +1000 Subject: [PATCH 075/111] fix: fault topology plugged into LoopStructural classes with observer updates. Could be improved to reduce update by adding/removing from the widget instead of rebuilding. --- .../gui/modelling/fault_adjacency_tab.py | 167 ++++++++++++------ loopstructural/main/data_manager.py | 15 +- loopstructural/main/model_manager.py | 17 +- 3 files changed, 138 insertions(+), 61 deletions(-) diff --git a/loopstructural/gui/modelling/fault_adjacency_tab.py b/loopstructural/gui/modelling/fault_adjacency_tab.py index bc9ded4..631a427 100644 --- a/loopstructural/gui/modelling/fault_adjacency_tab.py +++ b/loopstructural/gui/modelling/fault_adjacency_tab.py @@ -1,6 +1,12 @@ +from ast import arg from PyQt5.QtWidgets import QTabWidget, QVBoxLayout, QWidget, QLabel, QTableWidget, QTableWidgetItem, QPushButton, QGroupBox from PyQt5.QtCore import Qt -# from qgis.PyQt.QtWidgets import QgsCollapsibleGroupBox +from enum import Enum + +class FaultRelationshipType(Enum): + NO_EDGE = "no_edge" + ABUTTING = "abutting" + FAULTED = "faulted" class FaultAdjacencyTab(QWidget): def __init__(self, parent=None, data_manager=None): @@ -17,24 +23,11 @@ def init_ui(self): # 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) - fault_table_layout = QVBoxLayout(self.fault_table_group) + self.stratigraphic_table_layout = QVBoxLayout(self.stratigraphic_table_group) # Create the fault adjacency table - self.create_fault_adjacency_table() - fault_table_layout.addWidget(self.table) - self.layout().addWidget(self.fault_table_group) - - # Create the stratigraphic units table - self.create_stratigraphic_units_table() - stratigraphic_table_layout = QVBoxLayout(self.stratigraphic_table_group) - stratigraphic_table_layout.addWidget(self.stratigraphic_table) - self.layout().addWidget(self.stratigraphic_table_group) - - def create_fault_adjacency_table(self): - """Create a table with QPushButtons for fault adjacency.""" - faults = ['Fault A', 'Fault B', 'Fault C'] # Example fault names, replace with actual data - # Add instructions label - instructions = ( + self.fault_fault_instructions = ( "Rows: faults being affected\n" "Columns: affecting faults\n" "Toggle cell colour to indicate fault interaction:\n" @@ -42,10 +35,69 @@ def create_fault_adjacency_table(self): "Red: row fault stops at column fault\n" "White: no interaction" ) - instructions_label = QLabel(instructions) - instructions_label.setWordWrap(True) - self.layout().addWidget(instructions_label) - self.table = QTableWidget(len(faults), len(faults), self) + 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.NO_EDGE + 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) @@ -58,49 +110,58 @@ def create_fault_adjacency_table(self): self.table.setItem(row, col, item) else: button = QPushButton() - button.setStyleSheet("background-color: white;") - button.clicked.connect(lambda _, b=button: self.change_button_color(b)) + 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 create_stratigraphic_units_table(self): - """Create a table with QPushButtons for stratigraphic units.""" - # Add instructions label - 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" - ) - instructions_label = QLabel(instructions) - instructions_label.setWordWrap(True) - self.layout().addWidget(instructions_label) - units = ['unit1', 'unit2', 'unit3'] - faults = ['Fault A', 'Fault B', 'Fault C'] # Example fault names, replace with actual data + 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 - self.stratigraphic_table = QTableWidget(len(faults), len(units), self) + 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(faults)): - for col in range(len(units)): + for row in range(len(units)): + for col in range(len(faults)): button = QPushButton() - button.setStyleSheet("background-color: white;") - button.clicked.connect(lambda _, b=button: self.change_button_colour_binary(b)) + 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): + 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;") - def change_button_color(self, button): - """Cycle the button color between red, green, and black.""" - current_color = button.styleSheet() - if "red" in current_color: - button.setStyleSheet("background-color: green;") - elif "green" in current_color: - button.setStyleSheet("background-color: white;") - else: - button.setStyleSheet("background-color: red;") \ No newline at end of file + 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/main/data_manager.py b/loopstructural/main/data_manager.py index 0616eb6..e934f0a 100644 --- a/loopstructural/main/data_manager.py +++ b/loopstructural/main/data_manager.py @@ -1,6 +1,6 @@ from LoopStructural.datatypes import BoundingBox -from LoopStructural import StratigraphicColumn +from LoopStructural import StratigraphicColumn, FaultTopology from .vectorLayerWrapper import qgsLayerToGeoDataFrame from qgis.core import QgsProject, QgsVectorLayer, QgsPointXY import json @@ -34,6 +34,7 @@ def __init__(self, *, project=None, mapCanvas=None, logger=None): 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 @@ -72,6 +73,7 @@ def set_model_manager(self, model_manager): 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): @@ -138,9 +140,9 @@ 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, basal_contacts_use_z=False): + 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': basal_contacts_use_z} + 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 @@ -248,9 +250,6 @@ def get_structural_orientations(self): """Get the structural orientations.""" return self._structural_orientations - # def update_stratigraphic_column(self, stratigraphic_column): - # """Set the stratigraphic column for the model.""" - # self._stratigraphic_column = stratigraphic_column def get_stratigraphic_column(self): """Get the stratigraphic column.""" @@ -274,6 +273,10 @@ def update_stratigraphy(self): 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) 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']), diff --git a/loopstructural/main/model_manager.py b/loopstructural/main/model_manager.py index 6e37fdf..0038e9e 100644 --- a/loopstructural/main/model_manager.py +++ b/loopstructural/main/model_manager.py @@ -69,12 +69,15 @@ def __init__(self): 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. @@ -106,10 +109,20 @@ def update_fault_points(self, fault_trace: gpd.GeoDataFrame, *, fault_name_field fault_points['dip'] = fault_points[fault_dip_field] if fault_displacement_field is not None and fault_displacement_field in fault_points.columns: fault_points['displacement'] = fault_points[fault_displacement_field] + 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, ['X', 'Y', 'Z'] ] + 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): @@ -144,7 +157,7 @@ def update_structural_data(self, structural_orientations: gpd.GeoDataFrame, *, s 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'] From d231204b8fa6215c3a9cd2cac76c75b46bd3d183 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Wed, 23 Jul 2025 13:31:06 +1000 Subject: [PATCH 076/111] fix: add option to use shapefile z values instead of dem when the shapefile has a 3D element --- .../gui/modelling/model_definition/fault_layers.py | 12 +++++++++++- .../model_definition/stratigraphic_layers.py | 9 +++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/loopstructural/gui/modelling/model_definition/fault_layers.py b/loopstructural/gui/modelling/model_definition/fault_layers.py index b427505..6d641f6 100644 --- a/loopstructural/gui/modelling/model_definition/fault_layers.py +++ b/loopstructural/gui/modelling/model_definition/fault_layers.py @@ -37,7 +37,7 @@ def enableZCheckbox(self, enable): 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): + 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) @@ -45,6 +45,16 @@ def set_fault_trace_layer(self, layer, fault_name_field=None, fault_dip_field=No 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.data_manager.logger(message=f"Layer {layer.name()} has Z coordinate: {has_z}", log_level=2) + 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) diff --git a/loopstructural/gui/modelling/model_definition/stratigraphic_layers.py b/loopstructural/gui/modelling/model_definition/stratigraphic_layers.py index 375001b..63d60c2 100644 --- a/loopstructural/gui/modelling/model_definition/stratigraphic_layers.py +++ b/loopstructural/gui/modelling/model_definition/stratigraphic_layers.py @@ -49,7 +49,7 @@ def enableStructuralPointsZCheckBox(self, enable): self.useStructuralPointsZCoordinatesCheckBox.setChecked(self.structural_points_use_z) else: self.useStructuralPointsZCoordinatesCheckBox.setChecked(False) - def set_basal_contacts(self, layer, unitname_field=None): + 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: @@ -62,7 +62,9 @@ def set_basal_contacts(self, layer, unitname_field=None): self.enableBasalContactsZCheckBox(False) if unitname_field: self.unitNameField.setField(unitname_field) - def set_orientations_layer(self, layer, strike_field=None, dip_field=None, unitname_field=None, orientation_type=None): + 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: @@ -83,6 +85,9 @@ def set_orientations_layer(self, layer, strike_field=None, dip_field=None, unitn 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()) From 65b91cd5d916562ad9a99aeb17ba2a61ae2f2b77 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Wed, 23 Jul 2025 13:48:38 +1000 Subject: [PATCH 077/111] fix: add abutting relationships to model --- loopstructural/main/model_manager.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/loopstructural/main/model_manager.py b/loopstructural/main/model_manager.py index 0038e9e..88ca68a 100644 --- a/loopstructural/main/model_manager.py +++ b/loopstructural/main/model_manager.py @@ -1,7 +1,6 @@ from collections import defaultdict from collections.abc import Callable -from re import A -from tracemalloc import start + from typing import Callable import geopandas as gpd import pandas as pd @@ -221,7 +220,14 @@ def update_fault_features(self): # need to have a way of specifying the displacement from the trace # or maybe the model should calculate it self.model.create_and_add_fault(fault_name, displacement=10, fault_data=data) + 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 == FaultRelationshipType.ABUTTING: + self.model[f].add_abutting_fault(self.model[f2]) + @property def valid(self): valid = True From 996cdb88a496bff7e32d9a942ef167c008f87736 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Wed, 23 Jul 2025 14:40:33 +1000 Subject: [PATCH 078/111] fix: fault fault topology working --- .../gui/modelling/fault_adjacency_tab.py | 75 ++++++++---- loopstructural/main/model_manager.py | 115 ++++++++++++------ 2 files changed, 134 insertions(+), 56 deletions(-) diff --git a/loopstructural/gui/modelling/fault_adjacency_tab.py b/loopstructural/gui/modelling/fault_adjacency_tab.py index 631a427..9245d38 100644 --- a/loopstructural/gui/modelling/fault_adjacency_tab.py +++ b/loopstructural/gui/modelling/fault_adjacency_tab.py @@ -1,12 +1,17 @@ -from ast import arg -from PyQt5.QtWidgets import QTabWidget, QVBoxLayout, QWidget, QLabel, QTableWidget, QTableWidgetItem, QPushButton, QGroupBox from PyQt5.QtCore import Qt -from enum import Enum +from PyQt5.QtWidgets import ( + QGroupBox, + QLabel, + QPushButton, + QTableWidget, + QTableWidgetItem, + QTabWidget, + QVBoxLayout, + QWidget, +) + +from LoopStructural.modelling.core.fault_topology import FaultRelationshipType -class FaultRelationshipType(Enum): - NO_EDGE = "no_edge" - ABUTTING = "abutting" - FAULTED = "faulted" class FaultAdjacencyTab(QWidget): def __init__(self, parent=None, data_manager=None): @@ -17,9 +22,9 @@ def __init__(self, parent=None, data_manager=None): # 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) @@ -45,7 +50,7 @@ def init_ui(self): "Rows: stratigraphic units\n" "Columns: faults\n" "Toggle cell colour to indicate interaction:\n" - "Red: unit is faulted by fault\n" + "Red: unit is faulted by fault\n" "White: no interaction" ) self.strat_fault_instructions_label = QLabel(self.strat_fault_instructions) @@ -57,10 +62,13 @@ def init_ui(self): 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": + 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() @@ -72,7 +80,7 @@ def change_button_color(self, button, row, col): relationship = FaultRelationshipType.FAULTED elif "green" in current_color: new_color = "white" - relationship = FaultRelationshipType.NO_EDGE + relationship = FaultRelationshipType.NONE else: new_color = "red" relationship = FaultRelationshipType.ABUTTING @@ -84,7 +92,9 @@ def change_button_color(self, button, row, col): 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 + faults = ( + self.data_manager._fault_topology.faults + ) # Assuming faults is a list of fault names if not faults: self.fault_table_group.hide() return @@ -110,18 +120,32 @@ def update_fault_adjacency_table(self): self.table.setItem(row, col, item) else: button = QPushButton() - if self.data_manager._fault_topology.get_fault_relationship(faults[row], faults[col]) == FaultRelationshipType.FAULTED: + 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: + 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)) + 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 + 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: @@ -145,13 +169,18 @@ def update_stratigraphic_units_table(self): 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]): + 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)) + 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.""" @@ -162,6 +191,8 @@ def change_button_colour_binary(self, button, row, col): else: button.setStyleSheet("background-color: red;") flag = True - fault = self.data_manager._fault_topology.faults[col] + 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) + self.data_manager._fault_topology.update_fault_stratigraphy_relationship( + unit[1], fault, flag + ) diff --git a/loopstructural/main/model_manager.py b/loopstructural/main/model_manager.py index 88ca68a..892c8e2 100644 --- a/loopstructural/main/model_manager.py +++ b/loopstructural/main/model_manager.py @@ -1,18 +1,21 @@ from collections import defaultdict from collections.abc import Callable - from typing import Callable + import geopandas as gpd import pandas as pd + from LoopStructural import GeologicalModel from LoopStructural.datatypes import BoundingBox from loopstructural.main.stratigraphic_column import StratigraphicColumn +from LoopStructural.modelling.core.fault_topology import FaultRelationshipType 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 = [] @@ -29,7 +32,7 @@ def __call__(self, line: gpd.GeoDataFrame, dem: Callable, use_z: bool) -> pd.Dat # Use Z from geometry if available, otherwise use DEM if use_z and len(coord) > 2: z = coord[2] - else: + else: z = dem(x, y) points.append({'X': x, 'Y': y, 'Z': z, 'feature_id': feature_id, **attributes}) elif geom.geom_type == 'MultiLineString': @@ -37,12 +40,14 @@ def __call__(self, line: gpd.GeoDataFrame, dem: Callable, use_z: bool) -> pd.Dat coords = list(l.coords) for coord in coords: x, y = coord[0], coord[1] - # Use Z from geometry if available, otherwise use DEM + # 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}) + 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 @@ -60,6 +65,7 @@ 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]) @@ -70,13 +76,16 @@ def __init__(self): self.stratigraphic_column = None self.fault_topology = None self.observers = [] - self.dem_function = lambda x,y: 0 + 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. @@ -84,12 +93,23 @@ def update_bounding_box(self, bounding_box: BoundingBox): :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): + 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. @@ -106,7 +126,10 @@ def update_fault_points(self, fault_trace: gpd.GeoDataFrame, *, fault_name_field 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] - if fault_displacement_field is not None and fault_displacement_field in fault_points.columns: + if ( + fault_displacement_field is not None + and fault_displacement_field in fault_points.columns + ): fault_points['displacement'] = fault_points[fault_displacement_field] existing_faults = set(self.fault_topology.faults) for fault_name in fault_points['fault_name'].unique(): @@ -117,16 +140,20 @@ def update_fault_points(self, fault_trace: gpd.GeoDataFrame, *, fault_name_field 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, + ): - - def update_contact_traces(self, basal_contacts: gpd.GeoDataFrame, *, sampler=AllSampler(), unit_name_field=None, use_z_coordinate=False): - - unit_points = sampler(basal_contacts,self.dem_function, use_z_coordinate) + 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 @@ -139,23 +166,37 @@ def update_contact_traces(self, basal_contacts: gpd.GeoDataFrame, *, sampler=All 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): + 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 strike_field is None or dip_field is None: return if unit_name_field is not None: return - structural_orientations = sampler(structural_orientations, self.dem_function, use_z_coordinate) - + structural_orientations = sampler( + structural_orientations, self.dem_function, use_z_coordinate + ) + structural_orientations['unit_name'] = structural_orientations[unit_name_field] - + 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']] + 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 + structural_orientations['strike'] = structural_orientations[strike_field] + 90 for unit_name in structural_orientations['unit_name'].unique(): orientations = structural_orientations.loc[ @@ -200,14 +241,14 @@ def update_foliation_features(self): 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) - self.model.add_unconformity(foliation,0) + self.model.add_unconformity(foliation, 0) self.model.stratigraphic_column = self.stratigraphic_column def update_fault_features(self): @@ -220,14 +261,25 @@ def update_fault_features(self): # need to have a way of specifying the displacement from the trace # or maybe the model should calculate it self.model.create_and_add_fault(fault_name, displacement=10, fault_data=data) + print("Faults in model:") for f in self.fault_topology.faults: + print(f"Fault {f} relationships:") for f2 in self.fault_topology.faults: + if f != f2: relationship = self.fault_topology.get_fault_relationship(f, f2) - if relationship == FaultRelationshipType.ABUTTING: + print(f"Fault {f} and {f2} relationship: {relationship}") + print( + relationship is FaultRelationshipType.ABUTTING, + relationship, + FaultRelationshipType.ABUTTING, + ) + print(id(relationship), id(FaultRelationshipType.ABUTTING)) + + if relationship is FaultRelationshipType.ABUTTING: + print(f"Adding abutting fault relationship between {f} and {f2}") self.model[f].add_abutting_fault(self.model[f2]) - @property def valid(self): valid = True @@ -245,21 +297,16 @@ def valid(self): def update_model(self): """Update the geological model with the current stratigraphy and faults.""" - + self.model.features = [] - self.model.feature_name_index={} - 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 - self.model.create_and_add_fault(fault_name, 10,fault_data=data) + 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 From 819cea1918498c8f7920b7f707b1dbf58dd3a70f Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Wed, 23 Jul 2025 16:23:27 +1000 Subject: [PATCH 079/111] fix: get displacement/dip/pitch from fault trace attributes --- loopstructural/main/model_manager.py | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/loopstructural/main/model_manager.py b/loopstructural/main/model_manager.py index 892c8e2..35bba02 100644 --- a/loopstructural/main/model_manager.py +++ b/loopstructural/main/model_manager.py @@ -120,21 +120,24 @@ def update_fault_points( # 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] 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, ['X', 'Y', 'Z'] + fault_points['fault_name'] == fault_name, cols ] if fault_name not in existing_faults: self.fault_topology.add_fault(fault_name) @@ -260,7 +263,28 @@ def update_fault_features(self): data['val'] = 0 # need to have a way of specifying the displacement from the trace # or maybe the model should calculate it - self.model.create_and_add_fault(fault_name, displacement=10, fault_data=data) + 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, + ) print("Faults in model:") for f in self.fault_topology.faults: print(f"Fault {f} relationships:") From 82de51edcdb3e7dba1fd93261967040c2bae916c Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Thu, 24 Jul 2025 15:47:29 +1000 Subject: [PATCH 080/111] temp disable fault parameters --- .../gui/modelling/feature_details_panel.py | 182 ++++++++++-------- 1 file changed, 99 insertions(+), 83 deletions(-) diff --git a/loopstructural/gui/modelling/feature_details_panel.py b/loopstructural/gui/modelling/feature_details_panel.py index 111103d..bf2e8ac 100644 --- a/loopstructural/gui/modelling/feature_details_panel.py +++ b/loopstructural/gui/modelling/feature_details_panel.py @@ -1,12 +1,22 @@ +from PyQt5.QtCore import Qt from PyQt5.QtWidgets import ( - QWidget, QVBoxLayout, QSlider, QLabel, QDoubleSpinBox, QCheckBox, QFormLayout, QComboBox + QCheckBox, + QComboBox, + QDoubleSpinBox, + QFormLayout, + QLabel, + QScrollArea, + QSlider, + QVBoxLayout, + QWidget, ) -from PyQt5.QtCore import Qt -from PyQt5.QtWidgets import QScrollArea + +from LoopStructural.modelling.features import StructuralFrame from LoopStructural.utils import normal_vector_to_strike_and_dip -from LoopStructural.modelling.features import StructuralFrame + + class BaseFeatureDetailsPanel(QWidget): - def __init__(self, parent=None,*, feature=None): + def __init__(self, parent=None, *, feature=None): super().__init__(parent) # Create a scroll area for horizontal scrolling scroll = QScrollArea(self) @@ -31,7 +41,9 @@ def __init__(self, parent=None,*, feature=None): # 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.setValue( + feature.builder.build_arguments.get('regularisation', 1.0) + ) self.regularisation_spin_box.valueChanged.connect( lambda value: feature.builder.update_build_arguments({'regularisation': value}) ) @@ -41,7 +53,7 @@ def __init__(self, parent=None,*, feature=None): 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)) @@ -70,13 +82,13 @@ def __init__(self, parent=None,*, feature=None): 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): + if issubclass(type(self.feature), StructuralFrame): for i in range(3): if self.feature[i].interpolator is not None: self.feature[i].interpolator.n_elements = value @@ -89,98 +101,102 @@ def updateNelements(self, 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): + 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) + 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] - - 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' - # '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) - ) + 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( - - # Enabled field - # self.enabled_checkbox = QCheckBox("Enabled") - # self.enabled_checkbox.setChecked(False) + # 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) - # 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.layout.addLayout(form_layout) # self.setLayout(self.layout) + class FoliationFeatureDetailsPanel(BaseFeatureDetailsPanel): - def __init__(self, parent=None,*, feature=None): + def __init__(self, parent=None, *, feature=None): super().__init__(parent, feature=feature) - # Remove redundant layout setting # self.setLayout(self.layout) From 1b0014b7a81ef8534368815663991139e22970ad Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Thu, 24 Jul 2025 15:48:15 +1000 Subject: [PATCH 081/111] fix: adding checks to make sure the fields are not none and are in the layer --- .../model_definition/stratigraphic_layers.py | 69 +++++++++++++++---- 1 file changed, 57 insertions(+), 12 deletions(-) diff --git a/loopstructural/gui/modelling/model_definition/stratigraphic_layers.py b/loopstructural/gui/modelling/model_definition/stratigraphic_layers.py index 63d60c2..50fbb04 100644 --- a/loopstructural/gui/modelling/model_definition/stratigraphic_layers.py +++ b/loopstructural/gui/modelling/model_definition/stratigraphic_layers.py @@ -1,9 +1,10 @@ import os +from PyQt5.QtCore import Qt from PyQt5.QtWidgets import QWidget from qgis.core import QgsMapLayerProxyModel, QgsWkbTypes from qgis.PyQt import uic -from PyQt5.QtCore import Qt + class StratigraphicLayersWidget(QWidget): def __init__(self, parent=None, data_manager=None): @@ -27,15 +28,28 @@ def __init__(self, parent=None, data_manager=None): 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) + 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) @@ -43,36 +57,50 @@ def enableBasalContactsZCheckBox(self, 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.data_manager.logger(message=f"Layer {layer.name()} has Z coordinate: {has_z}",log_level=2) + self.data_manager.logger( + message=f"Layer {layer.name()} has Z coordinate: {has_z}", log_level=2 + ) self.enableBasalContactsZCheckBox(has_z) else: - self.data_manager.logger(message="Unknown geometry type.",log_level=2) + 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): + + 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.data_manager.logger(message=f"Layer {layer.name()} has Z coordinate: {has_z}",level=2) + # self.data_manager.logger(m + # essage=f"Layer {layer.name()} has Z coordinate: {has_z}",level=2) self.enableStructuralPointsZCheckBox(has_z) else: - self.data_manager.logger(message="Unknown geometry type.",level=2) + self.data_manager.logger(message="Unknown geometry type.", level=2) else: self.enableStructuralPointsZCheckBox(False) if strike_field: @@ -88,9 +116,11 @@ def set_orientations_layer(self, layer, strike_field=None, dip_field=None, unitn 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") @@ -101,6 +131,8 @@ 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(), @@ -108,17 +140,30 @@ def onStructuralDataLayerChanged(self, layer): 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 + 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.data_manager.set_basal_contacts( + self.basalContactsLayer.currentLayer(), + field, + use_z_coordinate=self.basal_contacts_use_z, + ) # self.updateDataManager() From 95a7b15a60eb288f84862ea31c35d2ed6c939d66 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Thu, 24 Jul 2025 15:48:41 +1000 Subject: [PATCH 082/111] fix: store reference to object instead of actor --- loopstructural/gui/visualisation/loop_pyvistaqt_wrapper.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/loopstructural/gui/visualisation/loop_pyvistaqt_wrapper.py b/loopstructural/gui/visualisation/loop_pyvistaqt_wrapper.py index 8bf1b7d..b798e1b 100644 --- a/loopstructural/gui/visualisation/loop_pyvistaqt_wrapper.py +++ b/loopstructural/gui/visualisation/loop_pyvistaqt_wrapper.py @@ -9,6 +9,7 @@ class LoopPyVistaQTPlotter(QtInteractor): def __init__(self, parent): super().__init__(parent=parent) + self.objects = {} def increment_name(self, name): parts = name.split('_') @@ -42,6 +43,7 @@ def add_mesh(self, *args, **kwargs): 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 From 4c3fd30b0b3e32039668f257bc3103f50293dc66 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Thu, 24 Jul 2025 15:52:01 +1000 Subject: [PATCH 083/111] fix: make sure unit name is a string --- loopstructural/main/model_manager.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/loopstructural/main/model_manager.py b/loopstructural/main/model_manager.py index 35bba02..d3c6aa4 100644 --- a/loopstructural/main/model_manager.py +++ b/loopstructural/main/model_manager.py @@ -122,7 +122,7 @@ def update_fault_points( 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] + 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: @@ -155,13 +155,13 @@ def update_contact_traces( 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] + unit_points['unit_name'] = unit_points[unit_name_field].astype(str) else: return for unit_name in unit_points['unit_name'].unique(): @@ -181,16 +181,21 @@ def update_structural_data( use_z_coordinate=False, ): """Add structural orientation data to the geological model.""" - if strike_field is None or dip_field is None: + + 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 not None: + 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] + 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] @@ -200,7 +205,6 @@ def update_structural_data( 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'] From be06c6f1dc0cebaa36ba7875afeadceb84f7ee2a Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Thu, 24 Jul 2025 15:52:34 +1000 Subject: [PATCH 084/111] fix: adding placeholder to save object to file --- .../gui/visualisation/object_list_widget.py | 90 +++++++++++++++++-- 1 file changed, 82 insertions(+), 8 deletions(-) diff --git a/loopstructural/gui/visualisation/object_list_widget.py b/loopstructural/gui/visualisation/object_list_widget.py index d71409e..3a312e3 100644 --- a/loopstructural/gui/visualisation/object_list_widget.py +++ b/loopstructural/gui/visualisation/object_list_widget.py @@ -1,17 +1,18 @@ +import geoh5py +import pyvista as pv from PyQt5.QtCore import Qt from PyQt5.QtWidgets import ( QCheckBox, - QVBoxLayout, + QFileDialog, + QHBoxLayout, # Add missing import QLabel, QMenu, + QPushButton, QTreeWidget, QTreeWidgetItem, + QVBoxLayout, QWidget, - QPushButton, - QFileDialog, - QHBoxLayout, # Add missing import ) -import pyvista as pv class ObjectListWidget(QWidget): @@ -97,8 +98,79 @@ def export_selected_object(self): 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, "points"): # Likely a point cloud + formats = ["vtp"] + if has_geoh5py: + formats.append("geoh5") + elif hasattr(object, "faces"): # Likely a surface/mesh + formats = ["obj", "vtk", "ply"] + 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 - print(f"Exporting object: {object_label}") def remove_selected_object(self): selected_items = self.treeWidget.selectedItems() @@ -132,7 +204,9 @@ def add_feature_from_geological_model(self): 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_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 @@ -143,7 +217,7 @@ def load_feature_from_file(self): raise ValueError("The file does not contain a valid mesh.") # Add the mesh to the viewer - self.viewer.add_mesh(mesh,name=file_name) + self.viewer.add_mesh(mesh, name=file_name) print(f"Loaded mesh from file: {file_path}") except Exception as e: print(f"Failed to load mesh: {e}") From a911bb9c2c3b88b3beea783cb31be3ae1ab814b5 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Thu, 24 Jul 2025 15:54:07 +1000 Subject: [PATCH 085/111] fix: use default bb zmin/zmax --- .../gui/modelling/model_definition/bounding_box.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/loopstructural/gui/modelling/model_definition/bounding_box.py b/loopstructural/gui/modelling/model_definition/bounding_box.py index c49487d..7d3c94a 100644 --- a/loopstructural/gui/modelling/model_definition/bounding_box.py +++ b/loopstructural/gui/modelling/model_definition/bounding_box.py @@ -3,6 +3,8 @@ 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): @@ -19,6 +21,7 @@ def __init__(self, parent=None, data_manager=None): 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. @@ -55,10 +58,10 @@ def selectFromCurrentLayer(self): extent = layer.extent() self.originXSpinBox.setValue(extent.xMinimum()) self.originYSpinBox.setValue(extent.yMinimum()) - self.originZSpinBox.setValue(0) + self.originZSpinBox.setValue(default_bounding_box['zmin']) self.maxXSpinBox.setValue(extent.xMaximum()) self.maxYSpinBox.setValue(extent.yMaximum()) - self.maxZSpinBox.setValue(1000) + self.maxZSpinBox.setValue(default_bounding_box['zmax']) def onChangeExtent(self, value): self.data_manager.set_bounding_box(**value) From 18d89ec13bc81435eb2cd4dcedb3a2679f232051 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Thu, 24 Jul 2025 15:54:24 +1000 Subject: [PATCH 086/111] style: autoformatting --- loopstructural/main/data_manager.py | 255 ++++++++++++++++++++-------- 1 file changed, 183 insertions(+), 72 deletions(-) diff --git a/loopstructural/main/data_manager.py b/loopstructural/main/data_manager.py index e934f0a..ee42c2f 100644 --- a/loopstructural/main/data_manager.py +++ b/loopstructural/main/data_manager.py @@ -1,10 +1,13 @@ +import json + +import numpy as np +from qgis.core import QgsPointXY, QgsProject, QgsVectorLayer + +from LoopStructural import FaultTopology, StratigraphicColumn from LoopStructural.datatypes import BoundingBox -from LoopStructural import StratigraphicColumn, FaultTopology from .vectorLayerWrapper import qgsLayerToGeoDataFrame -from qgis.core import QgsProject, QgsVectorLayer, QgsPointXY -import json -import numpy as np + __title__ = "LoopStructural" default_bounding_box = { 'xmin': 0, @@ -12,8 +15,10 @@ 'ymin': 0, 'ymax': 1000, 'zmin': -7000, - 'zmax': 1000 + 'zmax': 1000, } + + class ModellingDataManager: def __init__(self, *, project=None, mapCanvas=None, logger=None): if project is None: @@ -25,7 +30,18 @@ def __init__(self, *, project=None, mapCanvas=None, logger=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._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 @@ -48,7 +64,6 @@ def __init__(self, *, project=None, mapCanvas=None, logger=None): self.use_dem = True self.dem_callback = None - def onSaveProject(self): """Save project data.""" self.logger(message="Saving project data...", log_level=3) @@ -63,7 +78,7 @@ def onLoadProject(self): 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) @@ -75,7 +90,7 @@ def set_model_manager(self, 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 @@ -100,23 +115,27 @@ def set_bounding_box(self, xmin=None, xmax=None, ymin=None, ymax=None, zmin=None 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 get_bounding_box(self): """Get the current bounding box.""" return self._bounding_box @@ -131,9 +150,16 @@ 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) + 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: - self.dem_function = lambda x, y: self.dem_layer.dataProvider().sample(QgsPointXY(x, y), 1)[0] if self.dem_layer else np.nan + self.dem_function = lambda x, y: ( + self.dem_layer.dataProvider().sample(QgsPointXY(x, y), 1)[0] + if self.dem_layer + else np.nan + ) self._model_manager.set_dem_function(self.dem_function) def set_use_dem(self, use_dem): @@ -142,18 +168,25 @@ def set_use_dem(self, use_dem): 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._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: + 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: + 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']] @@ -171,6 +204,7 @@ def init_stratigraphic_column_from_basal_contacts(self): # 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 @@ -180,15 +214,20 @@ def add_to_stratigraphic_column(self, unit_data): 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')) + 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.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) @@ -201,10 +240,10 @@ def update_stratigraphic_column_order(self, new_order): 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: @@ -214,19 +253,31 @@ def get_unique_faults(self): fault_name = feature[self._fault_traces['fault_name_field']] unique_faults.add(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): + + 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.""" if fault_trace_layer is None: print("Fault trace layer is None, cannot set fault traces.") return - if fault_trace_layer.featureCount()==0: + if fault_trace_layer.featureCount() == 0: self.logger(message="Fault trace layer is empty, cannot set fault traces.") return - 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._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) @@ -235,7 +286,15 @@ 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): + 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 @@ -246,11 +305,12 @@ def set_structural_orientations(self, structural_orientations, strike_field=None 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 @@ -260,13 +320,23 @@ def update_stratigraphy(self): 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']) + 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: - 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) + 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.") @@ -279,17 +349,23 @@ def update_faults(self): self._fault_topology.add_fault(f) 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']) + 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() @@ -301,15 +377,21 @@ 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 + 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: + if ( + structural_orientations + and 'layer' in structural_orientations + and structural_orientations['layer'] is not None + ): structural_orientations['layer'] = structural_orientations['layer'].name() return { @@ -317,19 +399,22 @@ def to_dict(self): '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 + 'stratigraphic_column': ( + self._stratigraphic_column.to_dict() if self._stratigraphic_column else None + ), } 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]) - + 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 'basal_contacts' in data: self._basal_contacts = data['basal_contacts'] @@ -340,40 +425,64 @@ def from_dict(self, data): 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]) + 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 'basal_contacts' in data and data['basal_contacts'] is not None and 'layer' in data['basal_contacts']: + 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']: + 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']: + 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)) + 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() @@ -387,15 +496,17 @@ def find_layer_by_name(self, layer_name): 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) + 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): - + 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 - From 65de3be544957bee87b22b6bb547d98cfef70076 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Thu, 24 Jul 2025 15:54:42 +1000 Subject: [PATCH 087/111] fix: add meshio as requirement --- loopstructural/requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/loopstructural/requirements.txt b/loopstructural/requirements.txt index 663b0d1..b5f5e65 100644 --- a/loopstructural/requirements.txt +++ b/loopstructural/requirements.txt @@ -2,3 +2,4 @@ pyvistaqt pyvista LoopStructural geoh5py +meshio \ No newline at end of file From d25dcede918d9071804ba0e9b118a5ad74593f44 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Thu, 24 Jul 2025 16:05:48 +1000 Subject: [PATCH 088/111] fix: use 3D extent for setting bounding box --- .../gui/modelling/model_definition/bounding_box.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/loopstructural/gui/modelling/model_definition/bounding_box.py b/loopstructural/gui/modelling/model_definition/bounding_box.py index 7d3c94a..126ca52 100644 --- a/loopstructural/gui/modelling/model_definition/bounding_box.py +++ b/loopstructural/gui/modelling/model_definition/bounding_box.py @@ -1,5 +1,6 @@ import os +import numpy as np from PyQt5.QtWidgets import QWidget from qgis.PyQt import uic @@ -55,13 +56,20 @@ def selectFromCurrentLayer(self): """ layer = self.data_manager.map_canvas.currentLayer() if layer: - extent = layer.extent() + extent = layer.extent3D() self.originXSpinBox.setValue(extent.xMinimum()) self.originYSpinBox.setValue(extent.yMinimum()) - self.originZSpinBox.setValue(default_bounding_box['zmin']) + 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()) - self.maxZSpinBox.setValue(default_bounding_box['zmax']) + 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) From f92508500e189cb0a3802f60a2ae329e4d17d952 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Wed, 30 Jul 2025 10:45:10 +1000 Subject: [PATCH 089/111] fix: link feature to feature panel --- loopstructural/gui/modelling/feature_details_panel.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/loopstructural/gui/modelling/feature_details_panel.py b/loopstructural/gui/modelling/feature_details_panel.py index bf2e8ac..ed91710 100644 --- a/loopstructural/gui/modelling/feature_details_panel.py +++ b/loopstructural/gui/modelling/feature_details_panel.py @@ -18,6 +18,7 @@ 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) @@ -91,12 +92,12 @@ def updateNelements(self, value): if issubclass(type(self.feature), StructuralFrame): for i in range(3): if self.feature[i].interpolator is not None: - self.feature[i].interpolator.n_elements = value + self.feature[i].interpolator.nelements = value self.feature[i].builder.update_build_arguments({'n_elements': value}) self.feature[i].builder.build() elif self.feature.interpolator is not None: - self.feature.interpolator.n_elements = value + self.feature.interpolator.nelements = value self.feature.builder.update_build_arguments({'n_elements': value}) self.feature.builder.build() else: From f22d286063f3db46e559cba5cc124162baed5d52 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Wed, 30 Jul 2025 10:46:21 +1000 Subject: [PATCH 090/111] fix: update fault when layer changes and remove faults when changing layers --- .../model_definition/fault_layers.py | 36 ++++++++++++++----- loopstructural/main/data_manager.py | 12 +++---- 2 files changed, 32 insertions(+), 16 deletions(-) diff --git a/loopstructural/gui/modelling/model_definition/fault_layers.py b/loopstructural/gui/modelling/model_definition/fault_layers.py index 6d641f6..9be9290 100644 --- a/loopstructural/gui/modelling/model_definition/fault_layers.py +++ b/loopstructural/gui/modelling/model_definition/fault_layers.py @@ -5,7 +5,6 @@ from qgis.PyQt import uic - class FaultLayersWidget(QWidget): def __init__(self, parent=None, data_manager=None): self.data_manager = data_manager @@ -27,6 +26,7 @@ def __init__(self, parent=None, data_manager=None): 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) @@ -34,10 +34,19 @@ def enableZCheckbox(self, 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): + + 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) @@ -48,28 +57,37 @@ def set_fault_trace_layer(self, layer, fault_name_field=None, fault_dip_field=No if layer is not None and layer.isValid(): if layer.wkbType() != QgsWkbTypes.Unknown: has_z = QgsWkbTypes.hasZ(layer.wkbType()) - self.data_manager.logger(message=f"Layer {layer.name()} has Z coordinate: {has_z}", log_level=2) + # self.data_manager.logger(message=f"Layer {layer.name()} has Z coordinate: {has_z}", log_level=2) 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()) print(f"Layer {layer.name()} has Z coordinate: {has_z}") 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 + 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/main/data_manager.py b/loopstructural/main/data_manager.py index ee42c2f..9578bba 100644 --- a/loopstructural/main/data_manager.py +++ b/loopstructural/main/data_manager.py @@ -251,7 +251,7 @@ def get_unique_faults(self): unique_faults = set() for feature in self._fault_traces['layer'].getFeatures(): fault_name = feature[self._fault_traces['fault_name_field']] - unique_faults.add(fault_name) + unique_faults.add(str(fault_name)) return list(unique_faults) def set_fault_trace_layer( @@ -264,12 +264,6 @@ def set_fault_trace_layer( use_z_coordinate=False, ): """Set the fault traces for the model.""" - if fault_trace_layer is None: - print("Fault trace layer is None, cannot set fault traces.") - return - if fault_trace_layer.featureCount() == 0: - self.logger(message="Fault trace layer is empty, cannot set fault traces.") - return self._fault_traces = { 'layer': fault_trace_layer, @@ -347,6 +341,10 @@ def update_faults(self): 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( From ba95b0e32534b44c2c38c86b815c669eccc1498f Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Wed, 30 Jul 2025 10:46:52 +1000 Subject: [PATCH 091/111] fix: add setter/getter for name field to prevent name not being a string --- .../stratigraphic_unit.py | 30 ++++++++++++++----- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.py b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.py index 8a68204..05f6d73 100644 --- a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.py +++ b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.py @@ -11,11 +11,19 @@ class StratigraphicUnitWidget(QWidget): 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): + + 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._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 @@ -23,6 +31,14 @@ def __init__(self, uuid, name: Optional[str] = None, colour: Optional[str] = Non 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. @@ -31,7 +47,7 @@ def set_thickness(self, thickness: float): self.thickness = thickness self.spinBoxThickness.setValue(thickness) self.validateFields() - + def onColourSelectClicked(self): """ Open a color dialog to select a color for the stratigraphic unit. @@ -51,6 +67,7 @@ def onThicknessChanged(self, thickness: float): self.thickness = thickness self.validateFields() self.thicknessChanged.emit(thickness) + def onNameChanged(self): """ Update the name of the stratigraphic unit. @@ -61,12 +78,11 @@ def onNameChanged(self): 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. @@ -90,7 +106,7 @@ def setData(self, data: Optional[dict] = None): :param data: A dictionary containing 'name' and 'colour' keys. """ if data: - self.name = data.get("name", "") + self.name = str(data.get("name", "")) self.colour = data.get("colour", "") self.lineEditName.setText(self.name) # self.lineEditColour.setText(self.colour) @@ -111,5 +127,5 @@ def getData(self) -> dict: "uuid": self.uuid, "name": self.name, "colour": self.colour, - "thickness": self.thickness + "thickness": self.thickness, } From f9c3efda141305934bbe7ee53b96e7d413c5e4c1 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Wed, 30 Jul 2025 10:47:18 +1000 Subject: [PATCH 092/111] fix: strike = dip_dir-90 --- loopstructural/main/model_manager.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/loopstructural/main/model_manager.py b/loopstructural/main/model_manager.py index d3c6aa4..c819ae5 100644 --- a/loopstructural/main/model_manager.py +++ b/loopstructural/main/model_manager.py @@ -181,7 +181,8 @@ def update_structural_data( 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 @@ -204,7 +205,7 @@ def update_structural_data( ] if dip_direction: structural_orientations['dip'] = structural_orientations[dip_field] - structural_orientations['strike'] = structural_orientations[strike_field] + 90 + 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'] @@ -254,7 +255,9 @@ def update_foliation_features(self): 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) + foliation = self.model.create_and_add_foliation( + groupname, series_surface_data=data, force_constrained=True + ) self.model.add_unconformity(foliation, 0) self.model.stratigraphic_column = self.stratigraphic_column From bb19086aac2e72e858e0c00058cddc426158424b Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Wed, 30 Jul 2025 10:47:27 +1000 Subject: [PATCH 093/111] fix: add axes to plot --- loopstructural/gui/visualisation/loop_pyvistaqt_wrapper.py | 1 + 1 file changed, 1 insertion(+) diff --git a/loopstructural/gui/visualisation/loop_pyvistaqt_wrapper.py b/loopstructural/gui/visualisation/loop_pyvistaqt_wrapper.py index b798e1b..6c9bca4 100644 --- a/loopstructural/gui/visualisation/loop_pyvistaqt_wrapper.py +++ b/loopstructural/gui/visualisation/loop_pyvistaqt_wrapper.py @@ -10,6 +10,7 @@ class LoopPyVistaQTPlotter(QtInteractor): def __init__(self, parent): super().__init__(parent=parent) self.objects = {} + self.add_axes() def increment_name(self, name): parts = name.split('_') From 0b5a5fa1329c35b2e7bc070d2814b7fc94a56bd0 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Wed, 30 Jul 2025 10:47:40 +1000 Subject: [PATCH 094/111] fix: scale vector appropriate to model bb --- .../gui/visualisation/feature_list_widget.py | 39 ++++++++++++++----- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/loopstructural/gui/visualisation/feature_list_widget.py b/loopstructural/gui/visualisation/feature_list_widget.py index e58f9d6..c0cb18f 100644 --- a/loopstructural/gui/visualisation/feature_list_widget.py +++ b/loopstructural/gui/visualisation/feature_list_widget.py @@ -1,12 +1,10 @@ +from typing import Optional, Union + from PyQt5.QtCore import Qt -from PyQt5.QtWidgets import ( - QVBoxLayout, - QMenu, - QTreeWidget, - QTreeWidgetItem, - QWidget, - QPushButton -) +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): @@ -37,6 +35,7 @@ def __init__(self, parent=None, *, model_manager=None, viewer=None): # 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 @@ -45,6 +44,18 @@ def update_feature_list(self): for feature in self.model_manager.features(): 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): featureItem = QTreeWidgetItem(self.treeWidget) featureItem.setText(0, feature.name) @@ -87,13 +98,21 @@ def add_surface(self, feature_name): def add_vector_field(self, feature_name): vector_field = self.model_manager.model[feature_name].vector_field() - self.viewer.add_mesh(vector_field.vtk(), name=f'{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: - self.viewer.add_mesh(d.vtk(), name=f'{feature_name}_{d.name}') + 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): From a39148064c834f4868355b5a12a55f75af099466 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Wed, 30 Jul 2025 11:41:53 +1000 Subject: [PATCH 095/111] fix: remove print statements and add default sampler output --- loopstructural/main/model_manager.py | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/loopstructural/main/model_manager.py b/loopstructural/main/model_manager.py index c819ae5..eb5196d 100644 --- a/loopstructural/main/model_manager.py +++ b/loopstructural/main/model_manager.py @@ -21,7 +21,7 @@ def __call__(self, line: gpd.GeoDataFrame, dem: Callable, use_z: bool) -> pd.Dat points = [] feature_id = 0 if line is None: - return pd.DataFrame(points) + 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 @@ -292,23 +292,13 @@ def update_fault_features(self): fault_pitch=pitch, fault_data=data, ) - print("Faults in model:") for f in self.fault_topology.faults: - print(f"Fault {f} relationships:") for f2 in self.fault_topology.faults: if f != f2: relationship = self.fault_topology.get_fault_relationship(f, f2) - print(f"Fault {f} and {f2} relationship: {relationship}") - print( - relationship is FaultRelationshipType.ABUTTING, - relationship, - FaultRelationshipType.ABUTTING, - ) - print(id(relationship), id(FaultRelationshipType.ABUTTING)) if relationship is FaultRelationshipType.ABUTTING: - print(f"Adding abutting fault relationship between {f} and {f2}") self.model[f].add_abutting_fault(self.model[f2]) @property From e61e4e2cbf938dbddcc08344d293d815b239f15b Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Wed, 30 Jul 2025 11:42:09 +1000 Subject: [PATCH 096/111] fix: nelements not n_elements --- loopstructural/gui/modelling/feature_details_panel.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/loopstructural/gui/modelling/feature_details_panel.py b/loopstructural/gui/modelling/feature_details_panel.py index ed91710..e3a9d79 100644 --- a/loopstructural/gui/modelling/feature_details_panel.py +++ b/loopstructural/gui/modelling/feature_details_panel.py @@ -93,12 +93,12 @@ def updateNelements(self, value): 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({'n_elements': 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({'n_elements': value}) + self.feature.builder.update_build_arguments({'nelements': value}) self.feature.builder.build() else: print("Error: Feature is not initialized.") From f51f85049649bddba1533d4aad74ddc07698ff9f Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Wed, 30 Jul 2025 11:43:09 +1000 Subject: [PATCH 097/111] style: clean up --- .../modelling/model_definition/fault_layers.py | 2 -- .../model_definition/stratigraphic_layers.py | 6 +----- loopstructural/gui/modelling/modelling_widget.py | 15 ++++++++++++--- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/loopstructural/gui/modelling/model_definition/fault_layers.py b/loopstructural/gui/modelling/model_definition/fault_layers.py index 9be9290..dbaf486 100644 --- a/loopstructural/gui/modelling/model_definition/fault_layers.py +++ b/loopstructural/gui/modelling/model_definition/fault_layers.py @@ -57,7 +57,6 @@ def set_fault_trace_layer( if layer is not None and layer.isValid(): if layer.wkbType() != QgsWkbTypes.Unknown: has_z = QgsWkbTypes.hasZ(layer.wkbType()) - # self.data_manager.logger(message=f"Layer {layer.name()} has Z coordinate: {has_z}", log_level=2) self.enableZCheckbox(has_z) self.useZCoordinateCheckBox.setChecked(use_z_coordinate) self.useZCoordinate = use_z_coordinate @@ -72,7 +71,6 @@ def onFaultTraceLayerChanged(self, layer): if layer.wkbType() != QgsWkbTypes.Unknown: has_z = QgsWkbTypes.hasZ(layer.wkbType()) - print(f"Layer {layer.name()} has Z coordinate: {has_z}") self.enableZCheckbox(has_z) if layer is None or not layer.isValid(): self.data_manager.set_fault_trace_layer( diff --git a/loopstructural/gui/modelling/model_definition/stratigraphic_layers.py b/loopstructural/gui/modelling/model_definition/stratigraphic_layers.py index 50fbb04..8df3de3 100644 --- a/loopstructural/gui/modelling/model_definition/stratigraphic_layers.py +++ b/loopstructural/gui/modelling/model_definition/stratigraphic_layers.py @@ -70,9 +70,7 @@ def set_basal_contacts(self, layer, unitname_field=None, use_z_coordinate=False) if layer is not None and layer.isValid(): if layer.wkbType() != QgsWkbTypes.Unknown: has_z = QgsWkbTypes.hasZ(layer.wkbType()) - self.data_manager.logger( - message=f"Layer {layer.name()} has Z coordinate: {has_z}", log_level=2 - ) + self.enableBasalContactsZCheckBox(has_z) else: self.data_manager.logger(message="Unknown geometry type.", log_level=2) @@ -96,8 +94,6 @@ def set_orientations_layer( if layer is not None and layer.isValid(): if layer.wkbType() != QgsWkbTypes.Unknown: has_z = QgsWkbTypes.hasZ(layer.wkbType()) - # self.data_manager.logger(m - # essage=f"Layer {layer.name()} has Z coordinate: {has_z}",level=2) self.enableStructuralPointsZCheckBox(has_z) else: self.data_manager.logger(message="Unknown geometry type.", level=2) diff --git a/loopstructural/gui/modelling/modelling_widget.py b/loopstructural/gui/modelling/modelling_widget.py index 5c7b70a..cbba115 100644 --- a/loopstructural/gui/modelling/modelling_widget.py +++ b/loopstructural/gui/modelling/modelling_widget.py @@ -1,19 +1,28 @@ from xmlrpc.client import Fault + from PyQt5.QtWidgets import QTabWidget, QVBoxLayout, QWidget +from loopstructural.gui.modelling.fault_adjacency_tab import FaultAdjacencyTab from loopstructural.gui.modelling.geological_history_tab import GeologialHistoryTab from loopstructural.gui.modelling.geological_model_tab import GeologicalModelTab from loopstructural.gui.modelling.model_definition import ModelDefinitionTab -from loopstructural.gui.modelling.fault_adjacency_tab import FaultAdjacencyTab class ModellingWidget(QWidget): - def __init__(self, parent: QWidget = None, *, mapCanvas=None, logger=None, data_manager=None, model_manager=None): + def __init__( + self, + parent: QWidget = 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#ModellingDataManager(mapCanvas=mapCanvas, logger=logger) + self.data_manager = data_manager # ModellingDataManager(mapCanvas=mapCanvas, logger=logger) self.model_manager = model_manager self.geological_history_tab_widget = None self.stratigraphic_column_tab_widget = None From 8bf531b50ba72ba5dc2568e3221718dcd912302c Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Wed, 30 Jul 2025 11:59:11 +1000 Subject: [PATCH 098/111] fix: don't add unconformities to feature list --- loopstructural/gui/visualisation/feature_list_widget.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/loopstructural/gui/visualisation/feature_list_widget.py b/loopstructural/gui/visualisation/feature_list_widget.py index c0cb18f..0357931 100644 --- a/loopstructural/gui/visualisation/feature_list_widget.py +++ b/loopstructural/gui/visualisation/feature_list_widget.py @@ -42,7 +42,8 @@ def update_feature_list(self): self.treeWidget.clear() for feature in self.model_manager.features(): - self.add_feature(feature) + if not feature.name.startswith('__'): + self.add_feature(feature) def _get_vector_scale(self, scale: Optional[Union[float, int]] = None) -> float: autoscale = 1.0 From dd06eb32bab8f14d1b6c28363d3f3092a19b75e7 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Wed, 30 Jul 2025 11:59:54 +1000 Subject: [PATCH 099/111] fix: add key mapping for view/delete --- .../gui/visualisation/object_list_widget.py | 55 ++++++++++++++++--- 1 file changed, 48 insertions(+), 7 deletions(-) diff --git a/loopstructural/gui/visualisation/object_list_widget.py b/loopstructural/gui/visualisation/object_list_widget.py index 3a312e3..cb53e48 100644 --- a/loopstructural/gui/visualisation/object_list_widget.py +++ b/loopstructural/gui/visualisation/object_list_widget.py @@ -31,6 +31,7 @@ def __init__(self, parent=None, *, viewer=None): 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): @@ -109,12 +110,12 @@ def export_selected_object(self): except ImportError: has_geoh5py = False - if hasattr(object, "points"): # Likely a point cloud - formats = ["vtp"] + if hasattr(object, "faces"): # Likely a surface/mesh + formats = ["obj", "vtk", "ply"] if has_geoh5py: formats.append("geoh5") - elif hasattr(object, "faces"): # Likely a surface/mesh - formats = ["obj", "vtk", "ply"] + elif hasattr(object, "points"): # Likely a point cloud + formats = ["vtp"] if has_geoh5py: formats.append("geoh5") else: @@ -181,9 +182,11 @@ def remove_selected_object(self): item_widget = self.treeWidget.itemWidget(item, 0) object_label = item_widget.findChild(QLabel).text() # Logic for removing the object - self.viewer.remove_object(object_label) + if self.viewer and hasattr(self.viewer, 'remove_object'): + self.viewer.remove_object(object_label) + else: + print(f"Error: Viewer is not initialized or does not support object removal.") self.treeWidget.takeTopLevelItem(self.treeWidget.indexOfTopLevelItem(item)) - print(f"Removing object: {object_label}") def show_add_object_menu(self): menu = QMenu(self) @@ -217,7 +220,45 @@ def load_feature_from_file(self): raise ValueError("The file does not contain a valid mesh.") # Add the mesh to the viewer - self.viewer.add_mesh(mesh, name=file_name) + 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) From 61dd48222e0cf34523086f36225ec023a70a83e0 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Wed, 30 Jul 2025 12:31:00 +1000 Subject: [PATCH 100/111] fix: set defaults for interpolator in qgs plugin --- loopstructural/gui/dlg_settings.py | 12 +- loopstructural/gui/dlg_settings.ui | 518 +++++++++++++------------ loopstructural/main/model_manager.py | 16 +- loopstructural/toolbelt/preferences.py | 10 +- 4 files changed, 311 insertions(+), 245 deletions(-) diff --git a/loopstructural/gui/dlg_settings.py b/loopstructural/gui/dlg_settings.py index 50a6433..dbb975e 100644 --- a/loopstructural/gui/dlg_settings.py +++ b/loopstructural/gui/dlg_settings.py @@ -1,7 +1,6 @@ #! python3 -"""Plugin settings form integrated into QGIS 'Options' menu. -""" +"""Plugin settings form integrated into QGIS 'Options' menu.""" # standard import platform @@ -89,6 +88,10 @@ def apply(self): # 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/main/model_manager.py b/loopstructural/main/model_manager.py index eb5196d..57e9fac 100644 --- a/loopstructural/main/model_manager.py +++ b/loopstructural/main/model_manager.py @@ -1,5 +1,6 @@ from collections import defaultdict from collections.abc import Callable +from operator import ne from typing import Callable import geopandas as gpd @@ -7,8 +8,9 @@ from LoopStructural import GeologicalModel from LoopStructural.datatypes import BoundingBox -from loopstructural.main.stratigraphic_column import StratigraphicColumn from LoopStructural.modelling.core.fault_topology import FaultRelationshipType +from LoopStructural.modelling.core.stratigraphic_column import StratigraphicColumn +from loopstructural.toolbelt.preferences import PlgSettingsStructure class AllSampler: @@ -256,7 +258,13 @@ def update_foliation_features(self): continue data = pd.concat(data, ignore_index=True) foliation = self.model.create_and_add_foliation( - groupname, series_surface_data=data, force_constrained=True + 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 @@ -291,6 +299,10 @@ def update_fault_features(self): 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: diff --git a/loopstructural/toolbelt/preferences.py b/loopstructural/toolbelt/preferences.py index 402304a..239938c 100644 --- a/loopstructural/toolbelt/preferences.py +++ b/loopstructural/toolbelt/preferences.py @@ -1,11 +1,12 @@ #! python3 -"""Plugin settings. -""" +"""Plugin settings.""" # standard from dataclasses import asdict, dataclass, fields +from numpy import interp + # PyQGIS from qgis.core import QgsSettings @@ -25,6 +26,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: From e9175391b35b00b50105b348397a4c5c1d9024da Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Wed, 30 Jul 2025 13:31:40 +1000 Subject: [PATCH 101/111] fix: add bounding box outline instead of solid box --- loopstructural/gui/visualisation/feature_list_widget.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/loopstructural/gui/visualisation/feature_list_widget.py b/loopstructural/gui/visualisation/feature_list_widget.py index 0357931..1ea1b11 100644 --- a/loopstructural/gui/visualisation/feature_list_widget.py +++ b/loopstructural/gui/visualisation/feature_list_widget.py @@ -120,7 +120,7 @@ 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() + 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...") @@ -140,5 +140,5 @@ def add_stratigraphic_surfaces(self): return stratigraphic_surfaces = self.model_manager.model.get_stratigraphic_surfaces() for surface in stratigraphic_surfaces: - self.viewer.add_mesh(surface.vtk(), name=f'stratigraphic_surface_{surface.name}') + self.viewer.add_mesh(surface.vtk(), name=surface.name) print("Adding stratigraphic surfaces...") From 58c25ed3192acd2aa2ae261f3ebc3b513998dc31 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Wed, 30 Jul 2025 13:37:43 +1000 Subject: [PATCH 102/111] fix: update dem layer from project save --- .../gui/modelling/model_definition/dem.py | 19 +++--- loopstructural/main/data_manager.py | 60 +++++++++++++++++-- 2 files changed, 66 insertions(+), 13 deletions(-) diff --git a/loopstructural/gui/modelling/model_definition/dem.py b/loopstructural/gui/modelling/model_definition/dem.py index d4dc0b3..713b9a0 100644 --- a/loopstructural/gui/modelling/model_definition/dem.py +++ b/loopstructural/gui/modelling/model_definition/dem.py @@ -1,9 +1,10 @@ import os from PyQt5.QtWidgets import QWidget +from qgis.core import QgsMapLayerProxyModel from qgis.PyQt import uic from qgis.PyQt.QtWidgets import QSizePolicy -from qgis.core import QgsMapLayerProxyModel + class DEMWidget(QWidget): def __init__(self, parent=None, data_manager=None): @@ -11,12 +12,18 @@ def __init__(self, parent=None, data_manager=None): 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.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) @@ -38,11 +45,9 @@ def onDEMLayerChanged(self): 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) - - \ No newline at end of file diff --git a/loopstructural/main/data_manager.py b/loopstructural/main/data_manager.py index 9578bba..c788723 100644 --- a/loopstructural/main/data_manager.py +++ b/loopstructural/main/data_manager.py @@ -136,6 +136,12 @@ 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 @@ -155,12 +161,24 @@ def set_dem_layer(self, dem_layer): log_level=2, ) else: - self.dem_function = lambda x, y: ( - self.dem_layer.dataProvider().sample(QgsPointXY(x, y), 1)[0] - if self.dem_layer - else np.nan - ) + + 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 @@ -391,6 +409,8 @@ def to_dict(self): 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(), @@ -400,6 +420,9 @@ def to_dict(self): '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): @@ -413,7 +436,19 @@ def from_dict(self, data): 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: @@ -437,6 +472,19 @@ def update_from_dict(self, data): ) 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 From 6a138cf9d7efbd67ec2010ec55b1563d5b67a9d8 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Wed, 30 Jul 2025 13:37:57 +1000 Subject: [PATCH 103/111] fix: update requirements for LoopStructural --- loopstructural/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/loopstructural/requirements.txt b/loopstructural/requirements.txt index b5f5e65..7eaada0 100644 --- a/loopstructural/requirements.txt +++ b/loopstructural/requirements.txt @@ -1,5 +1,5 @@ pyvistaqt pyvista -LoopStructural +LoopStructural==1.6.17 geoh5py meshio \ No newline at end of file From 7b036d5200b81cb59f42622a5d76c9c6599410bd Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Wed, 30 Jul 2025 14:03:28 +1000 Subject: [PATCH 104/111] fix: remove unused buttons --- .../gui/modelling/geological_model_tab.py | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/loopstructural/gui/modelling/geological_model_tab.py b/loopstructural/gui/modelling/geological_model_tab.py index 7d0ce75..c7121d4 100644 --- a/loopstructural/gui/modelling/geological_model_tab.py +++ b/loopstructural/gui/modelling/geological_model_tab.py @@ -8,12 +8,15 @@ QWidget, ) -from loopstructural.gui.modelling.feature_details_panel import FaultFeatureDetailsPanel, FoliationFeatureDetailsPanel - +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): + def __init__(self, parent=None, *, model_manager=None): super().__init__(parent) self.model_manager = model_manager @@ -43,14 +46,7 @@ def __init__(self, parent=None,*, model_manager=None): mainLayout.insertWidget(0, self.initializeModelButton) # Action buttons - self.saveButton = QPushButton("Save Changes") - self.resetButton = QPushButton("Reset Parameters") - mainLayout.addWidget(self.saveButton) - mainLayout.addWidget(self.resetButton) - - # Connect signals - self.saveButton.clicked.connect(self.save_changes) - self.resetButton.clicked.connect(self.reset_parameters) + self.initializeModelButton.clicked.connect(self.initialize_model) # Connect feature selection to update details panel @@ -86,7 +82,7 @@ def on_feature_selected(self, item): print("Fault feature selected") self.featureDetailsPanel = FaultFeatureDetailsPanel(fault=feature) elif feature.type == FeatureType.INTERPOLATED: - self.featureDetailsPanel = FoliationFeatureDetailsPanel(feature=feature ) + self.featureDetailsPanel = FoliationFeatureDetailsPanel(feature=feature) else: self.featureDetailsPanel = QWidget() # Default empty panel From 40fa1424aec3f6cb68a548b3666c477c1077496e Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Wed, 30 Jul 2025 14:08:26 +1000 Subject: [PATCH 105/111] fix: remove unused imports and clean up code --- loopstructural/gui/modelling/fault_adjacency_tab.py | 1 - loopstructural/gui/modelling/feature_details_panel.py | 2 -- loopstructural/gui/modelling/model_definition/dem.py | 1 - loopstructural/gui/modelling/modelling_widget.py | 6 +++--- loopstructural/gui/visualisation/feature_list_widget.py | 1 - loopstructural/gui/visualisation/object_list_widget.py | 2 +- loopstructural/main/model_manager.py | 7 ++----- loopstructural/toolbelt/preferences.py | 2 -- 8 files changed, 6 insertions(+), 16 deletions(-) diff --git a/loopstructural/gui/modelling/fault_adjacency_tab.py b/loopstructural/gui/modelling/fault_adjacency_tab.py index 9245d38..f47c92b 100644 --- a/loopstructural/gui/modelling/fault_adjacency_tab.py +++ b/loopstructural/gui/modelling/fault_adjacency_tab.py @@ -5,7 +5,6 @@ QPushButton, QTableWidget, QTableWidgetItem, - QTabWidget, QVBoxLayout, QWidget, ) diff --git a/loopstructural/gui/modelling/feature_details_panel.py b/loopstructural/gui/modelling/feature_details_panel.py index e3a9d79..c369ae5 100644 --- a/loopstructural/gui/modelling/feature_details_panel.py +++ b/loopstructural/gui/modelling/feature_details_panel.py @@ -1,12 +1,10 @@ from PyQt5.QtCore import Qt from PyQt5.QtWidgets import ( - QCheckBox, QComboBox, QDoubleSpinBox, QFormLayout, QLabel, QScrollArea, - QSlider, QVBoxLayout, QWidget, ) diff --git a/loopstructural/gui/modelling/model_definition/dem.py b/loopstructural/gui/modelling/model_definition/dem.py index 713b9a0..a2d3a6c 100644 --- a/loopstructural/gui/modelling/model_definition/dem.py +++ b/loopstructural/gui/modelling/model_definition/dem.py @@ -3,7 +3,6 @@ from PyQt5.QtWidgets import QWidget from qgis.core import QgsMapLayerProxyModel from qgis.PyQt import uic -from qgis.PyQt.QtWidgets import QSizePolicy class DEMWidget(QWidget): diff --git a/loopstructural/gui/modelling/modelling_widget.py b/loopstructural/gui/modelling/modelling_widget.py index cbba115..242c6e7 100644 --- a/loopstructural/gui/modelling/modelling_widget.py +++ b/loopstructural/gui/modelling/modelling_widget.py @@ -1,5 +1,3 @@ -from xmlrpc.client import Fault - from PyQt5.QtWidgets import QTabWidget, QVBoxLayout, QWidget from loopstructural.gui.modelling.fault_adjacency_tab import FaultAdjacencyTab @@ -32,7 +30,9 @@ def __init__( 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) + self.geological_model_tab_widget = GeologicalModelTab( + self, model_manager=self.model_manager + ) mainLayout = QVBoxLayout(self) self.setLayout(mainLayout) diff --git a/loopstructural/gui/visualisation/feature_list_widget.py b/loopstructural/gui/visualisation/feature_list_widget.py index 1ea1b11..e3816ca 100644 --- a/loopstructural/gui/visualisation/feature_list_widget.py +++ b/loopstructural/gui/visualisation/feature_list_widget.py @@ -1,6 +1,5 @@ from typing import Optional, Union -from PyQt5.QtCore import Qt from PyQt5.QtWidgets import QMenu, QPushButton, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget from LoopStructural.datatypes import VectorPoints diff --git a/loopstructural/gui/visualisation/object_list_widget.py b/loopstructural/gui/visualisation/object_list_widget.py index cb53e48..7b4eac9 100644 --- a/loopstructural/gui/visualisation/object_list_widget.py +++ b/loopstructural/gui/visualisation/object_list_widget.py @@ -185,7 +185,7 @@ def remove_selected_object(self): if self.viewer and hasattr(self.viewer, 'remove_object'): self.viewer.remove_object(object_label) else: - print(f"Error: Viewer is not initialized or does not support object removal.") + 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): diff --git a/loopstructural/main/model_manager.py b/loopstructural/main/model_manager.py index 57e9fac..75b42bc 100644 --- a/loopstructural/main/model_manager.py +++ b/loopstructural/main/model_manager.py @@ -1,6 +1,4 @@ from collections import defaultdict -from collections.abc import Callable -from operator import ne from typing import Callable import geopandas as gpd @@ -228,8 +226,7 @@ def update_foliation_features(self): This method will automatically add unconformities based on the stratigraphic column. """ stratigraphic_column = {} - unit_id = 0 - for i, group in enumerate(reversed(self.stratigraphic_column.get_groups())): + for _i, group in enumerate(reversed(self.stratigraphic_column.get_groups())): val = 0 data = [] groupname = group.name @@ -321,7 +318,7 @@ def valid(self): if len(self.stratigraphy) == 0: valid = False if len(self.faults) > 0: - for fault_name, fault_data in self.faults.items(): + for _fault_name, fault_data in self.faults.items(): if 'data' in fault_data and not fault_data['data'].empty: valid = True else: diff --git a/loopstructural/toolbelt/preferences.py b/loopstructural/toolbelt/preferences.py index 239938c..1d442d2 100644 --- a/loopstructural/toolbelt/preferences.py +++ b/loopstructural/toolbelt/preferences.py @@ -5,8 +5,6 @@ # standard from dataclasses import asdict, dataclass, fields -from numpy import interp - # PyQGIS from qgis.core import QgsSettings From de2fa389ec6b65f59d79ed6f6d97c7932f269c04 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Wed, 30 Jul 2025 14:08:55 +1000 Subject: [PATCH 106/111] docs: adding design document --- docs/development/design.md | 62 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 docs/development/design.md diff --git a/docs/development/design.md b/docs/development/design.md new file mode 100644 index 0000000..a3f92db --- /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. \ No newline at end of file From 6afc5692dbecf9cc8c2da1a0553e4a0a9266c48e Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Wed, 30 Jul 2025 14:12:28 +1000 Subject: [PATCH 107/111] fix: reorder import statements and improve error handling for dependencies --- loopstructural/plugin_main.py | 37 ++++++++++++++++------------------- 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/loopstructural/plugin_main.py b/loopstructural/plugin_main.py index fe90796..ddf69cf 100644 --- a/loopstructural/plugin_main.py +++ b/loopstructural/plugin_main.py @@ -3,12 +3,13 @@ """Main plugin module.""" # standard +import importlib.util import os from functools import partial from pathlib import Path # PyQGIS -from qgis.core import QgsApplication, QgsSettings, QgsProject +from qgis.core import QgsApplication, QgsProject, QgsSettings from qgis.gui import QgisInterface from qgis.PyQt.QtCore import QCoreApplication, QLocale, Qt, QTranslator, QUrl from qgis.PyQt.QtGui import QDesktopServices, QIcon @@ -21,28 +22,26 @@ __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.loop_widget import LoopWidget from loopstructural.main.data_manager import ModellingDataManager from loopstructural.main.model_manager import GeologicalModelManager -from loopstructural.gui.loop_widget import LoopWidget from loopstructural.toolbelt import PlgLogger # ############################################################################ # ########## Classes ############### # ################################## + class LoopstructuralPlugin: def __init__(self, iface: QgisInterface): """Constructor. @@ -68,18 +67,20 @@ def __init__(self, iface: QgisInterface): self.data_manager = ModellingDataManager( mapCanvas=self.iface.mapCanvas(), logger=self.log, project=QgsProject.instance() ) - self.model_manager = GeologicalModelManager( - ) + self.model_manager = GeologicalModelManager() self.data_manager.set_model_manager(self.model_manager) def injectLogHandler(self): - import LoopStructural 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() @@ -112,7 +113,7 @@ def initGui(self): self.tr("LoopStructural Modelling"), self.iface.mainWindow(), ) - + self.toolbar.addAction(self.action_modelling) # -- Menu @@ -143,7 +144,7 @@ def initGui(self): data_manager=self.data_manager, model_manager=self.model_manager, ) - + self.loop_dockwidget.setWidget(self.loop_widget) self.iface.addDockWidget(Qt.RightDockWidgetArea, self.loop_dockwidget) right_docks = [ @@ -163,12 +164,8 @@ def initGui(self): self.loop_dockwidget.close() - # -- Connect actions - self.action_modelling.triggered.connect( - self.loop_dockwidget.toggleViewAction().trigger - ) - + 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. From 62d18f0ea356d224b94b8c82dd7548add38237bc Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Thu, 31 Jul 2025 09:13:42 +1000 Subject: [PATCH 108/111] style: running pre-commit --- .github/workflows/release-please.yml | 1 - .github/workflows/releaser.yml | 1 - docs/development/design.md | 4 ++-- loopstructural/gui/loop_widget.py | 2 +- .../gui/modelling/model_definition/model_definition_tab.py | 2 +- .../modelling/stratigraphic_column/stratigraphic_column.py | 6 +++--- loopstructural/metadata.txt | 1 - loopstructural/requirements.txt | 1 - loopstructural/resources/images/infinity_loop_icon.svg | 2 +- loopstructural/toolbelt/log_handler.py | 2 +- 10 files changed, 9 insertions(+), 13 deletions(-) diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 7a80ab6..3803b3d 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -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 8046c7b..6611f5e 100644 --- a/.github/workflows/releaser.yml +++ b/.github/workflows/releaser.yml @@ -56,4 +56,3 @@ jobs: --create-plugin-repo --osgeo-username "$OSGEO_USERNAME" --osgeo-password "$OSGEO_PASSWORD" - diff --git a/docs/development/design.md b/docs/development/design.md index a3f92db..ad91706 100644 --- a/docs/development/design.md +++ b/docs/development/design.md @@ -36,7 +36,7 @@ The plugin is designed to be extensible, allowing developers to add new features - 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. @@ -59,4 +59,4 @@ The plugin includes a tests directory with unit tests for various components. Th ## Future Enhancements - Support for additional geological data types. - Improved visualization capabilities using advanced rendering libraries. -- Enhanced interactivity with more intuitive UI components. \ No newline at end of file +- Enhanced interactivity with more intuitive UI components. diff --git a/loopstructural/gui/loop_widget.py b/loopstructural/gui/loop_widget.py index fe60650..170bea2 100644 --- a/loopstructural/gui/loop_widget.py +++ b/loopstructural/gui/loop_widget.py @@ -23,4 +23,4 @@ def __init__(self, parent=None, *, mapCanvas=None, logger=None, data_manager=Non ) tabWidget.addTab(self.modelling_widget, "Modelling") tabWidget.addTab(self.visualisation_widget, "Visualisation") - \ No newline at end of file + diff --git a/loopstructural/gui/modelling/model_definition/model_definition_tab.py b/loopstructural/gui/modelling/model_definition/model_definition_tab.py index f898f75..111a1ba 100644 --- a/loopstructural/gui/modelling/model_definition/model_definition_tab.py +++ b/loopstructural/gui/modelling/model_definition/model_definition_tab.py @@ -10,7 +10,7 @@ class ModelDefinitionTab(BaseTab): def __init__(self, parent=None, data_manager=None): - super().__init__(parent, data_manager, scrollable=True) + 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) diff --git a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py index ec47280..90aa913 100644 --- a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py +++ b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py @@ -55,7 +55,7 @@ def __init__(self, parent=None, data_manager=None): # 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() @@ -71,7 +71,7 @@ def update_display(self): 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): @@ -100,7 +100,7 @@ def add_unit(self, *, unit_data=None, create_new=True): 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 diff --git a/loopstructural/metadata.txt b/loopstructural/metadata.txt index aa43768..ca67a04 100644 --- a/loopstructural/metadata.txt +++ b/loopstructural/metadata.txt @@ -26,4 +26,3 @@ changelog= # python deps plugin_dependencies=qpip - diff --git a/loopstructural/requirements.txt b/loopstructural/requirements.txt index fba9f60..1c14ece 100644 --- a/loopstructural/requirements.txt +++ b/loopstructural/requirements.txt @@ -3,4 +3,3 @@ 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 index a69ecd1..7d423b3 100644 --- a/loopstructural/resources/images/infinity_loop_icon.svg +++ b/loopstructural/resources/images/infinity_loop_icon.svg @@ -1,4 +1,4 @@ \ No newline at end of file + style="enable-background:new 0 0 122.88 62.63" xml:space="preserve"> diff --git a/loopstructural/toolbelt/log_handler.py b/loopstructural/toolbelt/log_handler.py index ccea28c..20d4303 100644 --- a/loopstructural/toolbelt/log_handler.py +++ b/loopstructural/toolbelt/log_handler.py @@ -194,4 +194,4 @@ def _map_log_level(py_level): elif py_level >= logging.INFO: return 0 else: - return 4 # "none" / debug / custom \ No newline at end of file + return 4 # "none" / debug / custom From ce3426bbefea47e0f6a0b146c309a6eeb5d382b9 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Thu, 31 Jul 2025 09:16:33 +1000 Subject: [PATCH 109/111] fix: adding permission to linter --- .github/workflows/linter.yml | 2 ++ loopstructural/gui/visualisation/feature_list_widget.py | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml index 7ac1c0d..66c08a6 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: diff --git a/loopstructural/gui/visualisation/feature_list_widget.py b/loopstructural/gui/visualisation/feature_list_widget.py index e3816ca..ad235c6 100644 --- a/loopstructural/gui/visualisation/feature_list_widget.py +++ b/loopstructural/gui/visualisation/feature_list_widget.py @@ -57,6 +57,11 @@ def _get_vector_scale(self, scale: Optional[Union[float, int]] = None) -> float: 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) From d37adb0578fde88812c3a2f0e778668635989054 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Thu, 31 Jul 2025 09:23:27 +1000 Subject: [PATCH 110/111] style: running black --- loopstructural/gui/loop_widget.py | 13 +- .../gui/modelling/geological_history_tab.py | 2 - .../model_definition/model_definition_tab.py | 8 +- .../gui/modelling/modelling_widget_back.py | 166 +++++++++++++----- .../stratigraphic_column.py | 12 +- .../gui/visualisation/geometry_object.py | 2 - loopstructural/main/stratigraphic_column.py | 5 + loopstructural/main/vectorLayerWrapper.py | 6 +- loopstructural/processing/provider.py | 3 +- loopstructural/toolbelt/log_handler.py | 2 + 10 files changed, 154 insertions(+), 65 deletions(-) diff --git a/loopstructural/gui/loop_widget.py b/loopstructural/gui/loop_widget.py index 170bea2..c807fe2 100644 --- a/loopstructural/gui/loop_widget.py +++ b/loopstructural/gui/loop_widget.py @@ -1,8 +1,12 @@ 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): + def __init__( + self, parent=None, *, mapCanvas=None, logger=None, data_manager=None, model_manager=None + ): super().__init__(parent) self.mapCanvas = mapCanvas self.logger = logger @@ -15,7 +19,11 @@ def __init__(self, parent=None, *, mapCanvas=None, logger=None, data_manager=Non 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, + mapCanvas=self.mapCanvas, + logger=self.logger, + data_manager=self.data_manager, + model_manager=self.model_manager, ) self.visualisation_widget = VisualisationWidget( @@ -23,4 +31,3 @@ def __init__(self, parent=None, *, mapCanvas=None, logger=None, data_manager=Non ) tabWidget.addTab(self.modelling_widget, "Modelling") tabWidget.addTab(self.visualisation_widget, "Visualisation") - diff --git a/loopstructural/gui/modelling/geological_history_tab.py b/loopstructural/gui/modelling/geological_history_tab.py index c5a6bdb..c21c216 100644 --- a/loopstructural/gui/modelling/geological_history_tab.py +++ b/loopstructural/gui/modelling/geological_history_tab.py @@ -1,5 +1,3 @@ - - from loopstructural.gui.modelling.base_tab import BaseTab from loopstructural.gui.modelling.stratigraphic_column.stratigraphic_column import StratColumnWidget diff --git a/loopstructural/gui/modelling/model_definition/model_definition_tab.py b/loopstructural/gui/modelling/model_definition/model_definition_tab.py index 111a1ba..d80cf64 100644 --- a/loopstructural/gui/modelling/model_definition/model_definition_tab.py +++ b/loopstructural/gui/modelling/model_definition/model_definition_tab.py @@ -1,4 +1,3 @@ - from PyQt5.QtWidgets import QSizePolicy from loopstructural.gui.modelling.base_tab import BaseTab @@ -8,6 +7,7 @@ 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) @@ -18,10 +18,12 @@ def __init__(self, parent=None, data_manager=None): self.stratigraphy_layers = StratigraphicLayersWidget(self, data_manager) # Set uniform size policy for all widgets - for widget in [self.bounding_box, self.fault_layers, self.dem,self.stratigraphy_layers]: + for widget in [self.bounding_box, self.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") + self.add_widget( + self.stratigraphy_layers, 'Stratigraphic Layers' + ) # , "Stratigraphic Layers") diff --git a/loopstructural/gui/modelling/modelling_widget_back.py b/loopstructural/gui/modelling/modelling_widget_back.py index 4511c7e..a0773fe 100644 --- a/loopstructural/gui/modelling/modelling_widget_back.py +++ b/loopstructural/gui/modelling/modelling_widget_back.py @@ -38,6 +38,8 @@ # 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) @@ -61,12 +63,13 @@ def __init__(self, parent: QWidget = None, mapCanvas=None, logger=None): 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) + print(layerName, layers) if len(layers) == 0: self.logger( message=f"Layer {layerName} not found in project", @@ -76,7 +79,10 @@ def setLayerComboBoxFromProject(self, comboBox: QComboBox, layerKey: str): return comboBox.setLayer(None) comboBox.setLayer(layers[0]) - def setLayerFieldComboBoxFromProject(self, comboBox: QComboBox, fieldKey: str, layer: QgsVectorLayer): + + 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 @@ -94,40 +100,56 @@ def setLayerFieldComboBoxFromProject(self, comboBox: QComboBox, fieldKey: str, l 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.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.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.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","") + 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","") + 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) - - + # self.logger(message="Faults not loaded", log_level=2, push=True) def _set_layer_filters(self): # Set filters for the layer selection comboboxes @@ -161,6 +183,7 @@ def _set_layer_filters(self): 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: @@ -170,7 +193,8 @@ 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): + + def saveSettingToProject(self, key: str, value: str): self.project.writeEntry(__title__, key, value) def _connectSignals(self): @@ -191,25 +215,50 @@ def _connectSignals(self): 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.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') + lambda: self.saveLayerFieldComboBoxState( + self.structuralDataUnitName, 'structuraldata_unitname_field' + ) ) self.faultPitchValue.valueChanged.connect( lambda value: self.updateFaultProperty('fault_pitch', value) @@ -230,7 +279,11 @@ def _connectSignals(self): lambda value: self.updateFaultProperty('minor_axis', value) ) - self.orientationType.currentIndexChanged.connect(lambda value: self.saveSettingToProject('orientation_type', self.orientationLabel.text())) + 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)) @@ -250,6 +303,7 @@ def _connectSignals(self): 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( @@ -336,6 +390,7 @@ def onInitialiseModel(self): 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") @@ -385,21 +440,24 @@ def onAddModelContactsToProject(self): 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) + 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) + 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') + 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) @@ -408,10 +466,12 @@ def addDataToPyvista(self): 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() @@ -475,6 +535,7 @@ def onAddScalarFieldToProject(self): def onBasalContactsChanged(self, layer): self.unitNameField.setLayer(layer) # self.saveLayersToProject() + def onFaultTraceLayerChanged(self, layer): self.faultNameField.setLayer(layer) self.faultDipField.setLayer(layer) @@ -616,8 +677,13 @@ def onFaultFieldChanged(self, field): 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()}, + '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, @@ -648,9 +714,7 @@ def saveLayersToProject(self): 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() - ) + 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: @@ -662,9 +726,7 @@ def saveLayersToProject(self): __title__, "faultname_field", self.faultNameField.currentField() ) if self.faultDipField.currentField() is not None: - self.project.writeEntry( - __title__, "fault_dip_field", self.faultDipField.currentField() - ) + 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() @@ -692,9 +754,11 @@ def onSelectedFaultChanged(self, index): 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)) @@ -731,6 +795,7 @@ def updateFaultProperty(self, prop, value): if prop == 'active': self._onActiveFaultChanged(value) self.saveFaultsToProject() + def drawFaultElipse(self): fault = self.faultSelection.currentText() if fault: @@ -779,9 +844,12 @@ def 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())) + 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() @@ -828,15 +896,18 @@ def pick_color(): 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: @@ -868,6 +939,7 @@ def stratigraphicColumnUnitNameChanged(self, unit, name): del self._units[old_name] self._initialiseStratigraphicColumn() self.saveUnitsToProject() + def updateGroups(self): columns = self._getSortedStratigraphicColumn() @@ -896,9 +968,11 @@ def onOrderChanged(self, old_index, new_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) diff --git a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py index 90aa913..2e1eb39 100644 --- a/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py +++ b/loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py @@ -1,4 +1,3 @@ - from PyQt5.QtWidgets import ( QAbstractItemView, QListWidget, @@ -63,6 +62,7 @@ def clearColumn(self): 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() @@ -72,7 +72,7 @@ def update_display(self): 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) + self.add_unconformity(unconformity_data=unit.to_dict(), create_new=False) def init_stratigraphic_column_from_basal_contacts(self): if self.data_manager: @@ -99,9 +99,13 @@ def add_unit(self, *, unit_data=None, create_new=True): 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.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.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() diff --git a/loopstructural/gui/visualisation/geometry_object.py b/loopstructural/gui/visualisation/geometry_object.py index e2bfc1c..15953db 100644 --- a/loopstructural/gui/visualisation/geometry_object.py +++ b/loopstructural/gui/visualisation/geometry_object.py @@ -1,5 +1,3 @@ - - class GeometryObject: def __init__(self, name, object, options=None): self.name = name diff --git a/loopstructural/main/stratigraphic_column.py b/loopstructural/main/stratigraphic_column.py index 7f8cedb..903c785 100644 --- a/loopstructural/main/stratigraphic_column.py +++ b/loopstructural/main/stratigraphic_column.py @@ -1,6 +1,7 @@ import enum from typing import Dict + class UnconformityType(enum.Enum): """ An enumeration for different types of unconformities in a stratigraphic column. @@ -213,6 +214,7 @@ def get_groups(self): if group: groups.append(group) return groups + def get_unitname_groups(self): groups = [] group = [] @@ -226,6 +228,7 @@ def get_unitname_groups(self): if group: groups.append(group) return groups + def __getitem__(self, uuid): """ Retrieves an element by its uuid from the stratigraphic column. @@ -244,6 +247,7 @@ def update_order(self, new_order): 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. @@ -261,6 +265,7 @@ def update_element(self, unit_data: Dict): element.unconformity_type = UnconformityType( unit_data.get('unconformity_type', element.unconformity_type.value) ) + def clear(self): """ Clears the stratigraphic column, removing all elements. diff --git a/loopstructural/main/vectorLayerWrapper.py b/loopstructural/main/vectorLayerWrapper.py index c4f2e6c..7e146db 100644 --- a/loopstructural/main/vectorLayerWrapper.py +++ b/loopstructural/main/vectorLayerWrapper.py @@ -2,14 +2,13 @@ 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': [] - } + data = {'geometry': []} for f in fields: data[f.name()] = [] for feature in features: @@ -21,6 +20,7 @@ def qgsLayerToGeoDataFrame(layer) -> gpd.GeoDataFrame: data[f.name()].append(feature[f.name()]) return gpd.GeoDataFrame(data, crs=layer.crs().authid()) + def qgsLayerToDataFrame(layer, dtm) -> pd.DataFrame: """Convert a vector layer to a pandas DataFrame samples the geometry using either points or the vertices of the lines diff --git a/loopstructural/processing/provider.py b/loopstructural/processing/provider.py index fcb3402..c7b1608 100644 --- a/loopstructural/processing/provider.py +++ b/loopstructural/processing/provider.py @@ -17,8 +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/toolbelt/log_handler.py b/loopstructural/toolbelt/log_handler.py index 20d4303..af0b6e2 100644 --- a/loopstructural/toolbelt/log_handler.py +++ b/loopstructural/toolbelt/log_handler.py @@ -146,6 +146,8 @@ def log( level=log_level, duration=duration, ) + + class PlgLoggerHandler(logging.Handler): """ Standard logging.Handler that forwards logs to PlgLogger.log(). From a34bb33b80687159972d7758bec7d9d103da8258 Mon Sep 17 00:00:00 2001 From: Lachlan Grose Date: Thu, 31 Jul 2025 09:24:53 +1000 Subject: [PATCH 111/111] ci: disable auto-commit --- .github/workflows/linter.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml index 66c08a6..d6871a4 100644 --- a/.github/workflows/linter.yml +++ b/.github/workflows/linter.yml @@ -46,6 +46,6 @@ jobs: - name: Lint with ruff run: | ruff check ${{env.PROJECT_FOLDER}} --fix - - uses: stefanzweifel/git-auto-commit-action@v6 - with: - 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"