Skip to content
6 changes: 5 additions & 1 deletion loopstructural/gui/dlg_settings.ui
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,11 @@
</widget>
</item>
<item row="3" column="1">
<widget class="QSpinBox" name="n_elements_spin_box"/>
<widget class="QSpinBox" name="n_elements_spin_box">
<property name="maximum">
<number>1000000</number>
</property>
</widget>
</item>
</layout>
</widget>
Expand Down
40 changes: 24 additions & 16 deletions loopstructural/gui/modelling/fault_adjacency_tab.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ def _update(self, observable, event, *args, **kwargs):
self.update_fault_adjacency_table()
self.update_stratigraphic_units_table()

def change_button_color(self, button, row, col):
def change_button_color(self, button, fault1, fault2):
"""Cycle the button color and update the fault relationship."""
current_color = button.styleSheet()
if "red" in current_color:
Expand All @@ -94,15 +94,23 @@ def change_button_color(self, button, row, col):
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)
self.data_manager._fault_topology.update_fault_relationship(fault1, fault2, relationship)

def _displacement_fault_names(self):
"""Fault names to show in these tables, excluding faults used as
stratigraphic-column domain boundaries (see `set_fault_boundary`).
Those are non-displacing splits, not faults that cut/abut other
faults or offset stratigraphic units, so FAULTED/ABUTTING and
fault-stratigraphy relationships don't apply to them.
"""
domain_boundary_faults = self.data_manager.get_fault_boundary_fault_names()
return [
f for f in self.data_manager._fault_topology.faults if f not in domain_boundary_faults
]

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._displacement_fault_names()
if not faults:
self.fault_table_group.hide()
return
Expand Down Expand Up @@ -145,15 +153,15 @@ def update_fault_adjacency_table(self):
else:
button.setStyleSheet("background-color: white;")
button.clicked.connect(
lambda _, b=button, r=row, c=col: self.change_button_color(b, r, c)
lambda _, b=button, f1=faults[row], f2=faults[
col
]: self.change_button_color(b, f1, f2)
)
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._displacement_fault_names()
group_units_pairs = self.data_manager._stratigraphic_column.get_group_unit_pairs()

if not faults or not group_units_pairs:
Expand Down Expand Up @@ -185,11 +193,13 @@ def update_stratigraphic_units_table(self):
# 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)
lambda _, b=button, u=units[row], f=faults[
col
]: self.change_button_colour_binary(b, u, f)
)
self.stratigraphic_table.setCellWidget(row, col, button)

def change_button_colour_binary(self, button, row, col):
def change_button_colour_binary(self, button, unit_name, fault_name):
"""Cycle the button color between red, green, and black."""

