Skip to content

Commit 1bef056

Browse files
fix: allow different crs for input data and model. Force model crs to be projected
* Initial plan * Add model CRS storage, validation, and UI controls Co-authored-by: lachlangrose <7371904+lachlangrose@users.noreply.github.com> * Fix linter issues and add CRS validation tests Co-authored-by: lachlangrose <7371904+lachlangrose@users.noreply.github.com> * Improve error handling and type safety in CRS transformation Co-authored-by: lachlangrose <7371904+lachlangrose@users.noreply.github.com> * Add robust error handling and improve code safety Co-authored-by: lachlangrose <7371904+lachlangrose@users.noreply.github.com> * Final code quality improvements and null-safe handling Co-authored-by: lachlangrose <7371904+lachlangrose@users.noreply.github.com> * Propagate project crs change to widget * use logging rather than warning --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lachlangrose <7371904+lachlangrose@users.noreply.github.com> Co-authored-by: Lachlan Grose <lachlan.grose@monash.edu>
1 parent 6dec6e2 commit 1bef056

File tree

6 files changed

+566
-9
lines changed

6 files changed

+566
-9
lines changed

loopstructural/gui/modelling/geological_model_tab/geological_model_tab.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,26 @@ def initialize_model(self):
150150
"Please set the bounding box before initializing the model.",
151151
)
152152
return
153+
154+
# Validate model CRS
155+
if not self.data_manager.is_model_crs_valid():
156+
crs = self.data_manager.get_model_crs()
157+
if crs is None or not crs.isValid():
158+
msg = "Model CRS is not set or invalid. Please select a valid projected CRS in the Model Definition tab."
159+
else:
160+
# Safely get CRS description
161+
try:
162+
crs_desc = crs.description() or crs.authid() or "Unknown"
163+
except Exception:
164+
crs_desc = crs.authid() if hasattr(crs, 'authid') else "Unknown"
165+
msg = f"Model CRS must be projected (in meters), not geographic.\nSelected CRS: {crs_desc}\n\nPlease select a valid projected CRS in the Model Definition tab."
166+
167+
QMessageBox.critical(
168+
self,
169+
"Invalid Model CRS",
170+
msg,
171+
)
172+
return
153173

154174
# create progress dialog (indeterminate)
155175
progress = QProgressDialog("Updating geological model...", "Cancel", 0, 0, self)

loopstructural/gui/modelling/model_definition/bounding_box.py

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import numpy as np
44
from PyQt5.QtWidgets import QWidget
5+
from qgis.core import QgsProject
56
from qgis.PyQt import uic
67

78
from loopstructural.main.data_manager import default_bounding_box
@@ -13,6 +14,8 @@ def __init__(self, parent=None, data_manager=None):
1314
super().__init__(parent)
1415
ui_path = os.path.join(os.path.dirname(__file__), "bounding_box.ui")
1516
uic.loadUi(ui_path, self)
17+
18+
# Connect bounding box spinbox signals
1619
self.originXSpinBox.valueChanged.connect(lambda x: self.onChangeExtent({'xmin': x}))
1720
self.maxXSpinBox.valueChanged.connect(lambda x: self.onChangeExtent({'xmax': x}))
1821
self.originYSpinBox.valueChanged.connect(lambda y: self.onChangeExtent({'ymin': y}))
@@ -21,9 +24,148 @@ def __init__(self, parent=None, data_manager=None):
2124
self.maxZSpinBox.valueChanged.connect(lambda z: self.onChangeExtent({'zmax': z}))
2225
self.useCurrentViewExtentButton.clicked.connect(self.useCurrentViewExtent)
2326
self.selectFromCurrentLayerButton.clicked.connect(self.selectFromCurrentLayer)
27+
28+
# Connect CRS control signals
29+
self.useProjectCrsRadioButton.toggled.connect(self.onCrsSourceChanged)
30+
self.useCustomCrsRadioButton.toggled.connect(self.onCrsSourceChanged)
31+
self.crsSelector.crsChanged.connect(self.onCrsChanged)
32+
33+
# Set up callbacks
2434
self.data_manager.set_bounding_box_update_callback(self.set_bounding_box)
35+
self.data_manager.set_model_crs_callback(self.update_crs_ui)
36+
37+
# Initialize CRS UI
38+
self.initialize_crs_ui()
2539
self._update_bounding_box_styles()
2640

