Skip to content

Commit c7d8c42

Browse files
lachlangroseclaude
andauthored
feat: add fault-domain-boundary (#112)
* test: pin training-value/isovalue direction agreement for stratigraphic columns Guards against the swap fixed in 814be12: model_manager.py's per-unit training value and LoopStructural's get_isovalues() must agree on which direction values increase, or extracted isosurfaces get labelled with the wrong unit while keeping correct geometry. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * feat: allow a fault to act as a stratigraphic domain boundary Lets a stratigraphic-column unconformity be linked to an existing fault instead of a flat isovalue surface, so the fault's own (non-displacing) geometry splits the model into two domains -- built via LoopStructural's create_and_add_domain_fault, reusing the same trace data already ingested for the fault. - Stratigraphic column UI gains a "fault" boundary type with a fault picker; faults used this way are excluded from the fault topology's FAULTED/ABUTTING and fault-stratigraphy tables, since those assume a displacement-modelled fault. - The fault's trace is automatically extended to the model's bounding box edges along its own trend, and given synthetic strike/dip orientation constraints, so the interpolated surface spans and properly varies across the whole domain rather than only being reliable near the digitised trace. - A domain-boundary fault is skipped by the ordinary displacement-fault build loop, and any region a later unconformity incorrectly attaches to it is stripped after each build (defensive; the root cause is fixed upstream in LoopStructural core separately). - Fixes a project-load ordering bug where the model CRS was restored after the layers that get reprojected against it, silently skipping reprojection for any layer already in the project's own CRS. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix: correct stratigraphic value assignment and domain-fault boundary follow-ups Several fixes to make domain-fault-bounded stratigraphic columns build and display correctly: - update_foliation_features now trains each unit's basal-contact data at its own max() (the boundary with the next-older unit, i.e. its true base) instead of min() (the boundary with the next-younger unit, i.e. its top). Basal contacts represent a unit's base, so training at min() anchored every unit's own data to the wrong boundary -- confirmed on a live project where units evaluated into their next-younger neighbour's bracket instead of their own, and a basement unit with no contact data of its own never appeared in the model at all. - Unit thickness now accumulates unconditionally while building that training data, so an undigitised placeholder unit no longer shifts every later unit's value by its own thickness. - Use each fault trace point's own local tangent (rather than one global best-fit line) when extending a domain-boundary fault to the model's bounding box and deriving its orientation constraints, so a curved trace doesn't get flattened into the wrong extrapolation. - Recompute stratigraphic unit value ranges after restoring a column from a saved project (both initial load and reload), matching what a fresh column already gets -- otherwise every restored unit kept the default (0, inf) range and couldn't be told apart from its neighbours. - Show the generic details panel for a domain-fault feature instead of an empty widget. - Skip an isosurface with no geometry when adding stratigraphic surfaces to the 3D viewer instead of crashing, since an undigitised unit can legitimately have no constrained geometry anywhere in the model. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix: increase thickness spin box limit + nelements spin box. BUmp default nelements to 50k * fix: value should be the basal value of a unit * style: fix black formatting and ruff lint (unnecessary generator) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix: training values no longer disagree with get_isovalues() direction update_foliation_features re-reversed group.units, which get_groups() already returns in the order get_isovalues() walks, so every basal contact was trained with the wrong scalar value. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent e460b30 commit c7d8c42

12 files changed

Lines changed: 962 additions & 54 deletions

File tree

loopstructural/gui/dlg_settings.ui

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -274,7 +274,11 @@
274274
</widget>
275275
</item>
276276
<item row="3" column="1">
277-
<widget class="QSpinBox" name="n_elements_spin_box"/>
277+
<widget class="QSpinBox" name="n_elements_spin_box">
278+
<property name="maximum">
279+
<number>1000000</number>
280+
</property>
281+
</widget>
278282
</item>
279283
</layout>
280284
</widget>

loopstructural/gui/modelling/fault_adjacency_tab.py

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ def _update(self, observable, event, *args, **kwargs):
8080
self.update_fault_adjacency_table()
8181
self.update_stratigraphic_units_table()
8282

83-
def change_button_color(self, button, row, col):
83+
def change_button_color(self, button, fault1, fault2):
8484
"""Cycle the button color and update the fault relationship."""
8585
current_color = button.styleSheet()
8686
if "red" in current_color:
@@ -94,15 +94,23 @@ def change_button_color(self, button, row, col):
9494
relationship = FaultRelationshipType.ABUTTING
9595

9696
button.setStyleSheet(f"background-color: {new_color};")
97-
f1 = self.data_manager._fault_topology.faults[row]
98-
f2 = self.data_manager._fault_topology.faults[col]
99-
self.data_manager._fault_topology.update_fault_relationship(f1, f2, relationship)
97+
self.data_manager._fault_topology.update_fault_relationship(fault1, fault2, relationship)
98+
99+
def _displacement_fault_names(self):
100+
"""Fault names to show in these tables, excluding faults used as
101+
stratigraphic-column domain boundaries (see `set_fault_boundary`).
102+
Those are non-displacing splits, not faults that cut/abut other
103+
faults or offset stratigraphic units, so FAULTED/ABUTTING and
104+
fault-stratigraphy relationships don't apply to them.
105+
"""
106+
domain_boundary_faults = self.data_manager.get_fault_boundary_fault_names()
107+
return [
108+
f for f in self.data_manager._fault_topology.faults if f not in domain_boundary_faults
109+
]
100110

101111
def update_fault_adjacency_table(self):
102112
"""Update the fault adjacency table with QPushButtons."""
103-
faults = (
104-
self.data_manager._fault_topology.faults
105-
) # Assuming faults is a list of fault names
113+
faults = self._displacement_fault_names()
106114
if not faults:
107115
self.fault_table_group.hide()
108116
return
@@ -145,15 +153,15 @@ def update_fault_adjacency_table(self):
145153
else:
146154
button.setStyleSheet("background-color: white;")
147155
button.clicked.connect(
148-
lambda _, b=button, r=row, c=col: self.change_button_color(b, r, c)
156+
lambda _, b=button, f1=faults[row], f2=faults[
157+
col
158+
]: self.change_button_color(b, f1, f2)
149159
)
150160
self.table.setCellWidget(row, col, button)
151161

152162
def update_stratigraphic_units_table(self):
153163
"""Update the stratigraphic units table with QPushButtons."""
154-
faults = (
155-
self.data_manager._fault_topology.faults
156-
) # Assuming faults is a list of fault names
164+
faults = self._displacement_fault_names()
157165
group_units_pairs = self.data_manager._stratigraphic_column.get_group_unit_pairs()
158166

159167
if not faults or not group_units_pairs:
@@ -185,11 +193,13 @@ def update_stratigraphic_units_table(self):
185193
# Default to white if no relationship or not faulted
186194
button.setStyleSheet("background-color: white;")
187195
button.clicked.connect(
188-
lambda _, b=button, r=row, c=col: self.change_button_colour_binary(b, r, c)
196+
lambda _, b=button, u=units[row], f=faults[
197+
col
198+
]: self.change_button_colour_binary(b, u, f)
189199
)
190200
self.stratigraphic_table.setCellWidget(row, col, button)
191201

192-
def change_button_colour_binary(self, button, row, col):
202+
def change_button_colour_binary(self, button, unit_name, fault_name):
193203
"""Cycle the button color between red, green, and black."""
194204

195205
current_color = button.styleSheet()
@@ -199,8 +209,6 @@ def change_button_colour_binary(self, button, row, col):
199209
else:
200210
button.setStyleSheet("background-color: red;")
201211
flag = True
202-
fault = self.data_manager._fault_topology.faults[col]
203-
unit = self.data_manager._stratigraphic_column.get_group_unit_pairs()[row]
204212
self.data_manager._fault_topology.update_fault_stratigraphy_relationship(
205-
unit[1], fault, flag
213+
unit_name, fault_name, flag
206214
)

loopstructural/gui/modelling/geological_model_tab/geological_model_tab.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from .add_foliation_dialog import AddFoliationDialog
2121
from .add_unconformity_dialog import AddUnconformityDialog
2222
from .feature_details_panel import (
23+
BaseFeatureDetailsPanel,
2324
FaultFeatureDetailsPanel,
2425
FoldedFeatureDetailsPanel,
2526
FoliationFeatureDetailsPanel,
@@ -461,6 +462,17 @@ def on_feature_selected(self, item):
461462
self.featureDetailsPanel = FoldedFeatureDetailsPanel(
462463
feature=feature, model_manager=self.model_manager, data_manager=self.data_manager
463464
)
465+
elif feature.type == FeatureType.DOMAINFAULT:
466+
# A domain fault is built by the same GeologicalFeatureBuilder
467+
# as a foliation (see create_and_add_domain_fault), just with a
468+
# different .type tag -- the generic base panel (interpolator
469+
# settings, data layers, export/evaluate) already applies to it
470+
# unchanged. Skip FoliationFeatureDetailsPanel's fold-frame
471+
# attachment controls, which don't make sense for a domain
472+
# boundary.
473+
self.featureDetailsPanel = BaseFeatureDetailsPanel(
474+
feature=feature, model_manager=self.model_manager, data_manager=self.data_manager
475+
)
464476
else:
465477
self.featureDetailsPanel = QWidget() # Default empty panel
466478

loopstructural/gui/modelling/stratigraphic_column/stratigraphic_column.py

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,11 @@ def update_display(self):
232232
widget, _ = self._widget_cache[unit.uuid]
233233
# Update widget data without rebuilding
234234
if hasattr(widget, 'setData'):
235-
widget.setData(unit.to_dict())
235+
unit_data = unit.to_dict()
236+
if isinstance(widget, UnconformityWidget):
237+
unit_data = self._enrich_unconformity_data(unit_data)
238+
widget.set_available_faults(self._get_available_fault_names())
239+
widget.setData(unit_data)
236240
return
237241

238242
# If order/content differs, do a full rebuild
@@ -263,7 +267,31 @@ def _full_rebuild_display(self, current_order):
263267
if unit.element_type == StratigraphicColumnElementType.UNIT:
264268
self.add_unit(unit_data=unit.to_dict(), create_new=False)
265269
elif unit.element_type == StratigraphicColumnElementType.UNCONFORMITY:
266-
self.add_unconformity(unconformity_data=unit.to_dict(), create_new=False)
270+
self.add_unconformity(
271+
unconformity_data=self._enrich_unconformity_data(unit.to_dict()),
272+
create_new=False,
273+
)
274+
275+
def _enrich_unconformity_data(self, unconformity_data):
276+
"""Merge in the plugin-side fault-boundary link for an unconformity row.
277+
278+
`StratigraphicUnconformity.to_dict()` (core) only knows `erode`/
279+
`onlap`; the fault link is tracked separately in the data manager
280+
(see `ModellingDataManager.set_fault_boundary`), so it has to be
281+
folded in here for display.
282+
"""
283+
fault_name = self.data_manager.get_fault_boundary(unconformity_data.get('uuid'))
284+
if fault_name:
285+
unconformity_data = dict(unconformity_data)
286+
unconformity_data['unconformity_type'] = 'fault'
287+
unconformity_data['fault_name'] = fault_name
288+
return unconformity_data
289+
290+
def _get_available_fault_names(self):
291+
"""Fault names offered when marking an unconformity as a domain boundary."""
292+
if not self.data_manager:
293+
return []
294+
return list(self.data_manager._fault_topology.faults)
267295

268296
def init_stratigraphic_column_from_basal_contacts(self):
269297
if self.data_manager:
@@ -482,11 +510,13 @@ def add_unconformity(self, *, unconformity_data=None, create_new=True):
482510
widget, _ = self._widget_cache[unconformity.uuid]
483511
# Just update the data, don't recreate the widget
484512
if hasattr(widget, 'setData'):
513+
widget.set_available_faults(self._get_available_fault_names())
485514
widget.setData(unconformity_data)
486515
return
487516

488517
unconformity_widget = UnconformityWidget(uuid=unconformity.uuid)
489518
unconformity_widget.deleteRequested.connect(self.delete_unit)
519+
unconformity_widget.dataChanged.connect(lambda: self.update_element(unconformity_widget))
490520
unconformity_widget.dragHandlePressed.connect(
491521
lambda: self._on_drag_start(unconformity_widget)
492522
)
@@ -500,6 +530,8 @@ def add_unconformity(self, *, unconformity_data=None, create_new=True):
500530
item.setSizeHint(unconformity_widget.sizeHint())
501531
self.unitList.addItem(item)
502532
self.unitList.setItemWidget(item, unconformity_widget)
533+
unconformity_widget.set_available_faults(self._get_available_fault_names())
534+
unconformity_widget.setData(unconformity_data)
503535

504536
# Cache the widget for efficient updates
505537
self._widget_cache[unconformity.uuid] = (unconformity_widget, item)
@@ -605,6 +637,30 @@ def update_element(self, unit_widget):
605637
"""
606638
if self.data_manager:
607639
unit_data = unit_widget.getData()
640+
if isinstance(unit_widget, UnconformityWidget):
641+
fault_name = unit_data.pop('fault_name', None)
642+
is_fault_boundary = unit_data.get('unconformity_type') == 'fault'
643+
if is_fault_boundary:
644+
# The core stratigraphic column only knows erode/onlap --
645+
# the fault link lives in the data manager's side table
646+
# (see set_fault_boundary), so store it as a plain
647+
# erosional boundary here.
648+
unit_data['unconformity_type'] = 'erode'
649+
if is_fault_boundary and fault_name:
650+
self.data_manager.set_fault_boundary(unit_widget.uuid, fault_name)
651+
if not self.data_manager.fault_spans_model_domain(fault_name):
652+
QMessageBox.information(
653+
self,
654+
"Fault Domain Boundary",
655+
f"Fault '{fault_name}' does not reach every edge of the model "
656+
"bounding box.\n\nA fault used as a domain boundary crops the "
657+
"whole model, so its digitised trace will automatically be "
658+
"extended out to the domain edges along its overall trend when "
659+
"the model is built. For best results the trace should still "
660+
"roughly follow the fault's real direction across the gap.",
661+
)
662+
else:
663+
self.data_manager.clear_fault_boundary(unit_widget.uuid)
608664
self.data_manager._stratigraphic_column.update_element(unit_data)
609665
# Trigger callback to notify all listeners of the change
610666
if self.data_manager.stratigraphic_column_callback:

loopstructural/gui/modelling/stratigraphic_column/stratigraphic_unit.ui

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@
8080
<double>0.000000000000000</double>
8181
</property>
8282
<property name="maximum">
83-
<double>10000.000000000000000</double>
83+
<double>100000000.000000000000000</double>
8484
</property>
8585
</widget>
8686
</item>

loopstructural/gui/modelling/stratigraphic_column/unconformity.py

Lines changed: 67 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
class UnconformityWidget(QWidget):
1212
deleteRequested = pyqtSignal(QWidget) # Signal to request deletion
13+
dataChanged = pyqtSignal() # Type or fault-name changed
1314
dragHandlePressed = pyqtSignal() # Drag handle mouse-down
1415
dragHandleMoved = pyqtSignal(QPoint) # Drag handle mouse-move (global pos)
1516
dragHandleReleased = pyqtSignal() # Drag handle mouse-up
@@ -25,9 +26,9 @@ def __init__(
2526
self.buttonDelete.clicked.connect(self.request_delete)
2627
self.uuid = uuid
2728
self.unconformity_type = 'erode'
28-
# self.comboBoxUnconformityType.currentIndexChanged.connect(
29-
# lambda: setattr(self, 'unconformity_type', self.comboBoxUnconformityType.currentText())
30-
# )
29+
self.fault_name = None
30+
self.comboBoxUnconformityType.currentIndexChanged.connect(self._on_type_changed)
31+
self.comboBoxFaultName.currentIndexChanged.connect(self._on_fault_name_changed)
3132
# The row's combo box/buttons cover the whole widget, so a QListWidget's
3233
# built-in drag-and-drop can never see a mouse press to start a
3334
# reorder. Route presses on the dedicated grip label through here instead.
@@ -55,21 +56,73 @@ def request_delete(self):
5556

5657
self.deleteRequested.emit(self)
5758

59+
def _on_type_changed(self, _index):
60+
self.unconformity_type = self.comboBoxUnconformityType.currentText()
61+
self.comboBoxFaultName.setVisible(self.unconformity_type == 'fault')
62+
if self.unconformity_type == 'fault':
63+
self.fault_name = self.comboBoxFaultName.currentText() or None
64+
else:
65+
self.fault_name = None
66+
self.dataChanged.emit()
67+
68+
def _on_fault_name_changed(self, _index):
69+
if self.unconformity_type != 'fault':
70+
return
71+
self.fault_name = self.comboBoxFaultName.currentText() or None
72+
self.dataChanged.emit()
73+
74+
def set_available_faults(self, fault_names):
75+
"""Populate the fault-name picker, keeping the current selection if
76+
it is still available (e.g. after the fault trace layer changes).
77+
"""
78+
fault_names = list(fault_names or [])
79+
if [
80+
self.comboBoxFaultName.itemText(i) for i in range(self.comboBoxFaultName.count())
81+
] == fault_names:
82+
return
83+
self.comboBoxFaultName.blockSignals(True)
84+
try:
85+
self.comboBoxFaultName.clear()
86+
self.comboBoxFaultName.addItems(fault_names)
87+
if self.fault_name and self.fault_name in fault_names:
88+
self.comboBoxFaultName.setCurrentText(self.fault_name)
89+
finally:
90+
self.comboBoxFaultName.blockSignals(False)
91+
5892
def setData(self, data: Optional[dict] = None):
5993
"""Set the data for the unconformity widget.
6094
6195
Parameters
6296
----------
6397
data : dict or None
64-
Dictionary containing 'unconformity_type' key. If None, defaults are used.
98+
Dictionary with an 'unconformity_type' key ('erode', 'onlap' or
99+
'fault'), and a 'fault_name' key when the type is 'fault'. If
100+
None, defaults are used.
65101
"""
66-
if data:
67-
self.unconformity_type = data.get("unconformity_type", "")
68-
# self.unconformityTypeComboBox.setCurrentIndex(
69-
# self.unconformityTypeComboBox.findText(self.unconformity_type)
70-
# )
71-
else:
72-
self.unconformity_type = 'erode'
73-
# self.unconformityTypeComboBox.setCurrentIndex(
74-
# self.unconformityTypeComboBox.findText(self.unconformity_type)
75-
# )
102+
self.unconformity_type = (data or {}).get("unconformity_type", "erode")
103+
self.fault_name = (
104+
(data or {}).get("fault_name") if self.unconformity_type == 'fault' else None
105+
)
106+
107+
self.comboBoxUnconformityType.blockSignals(True)
108+
self.comboBoxFaultName.blockSignals(True)
109+
try:
110+
index = self.comboBoxUnconformityType.findText(self.unconformity_type)
111+
if index >= 0:
112+
self.comboBoxUnconformityType.setCurrentIndex(index)
113+
self.comboBoxFaultName.setVisible(self.unconformity_type == 'fault')
114+
if self.fault_name:
115+
self.comboBoxFaultName.setCurrentText(self.fault_name)
116+
finally:
117+
self.comboBoxUnconformityType.blockSignals(False)
118+
self.comboBoxFaultName.blockSignals(False)
119+
120+
def getData(self):
121+
"""Return this row's data for the data manager: uuid, unconformity_type
122+
and (when the boundary is fault-linked) fault_name.
123+
"""
124+
return {
125+
'uuid': self.uuid,
126+
'unconformity_type': self.unconformity_type,
127+
'fault_name': self.fault_name,
128+
}

loopstructural/gui/modelling/stratigraphic_column/unconformity.ui

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,11 @@
6161
<string>onlap</string>
6262
</property>
6363
</item>
64+
<item>
65+
<property name="text">
66+
<string>fault</string>
67+
</property>
68+
</item>
6469
</widget>
6570
</item>
6671
<item row="0" column="1">
@@ -70,6 +75,16 @@
7075
</property>
7176
</widget>
7277
</item>
78+
<item row="1" column="1" colspan="2">
79+
<widget class="QComboBox" name="comboBoxFaultName">
80+
<property name="toolTip">
81+
<string>Fault whose surface realises this boundary as a domain split</string>
82+
</property>
83+
<property name="visible">
84+
<bool>false</bool>
85+
</property>
86+
</widget>
87+
</item>
7388
</layout>
7489
</widget>
7590
<resources/>

loopstructural/gui/visualisation/feature_list_widget.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -456,8 +456,18 @@ def add_stratigraphic_surfaces(self):
456456
stratigraphic_surfaces = self.model_manager.model.get_stratigraphic_surfaces()
457457

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

0 commit comments

Comments
 (0)