Skip to content

Commit 9e7dbf1

Browse files
committed
Export basemap as mbtiles during cable export
Set max tiles zoom level export to 14 Use a cloned version of basemap layer when for independant project export Remove old tiff offline export Add comment for raster layer cloning Clone project to generate tiles based on map theme Connect native mbtiles alg progress to custom progress signal Remove unused tiff basemap generation method Add offline converter cancel logic Update libqfieldsync/offline_converter.py Co-authored-by: Ivan Ivanov <suricactus@users.noreply.github.com> Add flag to store and check canceled state of the packaging Add comment for the try packaging finally clause
1 parent 76564a8 commit 9e7dbf1

File tree

2 files changed

+180
-32
lines changed

2 files changed

+180
-32
lines changed

libqfieldsync/offline_converter.py

Lines changed: 152 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -20,27 +20,29 @@
2020
"""
2121

2222
import sys
23+
import tempfile
2324
from enum import Enum
2425
from pathlib import Path
2526
from typing import Dict, List, Optional, Union
2627

2728
from qgis.core import (
2829
Qgis,
2930
QgsApplication,
30-
QgsBilinearRasterResampler,
3131
QgsCoordinateReferenceSystem,
3232
QgsCoordinateTransform,
33-
QgsCubicRasterResampler,
3433
QgsEditorWidgetSetup,
3534
QgsField,
3635
QgsFields,
3736
QgsLayerTreeGroup,
37+
QgsLayerTreeModel,
3838
QgsMapLayer,
39+
QgsMapThemeCollection,
3940
QgsPolygon,
4041
QgsProcessingContext,
4142
QgsProcessingFeedback,
4243
QgsProject,
4344
QgsRasterLayer,
45+
QgsRectangle,
4446
QgsValueRelationFieldFormatter,
4547
QgsVectorLayer,
4648
)
@@ -70,6 +72,13 @@ class LayerData(TypedDict):
7072
LayerData = Dict
7173

7274

75+
class PackagingCanceledException(Exception):
76+
"""Exception to be raised when offline converting is canceled"""
77+
78+
def __init__(self, *args):
79+
super().__init__(QObject().tr("Packaging canceled by the user"), *args)
80+
81+
7382
class ExportType(Enum):
7483
Cable = "cable"
7584
Cloud = "cloud"
@@ -81,6 +90,11 @@ class OfflineConverter(QObject):
8190
task_progress_updated = pyqtSignal(int, int)
8291
total_progress_updated = pyqtSignal(int, int, str)
8392

93+
# feedback used for basemap generation processing algorithm
94+
_feedback = QgsProcessingFeedback()
95+
96+
_is_canceled: bool = False
97+
8498
def __init__(
8599
self,
86100
project: QgsProject,
@@ -201,7 +215,13 @@ def _convert(self, project: QgsProject) -> None:
201215
copied_files = list()
202216

203217
if self.create_basemap and self.project_configuration.create_base_map:
204-
self._export_basemap()
218+
is_basemap_export_success = self._export_basemap()
219+
220+
if not is_basemap_export_success and not self._is_canceled:
221+
self.warning.emit(
222+
self.tr("Failed to create basemap"),
223+
self.tr("The basemap creation was unsuccessful."),
224+
)
205225

206226
# We store the pks of the original vector layers
207227
for layer_idx, layer in enumerate(project_layers):
@@ -265,6 +285,8 @@ def _convert(self, project: QgsProject) -> None:
265285
self.trUtf8("Copying layers…"),
266286
)
267287

288+
self._check_canceled()
289+
268290
if layer_action == SyncAction.OFFLINE:
269291
offline_layers.append(layer)
270292
self.__offline_layer_names.append(layer.name())
@@ -287,6 +309,8 @@ def _convert(self, project: QgsProject) -> None:
287309
# save the original project path
288310
self.project_configuration.original_project_path = str(self.original_filename)
289311

312+
self._check_canceled()
313+
290314
# save the offline project twice so that the offline plugin can "know" that it's a relative path
291315
QgsProject.instance().write(str(export_project_filename))
292316

@@ -301,6 +325,8 @@ def _convert(self, project: QgsProject) -> None:
301325
if not should_copy:
302326
continue
303327

328+
self._check_canceled()
329+
304330
copy_attachments(
305331
self.original_filename.parent,
306332
export_project_filename.parent,
@@ -310,6 +336,8 @@ def _convert(self, project: QgsProject) -> None:
310336
# copy project plugin if present
311337
plugin_file = Path("{}.qml".format(str(self.original_filename)[:-4]))
312338
if plugin_file.exists():
339+
self._check_canceled()
340+
313341
copy_multifile(
314342
plugin_file, export_project_filename.parent.joinpath(plugin_file.name)
315343
)
@@ -323,6 +351,8 @@ def _convert(self, project: QgsProject) -> None:
323351
QgsProject.instance(),
324352
).transformBoundingBox(self.area_of_interest.boundingBox())
325353

354+
self._check_canceled()
355+
326356
is_success = self.offliner.convert_to_offline(
327357
str(self._export_filename.with_name("data.gpkg")),
328358
offline_layers,
@@ -337,10 +367,14 @@ def _convert(self, project: QgsProject) -> None:
337367
)
338368
)
339369

370+
self._check_canceled()
371+
340372
# Disable project options that could create problems on a portable
341373
# project with offline layers
342374
self.post_process_offline_layers()
343375

376+
self._check_canceled()
377+
344378
# Now we have a project state which can be saved as offline project
345379
on_original_project_write = self._on_original_project_write_wrapper(
346380
xml_elements_to_preserve
@@ -556,53 +590,125 @@ def _export_basemap(self) -> bool:
556590
)
557591
return False
558592

559-
extent_string = "{},{},{},{}".format(
560-
extent.xMinimum(),
561-
extent.xMaximum(),
562-
extent.yMinimum(),
563-
extent.yMaximum(),
564-
)
593+
exported_mbtiles = self._export_basemap_as_mbtiles(extent, base_map_type)
594+
595+
return exported_mbtiles
596+
597+
def _export_basemap_as_mbtiles(
598+
self, extent: QgsRectangle, base_map_type: ProjectProperties.BaseMapType
599+
) -> bool:
600+
"""
601+
Exports a basemap to mbtiles format.
602+
This method handles several zoom levels.
603+
This should be preferred over the legacy `_export_basemap_as_tiff` method.
604+
605+
Args:
606+
extent (QgsRectangle): extent of the area of interest
607+
base_map_type (ProjectProperties.BaseMapType): basemap type (layer or theme)
608+
609+
Returns:
610+
bool: if basemap layer could be exported as mbtiles
611+
"""
565612

566613
alg = (
567614
QgsApplication.instance()
568615
.processingRegistry()
569-
.createAlgorithmById("native:rasterize")
616+
.createAlgorithmById("native:tilesxyzmbtiles")
570617
)
571618

572619
params = {
573-
"EXTENT": extent_string,
574-
"EXTENT_BUFFER": 0,
575-
"TILE_SIZE": self.project_configuration.base_map_tile_size,
576-
"MAP_UNITS_PER_PIXEL": self.project_configuration.base_map_mupp,
577-
"MAKE_BACKGROUND_TRANSPARENT": False,
578-
"OUTPUT": str(self._export_filename.with_name("basemap.gpkg")),
620+
"EXTENT": extent,
621+
"ZOOM_MIN": self.project_configuration.base_map_tiles_min_zoom_level,
622+
"ZOOM_MAX": self.project_configuration.base_map_tiles_max_zoom_level,
623+
"TILE_SIZE": 256,
624+
"OUTPUT_FILE": str(self._export_filename.with_name("basemap.mbtiles")),
579625
}
580626

627+
# clone current QGIS project
628+
current_project = QgsProject.instance()
629+
cloned_project = QgsProject(
630+
parent=current_project.parent(), capabilities=current_project.capabilities()
631+
)
632+
cloned_project.setCrs(current_project.crs())
633+
581634
if base_map_type == ProjectProperties.BaseMapType.SINGLE_LAYER:
582-
params["LAYERS"] = [self.project_configuration.base_map_layer]
635+
# the `native:tilesxyzmbtiles` alg does not have any LAYERS param
636+
# so just add basemap layer to the cloned project
637+
basemap_layer = current_project.mapLayer(
638+
self.project_configuration.base_map_layer
639+
)
640+
# here we use a cloned version of the raster layer, otherwise QGIS might crash
641+
clone_layer = basemap_layer.clone()
642+
cloned_project.addMapLayer(clone_layer)
643+
583644
elif base_map_type == ProjectProperties.BaseMapType.MAP_THEME:
584-
params["MAP_THEME"] = self.project_configuration.base_map_theme
645+
# clone and recreate the current QGIS project, and recreate original themes
646+
current_themes = QgsMapThemeCollection(current_project)
647+
themes_data = {}
648+
649+
for theme_name in current_themes.mapThemes():
650+
layers, visibility = current_themes.mapThemeLayers(theme_name)
651+
themes_data[theme_name] = (layers, visibility)
652+
653+
# create a temp file to store current QGIS project
654+
temp_file = tempfile.NamedTemporaryFile(suffix=".qgz", delete=False)
655+
temp_path = temp_file.name
656+
temp_file.close()
657+
current_project.write(temp_path)
658+
659+
cloned_project.read(temp_path)
660+
661+
cloned_themes_collection = QgsMapThemeCollection(cloned_project)
662+
for theme_name, (layers, visibility) in themes_data.items():
663+
cloned_themes_collection.storeMapTheme(theme_name, layers, visibility)
664+
665+
layer_tree_root = cloned_project.layerTreeRoot()
666+
layer_tree_model = QgsLayerTreeModel(layer_tree_root)
667+
cloned_project.mapThemeCollection().applyTheme(
668+
self.project_configuration.base_map_theme,
669+
layer_tree_root,
670+
layer_tree_model,
671+
)
585672

586-
feedback = QgsProcessingFeedback()
587673
context = QgsProcessingContext()
588-
context.setProject(QgsProject.instance())
674+
context.setProject(cloned_project)
589675

590-
results, ok = alg.run(params, context, feedback)
676+
# connect subtask feedback progress signal
677+
self._feedback.progressChanged.connect(self._on_tiles_gen_alg_progress_changed)
591678

592-
if not ok:
593-
self.warning.emit(self.tr("Failed to create basemap"), feedback.textLog())
594-
return False
679+
# we use a try clause to make sure the feedback's `progressChanged` signal
680+
# is disconnected in the finally clause.
681+
try:
682+
results, ok = alg.run(params, context, self._feedback)
683+
684+
if not ok:
685+
self.warning.emit(
686+
self.tr("Failed to create mbtiles basemap"),
687+
self._feedback.textLog(),
688+
)
689+
return False
690+
691+
new_layer = QgsRasterLayer(results["OUTPUT_FILE"], self.tr("Basemap"))
692+
693+
self.project_configuration.project.addMapLayer(new_layer, False)
595694

596-
new_layer = QgsRasterLayer(results["OUTPUT"], self.tr("Basemap"))
695+
layer_tree = QgsProject.instance().layerTreeRoot()
696+
layer_tree.insertLayer(len(layer_tree.children()), new_layer)
597697

598-
resample_filter = new_layer.resampleFilter()
599-
resample_filter.setZoomedInResampler(QgsCubicRasterResampler())
600-
resample_filter.setZoomedOutResampler(QgsBilinearRasterResampler())
601-
self.project_configuration.project.addMapLayer(new_layer, False)
602-
layer_tree = QgsProject.instance().layerTreeRoot()
603-
layer_tree.insertLayer(len(layer_tree.children()), new_layer)
698+
return True
604699

605-
return True
700+
finally:
701+
self._feedback.progressChanged.disconnect()
702+
703+
def _on_tiles_gen_alg_progress_changed(self, revision: float) -> None:
704+
"""
705+
Called when the native `native:tilesxyzmbtiles` algorithm's execution emits progress.
706+
This method will notify the accurate signal about this progress, e.g. QFieldSync progress bar UI.
707+
708+
Args:
709+
revision (float): progress value of the tiles generation algorithm (between 0 and 100)
710+
"""
711+
self.task_progress_updated.emit(int(revision), 100)
606712

607713
def _on_offline_editing_next_layer(self, layer_index, layer_count):
608714
msg = self.trUtf8("Packaging layer {layer_name}…").format(
@@ -634,6 +740,20 @@ def on_original_project_write(doc):
634740
def _on_offline_editing_task_progress(self, progress):
635741
self.task_progress_updated.emit(progress, self.__max_task_progress)
636742

743+
def cancel(self) -> None:
744+
"""
745+
Cancels the offline packaging of a QField project.
746+
Typically used when the QField export dialog is closed.
747+
"""
748+
self._is_canceled = True
749+
self._feedback.cancel()
750+
751+
def _check_canceled(self) -> None:
752+
"""Checks if packaging has been and should be canceled."""
753+
QCoreApplication.processEvents()
754+
if self._is_canceled:
755+
raise PackagingCanceledException()
756+
637757
def convertorProcessingProgress(self):
638758
"""
639759
Will create a new progress object for processing to get feedback from the basemap

