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 @@
qgsexpressionlineedit.h
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."