From 65fc7b03e234a8e574ce22543c52ebba36fc3966 Mon Sep 17 00:00:00 2001 From: Nyall Dawson Date: Fri, 31 May 2024 13:55:51 +1000 Subject: [PATCH] Add hidden QSettings switch to use a QgsGeometry/GEOS backend for label masking If the 'map/maskBackend' QSettings key is set to 'geometry', then QgsGeometryPaintEngine will be used for calculating clip paths for label masking instead of QPainterPath The intention here is that we gain improved flexibility to optimise the creation and logic behind clipping path generation, vs the limited API we get from QPainterPath. --- .../auto_generated/qgsrendercontext.sip.in | 22 ++++++++ .../auto_generated/qgsrendercontext.sip.in | 22 ++++++++ src/core/maprenderer/qgsmaprendererjob.cpp | 27 ++++++++-- src/core/maprenderer/qgsmaprendererjob.h | 8 +++ src/core/qgsrendercontext.cpp | 12 +++++ src/core/qgsrendercontext.h | 23 +++++++++ src/core/symbology/qgssymbollayer.cpp | 44 ++++++++++++---- tests/src/python/test_selective_masking.py | 51 ++++++++++++++++--- 8 files changed, 186 insertions(+), 23 deletions(-) diff --git a/python/PyQt6/core/auto_generated/qgsrendercontext.sip.in b/python/PyQt6/core/auto_generated/qgsrendercontext.sip.in index edba40a75cd09..38272f91b0e9f 100644 --- a/python/PyQt6/core/auto_generated/qgsrendercontext.sip.in +++ b/python/PyQt6/core/auto_generated/qgsrendercontext.sip.in @@ -955,6 +955,8 @@ rendering using QBrush objects. %Docstring Add a clip ``path`` to be applied to the ``symbolLayer`` before rendering +.. seealso:: :py:func:`addSymbolLayerClipGeometry` + .. versionadded:: 3.26 %End @@ -962,7 +964,27 @@ Add a clip ``path`` to be applied to the ``symbolLayer`` before rendering %Docstring Returns clip paths to be applied to the ``symbolLayer`` before rendering +.. seealso:: :py:func:`symbolLayerClipGeometries` + .. versionadded:: 3.26 +%End + + void addSymbolLayerClipGeometry( const QString &symbolLayerId, const QgsGeometry &geometry ); +%Docstring +Add a clip ``geometry`` to be applied to the ``symbolLayer`` before rendering. + +.. seealso:: :py:func:`symbolLayerClipGeometries` + +.. versionadded:: 3.38 +%End + + QVector symbolLayerClipGeometries( const QString &symbolLayerId ) const; +%Docstring +Returns clipping geometries to be applied to the ``symbolLayer`` before rendering + +.. seealso:: :py:func:`addSymbolLayerClipGeometry` + +.. versionadded:: 3.38 %End QgsDoubleRange zRange() const; diff --git a/python/core/auto_generated/qgsrendercontext.sip.in b/python/core/auto_generated/qgsrendercontext.sip.in index edba40a75cd09..38272f91b0e9f 100644 --- a/python/core/auto_generated/qgsrendercontext.sip.in +++ b/python/core/auto_generated/qgsrendercontext.sip.in @@ -955,6 +955,8 @@ rendering using QBrush objects. %Docstring Add a clip ``path`` to be applied to the ``symbolLayer`` before rendering +.. seealso:: :py:func:`addSymbolLayerClipGeometry` + .. versionadded:: 3.26 %End @@ -962,7 +964,27 @@ Add a clip ``path`` to be applied to the ``symbolLayer`` before rendering %Docstring Returns clip paths to be applied to the ``symbolLayer`` before rendering +.. seealso:: :py:func:`symbolLayerClipGeometries` + .. versionadded:: 3.26 +%End + + void addSymbolLayerClipGeometry( const QString &symbolLayerId, const QgsGeometry &geometry ); +%Docstring +Add a clip ``geometry`` to be applied to the ``symbolLayer`` before rendering. + +.. seealso:: :py:func:`symbolLayerClipGeometries` + +.. versionadded:: 3.38 +%End + + QVector symbolLayerClipGeometries( const QString &symbolLayerId ) const; +%Docstring +Returns clipping geometries to be applied to the ``symbolLayer`` before rendering + +.. seealso:: :py:func:`addSymbolLayerClipGeometry` + +.. versionadded:: 3.38 %End QgsDoubleRange zRange() const; diff --git a/src/core/maprenderer/qgsmaprendererjob.cpp b/src/core/maprenderer/qgsmaprendererjob.cpp index 3d10b8e7f3376..2054503c53df7 100644 --- a/src/core/maprenderer/qgsmaprendererjob.cpp +++ b/src/core/maprenderer/qgsmaprendererjob.cpp @@ -42,6 +42,7 @@ #include "qgsvectorlayerrenderer.h" #include "qgsrendereditemresults.h" #include "qgsmaskpaintdevice.h" +#include "qgsgeometrypaintdevice.h" #include "qgsrasterrenderer.h" #include "qgselevationmap.h" #include "qgssettingsentryimpl.h" @@ -51,6 +52,7 @@ #include "qgsmeshlayerlabeling.h" const QgsSettingsEntryBool *QgsMapRendererJob::settingsLogCanvasRefreshEvent = new QgsSettingsEntryBool( QStringLiteral( "logCanvasRefreshEvent" ), QgsSettingsTree::sTreeMap, false ); +const QgsSettingsEntryString *QgsMapRendererJob::settingsMaskBackend = new QgsSettingsEntryString( QStringLiteral( "maskBackend" ), QgsSettingsTree::sTreeMap, QString(), QStringLiteral( "Backend engine to use for selective masking" ) ); ///@cond PRIVATE @@ -683,6 +685,8 @@ std::vector QgsMapRendererJob::prepareJobs( QPainter *painter, Q std::vector< LayerRenderJob > QgsMapRendererJob::prepareSecondPassJobs( std::vector< LayerRenderJob > &firstPassJobs, LabelRenderJob &labelJob ) { + const bool useGeometryBackend = settingsMaskBackend->value().compare( QLatin1String( "geometry" ), Qt::CaseInsensitive ) == 0; + std::vector< LayerRenderJob > secondPassJobs; // We will need to quickly access the associated rendering job of a layer @@ -812,7 +816,7 @@ std::vector< LayerRenderJob > QgsMapRendererJob::prepareSecondPassJobs( std::vec if ( forceVector && !labelHasEffects[ maskId ] ) { // set a painter to get all masking instruction in order to later clip masked symbol layer - maskPaintDevice = new QgsMaskPaintDevice( true ); + maskPaintDevice = useGeometryBackend ? dynamic_cast< QPaintDevice *>( new QgsGeometryPaintDevice( true ) ) : dynamic_cast< QPaintDevice * >( new QgsMaskPaintDevice( true ) ); maskPainter = new QPainter( maskPaintDevice ); } else @@ -887,7 +891,7 @@ std::vector< LayerRenderJob > QgsMapRendererJob::prepareSecondPassJobs( std::vec if ( forceVector && !maskLayerHasEffects[ job.layerId ] ) { // set a painter to get all masking instruction in order to later clip masked symbol layer - maskPaintDevice = new QgsMaskPaintDevice(); + maskPaintDevice = useGeometryBackend ? dynamic_cast< QPaintDevice *>( new QgsGeometryPaintDevice( true ) ) : dynamic_cast< QPaintDevice * >( new QgsMaskPaintDevice( true ) ); maskPainter = new QPainter( maskPaintDevice ); } else @@ -990,10 +994,23 @@ void QgsMapRendererJob::initSecondPassJobs( std::vector< LayerRenderJob > &secon for ( const QPair &p : std::as_const( job.maskJobs ) ) { QPainter *maskPainter = p.first ? p.first->maskPainter.get() : labelJob.maskPainters[p.second].get(); - QPainterPath path = static_cast( maskPainter->device() )->maskPainterPath(); - for ( const QString &symbolLayerId : job.context()->disabledSymbolLayersV2() ) + + const QSet layers = job.context()->disabledSymbolLayersV2(); + if ( QgsMaskPaintDevice *maskDevice = dynamic_cast( maskPainter->device() ) ) + { + QPainterPath path = maskDevice->maskPainterPath(); + for ( const QString &symbolLayerId : layers ) + { + job.context()->addSymbolLayerClipPath( symbolLayerId, path ); + } + } + else if ( QgsGeometryPaintDevice *geometryDevice = dynamic_cast( maskPainter->device() ) ) { - job.context()->addSymbolLayerClipPath( symbolLayerId, path ); + const QgsGeometry geometry( geometryDevice->geometry().clone() ); + for ( const QString &symbolLayerId : layers ) + { + job.context()->addSymbolLayerClipGeometry( symbolLayerId, geometry ); + } } } diff --git a/src/core/maprenderer/qgsmaprendererjob.h b/src/core/maprenderer/qgsmaprendererjob.h index e4ff557d17f80..66e00c00db185 100644 --- a/src/core/maprenderer/qgsmaprendererjob.h +++ b/src/core/maprenderer/qgsmaprendererjob.h @@ -41,6 +41,7 @@ class QgsFeatureFilterProvider; class QgsRenderedItemResults; class QgsElevationMap; class QgsSettingsEntryBool; +class QgsSettingsEntryString; #ifndef SIP_RUN /// @cond PRIVATE @@ -481,6 +482,13 @@ class CORE_EXPORT QgsMapRendererJob : public QObject SIP_ABSTRACT #ifndef SIP_RUN //! Settings entry log canvas refresh event static const QgsSettingsEntryBool *settingsLogCanvasRefreshEvent; + + /** + * Settings entry for mask painting backend engine. + * + * \since QGIS 3.38 + */ + static const QgsSettingsEntryString *settingsMaskBackend; #endif signals: diff --git a/src/core/qgsrendercontext.cpp b/src/core/qgsrendercontext.cpp index 588019ef9ddce..3e76cf3f6d4c7 100644 --- a/src/core/qgsrendercontext.cpp +++ b/src/core/qgsrendercontext.cpp @@ -83,6 +83,7 @@ QgsRenderContext::QgsRenderContext( const QgsRenderContext &rh ) , mFrameRate( rh.mFrameRate ) , mCurrentFrame( rh.mCurrentFrame ) , mSymbolLayerClipPaths( rh.mSymbolLayerClipPaths ) + , mSymbolLayerClippingGeometries( rh.mSymbolLayerClippingGeometries ) #ifdef QGISDEBUG , mHasTransformContext( rh.mHasTransformContext ) #endif @@ -134,6 +135,7 @@ QgsRenderContext &QgsRenderContext::operator=( const QgsRenderContext &rh ) mFrameRate = rh.mFrameRate; mCurrentFrame = rh.mCurrentFrame; mSymbolLayerClipPaths = rh.mSymbolLayerClipPaths; + mSymbolLayerClippingGeometries = rh.mSymbolLayerClippingGeometries; if ( isTemporal() ) setTemporalRange( rh.temporalRange() ); #ifdef QGISDEBUG @@ -732,6 +734,16 @@ QList QgsRenderContext::symbolLayerClipPaths( const QString &symbo return mSymbolLayerClipPaths[ symbolLayerId ]; } +void QgsRenderContext::addSymbolLayerClipGeometry( const QString &symbolLayerId, const QgsGeometry &geometry ) +{ + mSymbolLayerClippingGeometries[ symbolLayerId ].append( geometry ); +} + +QVector QgsRenderContext::symbolLayerClipGeometries( const QString &symbolLayerId ) const +{ + return mSymbolLayerClippingGeometries[ symbolLayerId ]; +} + void QgsRenderContext::setDisabledSymbolLayers( const QSet &symbolLayers ) { mDisabledSymbolLayers.clear(); diff --git a/src/core/qgsrendercontext.h b/src/core/qgsrendercontext.h index bc25b7d87f7ce..c22c5ea352c00 100644 --- a/src/core/qgsrendercontext.h +++ b/src/core/qgsrendercontext.h @@ -931,16 +931,36 @@ class CORE_EXPORT QgsRenderContext : public QgsTemporalRangeObject /** * Add a clip \a path to be applied to the \a symbolLayer before rendering + * \see addSymbolLayerClipGeometry() + * * \since QGIS 3.26, arguments changed and public API since 3.30 */ void addSymbolLayerClipPath( const QString &symbolLayerId, QPainterPath path ); /** * Returns clip paths to be applied to the \a symbolLayer before rendering + * + *\see symbolLayerClipGeometries() * \since QGIS 3.26, arguments changed and public API since 3.30 */ QList symbolLayerClipPaths( const QString &symbolLayerId ) const; + /** + * Add a clip \a geometry to be applied to the \a symbolLayer before rendering. + * + * \see symbolLayerClipGeometries() + * \since QGIS 3.38 + */ + void addSymbolLayerClipGeometry( const QString &symbolLayerId, const QgsGeometry &geometry ); + + /** + * Returns clipping geometries to be applied to the \a symbolLayer before rendering + * + * \see addSymbolLayerClipGeometry() + * \since QGIS 3.38 + */ + QVector symbolLayerClipGeometries( const QString &symbolLayerId ) const; + /** * Returns the range of z-values which should be rendered. * @@ -1227,6 +1247,9 @@ class CORE_EXPORT QgsRenderContext : public QgsTemporalRangeObject //! clip paths to be applied to the symbol layer before rendering QMap< QString, QList > mSymbolLayerClipPaths; + //! Clip geometries to be applied to the symbol layer before rendering + QMap< QString, QVector > mSymbolLayerClippingGeometries; + #ifdef QGISDEBUG bool mHasTransformContext = false; #endif diff --git a/src/core/symbology/qgssymbollayer.cpp b/src/core/symbology/qgssymbollayer.cpp index 6aec2c788a4c1..b66e0a54b2c2f 100644 --- a/src/core/symbology/qgssymbollayer.cpp +++ b/src/core/symbology/qgssymbollayer.cpp @@ -957,21 +957,45 @@ void QgsSymbolLayer::prepareMasks( const QgsSymbolRenderContext &context ) mClipPath.clear(); const QgsRenderContext &renderContext = context.renderContext(); - const QList clipPaths = renderContext.symbolLayerClipPaths( id() ); - if ( !clipPaths.isEmpty() ) + + const QVector clipGeometries = renderContext.symbolLayerClipGeometries( id() ); + if ( !clipGeometries.empty() ) { - QPainterPath mergedPaths; - mergedPaths.setFillRule( Qt::WindingFill ); - for ( const QPainterPath &path : clipPaths ) + QVector< QgsGeometry > fixed; + for ( const QgsGeometry &geometry : clipGeometries ) { - mergedPaths.addPath( path ); + fixed << geometry.makeValid( Qgis::MakeValidMethod::Structure ); } - if ( !mergedPaths.isEmpty() ) + const QgsGeometry mergedGeom = QgsGeometry::unaryUnion( fixed ); + if ( !mergedGeom.isEmpty() ) + { + const QgsGeometry exterior = QgsGeometry::fromRect( + QgsRectangle( 0, 0, + renderContext.outputSize().width(), + renderContext.outputSize().height() ) ); + const QgsGeometry maskGeom = exterior.difference( mergedGeom ); + mClipPath = maskGeom.constGet()->asQPainterPath(); + } + } + else + { + const QList clipPaths = renderContext.symbolLayerClipPaths( id() ); + if ( !clipPaths.isEmpty() ) { - mClipPath.addRect( 0, 0, renderContext.outputSize().width(), - renderContext.outputSize().height() ); - mClipPath = mClipPath.subtracted( mergedPaths ); + QPainterPath mergedPaths; + mergedPaths.setFillRule( Qt::WindingFill ); + for ( const QPainterPath &path : clipPaths ) + { + mergedPaths.addPath( path ); + } + + if ( !mergedPaths.isEmpty() ) + { + mClipPath.addRect( 0, 0, renderContext.outputSize().width(), + renderContext.outputSize().height() ); + mClipPath = mClipPath.subtracted( mergedPaths ); + } } } } diff --git a/tests/src/python/test_selective_masking.py b/tests/src/python/test_selective_masking.py index 0531dca8cc554..3eceb0a6c35be 100644 --- a/tests/src/python/test_selective_masking.py +++ b/tests/src/python/test_selective_masking.py @@ -16,7 +16,7 @@ import subprocess import tempfile -from qgis.PyQt.QtCore import QRectF, QSize, Qt, QUuid +from qgis.PyQt.QtCore import QRectF, QSize, Qt, QUuid, QCoreApplication from qgis.PyQt.QtGui import QColor, QImage, QPainter from qgis.core import ( Qgis, @@ -48,7 +48,8 @@ QgsSymbolLayerUtils, QgsUnitTypes, QgsWkbTypes, - QgsFontUtils + QgsFontUtils, + QgsSettings ) import unittest from qgis.testing import start_app, QgisTestCase @@ -77,14 +78,9 @@ def renderMapToImageWithTime(mapsettings, parallel=False, cache=None): return (job.renderedImage(), job.renderingTime()) -class TestSelectiveMasking(QgisTestCase): - - @classmethod - def control_path_prefix(cls): - return "selective_masking" +class SelectiveMaskingTestBase(): def setUp(self): - self.map_settings = QgsMapSettings() crs = QgsCoordinateReferenceSystem('epsg:4326') extent = QgsRectangle(-123.0, 22.7, -76.4, 46.9) @@ -1350,6 +1346,45 @@ def test_layout_export_svg_marker_masking(self): self.check_layout_export("layout_export_svg_marker_masking", 0, [self.points_layer, self.lines_layer]) +class TestSelectiveMaskingQPainterPathBackend(QgisTestCase, SelectiveMaskingTestBase): + """ + Test selective masking with the QPainterPath backend + """ + @classmethod + def control_path_prefix(cls): + return "selective_masking" + + @classmethod + def setUpClass(cls): + QgsSettings().setValue('map/maskBackend', 'qpainterpath') + QgisTestCase.setUpClass() + + def setUp(self): + SelectiveMaskingTestBase.setUp(self) + + +class TestSelectiveMaskingGeometryBackend(QgisTestCase, SelectiveMaskingTestBase): + """ + Test selective masking with the QgsGeometry backend + """ + @classmethod + def control_path_prefix(cls): + return "selective_masking" + + @classmethod + def setUpClass(cls): + QgsSettings().setValue('map/maskBackend', 'geometry') + QgisTestCase.setUpClass() + + def setUp(self): + SelectiveMaskingTestBase.setUp(self) + + if __name__ == '__main__': + QCoreApplication.setOrganizationName("QGIS_Test") + QCoreApplication.setOrganizationDomain("SelectiveMaskingTestBase.com") + QCoreApplication.setApplicationName("SelectiveMaskingTestBase") + QgsSettings().clear() + start_app() unittest.main()