libqfieldsync/project.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ def __init__(self):
1111
BASE_MAP_LAYER = "/baseMapLayer"
1212
BASE_MAP_TILE_SIZE = "/baseMapTileSize"
1313
BASE_MAP_MUPP = "/baseMapMupp"
14+
BASE_MAP_TILES_MIN_ZOOM_LEVEL = "/baseMapTilesMinZoomLevel"
15+
BASE_MAP_TILES_MAX_ZOOM_LEVEL = "/baseMapTilesMaxZoomLevel"
1416
OFFLINE_COPY_ONLY_AOI = "/offlineCopyOnlyAoi"
1517
ORIGINAL_PROJECT_PATH = "/originalProjectPath"
1618
IMPORTED_FILES_CHECKSUMS = "/importedFilesChecksums"
@@ -259,6 +261,32 @@ def base_map_mupp(self, value):
259261
"qfieldsync", ProjectProperties.BASE_MAP_MUPP, value
260262
)
261263

264+
@property
265+
def base_map_tiles_min_zoom_level(self) -> int:
266+
base_map_tiles_min_zoom_level, _ = self.project.readNumEntry(
267+
"qfieldsync", ProjectProperties.BASE_MAP_TILES_MIN_ZOOM_LEVEL, 14
268+
)
269+
return base_map_tiles_min_zoom_level
270+
271+
@base_map_tiles_min_zoom_level.setter
272+
def base_map_tiles_min_zoom_level(self, value: int):
273+
self.project.writeEntry(
274+
"qfieldsync", ProjectProperties.BASE_MAP_TILES_MIN_ZOOM_LEVEL, value
275+
)
276+
277+
@property
278+
def base_map_tiles_max_zoom_level(self) -> int:
279+
base_map_tiles_max_zoom_level, _ = self.project.readNumEntry(
280+
"qfieldsync", ProjectProperties.BASE_MAP_TILES_MAX_ZOOM_LEVEL, 14
281+
)
282+
return base_map_tiles_max_zoom_level
283+
284+
@base_map_tiles_max_zoom_level.setter
285+
def base_map_tiles_max_zoom_level(self, value):
286+
self.project.writeEntry(
287+
"qfieldsync", ProjectProperties.BASE_MAP_TILES_MAX_ZOOM_LEVEL, value
288+
)
289+
262290
@property
263291
def offline_copy_only_aoi(self):
264292
offline_copy_only_aoi, _ = self.project.readBoolEntry(

0 commit comments

Comments
 (0)