41+
# Connect to project CRS changes so the widget updates when the project's CRS changes
42+
try:
43+
project = getattr(self.data_manager, 'project', None) or QgsProject.instance()
44+
project.crsChanged.connect(self._onProjectCrsChanged)
45+
except Exception:
46+
# If the signal isn't available or connection fails, ignore to keep widget functional
47+
pass
48+
49+
def initialize_crs_ui(self):
50+
"""Initialize CRS controls with current settings."""
51+
# Set initial CRS selector value
52+
crs = self.data_manager.get_model_crs()
53+
if crs is not None and crs.isValid():
54+
self.crsSelector.setCrs(crs)
55+
else:
56+
# Default to project CRS
57+
self.crsSelector.setCrs(self.data_manager.project.crs())
58+
59+
# Set radio button based on use_project_crs setting
60+
if self.data_manager._use_project_crs:
61+
self.useProjectCrsRadioButton.setChecked(True)
62+
else:
63+
self.useCustomCrsRadioButton.setChecked(True)
64+
65+
self.validate_crs()
66+
67+
def onCrsSourceChanged(self):
68+
"""Handle change in CRS source (project vs custom)."""
69+
use_project_crs = self.useProjectCrsRadioButton.isChecked()
70+
self.crsSelector.setEnabled(not use_project_crs)
71+
72+
if use_project_crs:
73+
# Use project CRS
74+
success, msg = self.data_manager.set_model_crs(None, use_project_crs=True)
75+
else:
76+
# Use custom CRS
77+
crs = self.crsSelector.crs()
78+
success, msg = self.data_manager.set_model_crs(crs, use_project_crs=False)
79+
80+
self.validate_crs()
81+
82+
def onCrsChanged(self):
83+
"""Handle change in custom CRS selection."""
84+
if self.useCustomCrsRadioButton.isChecked():
85+
crs = self.crsSelector.crs()
86+
success, msg = self.data_manager.set_model_crs(crs, use_project_crs=False)
87+
self.validate_crs()
88+
89+
def update_crs_ui(self, crs, use_project_crs):
90+
"""Update UI when model CRS changes externally.
91+
92+
Parameters
93+
----------
94+
crs : QgsCoordinateReferenceSystem or None
95+
The new model CRS
96+
use_project_crs : bool
97+
Whether to use project CRS
98+
"""
99+
# Block signals to avoid recursive updates
100+
self.useProjectCrsRadioButton.blockSignals(True)
101+
self.useCustomCrsRadioButton.blockSignals(True)
102+
self.crsSelector.blockSignals(True)
103+
104+
try:
105+
if use_project_crs:
106+
self.useProjectCrsRadioButton.setChecked(True)
107+
self.crsSelector.setEnabled(False)
108+
self.crsSelector.setCrs(crs)
109+
110+
else:
111+
self.useCustomCrsRadioButton.setChecked(True)
112+
self.crsSelector.setEnabled(True)
113+
if crs is not None and crs.isValid():
114+
self.crsSelector.setCrs(crs)
115+
116+
self.validate_crs()
117+
finally:
118+
# Unblock signals
119+
self.useProjectCrsRadioButton.blockSignals(False)
120+
self.useCustomCrsRadioButton.blockSignals(False)
121+
self.crsSelector.blockSignals(False)
122+
123+
def _onProjectCrsChanged(self, crs=None):
124+
"""Handle project CRS changes and update UI when the widget is using the project CRS.
125+
126+
Accept an optional `crs` argument because different QGIS versions may emit the
127+
new CRS or emit no arguments when the project's CRS changes.
128+
"""
129+
# If the signal didn't provide a CRS, try to obtain it from the project's current CRS
130+
if crs is None:
131+
try:
132+
project = getattr(self.data_manager, 'project', None) or QgsProject.instance()
133+
crs = project.crs()
134+
except Exception:
135+
crs = None
136+
137+
# Only update the UI if the model is configured to use the project CRS
138+
try:
139+
if getattr(self.data_manager, '_use_project_crs', False):
140+
# Update the UI to reflect the new project CRS
141+
self.update_crs_ui(crs, use_project_crs=True)
142+
except Exception:
143+
pass
144+
145+
def validate_crs(self):
146+
"""Validate the selected CRS and update warning label."""
147+
crs = self.data_manager.get_model_crs()
148+
149+
if crs is None or not crs.isValid():
150+
self.crsWarningLabel.setText("⚠ Invalid CRS selected. Model cannot be initialized.")
151+
return False
152+
153+
if crs.isGeographic():
154+
# Safely get CRS description
155+
try:
156+
crs_desc = crs.description() or crs.authid() or "Unknown"
157+
except Exception:
158+
crs_desc = crs.authid() if hasattr(crs, 'authid') else "Unknown"
159+
160+
self.crsWarningLabel.setText(
161+
f"⚠ CRS must be projected (in meters), not geographic.\n" f"Selected: {crs_desc}"
162+
)
163+
return False
164+
165+
# CRS is valid and projected
166+
self.crsWarningLabel.setText("")
167+
return True
168+
27169
def set_bounding_box(self, bounding_box):
28170
"""Populate UI controls with values from a BoundingBox object.
29171

loopstructural/gui/modelling/model_definition/bounding_box.ui

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,52 @@
162162
</item>
163163
</layout>
164164
</item>
165+
<item>
166+
<widget class="QGroupBox" name="crsGroupBox">
167+
<property name="title">
168+
<string>Coordinate Reference System (CRS)</string>
169+
</property>
170+
<layout class="QVBoxLayout" name="crsVerticalLayout">
171+
<item>
172+
<widget class="QRadioButton" name="useProjectCrsRadioButton">
173+
<property name="text">
174+
<string>Use Project CRS</string>
175+
</property>
176+
<property name="checked">
177+
<bool>true</bool>
178+
</property>
179+
</widget>
180+
</item>
181+
<item>
182+
<widget class="QRadioButton" name="useCustomCrsRadioButton">
183+
<property name="text">
184+
<string>Use Custom CRS</string>
185+
</property>
186+
</widget>
187+
</item>
188+
<item>
189+
<widget class="QgsProjectionSelectionWidget" name="crsSelector">
190+
<property name="enabled">
191+
<bool>false</bool>
192+
</property>
193+
</widget>
194+
</item>
195+
<item>
196+
<widget class="QLabel" name="crsWarningLabel">
197+
<property name="text">
198+
<string/>
199+
</property>
200+
<property name="wordWrap">
201+
<bool>true</bool>
202+
</property>
203+
<property name="styleSheet">
204+
<string>color: red; font-weight: bold;</string>
205+
</property>
206+
</widget>
207+
</item>
208+
</layout>
209+
</widget>
210+
</item>
165211
<item>
166212
<widget class="QPushButton" name="selectFromCurrentLayerButton">
167213
<property name="text">
@@ -185,6 +231,13 @@
185231
</item>
186232
</layout>
187233
</widget>
234+
<customwidgets>
235+
<customwidget>
236+
<class>QgsProjectionSelectionWidget</class>
237+
<extends>QWidget</extends>
238+
<header>qgis.gui</header>
239+
</customwidget>
240+
</customwidgets>
188241
<resources/>
189242
<connections/>
190243
</ui>

0 commit comments

Comments
 (0)