From 937cc63034486b8859de8156a2e87d8ea665d7a3 Mon Sep 17 00:00:00 2001 From: Tomas Mizera Date: Mon, 23 Feb 2026 09:38:52 +0100 Subject: [PATCH 01/24] Prototype for vertical CRS setup --- Mergin/project_settings_widget.py | 254 +++++++++++++++++++++++++++++- Mergin/ui/ui_project_config.ui | 58 +++++++ 2 files changed, 310 insertions(+), 2 deletions(-) diff --git a/Mergin/project_settings_widget.py b/Mergin/project_settings_widget.py index 67027ffa..91db2b39 100644 --- a/Mergin/project_settings_widget.py +++ b/Mergin/project_settings_widget.py @@ -3,10 +3,12 @@ import json import os +import re import typing +import urllib.request from qgis.PyQt import uic from qgis.PyQt.QtGui import QIcon, QColor -from qgis.PyQt.QtCore import Qt +from qgis.PyQt.QtCore import Qt, QTimer from qgis.PyQt.QtWidgets import QFileDialog, QMessageBox from qgis.core import ( QgsProject, @@ -16,8 +18,19 @@ QgsFeatureRequest, QgsExpression, QgsMapLayer, + QgsCoordinateReferenceSystem, + QgsProjUtils, + QgsDatumTransform, + QgsTask, + QgsApplication, +) +from qgis.gui import ( + QgsOptionsWidgetFactory, + QgsOptionsPageWidget, + QgsColorButton, + QgsProjectionSelectionWidget, + QgsCoordinateReferenceSystemProxyModel, ) -from qgis.gui import QgsOptionsWidgetFactory, QgsOptionsPageWidget, QgsColorButton from .attachment_fields_model import AttachmentFieldsModel from .utils import ( mm_symbol_path, @@ -37,6 +50,36 @@ ui_file = os.path.join(os.path.dirname(os.path.realpath(__file__)), "ui", "ui_project_config.ui") ProjectConfigUiWidget, _ = uic.loadUiType(ui_file) +# Matches both PROJ4-style (+geoidgrids=file.tif) and pipeline-style (+grids=file.tif). +_PROJ_GRIDS_RE = re.compile(r"\b(?:geoidgrids|grids)=([\w./@,]+)") +_SKIP_GRID_NAMES = {"@null", "null", "@none", "none"} + + +class _GridRef: + """Minimal grid reference parsed from a PROJ string (no download URL).""" + + __slots__ = ("shortName", "url") + + def __init__(self, name): + self.shortName = name + self.url = "" + + +def _grids_from_proj_string(proj_str): + """Extract grid references from a PROJ string. + + Covers two cases: + - PROJ pipeline style: +proj=vgridshift +grids=us_nga_egm96_15.tif + - PROJ4 style: +geoidgrids=us_nga_egm08_25.tif + """ + result = [] + for m in _PROJ_GRIDS_RE.finditer(proj_str or ""): + for name in m.group(1).split(","): + name = name.strip() + if name and name not in _SKIP_GRID_NAMES: + result.append(_GridRef(name)) + return result + class MerginProjectConfigFactory(QgsOptionsWidgetFactory): def __init__(self): @@ -133,6 +176,33 @@ def __init__(self, parent=None): self.attachment_fields.selectionModel().currentChanged.connect(self.update_expression_edit) self.edit_photo_expression.expressionChanged.connect(self.expression_changed) + # Vertical CRS section + self.cmb_vertical_crs.setFilters(QgsCoordinateReferenceSystemProxyModel.FilterVertical) + self.cmb_vertical_crs.setOptionVisible(QgsProjectionSelectionWidget.CurrentCrs, False) + self.cmb_vertical_crs.setDialogTitle("Target Vertical CRS") + self.label_vcrs_warning.setVisible(False) + self.label_vcrs_warning.setOpenExternalLinks(False) + self.label_vcrs_warning.linkActivated.connect(self._download_geoid_grid) + + skip, ok = QgsProject.instance().readBoolEntry("Mergin", "SkipElevationTransformation", True) + use_vcrs = not skip + self.chk_use_vertical_crs.setChecked(use_vcrs) + self.cmb_vertical_crs.setEnabled(use_vcrs) + + vcrs_wkt, ok = QgsProject.instance().readEntry("Mergin", "TargetVerticalCRS") + if ok and vcrs_wkt: + crs = QgsCoordinateReferenceSystem.fromWkt(vcrs_wkt) + if crs.isValid(): + self.cmb_vertical_crs.setCrs(crs) + + self._pending_grids = [] + self.chk_use_vertical_crs.stateChanged.connect(self._vcrs_checkbox_changed) + self.cmb_vertical_crs.crsChanged.connect(self._check_geoid_grid) + + # Run initial grid check if already enabled + if use_vcrs and vcrs_wkt: + self._check_geoid_grid(self.cmb_vertical_crs.crs()) + def get_sync_dir(self): abs_path = QFileDialog.getExistingDirectory( None, @@ -310,6 +380,178 @@ def setup_map_sketches(self): # create a new layer and add it as a map sketches layer create_map_sketches_layer(QgsProject.instance().absolutePath()) + def _vcrs_checkbox_changed(self, state): + enabled = self.chk_use_vertical_crs.isChecked() + self.cmb_vertical_crs.setEnabled(enabled) + if enabled: + self._check_geoid_grid(self.cmb_vertical_crs.crs()) + else: + self.label_vcrs_warning.setVisible(False) + + def _check_geoid_grid(self, crs): + """ + Check whether all grids required by at least one transformation operation from + WGS84 3D to the selected vertical CRS are present in the PROJ search paths. + Operations are alternatives — only one needs to be satisfied. + """ + self.label_vcrs_warning.setVisible(False) + self._pending_grids = [] + if not crs.isValid() or not self.chk_use_vertical_crs.isChecked(): + return + + # Validate CRS kind using WKT keywords (works across all QGIS versions; + # setFilter is not functional in all versions so we enforce the type here). + # WKT1: VERT_CS / COMPD_CS — WKT2: VERTCRS / COMPOUNDCRS + wkt = crs.toWkt() + if not wkt.startswith(("VERT_CS[", "COMPD_CS[", "VERTCRS[", "COMPOUNDCRS[")): + self.label_vcrs_warning.setText( + 'The selected CRS is not a vertical or compound CRS. ' + "Please select a vertical CRS (e.g. EGM2008 height) or a compound CRS " + "(e.g. WGS 84 + EGM96 height)." + ) + self.label_vcrs_warning.setVisible(True) + return + + wgs84_3d = QgsCoordinateReferenceSystem("EPSG:4979") + operations = QgsDatumTransform.operations(wgs84_3d, crs) + + def grid_available(grid): + # When a local project exists, only the project's proj/ folder counts — + # a system-wide PROJ installation is not sufficient for mobile sync. + if self.local_project_dir: + return os.path.exists(os.path.join(self.local_project_dir, "proj", grid.shortName)) + # No local project: fall back to any PROJ search path. + return any(os.path.exists(os.path.join(p, grid.shortName)) for p in QgsProjUtils.searchPaths()) + + # Level 1: operations that report grids via the proper QgsDatumTransform API. + ops_with_grids = [(op, list(op.grids)) for op in operations if op.grids] + + if not ops_with_grids: + # Level 2: some PROJ versions / ESRI-coded CRS leave op.grids empty but embed + # the grid name in the PROJ pipeline string — parse those as a fallback. + for op in operations: + parsed = _grids_from_proj_string(getattr(op, "proj", "") or "") + if parsed: + ops_with_grids.append((op, parsed)) + + if not ops_with_grids: + # Level 3: no operation has grid info at all — try the CRS's own PROJ string. + # Vertical CRS sometimes embed the geoid grid directly in their definition. + parsed = _grids_from_proj_string(crs.toProj()) + if parsed: + ops_with_grids = [(None, parsed)] + + if not ops_with_grids: + # No geoid grid data found at any level. PROJ uses a ballpark (approximate) + # transformation for this CRS — it does not know which geoid file is required. + # Show an advisory so the user is aware heights may be inaccurate. + self.label_vcrs_warning.setText( + 'Note: PROJ does not have geoid grid data for the selected vertical CRS. ' + "Heights reported in the field app may not accurately represent heights above the geoid. " + "If you have the appropriate geoid file, add it to your project\u2019s proj/ folder " + "and install it via the QGIS Resource Manager." + ) + self.label_vcrs_warning.setVisible(True) + return + + # If any single operation has all its grids locally available, nothing to do. + for _op, grids in ops_with_grids: + if all(grid_available(g) for g in grids): + return + + # No operation is fully satisfied. Pick the best one for the warning: + # prefer ops where all missing grids are downloadable (have a url), then fewest missing. + def op_score(item): + _, grids = item + missing = [g for g in grids if not grid_available(g)] + return (not all(g.url for g in missing), len(missing)) + + _, best_grids = min(ops_with_grids, key=op_score) + missing = [g for g in best_grids if not grid_available(g)] + if not missing: + return + + self._pending_grids = missing # may contain _GridRef instances (no URL) when parsed from PROJ string + names = ", ".join(g.shortName for g in missing) + self.label_vcrs_warning.setText( + f'The selected vertical CRS requires the following geoid grid(s) ' + f"in order to work properly \u2013 {names}. " + f'Click here to automatically ' + f"download it and add to your project." + ) + self.label_vcrs_warning.setVisible(True) + + def _download_geoid_grid(self, link): + if not self._pending_grids: + return + + no_url = [g.shortName for g in self._pending_grids if not g.url] + downloadable = [g for g in self._pending_grids if g.url] + + if no_url: + QMessageBox.warning( + self, + "Cannot download automatically", + "The following grid(s) have no download URL and must be installed manually " + "via the QGIS Resource Manager or by installing a PROJ data package:\n\n" + ", ".join(no_url), + ) + if not downloadable: + return + + if not self.local_project_dir: + urls = "\n".join(g.url for g in downloadable) + QMessageBox.information( + self, + "Download geoid grid", + f"Please download the geoid grid(s) manually and place them in your project's 'proj/' folder:\n{urls}", + ) + return + + dest_dir = os.path.join(self.local_project_dir, "proj") + # Capture data needed by the background task (no closure over self). + grids_snapshot = [(g.shortName, g.url) for g in downloadable] + + # Animate the warning label while the download runs. + self._download_dot_count = 0 + + def _tick(): + self._download_dot_count = (self._download_dot_count + 1) % 4 + dots = "." * self._download_dot_count + self.label_vcrs_warning.setText(f'Downloading geoid grid(s){dots}') + + self._download_timer = QTimer(self) + self._download_timer.timeout.connect(_tick) + _tick() + self._download_timer.start(400) + + def run_download(task): + os.makedirs(dest_dir, exist_ok=True) + failed = [] + for i, (name, url) in enumerate(grids_snapshot): + if task.isCanceled(): + break + try: + urllib.request.urlretrieve(url, os.path.join(dest_dir, name)) + except Exception as e: + failed.append(f"{name}: {e}") + task.setProgress((i + 1) / len(grids_snapshot) * 100) + return {"failed": failed} + + def on_finished(exception, result): + self._download_timer.stop() + if exception: + QMessageBox.warning(self, "Download failed", f"Download error: {exception}") + elif result and result["failed"]: + QMessageBox.warning(self, "Download failed", "Could not download:\n" + "\n".join(result["failed"])) + else: + self.label_vcrs_warning.setVisible(False) + QMessageBox.information( + self, "Download complete", "Geoid grid(s) downloaded and added to your project." + ) + + self._download_task = QgsTask.fromFunction("Downloading geoid grid(s)", run_download, on_finished=on_finished) + QgsApplication.taskManager().addTask(self._download_task) + def apply(self): QgsProject.instance().writeEntry("Mergin", "PhotoQuality", self.cmb_photo_quality.currentData()) QgsProject.instance().writeEntry("Mergin", "Snapping", self.cmb_snapping_mode.currentData()) @@ -351,6 +593,14 @@ def apply(self): self.setup_tracking() self.setup_map_sketches() + use_vcrs = self.chk_use_vertical_crs.isChecked() + QgsProject.instance().writeEntry("Mergin", "SkipElevationTransformation", not use_vcrs) + if use_vcrs: + crs = self.cmb_vertical_crs.crs() + QgsProject.instance().writeEntry("Mergin", "TargetVerticalCRS", crs.toWkt() if crs.isValid() else "") + else: + QgsProject.instance().writeEntry("Mergin", "TargetVerticalCRS", "") + def colors_change_state(self) -> None: """ Enable/disable color buttons based on the state of the map sketches checkbox. diff --git a/Mergin/ui/ui_project_config.ui b/Mergin/ui/ui_project_config.ui index e29310cc..d076555b 100644 --- a/Mergin/ui/ui_project_config.ui +++ b/Mergin/ui/ui_project_config.ui @@ -500,6 +500,58 @@ + + + + Vertical CRS + + + + + + <html><head/><body><p>Configure a vertical coordinate reference system for height reporting in the mobile app. <a href="https://merginmaps.com/docs"><span style=" text-decoration: underline; color:#1d99f3;">Read more here.</span></a></p></body></html> + + + true + + + true + + + + + + + Report heights in a specific vertical CRS + + + + + + + Vertical CRS + + + + + + + + + + + + + true + + + false + + + + + + @@ -531,6 +583,12 @@
qgsexpressionlineedit.h
1 + + QgsProjectionSelectionWidget + QWidget +
qgsprojectionselectionwidget.h
+ 1 +
chk_sync_enabled From e683c9fd8130541995b89cbaed3b35585afd6c8a Mon Sep 17 00:00:00 2001 From: Herman Snevajs Date: Fri, 27 Feb 2026 23:57:46 +0100 Subject: [PATCH 02/24] validations, move to utils, callbacks --- Mergin/project_settings_widget.py | 159 +++++++----------------------- Mergin/project_status_dialog.py | 57 ++++++++++- Mergin/utils.py | 133 +++++++++++++++++++++++++ Mergin/validation.py | 28 ++++++ 4 files changed, 250 insertions(+), 127 deletions(-) diff --git a/Mergin/project_settings_widget.py b/Mergin/project_settings_widget.py index 91db2b39..6dcfaffa 100644 --- a/Mergin/project_settings_widget.py +++ b/Mergin/project_settings_widget.py @@ -3,9 +3,7 @@ import json import os -import re import typing -import urllib.request from qgis.PyQt import uic from qgis.PyQt.QtGui import QIcon, QColor from qgis.PyQt.QtCore import Qt, QTimer @@ -19,10 +17,6 @@ QgsExpression, QgsMapLayer, QgsCoordinateReferenceSystem, - QgsProjUtils, - QgsDatumTransform, - QgsTask, - QgsApplication, ) from qgis.gui import ( QgsOptionsWidgetFactory, @@ -45,41 +39,13 @@ qvariant_to_string, escape_html_minimal, sanitize_path, + get_missing_geoid_grids, + download_grids_task, ) ui_file = os.path.join(os.path.dirname(os.path.realpath(__file__)), "ui", "ui_project_config.ui") ProjectConfigUiWidget, _ = uic.loadUiType(ui_file) -# Matches both PROJ4-style (+geoidgrids=file.tif) and pipeline-style (+grids=file.tif). -_PROJ_GRIDS_RE = re.compile(r"\b(?:geoidgrids|grids)=([\w./@,]+)") -_SKIP_GRID_NAMES = {"@null", "null", "@none", "none"} - - -class _GridRef: - """Minimal grid reference parsed from a PROJ string (no download URL).""" - - __slots__ = ("shortName", "url") - - def __init__(self, name): - self.shortName = name - self.url = "" - - -def _grids_from_proj_string(proj_str): - """Extract grid references from a PROJ string. - - Covers two cases: - - PROJ pipeline style: +proj=vgridshift +grids=us_nga_egm96_15.tif - - PROJ4 style: +geoidgrids=us_nga_egm08_25.tif - """ - result = [] - for m in _PROJ_GRIDS_RE.finditer(proj_str or ""): - for name in m.group(1).split(","): - name = name.strip() - if name and name not in _SKIP_GRID_NAMES: - result.append(_GridRef(name)) - return result - class MerginProjectConfigFactory(QgsOptionsWidgetFactory): def __init__(self): @@ -176,7 +142,7 @@ def __init__(self, parent=None): self.attachment_fields.selectionModel().currentChanged.connect(self.update_expression_edit) self.edit_photo_expression.expressionChanged.connect(self.expression_changed) - # Vertical CRS section + # Vertical CRS self.cmb_vertical_crs.setFilters(QgsCoordinateReferenceSystemProxyModel.FilterVertical) self.cmb_vertical_crs.setOptionVisible(QgsProjectionSelectionWidget.CurrentCrs, False) self.cmb_vertical_crs.setDialogTitle("Target Vertical CRS") @@ -199,7 +165,7 @@ def __init__(self, parent=None): self.chk_use_vertical_crs.stateChanged.connect(self._vcrs_checkbox_changed) self.cmb_vertical_crs.crsChanged.connect(self._check_geoid_grid) - # Run initial grid check if already enabled + # run initial grid check if already enabled if use_vcrs and vcrs_wkt: self._check_geoid_grid(self.cmb_vertical_crs.crs()) @@ -390,18 +356,14 @@ def _vcrs_checkbox_changed(self, state): def _check_geoid_grid(self, crs): """ - Check whether all grids required by at least one transformation operation from - WGS84 3D to the selected vertical CRS are present in the PROJ search paths. - Operations are alternatives — only one needs to be satisfied. + Evaluates the selected vertical CRS to determine if a PROJ transformation grid + is required for accurate elevation calculation in the mobile app. """ self.label_vcrs_warning.setVisible(False) self._pending_grids = [] if not crs.isValid() or not self.chk_use_vertical_crs.isChecked(): return - # Validate CRS kind using WKT keywords (works across all QGIS versions; - # setFilter is not functional in all versions so we enforce the type here). - # WKT1: VERT_CS / COMPD_CS — WKT2: VERTCRS / COMPOUNDCRS wkt = crs.toWkt() if not wkt.startswith(("VERT_CS[", "COMPD_CS[", "VERTCRS[", "COMPOUNDCRS[")): self.label_vcrs_warning.setText( @@ -412,66 +374,23 @@ def _check_geoid_grid(self, crs): self.label_vcrs_warning.setVisible(True) return - wgs84_3d = QgsCoordinateReferenceSystem("EPSG:4979") - operations = QgsDatumTransform.operations(wgs84_3d, crs) - - def grid_available(grid): - # When a local project exists, only the project's proj/ folder counts — - # a system-wide PROJ installation is not sufficient for mobile sync. - if self.local_project_dir: - return os.path.exists(os.path.join(self.local_project_dir, "proj", grid.shortName)) - # No local project: fall back to any PROJ search path. - return any(os.path.exists(os.path.join(p, grid.shortName)) for p in QgsProjUtils.searchPaths()) - - # Level 1: operations that report grids via the proper QgsDatumTransform API. - ops_with_grids = [(op, list(op.grids)) for op in operations if op.grids] - - if not ops_with_grids: - # Level 2: some PROJ versions / ESRI-coded CRS leave op.grids empty but embed - # the grid name in the PROJ pipeline string — parse those as a fallback. - for op in operations: - parsed = _grids_from_proj_string(getattr(op, "proj", "") or "") - if parsed: - ops_with_grids.append((op, parsed)) - - if not ops_with_grids: - # Level 3: no operation has grid info at all — try the CRS's own PROJ string. - # Vertical CRS sometimes embed the geoid grid directly in their definition. - parsed = _grids_from_proj_string(crs.toProj()) - if parsed: - ops_with_grids = [(None, parsed)] - - if not ops_with_grids: - # No geoid grid data found at any level. PROJ uses a ballpark (approximate) - # transformation for this CRS — it does not know which geoid file is required. - # Show an advisory so the user is aware heights may be inaccurate. + grid_status = get_missing_geoid_grids(crs, self.local_project_dir) + + if grid_status["ballpark"]: self.label_vcrs_warning.setText( 'Note: PROJ does not have geoid grid data for the selected vertical CRS. ' "Heights reported in the field app may not accurately represent heights above the geoid. " - "If you have the appropriate geoid file, add it to your project\u2019s proj/ folder " + "If you have the appropriate geoid file, add it to your project\u2019s 'proj/' folder " "and install it via the QGIS Resource Manager." ) self.label_vcrs_warning.setVisible(True) return - # If any single operation has all its grids locally available, nothing to do. - for _op, grids in ops_with_grids: - if all(grid_available(g) for g in grids): - return - - # No operation is fully satisfied. Pick the best one for the warning: - # prefer ops where all missing grids are downloadable (have a url), then fewest missing. - def op_score(item): - _, grids = item - missing = [g for g in grids if not grid_available(g)] - return (not all(g.url for g in missing), len(missing)) - - _, best_grids = min(ops_with_grids, key=op_score) - missing = [g for g in best_grids if not grid_available(g)] + missing = grid_status["missing"] if not missing: return - self._pending_grids = missing # may contain _GridRef instances (no URL) when parsed from PROJ string + self._pending_grids = missing names = ", ".join(g.shortName for g in missing) self.label_vcrs_warning.setText( f'The selected vertical CRS requires the following geoid grid(s) ' @@ -482,6 +401,14 @@ def op_score(item): self.label_vcrs_warning.setVisible(True) def _download_geoid_grid(self, link): + """ + Triggered when the user clicks the download link in the missing grid warning label. + + Initiates a background QgsTask to download the required PROJ grids + from the official CDN directly into the project's 'proj/' directory. + + :param link: The href string of the clicked HTML link. + """ if not self._pending_grids: return @@ -507,11 +434,7 @@ def _download_geoid_grid(self, link): ) return - dest_dir = os.path.join(self.local_project_dir, "proj") - # Capture data needed by the background task (no closure over self). - grids_snapshot = [(g.shortName, g.url) for g in downloadable] - - # Animate the warning label while the download runs. + # start UI animation self._download_dot_count = 0 def _tick(): @@ -524,33 +447,23 @@ def _tick(): _tick() self._download_timer.start(400) - def run_download(task): - os.makedirs(dest_dir, exist_ok=True) - failed = [] - for i, (name, url) in enumerate(grids_snapshot): - if task.isCanceled(): - break - try: - urllib.request.urlretrieve(url, os.path.join(dest_dir, name)) - except Exception as e: - failed.append(f"{name}: {e}") - task.setProgress((i + 1) / len(grids_snapshot) * 100) - return {"failed": failed} - - def on_finished(exception, result): + # callbacks + def on_success(): self._download_timer.stop() - if exception: - QMessageBox.warning(self, "Download failed", f"Download error: {exception}") - elif result and result["failed"]: - QMessageBox.warning(self, "Download failed", "Could not download:\n" + "\n".join(result["failed"])) - else: - self.label_vcrs_warning.setVisible(False) - QMessageBox.information( - self, "Download complete", "Geoid grid(s) downloaded and added to your project." - ) + self.label_vcrs_warning.setVisible(False) + QMessageBox.information(self, "Download complete", "Geoid grid(s) downloaded and added to your project.") + # re-trigger the check to update the UI state + self._check_geoid_grid(self.cmb_vertical_crs.crs()) + + def on_error(errors): + self._download_timer.stop() + QMessageBox.warning(self, "Download failed", "Could not download:\n" + "\n".join(errors)) + # re-trigger the check to reset the label text back + self._check_geoid_grid(self.cmb_vertical_crs.crs()) - self._download_task = QgsTask.fromFunction("Downloading geoid grid(s)", run_download, on_finished=on_finished) - QgsApplication.taskManager().addTask(self._download_task) + # fire the task + dest_dir = os.path.join(self.local_project_dir, "proj") + self._download_task = download_grids_task(downloadable, dest_dir, on_success, on_error) def apply(self): QgsProject.instance().writeEntry("Mergin", "PhotoQuality", self.cmb_photo_quality.currentData()) diff --git a/Mergin/project_status_dialog.py b/Mergin/project_status_dialog.py index b2675062..39ccc387 100644 --- a/Mergin/project_status_dialog.py +++ b/Mergin/project_status_dialog.py @@ -17,7 +17,7 @@ from qgis.PyQt.QtCore import Qt from qgis.PyQt.QtGui import QStandardItemModel, QStandardItem, QIcon from qgis.gui import QgsGui -from qgis.core import Qgis, QgsApplication, QgsProject +from qgis.core import Qgis, QgsApplication, QgsProject, QgsCoordinateReferenceSystem from qgis.utils import OverrideCursor from .diff_dialog import DiffViewerDialog from .validation import ( @@ -31,6 +31,8 @@ icon_path, unsaved_project_check, UnsavedChangesStrategy, + get_missing_geoid_grids, + download_grids_task, ) from .repair import fix_datum_shift_grids, fix_project_home_path, activate_expression @@ -252,14 +254,17 @@ def link_clicked(self, url): if msg is not None: self.ui.messageBar.pushMessage("Mergin", f"Failed to fix issue: {msg}", Qgis.Warning) return - if parsed_url.path == "reset_file": + elif parsed_url.path == "reset_file": query_parameters = parse_qs(parsed_url.query) self.reset_local_changes(query_parameters["layer"][0]) - if parsed_url.path == "fix_project_home_path": + elif parsed_url.path == "fix_project_home_path": fix_project_home_path() - if parsed_url.path == "activate_expression": + elif parsed_url.path == "activate_expression": query_parameters = parse_qs(parsed_url.query) activate_expression(query_parameters["layer_id"][0], query_parameters["field_name"][0]) + elif parsed_url.path == "download_vcrs_grids": + self.download_vcrs_grids() + return self.validate_project() def validate_project(self): @@ -289,3 +294,47 @@ def reset_local_changes(self, file_to_reset=None): if btn_reply != QMessageBox.StandardButton.Yes: return return self.done(self.RESET_CHANGES) + + def download_vcrs_grids(self): + """ + Triggered when the user clicks the download link in the validation warning. + """ + project_path = QgsProject.instance().fileName() + if not project_path: + QMessageBox.warning(self, "Project Not Saved", "Please save your project first.") + return + + project_dir = os.path.dirname(project_path) + + vcrs_wkt, ok = QgsProject.instance().readEntry("Mergin", "TargetVerticalCRS") + if not ok or not vcrs_wkt: + return + + crs = QgsCoordinateReferenceSystem.fromWkt(vcrs_wkt) + if not crs.isValid(): + return + + # what is missing right now + status = get_missing_geoid_grids(crs, project_dir) + missing_grids = status.get("missing", []) + downloadable = [g for g in missing_grids if g.url] + + if not downloadable: + QMessageBox.information(self, "Info", "No downloadable grids found, or they are already downloaded.") + self.run_validation() # Assuming this is your method to refresh the validation screen + return + + # callbacks + def on_success(): + QMessageBox.information(self, "Success", "Geoid grid(s) successfully added to your project.") + # Instantly re-run validation so the warning disappears and Sync is unblocked! + # self.run_validation() + self.validate_project() + + def on_error(errors): + QMessageBox.warning(self, "Download failed", "Could not download:\n" + "\n".join(errors)) + + # fire the task + dest_dir = os.path.join(project_dir, "proj") + self._download_task = download_grids_task(downloadable, dest_dir, on_success, on_error) + diff --git a/Mergin/utils.py b/Mergin/utils.py index 9b9de2ab..37f2b1c5 100644 --- a/Mergin/utils.py +++ b/Mergin/utils.py @@ -68,6 +68,7 @@ QgsProperty, QgsSymbolLayer, QgsGeometry, + QgsTask, ) from qgis.gui import QgsFileWidget @@ -169,6 +170,10 @@ TILES_URL = "https://tiles.merginmaps.com" +# Matches both PROJ4-style (+geoidgrids=file.tif) and pipeline-style (+grids=file.tif). +_PROJ_GRIDS_RE = re.compile(r"\b(?:geoidgrids|grids)=([\w./@,]+)") +_SKIP_GRID_NAMES = {"@null", "null", "@none", "none"} + class PackagingError(Exception): pass @@ -1775,3 +1780,131 @@ def push_error_message(dlg, project_name, plugin, mc): f"Something went wrong while synchronising your project {project_name}.", mc, ) + + +class _GridRef: + """Minimal grid reference parsed from a PROJ string.""" + __slots__ = ("shortName", "url") + def __init__(self, name, url=""): + self.shortName = name + self.url = url + +def _grids_from_proj_string(proj_str): + """Extract grid references from a PROJ string.""" + result = [] + for m in _PROJ_GRIDS_RE.finditer(proj_str or ""): + for name in m.group(1).split(","): + name = name.strip() + if name and name not in _SKIP_GRID_NAMES: + result.append(_GridRef(name)) + return result + +def get_missing_geoid_grids(crs, local_project_dir): + """ + Checks if the given vertical CRS requires grid files that are missing + from the project's 'proj/' folder. + Returns a dict: {"missing": list of _GridRef, "ballpark": bool} + """ + result = {"missing": [], "ballpark": False} + + if not crs or not crs.isValid(): + return result + + wgs84_3d = QgsCoordinateReferenceSystem("EPSG:4979") + operations = QgsDatumTransform.operations(wgs84_3d, crs) + + def grid_available(grid): + if local_project_dir: + return os.path.exists(os.path.join(local_project_dir, "proj", grid.shortName)) + return False + + ops_with_grids = [(op, list(op.grids)) for op in operations if op.grids] + + if not ops_with_grids: + for op in operations: + parsed = _grids_from_proj_string(getattr(op, "proj", "") or "") + if parsed: + ops_with_grids.append((op, parsed)) + + if not ops_with_grids: + parsed = _grids_from_proj_string(crs.toProj()) + if parsed: + ops_with_grids = [(None, parsed)] + + if not ops_with_grids: + result["ballpark"] = True + return result + + for _op, grids in ops_with_grids: + if all(grid_available(g) for g in grids): + return result # Fully satisfied + + def op_score(item): + _, grids = item + missing = [g for g in grids if not grid_available(g)] + return (not all(g.url for g in missing), len(missing)) + + _, best_grids = min(ops_with_grids, key=op_score) + result["missing"] = [g for g in best_grids if not grid_available(g)] + + return result + + +def download_grids_task(grids, dest_dir, on_success_callback, on_error_callback=None): + """ + Starts a background QgsTask to download PROJ grids without freezing the QGIS UI. + + :param grids: List of _GridRef objects or (name, url) tuples. + :param dest_dir: String path to the target 'proj' directory. + :param on_success_callback: Callable with no arguments triggered on complete success. + :param on_error_callback: Callable accepting a list of error strings. + """ + # normalize the grids input so it safely accepts objects or tuples + grids_snapshot = [] + for g in grids: + if hasattr(g, 'shortName') and hasattr(g, 'url'): + grids_snapshot.append((g.shortName, g.url)) + elif isinstance(g, tuple) and len(g) == 2: + grids_snapshot.append(g) + + if not grids_snapshot: + if on_success_callback: + on_success_callback() + return None + + # Background Worker + def run_download(task): + os.makedirs(dest_dir, exist_ok=True) + failed = [] + + for i, (name, url) in enumerate(grids_snapshot): + if task.isCanceled(): + failed.append(f"{name}: Download canceled by user.") + break + + try: + urllib.request.urlretrieve(url, os.path.join(dest_dir, name)) + except Exception as e: + failed.append(f"{name}: {str(e)}") + + # Report progress back to the QGIS task manager (0 to 100%) + task.setProgress((i + 1) / len(grids_snapshot) * 100) + + return {"failed": failed} + + # Completion Handler (runs back on the main UI thread) + def on_finished(exception, result): + if exception: + if on_error_callback: + on_error_callback([f"Critical Task Exception: {exception}"]) + elif result and result.get("failed"): + if on_error_callback: + on_error_callback(result["failed"]) + else: + if on_success_callback: + on_success_callback() + + task = QgsTask.fromFunction("Downloading geoid grid(s)", run_download, on_finished=on_finished) + QgsApplication.taskManager().addTask(task) + + return task diff --git a/Mergin/validation.py b/Mergin/validation.py index 1096c510..2d43c598 100644 --- a/Mergin/validation.py +++ b/Mergin/validation.py @@ -13,6 +13,7 @@ QgsExpression, QgsRenderContext, QgsFeatureRequest, + QgsCoordinateReferenceSystem, ) from qgis.gui import QgsFileWidget @@ -30,6 +31,7 @@ get_layer_by_path, invalid_filename_character, is_inside, + get_missing_geoid_grids, ) INVALID_FIELD_NAME_CHARS = re.compile(r'[\\\/\(\)\[\]\{\}"\n\r]') @@ -65,6 +67,7 @@ class Warning(Enum): EDITOR_DIFFBASED_FILE_REMOVED = 27 PROJECT_HOME_PATH = 28 INVALID_ADDED_FILENAME = 29 + MISSING_VCRS_GRID = 30 class MultipleLayersWarning: @@ -132,6 +135,7 @@ def run_checks(self): self.check_svgs_embedded() self.check_editor_perms() self.check_filenames() + self.check_vertical_crs_grids() return self.issues @@ -502,6 +506,28 @@ def check_filenames(self): if invalid_filename_character(file["path"]): self.issues.append(MultipleLayersWarning(Warning.INVALID_ADDED_FILENAME, file["path"])) + def check_vertical_crs_grids(self): + """Check if custom vertical CRS is configured but the PROJ grid is missing from project.""" + skip, ok = QgsProject.instance().readBoolEntry("Mergin", "SkipElevationTransformation", True) + if skip: + return + + vcrs_wkt, ok = QgsProject.instance().readEntry("Mergin", "TargetVerticalCRS") + if not ok or not vcrs_wkt: + return + + crs = QgsCoordinateReferenceSystem.fromWkt(vcrs_wkt) + if not crs.isValid(): + return + + status = get_missing_geoid_grids(crs, self.qgis_proj_dir) + + if status["missing"]: + w = MultipleLayersWarning(Warning.MISSING_VCRS_GRID, details="download_vcrs_grids") + for grid in status["missing"]: + w.items.append(grid.shortName) + self.issues.append(w) + def warning_display_string(warning_id, details=None): """Returns a display string for a corresponding warning""" @@ -591,3 +617,5 @@ def warning_display_string(warning_id, details=None): return "QGIS Project Home Path is specified. Quick fix the issue. (This will unset project home)" elif warning_id == Warning.INVALID_ADDED_FILENAME: return f"You cannot synchronize a file with invalid characters in it's name. Please sanitize the name of this file '{details}'" + elif warning_id == Warning.MISSING_VCRS_GRID: + return f"Required vertical CRS transformation grid is missing from the 'proj/' folder. Click here to automatically download it." From 6181add7728832f4abc6608cd4e7c24c4c2ba811 Mon Sep 17 00:00:00 2001 From: Herman Snevajs Date: Sat, 28 Feb 2026 00:01:43 +0100 Subject: [PATCH 03/24] black --- Mergin/project_settings_widget.py | 6 +++--- Mergin/project_status_dialog.py | 9 +++------ Mergin/utils.py | 26 +++++++++++++++----------- Mergin/validation.py | 2 +- 4 files changed, 22 insertions(+), 21 deletions(-) diff --git a/Mergin/project_settings_widget.py b/Mergin/project_settings_widget.py index 6dcfaffa..88f71724 100644 --- a/Mergin/project_settings_widget.py +++ b/Mergin/project_settings_widget.py @@ -356,7 +356,7 @@ def _vcrs_checkbox_changed(self, state): def _check_geoid_grid(self, crs): """ - Evaluates the selected vertical CRS to determine if a PROJ transformation grid + Evaluates the selected vertical CRS to determine if a PROJ transformation grid is required for accurate elevation calculation in the mobile app. """ self.label_vcrs_warning.setVisible(False) @@ -403,8 +403,8 @@ def _check_geoid_grid(self, crs): def _download_geoid_grid(self, link): """ Triggered when the user clicks the download link in the missing grid warning label. - - Initiates a background QgsTask to download the required PROJ grids + + Initiates a background QgsTask to download the required PROJ grids from the official CDN directly into the project's 'proj/' directory. :param link: The href string of the clicked HTML link. diff --git a/Mergin/project_status_dialog.py b/Mergin/project_status_dialog.py index 39ccc387..605b51ea 100644 --- a/Mergin/project_status_dialog.py +++ b/Mergin/project_status_dialog.py @@ -294,7 +294,7 @@ def reset_local_changes(self, file_to_reset=None): if btn_reply != QMessageBox.StandardButton.Yes: return return self.done(self.RESET_CHANGES) - + def download_vcrs_grids(self): """ Triggered when the user clicks the download link in the validation warning. @@ -303,7 +303,7 @@ def download_vcrs_grids(self): if not project_path: QMessageBox.warning(self, "Project Not Saved", "Please save your project first.") return - + project_dir = os.path.dirname(project_path) vcrs_wkt, ok = QgsProject.instance().readEntry("Mergin", "TargetVerticalCRS") @@ -321,14 +321,12 @@ def download_vcrs_grids(self): if not downloadable: QMessageBox.information(self, "Info", "No downloadable grids found, or they are already downloaded.") - self.run_validation() # Assuming this is your method to refresh the validation screen + self.validate_project() return # callbacks def on_success(): QMessageBox.information(self, "Success", "Geoid grid(s) successfully added to your project.") - # Instantly re-run validation so the warning disappears and Sync is unblocked! - # self.run_validation() self.validate_project() def on_error(errors): @@ -337,4 +335,3 @@ def on_error(errors): # fire the task dest_dir = os.path.join(project_dir, "proj") self._download_task = download_grids_task(downloadable, dest_dir, on_success, on_error) - diff --git a/Mergin/utils.py b/Mergin/utils.py index 37f2b1c5..672a7ea3 100644 --- a/Mergin/utils.py +++ b/Mergin/utils.py @@ -1784,11 +1784,14 @@ def push_error_message(dlg, project_name, plugin, mc): class _GridRef: """Minimal grid reference parsed from a PROJ string.""" + __slots__ = ("shortName", "url") + def __init__(self, name, url=""): self.shortName = name self.url = url + def _grids_from_proj_string(proj_str): """Extract grid references from a PROJ string.""" result = [] @@ -1799,14 +1802,15 @@ def _grids_from_proj_string(proj_str): result.append(_GridRef(name)) return result + def get_missing_geoid_grids(crs, local_project_dir): """ - Checks if the given vertical CRS requires grid files that are missing + Checks if the given vertical CRS requires grid files that are missing from the project's 'proj/' folder. Returns a dict: {"missing": list of _GridRef, "ballpark": bool} """ result = {"missing": [], "ballpark": False} - + if not crs or not crs.isValid(): return result @@ -1837,7 +1841,7 @@ def grid_available(grid): for _op, grids in ops_with_grids: if all(grid_available(g) for g in grids): - return result # Fully satisfied + return result def op_score(item): _, grids = item @@ -1846,14 +1850,14 @@ def op_score(item): _, best_grids = min(ops_with_grids, key=op_score) result["missing"] = [g for g in best_grids if not grid_available(g)] - + return result def download_grids_task(grids, dest_dir, on_success_callback, on_error_callback=None): """ Starts a background QgsTask to download PROJ grids without freezing the QGIS UI. - + :param grids: List of _GridRef objects or (name, url) tuples. :param dest_dir: String path to the target 'proj' directory. :param on_success_callback: Callable with no arguments triggered on complete success. @@ -1862,7 +1866,7 @@ def download_grids_task(grids, dest_dir, on_success_callback, on_error_callback= # normalize the grids input so it safely accepts objects or tuples grids_snapshot = [] for g in grids: - if hasattr(g, 'shortName') and hasattr(g, 'url'): + if hasattr(g, "shortName") and hasattr(g, "url"): grids_snapshot.append((g.shortName, g.url)) elif isinstance(g, tuple) and len(g) == 2: grids_snapshot.append(g) @@ -1876,20 +1880,20 @@ def download_grids_task(grids, dest_dir, on_success_callback, on_error_callback= def run_download(task): os.makedirs(dest_dir, exist_ok=True) failed = [] - + for i, (name, url) in enumerate(grids_snapshot): if task.isCanceled(): failed.append(f"{name}: Download canceled by user.") break - + try: urllib.request.urlretrieve(url, os.path.join(dest_dir, name)) except Exception as e: failed.append(f"{name}: {str(e)}") - + # Report progress back to the QGIS task manager (0 to 100%) task.setProgress((i + 1) / len(grids_snapshot) * 100) - + return {"failed": failed} # Completion Handler (runs back on the main UI thread) @@ -1906,5 +1910,5 @@ def on_finished(exception, result): task = QgsTask.fromFunction("Downloading geoid grid(s)", run_download, on_finished=on_finished) QgsApplication.taskManager().addTask(task) - + return task diff --git a/Mergin/validation.py b/Mergin/validation.py index 2d43c598..5f586134 100644 --- a/Mergin/validation.py +++ b/Mergin/validation.py @@ -521,7 +521,7 @@ def check_vertical_crs_grids(self): return status = get_missing_geoid_grids(crs, self.qgis_proj_dir) - + if status["missing"]: w = MultipleLayersWarning(Warning.MISSING_VCRS_GRID, details="download_vcrs_grids") for grid in status["missing"]: From 427c9003f54de199f152d4af31062c86aaed10d4 Mon Sep 17 00:00:00 2001 From: Jan Caha Date: Thu, 12 Mar 2026 15:23:29 +0100 Subject: [PATCH 04/24] rename to ElevationTransformationEnabled --- Mergin/project_settings_widget.py | 5 ++--- Mergin/validation.py | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/Mergin/project_settings_widget.py b/Mergin/project_settings_widget.py index 88f71724..91ef2a18 100644 --- a/Mergin/project_settings_widget.py +++ b/Mergin/project_settings_widget.py @@ -150,8 +150,7 @@ def __init__(self, parent=None): self.label_vcrs_warning.setOpenExternalLinks(False) self.label_vcrs_warning.linkActivated.connect(self._download_geoid_grid) - skip, ok = QgsProject.instance().readBoolEntry("Mergin", "SkipElevationTransformation", True) - use_vcrs = not skip + use_vcrs, ok = QgsProject.instance().readBoolEntry("Mergin", "ElevationTransformationEnabled", False) self.chk_use_vertical_crs.setChecked(use_vcrs) self.cmb_vertical_crs.setEnabled(use_vcrs) @@ -507,7 +506,7 @@ def apply(self): self.setup_map_sketches() use_vcrs = self.chk_use_vertical_crs.isChecked() - QgsProject.instance().writeEntry("Mergin", "SkipElevationTransformation", not use_vcrs) + QgsProject.instance().writeEntry("Mergin", "ElevationTransformationEnabled", use_vcrs) if use_vcrs: crs = self.cmb_vertical_crs.crs() QgsProject.instance().writeEntry("Mergin", "TargetVerticalCRS", crs.toWkt() if crs.isValid() else "") diff --git a/Mergin/validation.py b/Mergin/validation.py index 5f586134..bbdd5d82 100644 --- a/Mergin/validation.py +++ b/Mergin/validation.py @@ -508,8 +508,8 @@ def check_filenames(self): def check_vertical_crs_grids(self): """Check if custom vertical CRS is configured but the PROJ grid is missing from project.""" - skip, ok = QgsProject.instance().readBoolEntry("Mergin", "SkipElevationTransformation", True) - if skip: + enabled, ok = QgsProject.instance().readBoolEntry("Mergin", "ElevationTransformationEnabled", True) + if not enabled: return vcrs_wkt, ok = QgsProject.instance().readEntry("Mergin", "TargetVerticalCRS") From 20baab92308964ed348f93072190f27c5afffb0a Mon Sep 17 00:00:00 2001 From: Jan Caha Date: Thu, 12 Mar 2026 16:11:43 +0100 Subject: [PATCH 05/24] split into functions to allow other usage --- Mergin/utils.py | 53 ++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 41 insertions(+), 12 deletions(-) diff --git a/Mergin/utils.py b/Mergin/utils.py index 672a7ea3..f6c466b1 100644 --- a/Mergin/utils.py +++ b/Mergin/utils.py @@ -1803,7 +1803,42 @@ def _grids_from_proj_string(proj_str): return result -def get_missing_geoid_grids(crs, local_project_dir): +def _get_operations( + crs: QgsCoordinateReferenceSystem, +) -> List[QgsDatumTransform.TransformDetails]: + return QgsDatumTransform.operations(QgsCoordinateReferenceSystem("EPSG:4979"), crs) + + +def _operations_with_grids( + operations: List[QgsDatumTransform.TransformDetails], +) -> List[Tuple[QgsDatumTransform.TransformDetails, List[QgsDatumTransform.GridDetails]]]: + return [(op, list(op.grids)) for op in operations if op.grids] + + +def _grid_names(operations: List[QgsDatumTransform.TransformDetails]) -> List[str]: + operations_grids = _operations_with_grids(operations) + names = set() + for _, grids in operations_grids: + for grid in grids: + names.add(grid.shortName) + return list(names) + + +def _grid_available_in_project(local_project_dir: str, grid_name: str) -> bool: + if local_project_dir: + return os.path.exists(os.path.join(local_project_dir, "proj", grid_name)) + return False + + +def existing_grid_files_for_crs(local_project_dir: str, crs: QgsCoordinateReferenceSystem) -> List[str]: + existing_grids = [] + for grid_name in _grid_names(_get_operations(crs)): + if _grid_available_in_project(local_project_dir, grid_name): + existing_grids.append(grid_name) + return existing_grids + + +def get_missing_geoid_grids(crs: QgsCoordinateReferenceSystem, local_project_dir: str) -> Dict[str, Any]: """ Checks if the given vertical CRS requires grid files that are missing from the project's 'proj/' folder. @@ -1814,15 +1849,9 @@ def get_missing_geoid_grids(crs, local_project_dir): if not crs or not crs.isValid(): return result - wgs84_3d = QgsCoordinateReferenceSystem("EPSG:4979") - operations = QgsDatumTransform.operations(wgs84_3d, crs) - - def grid_available(grid): - if local_project_dir: - return os.path.exists(os.path.join(local_project_dir, "proj", grid.shortName)) - return False + operations = _get_operations(crs) - ops_with_grids = [(op, list(op.grids)) for op in operations if op.grids] + ops_with_grids = _operations_with_grids(operations) if not ops_with_grids: for op in operations: @@ -1840,16 +1869,16 @@ def grid_available(grid): return result for _op, grids in ops_with_grids: - if all(grid_available(g) for g in grids): + if all(_grid_available_in_project(local_project_dir, g.shortName) for g in grids): return result def op_score(item): _, grids = item - missing = [g for g in grids if not grid_available(g)] + missing = [g for g in grids if not _grid_available_in_project(local_project_dir, g.shortName)] return (not all(g.url for g in missing), len(missing)) _, best_grids = min(ops_with_grids, key=op_score) - result["missing"] = [g for g in best_grids if not grid_available(g)] + result["missing"] = [g for g in best_grids if not _grid_available_in_project(local_project_dir, g.shortName)] return result From db3acae4adfadc3ef146f3d51c1470a3661ef255 Mon Sep 17 00:00:00 2001 From: Jan Caha Date: Thu, 12 Mar 2026 16:12:41 +0100 Subject: [PATCH 06/24] add message when the grid exist --- Mergin/project_settings_widget.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/Mergin/project_settings_widget.py b/Mergin/project_settings_widget.py index 91ef2a18..0b2ae3b2 100644 --- a/Mergin/project_settings_widget.py +++ b/Mergin/project_settings_widget.py @@ -41,6 +41,7 @@ sanitize_path, get_missing_geoid_grids, download_grids_task, + existing_grid_files_for_crs, ) ui_file = os.path.join(os.path.dirname(os.path.realpath(__file__)), "ui", "ui_project_config.ui") @@ -353,7 +354,7 @@ def _vcrs_checkbox_changed(self, state): else: self.label_vcrs_warning.setVisible(False) - def _check_geoid_grid(self, crs): + def _check_geoid_grid(self, crs: QgsCoordinateReferenceSystem): """ Evaluates the selected vertical CRS to determine if a PROJ transformation grid is required for accurate elevation calculation in the mobile app. @@ -373,6 +374,17 @@ def _check_geoid_grid(self, crs): self.label_vcrs_warning.setVisible(True) return + existing_grids = existing_grid_files_for_crs(self.local_project_dir, crs) + + if existing_grids: + text = f'Using grid {existing_grids[0]} \u2713.' + if len(existing_grids) > 1: + text += f' Also found other relevant grids: {', '.join(existing_grids[1:])}. You should only have one relevant grid for conversion.' + + self.label_vcrs_warning.setText(text) + self.label_vcrs_warning.setVisible(True) + return + grid_status = get_missing_geoid_grids(crs, self.local_project_dir) if grid_status["ballpark"]: @@ -387,6 +399,7 @@ def _check_geoid_grid(self, crs): missing = grid_status["missing"] if not missing: + self.label_vcrs_warning.setVisible(False) return self._pending_grids = missing From 9365ce4450c921f35ff6635c92000a656c8a8b2b Mon Sep 17 00:00:00 2001 From: Jan Caha Date: Thu, 12 Mar 2026 16:13:01 +0100 Subject: [PATCH 07/24] drop warning --- Mergin/project_settings_widget.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/Mergin/project_settings_widget.py b/Mergin/project_settings_widget.py index 0b2ae3b2..7cd7b5a8 100644 --- a/Mergin/project_settings_widget.py +++ b/Mergin/project_settings_widget.py @@ -387,14 +387,9 @@ def _check_geoid_grid(self, crs: QgsCoordinateReferenceSystem): grid_status = get_missing_geoid_grids(crs, self.local_project_dir) + # no grid required according to PROJ if grid_status["ballpark"]: - self.label_vcrs_warning.setText( - 'Note: PROJ does not have geoid grid data for the selected vertical CRS. ' - "Heights reported in the field app may not accurately represent heights above the geoid. " - "If you have the appropriate geoid file, add it to your project\u2019s 'proj/' folder " - "and install it via the QGIS Resource Manager." - ) - self.label_vcrs_warning.setVisible(True) + self.label_vcrs_warning.setVisible(False) return missing = grid_status["missing"] From 04ad2b2efb292c789d25af907e293b2cb231d609 Mon Sep 17 00:00:00 2001 From: Jan Caha Date: Thu, 12 Mar 2026 16:13:25 +0100 Subject: [PATCH 08/24] add typing --- Mergin/utils.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Mergin/utils.py b/Mergin/utils.py index f6c466b1..019e9a2b 100644 --- a/Mergin/utils.py +++ b/Mergin/utils.py @@ -4,7 +4,7 @@ import shutil from datetime import datetime, timezone from enum import Enum -from typing import Any, Dict, List +from typing import Any, Dict, List, Callable, Union, Optional, Tuple from urllib.error import URLError, HTTPError import configparser import os @@ -1883,7 +1883,12 @@ def op_score(item): return result -def download_grids_task(grids, dest_dir, on_success_callback, on_error_callback=None): +def download_grids_task( + grids: List[QgsDatumTransform.GridDetails], + dest_dir: str, + on_success_callback: Callable[[], None], + on_error_callback: Optional[Callable[[Union[str, List[str]]], None]] = None, +): """ Starts a background QgsTask to download PROJ grids without freezing the QGIS UI. From d230f106b91521da2d04c3a5a36a9a51f6bf132b Mon Sep 17 00:00:00 2001 From: Jan Caha Date: Fri, 13 Mar 2026 16:10:23 +0100 Subject: [PATCH 09/24] define default vertical crs --- Mergin/utils.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Mergin/utils.py b/Mergin/utils.py index 019e9a2b..c4cb277b 100644 --- a/Mergin/utils.py +++ b/Mergin/utils.py @@ -174,6 +174,8 @@ _PROJ_GRIDS_RE = re.compile(r"\b(?:geoidgrids|grids)=([\w./@,]+)") _SKIP_GRID_NAMES = {"@null", "null", "@none", "none"} +DEFAULT_VERTICAL_CRS = QgsCoordinateReferenceSystem("EPSG:4979") + class PackagingError(Exception): pass From c62c4c9094a90bf0fea1a67898ebcf08019078a2 Mon Sep 17 00:00:00 2001 From: Jan Caha Date: Fri, 13 Mar 2026 16:10:51 +0100 Subject: [PATCH 10/24] allow also - in the name --- Mergin/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mergin/utils.py b/Mergin/utils.py index c4cb277b..63356744 100644 --- a/Mergin/utils.py +++ b/Mergin/utils.py @@ -171,7 +171,7 @@ TILES_URL = "https://tiles.merginmaps.com" # Matches both PROJ4-style (+geoidgrids=file.tif) and pipeline-style (+grids=file.tif). -_PROJ_GRIDS_RE = re.compile(r"\b(?:geoidgrids|grids)=([\w./@,]+)") +_PROJ_GRIDS_RE = re.compile(r"\b(?:geoidgrids|grids)=([\w./@,-]+)") _SKIP_GRID_NAMES = {"@null", "null", "@none", "none"} DEFAULT_VERTICAL_CRS = QgsCoordinateReferenceSystem("EPSG:4979") From abdb27a9866367ed73d3d554d7348db2a9645287 Mon Sep 17 00:00:00 2001 From: Jan Caha Date: Fri, 13 Mar 2026 16:21:48 +0100 Subject: [PATCH 11/24] get project defined transformation --- Mergin/utils.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/Mergin/utils.py b/Mergin/utils.py index 63356744..b3d5b9de 100644 --- a/Mergin/utils.py +++ b/Mergin/utils.py @@ -1808,7 +1808,25 @@ def _grids_from_proj_string(proj_str): def _get_operations( crs: QgsCoordinateReferenceSystem, ) -> List[QgsDatumTransform.TransformDetails]: - return QgsDatumTransform.operations(QgsCoordinateReferenceSystem("EPSG:4979"), crs) + return QgsDatumTransform.operations(DEFAULT_VERTICAL_CRS, crs) + + +def project_defined_transformation(crs: QgsCoordinateReferenceSystem) -> Tuple[bool, str]: + """If extracting conversion from project's transform context we need to manually search + because the project specify compound CRS to compound CRS transformation, but MM provides + vertical CRS only. So we need to check if any operation fits with the given vertical CRS.""" + context = QgsProject.instance().transformContext() + has_transform = False + transform = "" + operations = context.coordinateOperations() + for src, dst in operations.keys(): + crs_src = QgsCoordinateReferenceSystem(src) + crs_dst = QgsCoordinateReferenceSystem(dst) + if crs_src == DEFAULT_VERTICAL_CRS and crs_dst.verticalCrs() == crs: + transform = operations[(src, dst)] + has_transform = True + break + return has_transform, transform def _operations_with_grids( From 79019121d79097f6f53f824e509697ac1fb11c9a Mon Sep 17 00:00:00 2001 From: Jan Caha Date: Fri, 13 Mar 2026 16:29:10 +0100 Subject: [PATCH 12/24] grid files handling logic --- Mergin/project_settings_widget.py | 84 ++++++++++++++++++++++++++++--- 1 file changed, 77 insertions(+), 7 deletions(-) diff --git a/Mergin/project_settings_widget.py b/Mergin/project_settings_widget.py index 7cd7b5a8..f39b6cf2 100644 --- a/Mergin/project_settings_widget.py +++ b/Mergin/project_settings_widget.py @@ -42,6 +42,10 @@ get_missing_geoid_grids, download_grids_task, existing_grid_files_for_crs, + project_defined_transformation, + _grids_from_proj_string, + _grid_available_in_project, + _get_operations, ) ui_file = os.path.join(os.path.dirname(os.path.realpath(__file__)), "ui", "ui_project_config.ui") @@ -358,6 +362,11 @@ def _check_geoid_grid(self, crs: QgsCoordinateReferenceSystem): """ Evaluates the selected vertical CRS to determine if a PROJ transformation grid is required for accurate elevation calculation in the mobile app. + + If the project has a datum transformation defined between EPSG:4979 and the CRS, + that specific transform is used to determine grid requirements. Otherwise the + standard PROJ operation list is consulted. When multiple operations exist and no + project-level transform is configured the user is warned to set one up. """ self.label_vcrs_warning.setVisible(False) self._pending_grids = [] @@ -374,14 +383,67 @@ def _check_geoid_grid(self, crs: QgsCoordinateReferenceSystem): self.label_vcrs_warning.setVisible(True) return + # Check if the project has an explicit datum transformation configured. + has_transform, transform_str = project_defined_transformation(crs) + + if has_transform: + # Derive grid requirements solely from the project-defined transform string. + grids = _grids_from_proj_string(transform_str) + if not grids: + # Transform does not require any grid file. + self.label_vcrs_warning.setVisible(False) + return + + project_dir = self.local_project_dir or "" + available = [g for g in grids if _grid_available_in_project(project_dir, g.shortName)] + missing = get_missing_geoid_grids(crs, self.local_project_dir)["missing"] + + if available: + text = f'Using grid {available[0].shortName} \u2713.' + if len(available) > 1: + text += ( + f' Also found other relevant grids: ' + f'{", ".join(g.shortName for g in available[1:])}. ' + f"You should only have one relevant grid for conversion." + ) + self.label_vcrs_warning.setText(text) + self.label_vcrs_warning.setVisible(True) + return + + self._pending_grids = missing + names = ", ".join(g.shortName for g in missing) + self.label_vcrs_warning.setText( + f'The selected vertical CRS requires the following geoid grid(s) ' + f"in order to work properly \u2013 {names}. " + f'Click here to automatically ' + f"download it and add to your project." + ) + self.label_vcrs_warning.setVisible(True) + return + + # No project-defined transformation – use the standard process. + # Warn when multiple operations are available so the user knows to pick one. + operations = _get_operations(crs) + multi_op_warning = "" + if len(operations) > 1: + multi_op_warning = ( + ' Multiple coordinate operations are available for this CRS. ' + "Please configure the datum transformation on the project level " + "(Project \u2192 Properties \u2192 Transformations) " + "to ensure the correct operation is used." + ) + existing_grids = existing_grid_files_for_crs(self.local_project_dir, crs) if existing_grids: text = f'Using grid {existing_grids[0]} \u2713.' if len(existing_grids) > 1: - text += f' Also found other relevant grids: {', '.join(existing_grids[1:])}. You should only have one relevant grid for conversion.' - - self.label_vcrs_warning.setText(text) + text += ( + f' Also found other relevant grids: ' + f'{", ".join(existing_grids[1:])}. ' + f"You should only have one relevant grid for conversion." + ) + self.label_vcrs_warning.setText(text + multi_op_warning) self.label_vcrs_warning.setVisible(True) return @@ -389,12 +451,20 @@ def _check_geoid_grid(self, crs: QgsCoordinateReferenceSystem): # no grid required according to PROJ if grid_status["ballpark"]: - self.label_vcrs_warning.setVisible(False) + if multi_op_warning: + self.label_vcrs_warning.setText(multi_op_warning.strip()) + self.label_vcrs_warning.setVisible(True) + else: + self.label_vcrs_warning.setVisible(False) return missing = grid_status["missing"] if not missing: - self.label_vcrs_warning.setVisible(False) + if multi_op_warning: + self.label_vcrs_warning.setText(multi_op_warning.strip()) + self.label_vcrs_warning.setVisible(True) + else: + self.label_vcrs_warning.setVisible(False) return self._pending_grids = missing @@ -403,11 +473,11 @@ def _check_geoid_grid(self, crs: QgsCoordinateReferenceSystem): f'The selected vertical CRS requires the following geoid grid(s) ' f"in order to work properly \u2013 {names}. " f'Click here to automatically ' - f"download it and add to your project." + f"download it and add to your project." + multi_op_warning ) self.label_vcrs_warning.setVisible(True) - def _download_geoid_grid(self, link): + def _download_geoid_grid(self, link: str): """ Triggered when the user clicks the download link in the missing grid warning label. From c0d64199949f27ef4308e49c8187c62fd056b6fc Mon Sep 17 00:00:00 2001 From: Jan Caha Date: Fri, 13 Mar 2026 16:42:48 +0100 Subject: [PATCH 13/24] do not show warning about multiple available grids if some of the was found locally --- Mergin/project_settings_widget.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mergin/project_settings_widget.py b/Mergin/project_settings_widget.py index f39b6cf2..50adefa8 100644 --- a/Mergin/project_settings_widget.py +++ b/Mergin/project_settings_widget.py @@ -443,7 +443,7 @@ def _check_geoid_grid(self, crs: QgsCoordinateReferenceSystem): f'{", ".join(existing_grids[1:])}. ' f"You should only have one relevant grid for conversion." ) - self.label_vcrs_warning.setText(text + multi_op_warning) + self.label_vcrs_warning.setText(text) self.label_vcrs_warning.setVisible(True) return From e68b05a380b0d49a1095a451616a097da74630ca Mon Sep 17 00:00:00 2001 From: Jan Caha Date: Wed, 18 Mar 2026 12:56:52 +0100 Subject: [PATCH 14/24] fix bandit issue by check the download goes trough http or https --- Mergin/utils.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Mergin/utils.py b/Mergin/utils.py index 6c42f785..2a765456 100644 --- a/Mergin/utils.py +++ b/Mergin/utils.py @@ -1941,7 +1941,11 @@ def run_download(task): break try: - urllib.request.urlretrieve(url, os.path.join(dest_dir, name)) + parsed = urllib.parse.urlparse(url) + if parsed.scheme in ("http", "https"): + urllib.request.urlretrieve(url, os.path.join(dest_dir, name)) + else: + failed.append(f"{name}: Unsupported URL scheme '{parsed.scheme}'.") except Exception as e: failed.append(f"{name}: {str(e)}") From c5443a20aba22f2074381c9bba48eb96c82226b2 Mon Sep 17 00:00:00 2001 From: Jan Caha Date: Wed, 18 Mar 2026 13:01:25 +0100 Subject: [PATCH 15/24] do not raise error --- Mergin/utils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Mergin/utils.py b/Mergin/utils.py index 2a765456..ec03e87a 100644 --- a/Mergin/utils.py +++ b/Mergin/utils.py @@ -1943,7 +1943,8 @@ def run_download(task): try: parsed = urllib.parse.urlparse(url) if parsed.scheme in ("http", "https"): - urllib.request.urlretrieve(url, os.path.join(dest_dir, name)) + # scheme is validated, bandit error can be suppressed on next line + urllib.request.urlretrieve(url, os.path.join(dest_dir, name)) # nosec B310 else: failed.append(f"{name}: Unsupported URL scheme '{parsed.scheme}'.") except Exception as e: From f9b3ffe4a4f4a170fd44180e8ad14cdb94bf38ba Mon Sep 17 00:00:00 2001 From: Jan Caha Date: Wed, 25 Mar 2026 12:10:50 +0100 Subject: [PATCH 16/24] connect to signal for transformation context change --- Mergin/project_settings_widget.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Mergin/project_settings_widget.py b/Mergin/project_settings_widget.py index 50adefa8..54e04c56 100644 --- a/Mergin/project_settings_widget.py +++ b/Mergin/project_settings_widget.py @@ -155,6 +155,10 @@ def __init__(self, parent=None): self.label_vcrs_warning.setOpenExternalLinks(False) self.label_vcrs_warning.linkActivated.connect(self._download_geoid_grid) + QgsProject.instance().transformContextChanged.connect( + lambda: self._check_geoid_grid(self.cmb_vertical_crs.crs()) + ) + use_vcrs, ok = QgsProject.instance().readBoolEntry("Mergin", "ElevationTransformationEnabled", False) self.chk_use_vertical_crs.setChecked(use_vcrs) self.cmb_vertical_crs.setEnabled(use_vcrs) From 49c115156bef5b7f1d808b26ee635a937d88109b Mon Sep 17 00:00:00 2001 From: Jan Caha Date: Wed, 25 Mar 2026 12:11:21 +0100 Subject: [PATCH 17/24] non vertical crs not possible here --- Mergin/project_settings_widget.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/Mergin/project_settings_widget.py b/Mergin/project_settings_widget.py index 54e04c56..bd06fda5 100644 --- a/Mergin/project_settings_widget.py +++ b/Mergin/project_settings_widget.py @@ -377,16 +377,6 @@ def _check_geoid_grid(self, crs: QgsCoordinateReferenceSystem): if not crs.isValid() or not self.chk_use_vertical_crs.isChecked(): return - wkt = crs.toWkt() - if not wkt.startswith(("VERT_CS[", "COMPD_CS[", "VERTCRS[", "COMPOUNDCRS[")): - self.label_vcrs_warning.setText( - 'The selected CRS is not a vertical or compound CRS. ' - "Please select a vertical CRS (e.g. EGM2008 height) or a compound CRS " - "(e.g. WGS 84 + EGM96 height)." - ) - self.label_vcrs_warning.setVisible(True) - return - # Check if the project has an explicit datum transformation configured. has_transform, transform_str = project_defined_transformation(crs) From 68b8ef1783a574f1765d3a6b0e3a294043831ace Mon Sep 17 00:00:00 2001 From: Jan Caha Date: Wed, 25 Mar 2026 12:12:25 +0100 Subject: [PATCH 18/24] if transform is set but grid is missing --- Mergin/project_settings_widget.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/Mergin/project_settings_widget.py b/Mergin/project_settings_widget.py index bd06fda5..cedad3ca 100644 --- a/Mergin/project_settings_widget.py +++ b/Mergin/project_settings_widget.py @@ -390,7 +390,6 @@ def _check_geoid_grid(self, crs: QgsCoordinateReferenceSystem): project_dir = self.local_project_dir or "" available = [g for g in grids if _grid_available_in_project(project_dir, g.shortName)] - missing = get_missing_geoid_grids(crs, self.local_project_dir)["missing"] if available: text = f'Using grid {available[0].shortName} \u2713.' @@ -404,16 +403,17 @@ def _check_geoid_grid(self, crs: QgsCoordinateReferenceSystem): self.label_vcrs_warning.setVisible(True) return - self._pending_grids = missing - names = ", ".join(g.shortName for g in missing) - self.label_vcrs_warning.setText( - f'The selected vertical CRS requires the following geoid grid(s) ' - f"in order to work properly \u2013 {names}. " - f'Click here to automatically ' - f"download it and add to your project." - ) - self.label_vcrs_warning.setVisible(True) - return + else: + grids = _grids_from_proj_string(transform_str) + names = ", ".join(g.shortName for g in grids) + self.label_vcrs_warning.setText( + f'The selected vertical CRS requires the following geoid grid ' + f"in order to work properly \u2013 {names}. " + f'Click here to automatically ' + f"download it and add to your project." + ) + self.label_vcrs_warning.setVisible(True) + return # No project-defined transformation – use the standard process. # Warn when multiple operations are available so the user knows to pick one. From 630cad2b4766da0b0227872e2a2cd2060f036881 Mon Sep 17 00:00:00 2001 From: Jan Caha Date: Wed, 25 Mar 2026 12:12:56 +0100 Subject: [PATCH 19/24] on multiple operations shot and inform user --- Mergin/project_settings_widget.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Mergin/project_settings_widget.py b/Mergin/project_settings_widget.py index cedad3ca..52938efa 100644 --- a/Mergin/project_settings_widget.py +++ b/Mergin/project_settings_widget.py @@ -416,16 +416,20 @@ def _check_geoid_grid(self, crs: QgsCoordinateReferenceSystem): return # No project-defined transformation – use the standard process. - # Warn when multiple operations are available so the user knows to pick one. operations = _get_operations(crs) + + # Warn when multiple operations are available so the user knows to pick one in transformations multi_op_warning = "" if len(operations) > 1: multi_op_warning = ( - ' Multiple coordinate operations are available for this CRS. ' + 'Multiple coordinate operations are available for this CRS. ' "Please configure the datum transformation on the project level " "(Project \u2192 Properties \u2192 Transformations) " "to ensure the correct operation is used." ) + self.label_vcrs_warning.setText(multi_op_warning) + self.label_vcrs_warning.setVisible(True) + return existing_grids = existing_grid_files_for_crs(self.local_project_dir, crs) From 2df68482aa19c69043ee83bf85699fde953e5076 Mon Sep 17 00:00:00 2001 From: Jan Caha Date: Wed, 25 Mar 2026 12:13:44 +0100 Subject: [PATCH 20/24] multiple relevant grids should not be really possible --- Mergin/project_settings_widget.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/Mergin/project_settings_widget.py b/Mergin/project_settings_widget.py index 52938efa..34e59465 100644 --- a/Mergin/project_settings_widget.py +++ b/Mergin/project_settings_widget.py @@ -435,12 +435,6 @@ def _check_geoid_grid(self, crs: QgsCoordinateReferenceSystem): if existing_grids: text = f'Using grid {existing_grids[0]} \u2713.' - if len(existing_grids) > 1: - text += ( - f' Also found other relevant grids: ' - f'{", ".join(existing_grids[1:])}. ' - f"You should only have one relevant grid for conversion." - ) self.label_vcrs_warning.setText(text) self.label_vcrs_warning.setVisible(True) return From 9f2f6e90b3a9acf0755f559cb0ae453c6ff23ae3 Mon Sep 17 00:00:00 2001 From: Jan Caha Date: Wed, 25 Mar 2026 12:31:16 +0100 Subject: [PATCH 21/24] get GridDetails from grid name --- Mergin/project_settings_widget.py | 3 ++- Mergin/utils.py | 14 +++++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/Mergin/project_settings_widget.py b/Mergin/project_settings_widget.py index 34e59465..16168b3f 100644 --- a/Mergin/project_settings_widget.py +++ b/Mergin/project_settings_widget.py @@ -46,6 +46,7 @@ _grids_from_proj_string, _grid_available_in_project, _get_operations, + grid_details_for_names, ) ui_file = os.path.join(os.path.dirname(os.path.realpath(__file__)), "ui", "ui_project_config.ui") @@ -404,7 +405,7 @@ def _check_geoid_grid(self, crs: QgsCoordinateReferenceSystem): return else: - grids = _grids_from_proj_string(transform_str) + self._pending_grids = grid_details_for_names(set(g.shortName for g in grids), crs) names = ", ".join(g.shortName for g in grids) self.label_vcrs_warning.setText( f'The selected vertical CRS requires the following geoid grid ' diff --git a/Mergin/utils.py b/Mergin/utils.py index ec03e87a..04a3f473 100644 --- a/Mergin/utils.py +++ b/Mergin/utils.py @@ -4,7 +4,7 @@ import shutil from datetime import datetime, timezone from enum import Enum -from typing import Any, Dict, List, Callable, Union, Optional, Tuple +from typing import Any, Dict, List, Callable, Union, Optional, Tuple, Set from urllib.error import URLError, HTTPError import configparser import os @@ -1971,3 +1971,15 @@ def on_finished(exception, result): QgsApplication.taskManager().addTask(task) return task + + +def grid_details_for_names(names: Set[str], crs) -> List[QgsDatumTransform.GridDetails]: + """Return GridDetails objects matching the given short names, searched across all operations.""" + result = [] + seen = set() + for op in _get_operations(crs): + for gd in op.grids: + if gd.shortName in names and gd.shortName not in seen: + result.append(gd) + seen.add(gd.shortName) + return result From 97d0a3c0db406e0ee2440b7a6b4466ab8b40a549 Mon Sep 17 00:00:00 2001 From: Jan Caha Date: Wed, 25 Mar 2026 12:37:11 +0100 Subject: [PATCH 22/24] add default value --- Mergin/project_settings_widget.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mergin/project_settings_widget.py b/Mergin/project_settings_widget.py index 16168b3f..08b7ca3b 100644 --- a/Mergin/project_settings_widget.py +++ b/Mergin/project_settings_widget.py @@ -405,7 +405,7 @@ def _check_geoid_grid(self, crs: QgsCoordinateReferenceSystem): return else: - self._pending_grids = grid_details_for_names(set(g.shortName for g in grids), crs) + self._pending_grids = grid_details_for_names(set(g.shortName for g in grids), crs) or grids names = ", ".join(g.shortName for g in grids) self.label_vcrs_warning.setText( f'The selected vertical CRS requires the following geoid grid ' From e03b3d1efa6a3eece6a28394b376373699f9b21a Mon Sep 17 00:00:00 2001 From: Jan Caha Date: Wed, 25 Mar 2026 12:37:23 +0100 Subject: [PATCH 23/24] drop unnecessary code --- Mergin/project_settings_widget.py | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/Mergin/project_settings_widget.py b/Mergin/project_settings_widget.py index 08b7ca3b..bc28d524 100644 --- a/Mergin/project_settings_widget.py +++ b/Mergin/project_settings_widget.py @@ -444,20 +444,12 @@ def _check_geoid_grid(self, crs: QgsCoordinateReferenceSystem): # no grid required according to PROJ if grid_status["ballpark"]: - if multi_op_warning: - self.label_vcrs_warning.setText(multi_op_warning.strip()) - self.label_vcrs_warning.setVisible(True) - else: - self.label_vcrs_warning.setVisible(False) + self.label_vcrs_warning.setVisible(False) return missing = grid_status["missing"] if not missing: - if multi_op_warning: - self.label_vcrs_warning.setText(multi_op_warning.strip()) - self.label_vcrs_warning.setVisible(True) - else: - self.label_vcrs_warning.setVisible(False) + self.label_vcrs_warning.setVisible(False) return self._pending_grids = missing @@ -466,7 +458,7 @@ def _check_geoid_grid(self, crs: QgsCoordinateReferenceSystem): f'The selected vertical CRS requires the following geoid grid(s) ' f"in order to work properly \u2013 {names}. " f'Click here to automatically ' - f"download it and add to your project." + multi_op_warning + f"download it and add to your project." ) self.label_vcrs_warning.setVisible(True) From 82aea9d31e13b20a43149d76a8dfbc8c94900a6b Mon Sep 17 00:00:00 2001 From: Jan Caha Date: Mon, 11 May 2026 16:37:40 +0200 Subject: [PATCH 24/24] fix bad lambda function --- Mergin/project_settings_widget.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Mergin/project_settings_widget.py b/Mergin/project_settings_widget.py index bc28d524..34acb150 100644 --- a/Mergin/project_settings_widget.py +++ b/Mergin/project_settings_widget.py @@ -156,9 +156,7 @@ def __init__(self, parent=None): self.label_vcrs_warning.setOpenExternalLinks(False) self.label_vcrs_warning.linkActivated.connect(self._download_geoid_grid) - QgsProject.instance().transformContextChanged.connect( - lambda: self._check_geoid_grid(self.cmb_vertical_crs.crs()) - ) + QgsProject.instance().transformContextChanged.connect(self._on_transformation_modified) use_vcrs, ok = QgsProject.instance().readBoolEntry("Mergin", "ElevationTransformationEnabled", False) self.chk_use_vertical_crs.setChecked(use_vcrs) @@ -584,3 +582,7 @@ def colors_change_state(self) -> None: item = self.mColorsHorizontalLayout.itemAt(i).widget() if isinstance(item, QgsColorButton): item.setEnabled(self.chk_map_sketches_enabled.isChecked()) + + def _on_transformation_modified(self): + if self.cmb_vertical_crs: + self._check_geoid_grid(self.cmb_vertical_crs.crs())