current_color = button.styleSheet()
Expand All @@ -199,8 +209,6 @@ 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]
unit = self.data_manager._stratigraphic_column.get_group_unit_pairs()[row]
self.data_manager._fault_topology.update_fault_stratigraphy_relationship(
unit[1], fault, flag
unit_name, fault_name, flag
)
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from .add_foliation_dialog import AddFoliationDialog
from .add_unconformity_dialog import AddUnconformityDialog
from .feature_details_panel import (
BaseFeatureDetailsPanel,
FaultFeatureDetailsPanel,
FoldedFeatureDetailsPanel,
FoliationFeatureDetailsPanel,
Expand Down Expand Up @@ -461,6 +462,17 @@ def on_feature_selected(self, item):
self.featureDetailsPanel = FoldedFeatureDetailsPanel(
feature=feature, model_manager=self.model_manager, data_manager=self.data_manager
)
elif feature.type == FeatureType.DOMAINFAULT:
# A domain fault is built by the same GeologicalFeatureBuilder
# as a foliation (see create_and_add_domain_fault), just with a
# different .type tag -- the generic base panel (interpolator
# settings, data layers, export/evaluate) already applies to it
# unchanged. Skip FoliationFeatureDetailsPanel's fold-frame
# attachment controls, which don't make sense for a domain
# boundary.
self.featureDetailsPanel = BaseFeatureDetailsPanel(
feature=feature, model_manager=self.model_manager, data_manager=self.data_manager
)
else:
self.featureDetailsPanel = QWidget() # Default empty panel

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,11 @@ def update_display(self):
widget, _ = self._widget_cache[unit.uuid]
# Update widget data without rebuilding
if hasattr(widget, 'setData'):
widget.setData(unit.to_dict())
unit_data = unit.to_dict()
if isinstance(widget, UnconformityWidget):
unit_data = self._enrich_unconformity_data(unit_data)
widget.set_available_faults(self._get_available_fault_names())
widget.setData(unit_data)
return

# If order/content differs, do a full rebuild
Expand Down Expand Up @@ -263,7 +267,31 @@ def _full_rebuild_display(self, current_order):
if unit.element_type == StratigraphicColumnElementType.UNIT:
self.add_unit(unit_data=unit.to_dict(), create_new=False)
elif unit.element_type == StratigraphicColumnElementType.UNCONFORMITY:
self.add_unconformity(unconformity_data=unit.to_dict(), create_new=False)
self.add_unconformity(
unconformity_data=self._enrich_unconformity_data(unit.to_dict()),
create_new=False,
)

def _enrich_unconformity_data(self, unconformity_data):
"""Merge in the plugin-side fault-boundary link for an unconformity row.

`StratigraphicUnconformity.to_dict()` (core) only knows `erode`/
`onlap`; the fault link is tracked separately in the data manager
(see `ModellingDataManager.set_fault_boundary`), so it has to be
folded in here for display.
"""
fault_name = self.data_manager.get_fault_boundary(unconformity_data.get('uuid'))
if fault_name:
unconformity_data = dict(unconformity_data)
unconformity_data['unconformity_type'] = 'fault'
unconformity_data['fault_name'] = fault_name
return unconformity_data

def _get_available_fault_names(self):
"""Fault names offered when marking an unconformity as a domain boundary."""
if not self.data_manager:
return []
return list(self.data_manager._fault_topology.faults)

def init_stratigraphic_column_from_basal_contacts(self):
if self.data_manager:
Expand Down Expand Up @@ -482,11 +510,13 @@ def add_unconformity(self, *, unconformity_data=None, create_new=True):
widget, _ = self._widget_cache[unconformity.uuid]
# Just update the data, don't recreate the widget
if hasattr(widget, 'setData'):
widget.set_available_faults(self._get_available_fault_names())
widget.setData(unconformity_data)
return

unconformity_widget = UnconformityWidget(uuid=unconformity.uuid)
unconformity_widget.deleteRequested.connect(self.delete_unit)
unconformity_widget.dataChanged.connect(lambda: self.update_element(unconformity_widget))
unconformity_widget.dragHandlePressed.connect(
lambda: self._on_drag_start(unconformity_widget)
)
Expand All @@ -500,6 +530,8 @@ def add_unconformity(self, *, unconformity_data=None, create_new=True):
item.setSizeHint(unconformity_widget.sizeHint())
self.unitList.addItem(item)
self.unitList.setItemWidget(item, unconformity_widget)
unconformity_widget.set_available_faults(self._get_available_fault_names())
unconformity_widget.setData(unconformity_data)

# Cache the widget for efficient updates
self._widget_cache[unconformity.uuid] = (unconformity_widget, item)
Expand Down Expand Up @@ -605,6 +637,30 @@ def update_element(self, unit_widget):
"""
if self.data_manager:
unit_data = unit_widget.getData()
if isinstance(unit_widget, UnconformityWidget):
fault_name = unit_data.pop('fault_name', None)
is_fault_boundary = unit_data.get('unconformity_type') == 'fault'
if is_fault_boundary:
# The core stratigraphic column only knows erode/onlap --
# the fault link lives in the data manager's side table
# (see set_fault_boundary), so store it as a plain
# erosional boundary here.
unit_data['unconformity_type'] = 'erode'
if is_fault_boundary and fault_name:
self.data_manager.set_fault_boundary(unit_widget.uuid, fault_name)
if not self.data_manager.fault_spans_model_domain(fault_name):
QMessageBox.information(
self,
"Fault Domain Boundary",
f"Fault '{fault_name}' does not reach every edge of the model "
"bounding box.\n\nA fault used as a domain boundary crops the "
"whole model, so its digitised trace will automatically be "
"extended out to the domain edges along its overall trend when "
"the model is built. For best results the trace should still "
"roughly follow the fault's real direction across the gap.",
)
else:
self.data_manager.clear_fault_boundary(unit_widget.uuid)
self.data_manager._stratigraphic_column.update_element(unit_data)
# Trigger callback to notify all listeners of the change
if self.data_manager.stratigraphic_column_callback:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@
<double>0.000000000000000</double>
</property>
<property name="maximum">
<double>10000.000000000000000</double>
<double>100000000.000000000000000</double>
</property>
</widget>
</item>
Expand Down
81 changes: 67 additions & 14 deletions loopstructural/gui/modelling/stratigraphic_column/unconformity.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

class UnconformityWidget(QWidget):
deleteRequested = pyqtSignal(QWidget) # Signal to request deletion
dataChanged = pyqtSignal() # Type or fault-name changed
dragHandlePressed = pyqtSignal() # Drag handle mouse-down
dragHandleMoved = pyqtSignal(QPoint) # Drag handle mouse-move (global pos)
dragHandleReleased = pyqtSignal() # Drag handle mouse-up
Expand All @@ -25,9 +26,9 @@ def __init__(
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())
# )
self.fault_name = None
self.comboBoxUnconformityType.currentIndexChanged.connect(self._on_type_changed)
self.comboBoxFaultName.currentIndexChanged.connect(self._on_fault_name_changed)
# The row's combo box/buttons cover the whole widget, so a QListWidget's
# built-in drag-and-drop can never see a mouse press to start a
# reorder. Route presses on the dedicated grip label through here instead.
Expand Down Expand Up @@ -55,21 +56,73 @@ def request_delete(self):

self.deleteRequested.emit(self)

def _on_type_changed(self, _index):
self.unconformity_type = self.comboBoxUnconformityType.currentText()
self.comboBoxFaultName.setVisible(self.unconformity_type == 'fault')
if self.unconformity_type == 'fault':
self.fault_name = self.comboBoxFaultName.currentText() or None
else:
self.fault_name = None
self.dataChanged.emit()

def _on_fault_name_changed(self, _index):
if self.unconformity_type != 'fault':
return
self.fault_name = self.comboBoxFaultName.currentText() or None
self.dataChanged.emit()

def set_available_faults(self, fault_names):
"""Populate the fault-name picker, keeping the current selection if
it is still available (e.g. after the fault trace layer changes).
"""
fault_names = list(fault_names or [])
if [
self.comboBoxFaultName.itemText(i) for i in range(self.comboBoxFaultName.count())
] == fault_names:
return
self.comboBoxFaultName.blockSignals(True)
try:
self.comboBoxFaultName.clear()
self.comboBoxFaultName.addItems(fault_names)
if self.fault_name and self.fault_name in fault_names:
self.comboBoxFaultName.setCurrentText(self.fault_name)
finally:
self.comboBoxFaultName.blockSignals(False)

def setData(self, data: Optional[dict] = None):
"""Set the data for the unconformity widget.

Parameters
----------
data : dict or None
Dictionary containing 'unconformity_type' key. If None, defaults are used.
Dictionary with an 'unconformity_type' key ('erode', 'onlap' or
'fault'), and a 'fault_name' key when the type is 'fault'. If
None, defaults are used.
"""
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)
# )
self.unconformity_type = (data or {}).get("unconformity_type", "erode")
self.fault_name = (
(data or {}).get("fault_name") if self.unconformity_type == 'fault' else None
)

self.comboBoxUnconformityType.blockSignals(True)
self.comboBoxFaultName.blockSignals(True)
try:
index = self.comboBoxUnconformityType.findText(self.unconformity_type)
if index >= 0:
self.comboBoxUnconformityType.setCurrentIndex(index)
self.comboBoxFaultName.setVisible(self.unconformity_type == 'fault')
if self.fault_name:
self.comboBoxFaultName.setCurrentText(self.fault_name)
finally:
self.comboBoxUnconformityType.blockSignals(False)
self.comboBoxFaultName.blockSignals(False)

def getData(self):
"""Return this row's data for the data manager: uuid, unconformity_type
and (when the boundary is fault-linked) fault_name.
"""
return {
'uuid': self.uuid,
'unconformity_type': self.unconformity_type,
'fault_name': self.fault_name,
}
15 changes: 15 additions & 0 deletions loopstructural/gui/modelling/stratigraphic_column/unconformity.ui
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@
<string>onlap</string>
</property>
</item>
<item>
<property name="text">
<string>fault</string>
</property>
</item>
</widget>
</item>
<item row="0" column="1">
Expand All @@ -70,6 +75,16 @@
</property>
</widget>
</item>
<item row="1" column="1" colspan="2">
<widget class="QComboBox" name="comboBoxFaultName">
<property name="toolTip">
<string>Fault whose surface realises this boundary as a domain split</string>
</property>
<property name="visible">
<bool>false</bool>
</property>
</widget>
</item>
</layout>
</widget>
<resources/>
Expand Down
12 changes: 11 additions & 1 deletion loopstructural/gui/visualisation/feature_list_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -456,8 +456,18 @@ def add_stratigraphic_surfaces(self):
stratigraphic_surfaces = self.model_manager.model.get_stratigraphic_surfaces()

for surface in stratigraphic_surfaces:
mesh = surface.vtk()
if mesh.n_points == 0:
# A unit with no digitised data of its own (e.g. an
# undigitised placeholder like "Top") can have no
# constrained geometry anywhere in the model, so its
# isovalue may not intersect the solved field at all --
# pyvista refuses to plot an empty mesh, so skip it rather
# than crashing every surface after it in this loop.
logger.info(f"Skipping '{surface.name}': isosurface has no geometry.")
continue
self.viewer.add_mesh_object(
surface.vtk(),
mesh,
name=surface.name,
color=surface.colour,
source_feature=surface.name,
Expand Down
Loading
Loading