diff --git a/controller.py b/controller.py index 252238c..2ea8e75 100644 --- a/controller.py +++ b/controller.py @@ -1,15 +1,15 @@ from typing import Iterable, Optional, List from qgis.PyQt.QtCore import pyqtSignal, QObject -from qgis.core import Qgis, QgsProject, QgsMapLayer, QgsMapLayerType, QgsWkbTypes, QgsGeometry +from qgis.core import QgsProject, QgsMapLayer, QgsMapLayerType, QgsWkbTypes, QgsGeometry from qgis.gui import QgsRubberBand from qgis.utils import iface from .maptool import PolygonTool from .filters import FilterDefinition, Predicate from .helpers import getSupportedLayers, removeFilterFromLayer, addFilterToLayer, refreshLayerTree, hasLayerException, \ - warnAboutCurveGeoms -from .settings import FILTER_COMMENT_START, LOCALIZED_PLUGIN_NAME + warnAboutCurveGeoms, getFilterStartStopString +from .settings import LOCALIZED_PLUGIN_NAME class FilterController(QObject): @@ -51,7 +51,8 @@ def onLayersAdded(self, layers: Iterable[QgsMapLayer]): else: # Look for saved filters to use with the plugin (possible when project was loaded) for layer in getSupportedLayers(layers): - if FILTER_COMMENT_START in layer.subsetString(): + FILTER_START_STRING, _ = getFilterStartStopString(layer) + if FILTER_START_STRING in layer.subsetString(): self.setFilterFromLayer(layer) return @@ -63,7 +64,7 @@ def onProjectCleared(self): self.removeFilter() def setFilterFromLayer(self, layer): - filterDefinition = FilterDefinition.fromFilterString(layer.subsetString()) + filterDefinition = FilterDefinition.fromFilterString(layer) self.currentFilter = filterDefinition self.refreshFilter() diff --git a/filters.py b/filters.py index 881340a..dc0d9f9 100644 --- a/filters.py +++ b/filters.py @@ -3,16 +3,43 @@ from typing import List from qgis.PyQt.QtWidgets import QMessageBox -from qgis.core import QgsVectorLayer, QgsGeometry, QgsCoordinateReferenceSystem + +from qgis.core import ( + QgsVectorLayer, + QgsGeometry, + QgsCoordinateReferenceSystem, + Qgis +) + +if Qgis.QGIS_VERSION_INT > 33600: + from qgis.core import QgsSensorThingsUtils + from qgis.utils import iface -from .settings import FILTER_COMMENT_START, FILTER_COMMENT_STOP, LOCALIZED_PLUGIN_NAME -from .helpers import tr, saveSettingsValue, readSettingsValue, allSettingsValues, removeSettingsValue, \ - getLayerGeomName, matchFormatString +from .settings import ( + LOCALIZED_PLUGIN_NAME, + SENSORTHINGS_STORAGE_TYPE +) + +from .helpers import ( + tr, + saveSettingsValue, + readSettingsValue, + allSettingsValues, + removeSettingsValue, + getLayerGeomName, + matchFormatString, + reproject_geometry, + getFilterStartStopString +) FILTERSTRING_TEMPLATE = "{spatial_predicate}({geom_name}, ST_TRANSFORM(ST_GeomFromText('{wkt}', {srid}), {layer_srid}))" +# sensorthings filter does not support reprojection (st_transform) +# reprojection happens in helpers.py -> addFilterToLayer +FILTERSTRING_TEMPLATE_SENSORTHINGS = "{spatial_predicate}({geom_name}, geography'{wkt}')" + class Predicate(IntEnum): INTERSECTS = 1 @@ -51,35 +78,60 @@ def filterString(self, layer: QgsVectorLayer) -> str: Returns: str: A layer filter string """ - # ST_DISJOINT does not use spatial indexes, but we can use its opposite "NOT ST_INTERSECTS" which does spatial_predicate = f"ST_{Predicate(self.predicate).name}" - if self.predicate == Predicate.DISJOINT: - spatial_predicate = "NOT ST_INTERSECTS" + wkt = self.wkt if not self.bbox else self.boxGeometry.asWkt() + srid=self.crs.postgisSrid() + layer_srid=layer.crs().postgisSrid() + geom_name = getLayerGeomName(layer) - wkt = self.wkt - if self.bbox: - wkt = self.boxGeometry.asWkt() + if layer.storageType() == SENSORTHINGS_STORAGE_TYPE: + # SensorThings only supports single geometry types + single_geometry = QgsGeometry.fromWkt(wkt) + single_geometry.convertToSingleType() - geom_name = getLayerGeomName(layer) + # SensorThings filter does not support reprojection (st_transform) + # thats why the reprojection must be executed on client-side. + reprojected_geometry = reproject_geometry(single_geometry, srid, layer_srid) + + spatial_predicate = spatial_predicate.lower() # sensorthings specification uses lower case + + return FILTERSTRING_TEMPLATE_SENSORTHINGS.format( + spatial_predicate=spatial_predicate, + geom_name=geom_name, + wkt=reprojected_geometry.asWkt() + ) + # ST_DISJOINT does not use spatial indexes, but we can use its opposite "NOT ST_INTERSECTS" which does + if self.predicate == Predicate.DISJOINT: + spatial_predicate = "NOT ST_INTERSECTS" return FILTERSTRING_TEMPLATE.format( spatial_predicate=spatial_predicate, geom_name=geom_name, wkt=wkt, - srid=self.crs.postgisSrid(), - layer_srid=layer.crs().postgisSrid() + srid=srid, + layer_srid=layer_srid ) + @staticmethod - def fromFilterString(subsetString: str) -> 'FilterDefinition': - start_index = subsetString.find(FILTER_COMMENT_START) + len(FILTER_COMMENT_START) - stop_index = subsetString.find(FILTER_COMMENT_STOP) + def fromFilterString(layer: QgsVectorLayer) -> 'FilterDefinition': + subsetString = layer.subsetString() + FILTER_START_STRING, FILTER_STOP_STRING = getFilterStartStopString(layer) + start_index = subsetString.find(FILTER_START_STRING) + len(FILTER_START_STRING) + stop_index = subsetString.find(FILTER_STOP_STRING) filterString = subsetString[start_index: stop_index] filterString = filterString.replace(' AND ', '') - params = matchFormatString(FILTERSTRING_TEMPLATE, filterString) - predicateName = params['spatial_predicate'][len('ST_'):] - if filterString.startswith('NOT ST_INTERSECTS'): - predicateName = 'DISJOINT' + + if layer.storageType() == SENSORTHINGS_STORAGE_TYPE: + params = matchFormatString(FILTERSTRING_TEMPLATE, filterString) + predicateName = params['spatial_predicate'][len('st_'):] + + else: + params = matchFormatString(FILTERSTRING_TEMPLATE, filterString) + predicateName = params['spatial_predicate'][len('ST_'):] + if filterString.startswith('NOT ST_INTERSECTS'): + predicateName = 'DISJOINT'# + predicate = Predicate[predicateName] filterDefinition = FilterDefinition( name=tr('Unknown filter'), diff --git a/helpers.py b/helpers.py index 8dd0340..f55ce18 100644 --- a/helpers.py +++ b/helpers.py @@ -5,13 +5,41 @@ from osgeo import ogr from qgis.PyQt.QtCore import QCoreApplication -from qgis.core import Qgis, QgsExpressionContextUtils, QgsSettings, QgsMapLayer, QgsMapLayerType, QgsVectorLayer,\ - QgsWkbTypes +from qgis.core import ( + Qgis, + QgsExpressionContextUtils, + QgsSettings, + QgsMapLayer, + QgsMapLayerType, + QgsVectorLayer, + QgsWkbTypes, + QgsGeometry, + QgsCoordinateReferenceSystem, + QgsCoordinateTransform, + QgsProject +) + +if Qgis.QGIS_VERSION_INT > 33600: + from qgis.core import QgsSensorThingsUtils + from qgis.utils import iface -from .settings import SUPPORTED_STORAGE_TYPES, GROUP, FILTER_COMMENT_START, FILTER_COMMENT_STOP, \ - LAYER_EXCEPTION_VARIABLE, LOCALIZED_PLUGIN_NAME +from .settings import ( + SUPPORTED_STORAGE_TYPES, + GROUP, + FILTER_COMMENT_START, + FILTER_COMMENT_STOP, + FILTER_COMMENT_START_SENSORTHINGS, + FILTER_COMMENT_STOP_SENSORTHINGS, + LAYER_EXCEPTION_VARIABLE, + LOCALIZED_PLUGIN_NAME, + SENSORTHINGS_STORAGE_TYPE +) + +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from .filters import FilterDefinition def tr(message): return QCoreApplication.translate('@default', message) @@ -72,27 +100,73 @@ def isLayerSupported(layer: QgsMapLayer): return True +def getFilterStartStopString(layer: QgsVectorLayer) -> tuple[str, str]: + if layer.storageType() == SENSORTHINGS_STORAGE_TYPE: + return FILTER_COMMENT_START_SENSORTHINGS, FILTER_COMMENT_STOP_SENSORTHINGS + else: + return FILTER_COMMENT_START, FILTER_COMMENT_STOP + def removeFilterFromLayer(layer: QgsVectorLayer): + # sensorthings filter does not support inline comments (FILTER_COMMENT_START) + # The workaround for this is a string that always evals to true and is only used with this plugin + FILTER_START_STRING, FILTER_STOP_STRING = getFilterStartStopString(layer) currentFilter = layer.subsetString() - if FILTER_COMMENT_START not in currentFilter: + if FILTER_START_STRING not in currentFilter: return - start_index = currentFilter.find(FILTER_COMMENT_START) - stop_index = currentFilter.find(FILTER_COMMENT_STOP) + len(FILTER_COMMENT_STOP) + start_index = currentFilter.find(FILTER_START_STRING) + stop_index = currentFilter.find(FILTER_STOP_STRING) + len(FILTER_STOP_STRING) newFilter = currentFilter[:start_index] + currentFilter[stop_index:] + newFilter = newFilter.rstrip(' and ') layer.setSubsetString(newFilter) def addFilterToLayer(layer: QgsVectorLayer, filterDef: 'FilterDefinition'): currentFilter = layer.subsetString() - if FILTER_COMMENT_START in currentFilter: + FILTER_START_STRING, FILTER_STOP_STRING = getFilterStartStopString(layer) + if FILTER_START_STRING in currentFilter: removeFilterFromLayer(layer) + currentFilter = layer.subsetString() + connect = " AND " if currentFilter else "" - newFilter = f'{currentFilter}{FILTER_COMMENT_START}{connect}{filterDef.filterString(layer)}{FILTER_COMMENT_STOP}' + if layer.storageType() == SENSORTHINGS_STORAGE_TYPE: + connect = connect.lower() # SensorThings only supports lowercase 'and' + newFilter = f'{currentFilter}{connect}{FILTER_START_STRING}{filterDef.filterString(layer)}{FILTER_STOP_STRING}' + else: + newFilter = f'{currentFilter}{FILTER_START_STRING}{connect}{filterDef.filterString(layer)}{FILTER_STOP_STRING}' layer.setSubsetString(newFilter) + + +def reproject_geometry(geometry: QgsGeometry, source_crs_epsg: int, target_crs_epsg: int) -> QgsGeometry: + """ + Reproject a QgsGeometry from a source CRS to a target CRS. + + Args: + geometry (QgsGeometry): The QgsGeometry to reproject. + source_crs_epsg (int): The EPSG code of the source CRS. + target_crs_epsg (int): The EPSG code of the target CRS. + + Returns: + str: The reprojected geometry. + """ + source_crs = QgsCoordinateReferenceSystem(source_crs_epsg) + target_crs = QgsCoordinateReferenceSystem(target_crs_epsg) + if source_crs == target_crs: + return geometry + transform = QgsCoordinateTransform(source_crs, target_crs, QgsProject.instance()) + geometry.transform(transform) + return geometry + + + def getLayerGeomName(layer: QgsVectorLayer): + if layer.storageType() == SENSORTHINGS_STORAGE_TYPE: + entity_str = layer.dataProvider().uri().param('entity') + entity_type = QgsSensorThingsUtils.stringToEntity(entity_str) + geom_field = QgsSensorThingsUtils.geometryFieldForEntityType(entity_type) + return geom_field return layer.dataProvider().uri().geometryColumn() or getLayerGeomNameOgr(layer) diff --git a/settings.py b/settings.py index 5263872..e849989 100644 --- a/settings.py +++ b/settings.py @@ -1,4 +1,5 @@ from qgis.PyQt.QtCore import QCoreApplication +from qgis.core import Qgis def tr(message): return QCoreApplication.translate('@default', message) @@ -17,5 +18,13 @@ def tr(message): FILTER_COMMENT_START = '/* SpatialFilter Plugin Start */' FILTER_COMMENT_STOP = '/* SpatialFilter Plugin Stop */' +FILTER_COMMENT_START_SENSORTHINGS = "'SpatialFilter Plugin Start' eq 'SpatialFilter Plugin Start' and " +FILTER_COMMENT_STOP_SENSORTHINGS = " and 'SpatialFilter Plugin Stop' eq 'SpatialFilter Plugin Stop'" + # The QGIS Storage Types that can be filtered by the plugin SUPPORTED_STORAGE_TYPES = ['POSTGRESQL DATABASE WITH POSTGIS EXTENSION', 'GPKG', 'SQLITE'] + +SENSORTHINGS_STORAGE_TYPE = 'OGC SensorThings API' + +if Qgis.QGIS_VERSION_INT > 33600: + SUPPORTED_STORAGE_TYPES.append(SENSORTHINGS_STORAGE_TYPE.upper())