diff --git a/python/PyQt6/core/auto_generated/elevation/qgsprofilesourceregistry.sip.in b/python/PyQt6/core/auto_generated/elevation/qgsprofilesourceregistry.sip.in new file mode 100644 index 000000000000..a116003170e6 --- /dev/null +++ b/python/PyQt6/core/auto_generated/elevation/qgsprofilesourceregistry.sip.in @@ -0,0 +1,60 @@ +/************************************************************************ + * This file has been generated automatically from * + * * + * src/core/elevation/qgsprofilesourceregistry.h * + * * + * Do not edit manually ! Edit header and run scripts/sipify.pl again * + ************************************************************************/ + + + + + + +class QgsProfileSourceRegistry +{ +%Docstring(signature="appended") +Registry of profile sources used by :py:class:`QgsProfilePlotRenderer` + +:py:class:`QgsProfileSourceRegistry` is not usually directly created, but rather accessed through +:py:func:`QgsApplication.profileSourceRegistry()`. + +.. versionadded:: 3.38 +%End + +%TypeHeaderCode +#include "qgsprofilesourceregistry.h" +%End + public: + + QgsProfileSourceRegistry(); +%Docstring +Constructor - creates a registry of profile sources +%End + + ~QgsProfileSourceRegistry(); + + QList< QgsAbstractProfileSource * > profileSources() const; +%Docstring +Returns a list of registered profile sources +%End + + void registerProfileSource( QgsAbstractProfileSource *source /Transfer/ ); +%Docstring +Registers a profile ``source`` and takes ownership of it +%End + + void unregisterProfileSource( QgsAbstractProfileSource *source ); +%Docstring +Unregisters a profile ``source`` and destroys its instance +%End + +}; + +/************************************************************************ + * This file has been generated automatically from * + * * + * src/core/elevation/qgsprofilesourceregistry.h * + * * + * Do not edit manually ! Edit header and run scripts/sipify.pl again * + ************************************************************************/ diff --git a/python/PyQt6/core/auto_generated/qgsapplication.sip.in b/python/PyQt6/core/auto_generated/qgsapplication.sip.in index a8faeeb3c652..2eecdf517efd 100644 --- a/python/PyQt6/core/auto_generated/qgsapplication.sip.in +++ b/python/PyQt6/core/auto_generated/qgsapplication.sip.in @@ -964,6 +964,13 @@ Returns registry of available layer metadata provider implementations. Returns registry of available external storage implementations. .. versionadded:: 3.20 +%End + + static QgsProfileSourceRegistry *profileSourceRegistry() /KeepReference/; +%Docstring +Returns registry of available profile source implementations. + +.. versionadded:: 3.38 %End static QgsLocalizedDataPathRegistry *localizedDataPathRegistry() /KeepReference/; diff --git a/python/PyQt6/core/core_auto.sip b/python/PyQt6/core/core_auto.sip index b70acd56a468..2e7fc385d389 100644 --- a/python/PyQt6/core/core_auto.sip +++ b/python/PyQt6/core/core_auto.sip @@ -296,6 +296,7 @@ %Include auto_generated/elevation/qgsprofilerenderer.sip %Include auto_generated/elevation/qgsprofilerequest.sip %Include auto_generated/elevation/qgsprofilesnapping.sip +%Include auto_generated/elevation/qgsprofilesourceregistry.sip %Include auto_generated/elevation/qgsterrainprovider.sip %Include auto_generated/externalstorage/qgsexternalstorage.sip %Include auto_generated/externalstorage/qgsexternalstorageregistry.sip diff --git a/python/core/auto_generated/elevation/qgsprofilesourceregistry.sip.in b/python/core/auto_generated/elevation/qgsprofilesourceregistry.sip.in new file mode 100644 index 000000000000..a116003170e6 --- /dev/null +++ b/python/core/auto_generated/elevation/qgsprofilesourceregistry.sip.in @@ -0,0 +1,60 @@ +/************************************************************************ + * This file has been generated automatically from * + * * + * src/core/elevation/qgsprofilesourceregistry.h * + * * + * Do not edit manually ! Edit header and run scripts/sipify.pl again * + ************************************************************************/ + + + + + + +class QgsProfileSourceRegistry +{ +%Docstring(signature="appended") +Registry of profile sources used by :py:class:`QgsProfilePlotRenderer` + +:py:class:`QgsProfileSourceRegistry` is not usually directly created, but rather accessed through +:py:func:`QgsApplication.profileSourceRegistry()`. + +.. versionadded:: 3.38 +%End + +%TypeHeaderCode +#include "qgsprofilesourceregistry.h" +%End + public: + + QgsProfileSourceRegistry(); +%Docstring +Constructor - creates a registry of profile sources +%End + + ~QgsProfileSourceRegistry(); + + QList< QgsAbstractProfileSource * > profileSources() const; +%Docstring +Returns a list of registered profile sources +%End + + void registerProfileSource( QgsAbstractProfileSource *source /Transfer/ ); +%Docstring +Registers a profile ``source`` and takes ownership of it +%End + + void unregisterProfileSource( QgsAbstractProfileSource *source ); +%Docstring +Unregisters a profile ``source`` and destroys its instance +%End + +}; + +/************************************************************************ + * This file has been generated automatically from * + * * + * src/core/elevation/qgsprofilesourceregistry.h * + * * + * Do not edit manually ! Edit header and run scripts/sipify.pl again * + ************************************************************************/ diff --git a/python/core/auto_generated/qgsapplication.sip.in b/python/core/auto_generated/qgsapplication.sip.in index 44c3cc4ebd19..b5764e291047 100644 --- a/python/core/auto_generated/qgsapplication.sip.in +++ b/python/core/auto_generated/qgsapplication.sip.in @@ -964,6 +964,13 @@ Returns registry of available layer metadata provider implementations. Returns registry of available external storage implementations. .. versionadded:: 3.20 +%End + + static QgsProfileSourceRegistry *profileSourceRegistry() /KeepReference/; +%Docstring +Returns registry of available profile source implementations. + +.. versionadded:: 3.38 %End static QgsLocalizedDataPathRegistry *localizedDataPathRegistry() /KeepReference/; diff --git a/python/core/core_auto.sip b/python/core/core_auto.sip index b70acd56a468..2e7fc385d389 100644 --- a/python/core/core_auto.sip +++ b/python/core/core_auto.sip @@ -296,6 +296,7 @@ %Include auto_generated/elevation/qgsprofilerenderer.sip %Include auto_generated/elevation/qgsprofilerequest.sip %Include auto_generated/elevation/qgsprofilesnapping.sip +%Include auto_generated/elevation/qgsprofilesourceregistry.sip %Include auto_generated/elevation/qgsterrainprovider.sip %Include auto_generated/externalstorage/qgsexternalstorage.sip %Include auto_generated/externalstorage/qgsexternalstorageregistry.sip diff --git a/src/app/elevation/qgselevationprofilewidget.cpp b/src/app/elevation/qgselevationprofilewidget.cpp index 17533392429f..56f1b4bb8f62 100644 --- a/src/app/elevation/qgselevationprofilewidget.cpp +++ b/src/app/elevation/qgselevationprofilewidget.cpp @@ -56,6 +56,7 @@ #include "qgsprofileexporter.h" #include "qgsexpressioncontextutils.h" #include "qgsterrainprovider.h" +#include "qgsprofilesourceregistry.h" #include #include @@ -890,7 +891,10 @@ void QgsElevationProfileWidget::exportResults( Qgis::ProfileExportType type ) const QList< QgsMapLayer * > layersToGenerate = mCanvas->layers(); QList< QgsAbstractProfileSource * > sources; - sources.reserve( layersToGenerate.size() ); + const QList< QgsAbstractProfileSource * > registrySources = QgsApplication::profileSourceRegistry()->profileSources(); + sources.reserve( layersToGenerate.size() + registrySources.size() ); + + sources << registrySources; for ( QgsMapLayer *layer : layersToGenerate ) { if ( QgsAbstractProfileSource *source = dynamic_cast< QgsAbstractProfileSource * >( layer ) ) diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt index 070d417c746f..a36f9187adc6 100644 --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -65,6 +65,7 @@ set(QGIS_CORE_SRCS elevation/qgsprofilerenderer.cpp elevation/qgsprofilerequest.cpp elevation/qgsprofilesnapping.cpp + elevation/qgsprofilesourceregistry.cpp elevation/qgsterrainprovider.cpp geocoding/qgsabstractgeocoderlocatorfilter.cpp @@ -1410,6 +1411,7 @@ set(QGIS_CORE_HDRS elevation/qgsprofilerenderer.h elevation/qgsprofilerequest.h elevation/qgsprofilesnapping.h + elevation/qgsprofilesourceregistry.h elevation/qgsterrainprovider.h externalstorage/qgsexternalstorage.h diff --git a/src/core/elevation/qgsprofilesourceregistry.cpp b/src/core/elevation/qgsprofilesourceregistry.cpp new file mode 100644 index 000000000000..e28a63e7a6be --- /dev/null +++ b/src/core/elevation/qgsprofilesourceregistry.cpp @@ -0,0 +1,45 @@ +/*************************************************************************** + qgsprofilesourceregistry.cpp + -------------------------------------- + Date : April 2024 + Copyright : (C) 2024 by Germán Carrillo + Email : german at opengis dot ch + *************************************************************************** + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ +#include "qgsprofilesourceregistry.h" + +#include "qgsabstractprofilesource.h" + +QgsProfileSourceRegistry::QgsProfileSourceRegistry() +{ + +} + +QgsProfileSourceRegistry::~QgsProfileSourceRegistry() +{ + qDeleteAll( mSources ); +} + +QList< QgsAbstractProfileSource * > QgsProfileSourceRegistry::profileSources() const +{ + return mSources; +} + +void QgsProfileSourceRegistry::registerProfileSource( QgsAbstractProfileSource *profileSource ) +{ + if ( !mSources.contains( profileSource ) ) + mSources.append( profileSource ); +} + +void QgsProfileSourceRegistry::unregisterProfileSource( QgsAbstractProfileSource *profileSource ) +{ + const int index = mSources.indexOf( profileSource ); + if ( index >= 0 ) + delete mSources.takeAt( index ); +} diff --git a/src/core/elevation/qgsprofilesourceregistry.h b/src/core/elevation/qgsprofilesourceregistry.h new file mode 100644 index 000000000000..b3890eb757d4 --- /dev/null +++ b/src/core/elevation/qgsprofilesourceregistry.h @@ -0,0 +1,69 @@ +/*************************************************************************** + qgsprofilesourceregistry.h + -------------------------------------- + Date : April 2024 + Copyright : (C) 2024 by Germán Carrillo + Email : german at opengis dot ch + *************************************************************************** + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + * * + ***************************************************************************/ + +#ifndef QGSPROFILESOURCEREGISTRY_H +#define QGSPROFILESOURCEREGISTRY_H + +#include "qgis_core.h" +#include "qgis_sip.h" + +class QgsAbstractProfileSource; + +#include + + +/** + * \ingroup core + * \brief Registry of profile sources used by QgsProfilePlotRenderer + * + * QgsProfileSourceRegistry is not usually directly created, but rather accessed through + * QgsApplication::profileSourceRegistry(). + * + * \since QGIS 3.38 + */ +class CORE_EXPORT QgsProfileSourceRegistry +{ + public: + + /** + * Constructor - creates a registry of profile sources + */ + QgsProfileSourceRegistry(); + + /** + * Destructor + */ + ~QgsProfileSourceRegistry(); + + /** + * Returns a list of registered profile sources + */ + QList< QgsAbstractProfileSource * > profileSources() const; + + /** + * Registers a profile \a source and takes ownership of it + */ + void registerProfileSource( QgsAbstractProfileSource *source SIP_TRANSFER ); + + /** + * Unregisters a profile \a source and destroys its instance + */ + void unregisterProfileSource( QgsAbstractProfileSource *source ); + + private: + QList< QgsAbstractProfileSource * > mSources; +}; + +#endif // QGSPROFILESOURCEREGISTRY_H diff --git a/src/core/layout/qgslayoutitemelevationprofile.cpp b/src/core/layout/qgslayoutitemelevationprofile.cpp index 3ca725cf176c..3349f13d7717 100644 --- a/src/core/layout/qgslayoutitemelevationprofile.cpp +++ b/src/core/layout/qgslayoutitemelevationprofile.cpp @@ -30,6 +30,7 @@ #include "qgsvectorlayer.h" #include "qgslayoutrendercontext.h" #include "qgslayoutreportcontext.h" +#include "qgsprofilesourceregistry.h" #include @@ -673,6 +674,7 @@ void QgsLayoutItemElevationProfile::paint( QPainter *painter, const QStyleOption rc.setMapToPixel( QgsMapToPixel( mapUnitsPerPixel ) ); QList< QgsAbstractProfileSource * > sources; + sources << QgsApplication::profileSourceRegistry()->profileSources(); for ( const QgsMapLayerRef &layer : std::as_const( mLayers ) ) { if ( QgsAbstractProfileSource *source = dynamic_cast< QgsAbstractProfileSource * >( layer.get() ) ) @@ -725,6 +727,7 @@ void QgsLayoutItemElevationProfile::paint( QPainter *painter, const QStyleOption rc.setMapToPixel( QgsMapToPixel( mapUnitsPerPixel ) ); QList< QgsAbstractProfileSource * > sources; + sources << QgsApplication::profileSourceRegistry()->profileSources(); for ( const QgsMapLayerRef &layer : std::as_const( mLayers ) ) { if ( QgsAbstractProfileSource *source = dynamic_cast< QgsAbstractProfileSource * >( layer.get() ) ) @@ -960,6 +963,7 @@ void QgsLayoutItemElevationProfile::recreateCachedImageInBackground() mPainter.reset( new QPainter( mCacheRenderingImage.get() ) ); QList< QgsAbstractProfileSource * > sources; + sources << QgsApplication::profileSourceRegistry()->profileSources(); for ( const QgsMapLayerRef &layer : std::as_const( mLayers ) ) { if ( QgsAbstractProfileSource *source = dynamic_cast< QgsAbstractProfileSource * >( layer.get() ) ) diff --git a/src/core/qgsapplication.cpp b/src/core/qgsapplication.cpp index af22cc849140..4e1b0d172fa1 100644 --- a/src/core/qgsapplication.cpp +++ b/src/core/qgsapplication.cpp @@ -87,6 +87,7 @@ #include "qgsgpsconnection.h" #include "qgssensorregistry.h" #include "qgssensorthingsutils.h" +#include "qgsprofilesourceregistry.h" #include "gps/qgsgpsconnectionregistry.h" #include "processing/qgsprocessingregistry.h" @@ -2640,6 +2641,11 @@ QgsExternalStorageRegistry *QgsApplication::externalStorageRegistry() return members()->mExternalStorageRegistry; } +QgsProfileSourceRegistry *QgsApplication::profileSourceRegistry() +{ + return members()->mProfileSourceRegistry; +} + QgsLocalizedDataPathRegistry *QgsApplication::localizedDataPathRegistry() { return members()->mLocalizedDataPathRegistry; @@ -2823,6 +2829,11 @@ QgsApplication::ApplicationMembers::ApplicationMembers() mExternalStorageRegistry = new QgsExternalStorageRegistry(); profiler->end(); } + { + profiler->start( tr( "Setup profile source registry" ) ); + mProfileSourceRegistry = new QgsProfileSourceRegistry(); + profiler->end(); + } { profiler->start( tr( "Setup network content cache" ) ); mNetworkContentFetcherRegistry = new QgsNetworkContentFetcherRegistry(); @@ -2888,6 +2899,7 @@ QgsApplication::ApplicationMembers::~ApplicationMembers() delete mRecentStyleHandler; delete mSymbolLayerRegistry; delete mExternalStorageRegistry; + delete mProfileSourceRegistry; delete mTaskManager; delete mNetworkContentFetcherRegistry; delete mClassificationMethodRegistry; diff --git a/src/core/qgsapplication.h b/src/core/qgsapplication.h index 5d931609c915..842e97961fb1 100644 --- a/src/core/qgsapplication.h +++ b/src/core/qgsapplication.h @@ -77,6 +77,7 @@ class QgsRecentStyleHandler; class QgsDatabaseQueryLog; class QgsFontManager; class QgsSensorRegistry; +class QgsProfileSourceRegistry; /** * \ingroup core @@ -947,6 +948,12 @@ class CORE_EXPORT QgsApplication : public QApplication */ static QgsExternalStorageRegistry *externalStorageRegistry() SIP_KEEPREFERENCE; + /** + * Returns registry of available profile source implementations. + * \since QGIS 3.38 + */ + static QgsProfileSourceRegistry *profileSourceRegistry() SIP_KEEPREFERENCE; + /** * Returns the registry of data repositories * These are used as paths for basemaps, logos, etc. which can be referenced @@ -1138,6 +1145,7 @@ class CORE_EXPORT QgsApplication : public QApplication QgsProjectStorageRegistry *mProjectStorageRegistry = nullptr; QgsLayerMetadataProviderRegistry *mLayerMetadataProviderRegistry = nullptr; QgsExternalStorageRegistry *mExternalStorageRegistry = nullptr; + QgsProfileSourceRegistry *mProfileSourceRegistry = nullptr; QgsPageSizeRegistry *mPageSizeRegistry = nullptr; QgsRasterRendererRegistry *mRasterRendererRegistry = nullptr; QgsRendererRegistry *mRendererRegistry = nullptr; diff --git a/src/gui/elevation/qgselevationprofilecanvas.cpp b/src/gui/elevation/qgselevationprofilecanvas.cpp index e8d4711d41cd..885accc7aa93 100644 --- a/src/gui/elevation/qgselevationprofilecanvas.cpp +++ b/src/gui/elevation/qgselevationprofilecanvas.cpp @@ -37,6 +37,7 @@ #include "qgsscreenhelper.h" #include "qgsfillsymbol.h" #include "qgslinesymbol.h" +#include "qgsprofilesourceregistry.h" #include #include @@ -844,7 +845,10 @@ void QgsElevationProfileCanvas::refresh() const QList< QgsMapLayer * > layersToGenerate = layers(); QList< QgsAbstractProfileSource * > sources; - sources.reserve( layersToGenerate .size() ); + const QList< QgsAbstractProfileSource * > registrySources = QgsApplication::profileSourceRegistry()->profileSources(); + sources.reserve( layersToGenerate.size() + registrySources.size() ); + + sources << registrySources; for ( QgsMapLayer *layer : layersToGenerate ) { if ( QgsAbstractProfileSource *source = dynamic_cast< QgsAbstractProfileSource * >( layer ) ) diff --git a/tests/src/python/CMakeLists.txt b/tests/src/python/CMakeLists.txt index ad77a8f5c02f..c7b57054f42e 100644 --- a/tests/src/python/CMakeLists.txt +++ b/tests/src/python/CMakeLists.txt @@ -237,6 +237,7 @@ ADD_PYTHON_TEST(PyQgsProcessingPackageLayersAlgorithm test_processing_packagelay ADD_PYTHON_TEST(PyQgsProfileExporter test_qgsprofileexporter.py) ADD_PYTHON_TEST(PyQgsProfilePoint test_qgsprofilepoint.py) ADD_PYTHON_TEST(PyQgsProfileRequest test_qgsprofilerequest.py) +ADD_PYTHON_TEST(PyQgsProfileSourceRegistry test_qgsprofilesourceregistry.py) ADD_PYTHON_TEST(PyQgsProjectElevationProperties test_qgsprojectelevationproperties.py) ADD_PYTHON_TEST(PyQgsProjectGpsSettings test_qgsprojectgpssettings.py) ADD_PYTHON_TEST(PyQgsProjectMetadata test_qgsprojectmetadata.py) diff --git a/tests/src/python/test_qgsprofilesourceregistry.py b/tests/src/python/test_qgsprofilesourceregistry.py new file mode 100644 index 000000000000..e0efc6d2ba02 --- /dev/null +++ b/tests/src/python/test_qgsprofilesourceregistry.py @@ -0,0 +1,409 @@ +"""QGIS Unit tests for QgsProfileSourceRegistry + +.. note:: This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation; either version 2 of the License, or +(at your option) any later version. +""" +__author__ = 'Germán Carrillo' +__date__ = '03/05/2024' +__copyright__ = 'Copyright 2024, The QGIS Project' + + +from qgis.PyQt.QtCore import ( + QRectF, + Qt, + QPointF +) +from qgis.PyQt.QtGui import QColor, QPainterPath, QPolygonF +from qgis.PyQt.QtTest import QSignalSpy +from qgis.core import ( + Qgis, + QgsAbstractProfileGenerator, + QgsAbstractProfileResults, + QgsAbstractProfileSource, + QgsApplication, + QgsCoordinateReferenceSystem, + QgsDoubleRange, + QgsFeedback, + QgsFillSymbol, + QgsFontUtils, + QgsGeometry, + QgsLayout, + QgsLayoutItemElevationProfile, + QgsLineString, + QgsLineSymbol, + QgsMarkerSymbol, + QgsPoint, + QgsProfileExporter, + QgsProfileGenerationContext, + QgsProfileRequest, + QgsProject, + QgsTextFormat +) +from qgis.gui import QgsElevationProfileCanvas + +import unittest +from qgis.testing import start_app, QgisTestCase + +start_app() + + +class MyProfileResults(QgsAbstractProfileResults): + def __init__(self): + QgsAbstractProfileResults.__init__(self) + + self.__profile_curve = None + self.raw_points = [] + + self.distance_to_height = {} + self.geometries = [] + self.cross_section_geometries = [] + self.min_z = 4500 + self.max_z = -100 + + self.marker_symbol = QgsMarkerSymbol.createSimple( + {'name': 'square', 'size': 2, 'color': '#00ff00', + 'outline_style': 'no'}) + + def asFeatures(self, type, feedback): + result = [] + + if type == Qgis.ProfileExportType.Features3D: + for g in self.geometries: + feature = QgsAbstractProfileResults.Feature() + feature.geometry = g + result.append(feature) + + elif type == Qgis.ProfileExportType.Profile2D: + for g in self.cross_section_geometries: + feature = QgsAbstractProfileResults.Feature() + feature.geometry = g + result.append(feature) + + elif type == Qgis.ProfileExportType.DistanceVsElevationTable: + for i, geom in enumerate(self.geometries): + feature = QgsAbstractProfileResults.Feature() + feature.geometry = geom + p = self.cross_section_geometries[i].asPoint() + feature.attributes = {"distance": p.x(), "elevation": p.y()} + result.append(feature) + + return result + + def asGeometries(self): + return self.geometries + + def sampledPoints(self): + return self.raw_points + + def zRange(self): + return QgsDoubleRange(self.min_z, self.max_z) + + def type(self): + return "my-web-service-profile" + + def renderResults(self, context): + painter = context.renderContext().painter() + if not painter: + return + + painter.setBrush(Qt.BrushStyle.NoBrush) + painter.setPen(Qt.PenStyle.NoPen) + + minDistance = context.distanceRange().lower() + maxDistance = context.distanceRange().upper() + minZ = context.elevationRange().lower() + maxZ = context.elevationRange().upper() + + visibleRegion = QRectF(minDistance, minZ, maxDistance - minDistance, maxZ - minZ) + clipPath = QPainterPath() + clipPath.addPolygon(context.worldTransform().map(QPolygonF(visibleRegion))) + painter.setClipPath(clipPath, Qt.ClipOperation.IntersectClip) + + self.marker_symbol.startRender(context.renderContext()) + + for k, v in self.distance_to_height.items(): + if not v: + continue + + self.marker_symbol.renderPoint( + context.worldTransform().map(QPointF(k, v)), + None, + context.renderContext() + ) + + self.marker_symbol.stopRender(context.renderContext()) + + +class MyProfileGenerator(QgsAbstractProfileGenerator): + def __init__(self, request): + QgsAbstractProfileGenerator.__init__(self) + self.__request = request + self.__profile_curve = request.profileCurve().clone() if request.profileCurve() else None + self.__results = None + self.__feedback = QgsFeedback() + + def sourceId(self): + return "my-profile" + + def feedback(self): + return self.__feedback + + def generateProfile(self, context): # QgsProfileGenerationContext + if self.__profile_curve is None: + return False + + self.__results = MyProfileResults() + + result = [ + {"z": 454.8, "d": 0, "x": 2584085.816, "y": 1216473.232}, + {"z": 429.3, "d": 2199.9, "x": 2582027.691, "y": 1217250.279}, + {"z": 702.5, "d": 4399.9, "x": 2579969.567, "y": 1218027.326}, + {"z": 857.9, "d": 6430.1, "x": 2578394.472, "y": 1219308.404}, + {"z": 1282.7, "d": 8460.4, "x": 2576819.377, "y": 1220589.481}] + + for point in result: + if self.__feedback.isCanceled(): + return False + + x, y, z, d = point["x"], point["y"], point["z"], point["d"] + p = QgsPoint(x, y, z) + self.__results.raw_points.append(p) + self.__results.distance_to_height[d] = z + if z < self.__results.min_z: + self.__results.min_z = z + + if z > self.__results.max_z: + self.__results.max_z = z + + self.__results.geometries.append(QgsGeometry(p)) + self.__results.cross_section_geometries.append(QgsGeometry(QgsPoint(d, z))) + + return not self.__feedback.isCanceled() + + def takeResults(self): + return self.__results + + +class MyProfileSource(QgsAbstractProfileSource): + def __init__(self): + QgsAbstractProfileSource.__init__(self) + + def createProfileGenerator(self, request): + return MyProfileGenerator(request) + + +class TestQgsProfileSourceRegistry(QgisTestCase): + + def test_register_unregister_source(self): + initial_sources = QgsApplication.profileSourceRegistry().profileSources() + + source = MyProfileSource() + QgsApplication.profileSourceRegistry().registerProfileSource(source) + self.assertEqual( + len(QgsApplication.profileSourceRegistry().profileSources()), + len(initial_sources) + 1 + ) + self.assertEqual(QgsApplication.profileSourceRegistry().profileSources()[-1], source) + QgsApplication.profileSourceRegistry().unregisterProfileSource(source) + self.assertEqual( + QgsApplication.profileSourceRegistry().profileSources(), + initial_sources + ) + + def test_generate_profile_from_custom_source(self): + curve = QgsLineString() + curve.fromWkt("LINESTRING (2584085.816 1216473.232, 2579969.567 1218027.326, 2576819.377 1220589.481)") + req = QgsProfileRequest(curve) + req.setCrs(QgsCoordinateReferenceSystem('EPSG:2056')) + + source = MyProfileSource() + generator = source.createProfileGenerator(req) + self.assertTrue(generator.generateProfile(QgsProfileGenerationContext())) + results = generator.takeResults() + self.assertEqual(results.type(), "my-web-service-profile") + self.assertEqual(results.zRange(), QgsDoubleRange(429.3, 1282.7)) + + self.assertTrue(len(results.asGeometries()), 5) + expected_geoms = [QgsGeometry(QgsPoint(2584085.816, 1216473.232, 454.8)), + QgsGeometry(QgsPoint(2582027.691, 1217250.279, 429.3)), + QgsGeometry(QgsPoint(2579969.567, 1218027.326, 702.5)), + QgsGeometry(QgsPoint(2578394.472, 1219308.404, 857.9)), + QgsGeometry(QgsPoint(2576819.377, 1220589.481, 1282.7))] + + for i, geom in enumerate(results.asGeometries()): + self.checkGeometriesEqual(geom, expected_geoms[i], 0, 0) + + features = results.asFeatures(Qgis.ProfileExportType.DistanceVsElevationTable, QgsFeedback()) + self.assertEqual(len(features), len(results.distance_to_height)) + for feature in features: + self.assertEqual(feature.geometry.wkbType(), Qgis.WkbType.PointZ) + self.assertTrue(not feature.geometry.isEmpty()) + d = feature.attributes["distance"] + self.assertIn(d, results.distance_to_height) + self.assertEqual(feature.attributes["elevation"], results.distance_to_height[d]) + + def test_export_3d_from_custom_source(self): + source = MyProfileSource() + curve = QgsLineString() + curve.fromWkt("LINESTRING (2584085.816 1216473.232, 2579969.567 1218027.326, 2576819.377 1220589.481)") + req = QgsProfileRequest(curve) + req.setCrs(QgsCoordinateReferenceSystem('EPSG:2056')) + + exporter = QgsProfileExporter([source], req, Qgis.ProfileExportType.Features3D) + exporter.run(QgsFeedback()) + layers = exporter.toLayers() + self.assertEqual(len(layers), 1) + layer = layers[0] + self.assertEqual(layer.wkbType(), Qgis.WkbType.PointZ) + self.assertEqual(layer.crs(), QgsCoordinateReferenceSystem("EPSG:2056")) + self.assertEqual(layer.featureCount(), 5) + + expected_z = [454.8, 429.3, 702.5, 857.9, 1282.7] + for i, feature in enumerate(layer.getFeatures()): + z = feature.geometry().constGet().z() + self.assertEqual(z, expected_z[i]) + + def test_export_2d_from_custom_source(self): + source = MyProfileSource() + curve = QgsLineString() + curve.fromWkt("LINESTRING (2584085.816 1216473.232, 2579969.567 1218027.326, 2576819.377 1220589.481)") + req = QgsProfileRequest(curve) + req.setCrs(QgsCoordinateReferenceSystem('EPSG:2056')) + + exporter = QgsProfileExporter([source], req, Qgis.ProfileExportType.Profile2D) + exporter.run(QgsFeedback()) + layers = exporter.toLayers() + self.assertEqual(len(layers), 1) + layer = layers[0] + self.assertEqual(layer.wkbType(), Qgis.WkbType.Point) + self.assertEqual(layer.featureCount(), 5) + + expected_values = { + 0: 454.8, + 2199.9: 429.3, + 4399.9: 702.5, + 6430.1: 857.9, + 8460.4: 1282.7 + } + for i, feature in enumerate(layer.getFeatures()): + geom = feature.geometry().constGet() + d = geom.x() + z = geom.y() + self.assertEqual(z, expected_values[d]) + + def test_export_distance_elevation_from_custom_source(self): + source = MyProfileSource() + curve = QgsLineString() + curve.fromWkt("LINESTRING (2584085.816 1216473.232, 2579969.567 1218027.326, 2576819.377 1220589.481)") + req = QgsProfileRequest(curve) + req.setCrs(QgsCoordinateReferenceSystem('EPSG:2056')) + + exporter = QgsProfileExporter([source], req, Qgis.ProfileExportType.DistanceVsElevationTable) + exporter.run(QgsFeedback()) + layers = exporter.toLayers() + self.assertEqual(len(layers), 1) + layer = layers[0] + self.assertEqual(layer.wkbType(), Qgis.WkbType.PointZ) + self.assertEqual(layer.crs(), QgsCoordinateReferenceSystem("EPSG:2056")) + self.assertEqual(layer.featureCount(), 5) + + expected_values = { + 0: 454.8, + 2199.9: 429.3, + 4399.9: 702.5, + 6430.1: 857.9, + 8460.4: 1282.7 + } + expected_z = list(expected_values.values()) + for i, feature in enumerate(layer.getFeatures()): + z = feature.geometry().constGet().z() + self.assertEqual(z, expected_z[i]) + self.assertEqual(z, feature["elevation"]) + self.assertIn(feature["distance"], expected_values) + self.assertEqual(expected_values[feature["distance"]], feature["elevation"]) + + def test_profile_canvas_custom_source(self): + canvas = QgsElevationProfileCanvas() + canvas.setProject(QgsProject.instance()) + canvas.setCrs(QgsCoordinateReferenceSystem("EPSG:2056")) + curve = QgsLineString() + curve.fromWkt("LINESTRING (2584085.816 1216473.232, 2579969.567 1218027.326, 2576819.377 1220589.481)") + canvas.setProfileCurve(curve.clone()) + spy = QSignalSpy(canvas.activeJobCountChanged) + self.assertTrue(spy.isValid()) + + source = MyProfileSource() + QgsApplication.profileSourceRegistry().registerProfileSource(source) + canvas.refresh() + spy.wait() + + distance_range = canvas.visibleDistanceRange() + self.assertTrue(distance_range.contains(0), f"Distance 0 (min) not included in range ({distance_range})") + self.assertTrue(distance_range.contains(8460.4), f"Distance 8460.4 (max) not included in range ({distance_range})") + + elevation_range = canvas.visibleElevationRange() + self.assertTrue(elevation_range.contains(429.3), f"Elevation 429.3 (min) not included in range ({elevation_range})") + self.assertTrue(elevation_range.contains(1282.7), f"Elevation 1282.7 (max) not included in range ({elevation_range})") + QgsApplication.profileSourceRegistry().unregisterProfileSource(source) + + def test_layout_item_profile_custom_source(self): + """ + Test getting a custom profile in a layout item + """ + source = MyProfileSource() + QgsApplication.profileSourceRegistry().registerProfileSource(source) + + layout = QgsLayout(QgsProject.instance()) + layout.initializeDefaults() + + profile_item = QgsLayoutItemElevationProfile(layout) + layout.addLayoutItem(profile_item) + profile_item.attemptSetSceneRect(QRectF(10, 10, 180, 180)) + + curve = QgsLineString() + curve.fromWkt( + "LINESTRING (2584085.816 1216473.232, 2579969.567 1218027.326, 2576819.377 1220589.481)") + + profile_item.setProfileCurve(curve) + profile_item.setCrs(QgsCoordinateReferenceSystem("EPSG:2056")) + + profile_item.plot().setXMinimum(-100) + profile_item.plot().setXMaximum(curve.length() + 100) + profile_item.plot().setYMaximum(1300) + + profile_item.plot().xAxis().setGridIntervalMajor(1000) + profile_item.plot().xAxis().setGridIntervalMinor(500) + profile_item.plot().xAxis().setGridMajorSymbol(QgsLineSymbol.createSimple({'color': '#ffaaff', 'width': 2})) + profile_item.plot().xAxis().setGridMinorSymbol( + QgsLineSymbol.createSimple({'color': '#ffffaa', 'width': 2})) + + format = QgsTextFormat() + format.setFont(QgsFontUtils.getStandardTestFont("Bold")) + format.setSize(20) + format.setNamedStyle("Bold") + format.setColor(QColor(0, 0, 0)) + profile_item.plot().xAxis().setTextFormat(format) + profile_item.plot().xAxis().setLabelInterval(2000) + + profile_item.plot().yAxis().setGridIntervalMajor(1000) + profile_item.plot().yAxis().setGridIntervalMinor(500) + profile_item.plot().yAxis().setGridMajorSymbol(QgsLineSymbol.createSimple({'color': '#ffffaa', 'width': 2})) + profile_item.plot().yAxis().setGridMinorSymbol( + QgsLineSymbol.createSimple({'color': '#aaffaa', 'width': 2})) + + profile_item.plot().yAxis().setTextFormat(format) + profile_item.plot().yAxis().setLabelInterval(500) + + profile_item.plot().setChartBorderSymbol( + QgsFillSymbol.createSimple({'style': 'no', 'color': '#aaffaa', 'width_border': 2})) + + self.assertTrue( + self.render_layout_check('custom_profile', layout) + ) + QgsApplication.profileSourceRegistry().unregisterProfileSource(source) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/testdata/control_images/expected_custom_profile/expected_custom_profile.png b/tests/testdata/control_images/expected_custom_profile/expected_custom_profile.png new file mode 100644 index 000000000000..b8672556e49a Binary files /dev/null and b/tests/testdata/control_images/expected_custom_profile/expected_custom_profile.png differ diff --git a/tests/testdata/control_images/expected_custom_profile/expected_custom_profile_mask.png b/tests/testdata/control_images/expected_custom_profile/expected_custom_profile_mask.png new file mode 100644 index 000000000000..4490b53f6573 Binary files /dev/null and b/tests/testdata/control_images/expected_custom_profile/expected_custom_profile_mask.png differ