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()