diff --git a/Mergin/project_settings_widget.py b/Mergin/project_settings_widget.py
index 67027ffa..bc28d524 100644
--- a/Mergin/project_settings_widget.py
+++ b/Mergin/project_settings_widget.py
@@ -6,7 +6,7 @@
import typing
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 +16,15 @@
QgsFeatureRequest,
QgsExpression,
QgsMapLayer,
+ QgsCoordinateReferenceSystem,
+)
+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,
@@ -32,6 +39,14 @@
qvariant_to_string,
escape_html_minimal,
sanitize_path,
+ 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,
+ grid_details_for_names,
)
ui_file = os.path.join(os.path.dirname(os.path.realpath(__file__)), "ui", "ui_project_config.ui")
@@ -133,6 +148,36 @@ 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
+ 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)
+
+ 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)
+
+ 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 +355,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: 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 = []
+ if not crs.isValid() or not self.chk_use_vertical_crs.isChecked():
+ 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)]
+
+ 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
+
+ else:
+ 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 '
+ 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.
+ 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. '
+ "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)
+
+ if existing_grids:
+ text = f'Using grid {existing_grids[0]} \u2713.'
+ self.label_vcrs_warning.setText(text)
+ self.label_vcrs_warning.setVisible(True)
+ return
+
+ 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.setVisible(False)
+ return
+
+ missing = grid_status["missing"]
+ if not missing:
+ self.label_vcrs_warning.setVisible(False)
+ 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)
+
+ def _download_geoid_grid(self, link: str):
+ """
+ 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
+
+ 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
+
+ # start UI animation
+ 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)
+
+ # callbacks
+ def on_success():
+ self._download_timer.stop()
+ 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())
+
+ # 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())
QgsProject.instance().writeEntry("Mergin", "Snapping", self.cmb_snapping_mode.currentData())
@@ -351,6 +568,14 @@ def apply(self):
self.setup_tracking()
self.setup_map_sketches()
+ use_vcrs = self.chk_use_vertical_crs.isChecked()
+ 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 "")
+ 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/project_status_dialog.py b/Mergin/project_status_dialog.py
index b2675062..605b51ea 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,44 @@ 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.validate_project()
+ return
+
+ # callbacks
+ def on_success():
+ QMessageBox.information(self, "Success", "Geoid grid(s) successfully added to your project.")
+ 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/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 @@
1
+
+ QgsProjectionSelectionWidget
+ QWidget
+ qgsprojectionselectionwidget.h
+ 1
+
chk_sync_enabled
diff --git a/Mergin/utils.py b/Mergin/utils.py
index a2f279b7..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
+from typing import Any, Dict, List, Callable, Union, Optional, Tuple, Set
from urllib.error import URLError, HTTPError
import configparser
import os
@@ -68,6 +68,7 @@
QgsProperty,
QgsSymbolLayer,
QgsGeometry,
+ QgsTask,
)
from qgis.gui import QgsFileWidget
@@ -169,6 +170,12 @@
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"}
+
+DEFAULT_VERTICAL_CRS = QgsCoordinateReferenceSystem("EPSG:4979")
+
class PackagingError(Exception):
pass
@@ -1775,3 +1782,204 @@ 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_operations(
+ crs: QgsCoordinateReferenceSystem,
+) -> List[QgsDatumTransform.TransformDetails]:
+ 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(
+ 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.
+ Returns a dict: {"missing": list of _GridRef, "ballpark": bool}
+ """
+ result = {"missing": [], "ballpark": False}
+
+ if not crs or not crs.isValid():
+ return result
+
+ operations = _get_operations(crs)
+
+ ops_with_grids = _operations_with_grids(operations)
+
+ 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_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_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_in_project(local_project_dir, g.shortName)]
+
+ return result
+
+
+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.
+
+ :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:
+ parsed = urllib.parse.urlparse(url)
+ if parsed.scheme in ("http", "https"):
+ # 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:
+ 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
+
+
+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
diff --git a/Mergin/validation.py b/Mergin/validation.py
index 411afcba..8a05e9a1 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
@@ -508,6 +512,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."""
+ enabled, ok = QgsProject.instance().readBoolEntry("Mergin", "ElevationTransformationEnabled", True)
+ if not enabled:
+ 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"""
@@ -597,3 +623,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."