From 915615aec4dcd6fb0bc7d6b267a637dfdb210bc0 Mon Sep 17 00:00:00 2001 From: Nyall Dawson Date: Mon, 27 Jul 2020 17:13:45 +1000 Subject: [PATCH] [layouts][api] Add API to allow map items to be clipped (take their shape from) other layout items which provide clipping paths Opens the door for non-rectangular layout maps --- .../layout/qgslayoutitemmap.sip.in | 138 +++++++++++ src/core/layout/qgslayoutitemmap.cpp | 216 +++++++++++++++++- src/core/layout/qgslayoutitemmap.h | 145 ++++++++++++ 3 files changed, 492 insertions(+), 7 deletions(-) diff --git a/python/core/auto_generated/layout/qgslayoutitemmap.sip.in b/python/core/auto_generated/layout/qgslayoutitemmap.sip.in index f5aaa2010f1..8659a22ffdb 100644 --- a/python/core/auto_generated/layout/qgslayoutitemmap.sip.in +++ b/python/core/auto_generated/layout/qgslayoutitemmap.sip.in @@ -113,6 +113,137 @@ Emitted when the atlas clipping settings are changed. }; +class QgsLayoutItemMapItemClipPathSettings : QObject +{ +%Docstring +Contains settings relating to clipping a layout map by another layout item. + +.. versionadded:: 3.16 +%End + +%TypeHeaderCode +#include "qgslayoutitemmap.h" +%End + public: + + QgsLayoutItemMapItemClipPathSettings( QgsLayoutItemMap *map /TransferThis/ = 0 ); +%Docstring +Constructor for QgsLayoutItemMapItemClipPathSettings, with the specified ``map`` parent. +%End + + bool isActive() const; +%Docstring +Returns ``True`` if the item clipping is enabled and set to a valid source item. + +.. seealso:: :py:func:`enabled` + +.. seealso:: :py:func:`sourceItem` +%End + + bool enabled() const; +%Docstring +Returns ``True`` if the map content should be clipped to the associated item. + +.. seealso:: :py:func:`setEnabled` +%End + + void setEnabled( bool enabled ); +%Docstring +Sets whether the map content should be clipped to the associated item. + +.. seealso:: :py:func:`enabled` +%End + + QgsGeometry clippedMapExtent() const; +%Docstring +Returns the geometry to use for clipping the parent map, in the map item's CRS. +%End + + QgsMapClippingRegion toMapClippingRegion() const; +%Docstring +Returns the clip path as a map clipping region. +%End + + void setSourceItem( QgsLayoutItem *item ); +%Docstring +Sets the source ``item`` which will provide the clipping path for the map. + +The specified ``item`` must return the QgsLayoutItem.FlagProvidesClipPath flag. + +.. seealso:: :py:func:`sourceItem` +%End + + QgsLayoutItem *sourceItem(); +%Docstring +Returns the source item which will provide the clipping path for the map, or ``None`` +if no item is set. + +.. seealso:: :py:func:`setSourceItem` +%End + + QgsMapClippingRegion::FeatureClippingType featureClippingType() const; +%Docstring +Returns the feature clipping type to apply when clipping to the associated item. + +.. seealso:: :py:func:`setFeatureClippingType` +%End + + void setFeatureClippingType( QgsMapClippingRegion::FeatureClippingType type ); +%Docstring +Sets the feature clipping ``type`` to apply when clipping to the associated item. + +.. seealso:: :py:func:`featureClippingType` +%End + + bool forceLabelsInsideFeature() const; +%Docstring +Returns ``True`` if labels should only be placed inside the clip path geometry. + +.. seealso:: :py:func:`setForceLabelsInsideFeature` +%End + + void setForceLabelsInsideFeature( bool forceInside ); +%Docstring +Sets whether labels should only be placed inside the clip path geometry. + +.. seealso:: :py:func:`forceLabelsInsideFeature` +%End + + bool writeXml( QDomElement &element, QDomDocument &document, const QgsReadWriteContext &context ) const; +%Docstring +Stores settings in a DOM element, where ``element`` is the DOM element +corresponding to a 'LayoutMap' tag. + +.. seealso:: :py:func:`readXml` +%End + + bool readXml( const QDomElement &element, const QDomDocument &doc, const QgsReadWriteContext &context ); +%Docstring +Sets the setting's state from a DOM document, where ``element`` is the DOM +node corresponding to a 'LayoutMap' tag. + +.. seealso:: :py:func:`writeXml` + +.. seealso:: :py:func:`finalizeRestoreFromXml` +%End + + void finalizeRestoreFromXml(); +%Docstring +To be called after all pending items have been restored from XML. + +.. seealso:: :py:func:`readXml` +%End + + signals: + + void changed(); +%Docstring +Emitted when the item clipping settings are changed. +%End + +}; + + class QgsLayoutItemMap : QgsLayoutItem, QgsTemporalRangeObject { %Docstring @@ -731,6 +862,13 @@ Creates a transform from layout coordinates to map coordinates. %Docstring Returns the map's atlas clipping settings. +.. versionadded:: 3.16 +%End + + QgsLayoutItemMapItemClipPathSettings *itemClippingSettings(); +%Docstring +Returns the map's item based clip path settings. + .. versionadded:: 3.16 %End diff --git a/src/core/layout/qgslayoutitemmap.cpp b/src/core/layout/qgslayoutitemmap.cpp index 54f5433d386..db1a2a98e36 100644 --- a/src/core/layout/qgslayoutitemmap.cpp +++ b/src/core/layout/qgslayoutitemmap.cpp @@ -40,6 +40,7 @@ QgsLayoutItemMap::QgsLayoutItemMap( QgsLayout *layout ) : QgsLayoutItem( layout ) , mAtlasClippingSettings( new QgsLayoutItemMapAtlasClippingSettings( this ) ) + , mItemClippingSettings( new QgsLayoutItemMapItemClipPathSettings( this ) ) { mBackgroundUpdateTimer = new QTimer( this ); mBackgroundUpdateTimer->setSingleShot( true ); @@ -62,6 +63,11 @@ QgsLayoutItemMap::QgsLayoutItemMap( QgsLayout *layout ) refresh(); } ); + connect( mItemClippingSettings, &QgsLayoutItemMapItemClipPathSettings::changed, this, [ = ] + { + refresh(); + } ); + if ( layout ) connectUpdateSlot(); } @@ -260,13 +266,28 @@ QgsRectangle QgsLayoutItemMap::extent() const return mExtent; } -QPolygonF QgsLayoutItemMap::visibleExtentPolygon() const +QPolygonF QgsLayoutItemMap::calculateVisibleExtentPolygon( bool includeClipping ) const { QPolygonF poly; mapPolygon( mExtent, poly ); + + if ( includeClipping && mItemClippingSettings->isActive() ) + { + const QgsGeometry geom = mItemClippingSettings->clippedMapExtent(); + if ( !geom.isEmpty() ) + { + poly = poly.intersected( geom.asQPolygonF() ); + } + } + return poly; } +QPolygonF QgsLayoutItemMap::visibleExtentPolygon() const +{ + return calculateVisibleExtentPolygon( true ); +} + QgsCoordinateReferenceSystem QgsLayoutItemMap::crs() const { if ( mCrs.isValid() ) @@ -664,6 +685,7 @@ bool QgsLayoutItemMap::writePropertiesToElement( QDomElement &mapElem, QDomDocum } mAtlasClippingSettings->writeXml( mapElem, doc, context ); + mItemClippingSettings->writeXml( mapElem, doc, context ); return true; } @@ -820,6 +842,7 @@ bool QgsLayoutItemMap::readPropertiesFromElement( const QDomElement &itemElem, c } mAtlasClippingSettings->readXml( itemElem, doc, context ); + mItemClippingSettings->readXml( itemElem, doc, context ); updateBoundingRect(); @@ -1463,6 +1486,7 @@ QgsMapSettings QgsLayoutItemMap::mapSettings( const QgsRectangle &extent, QSizeF // override the default text render format inherited from the labeling engine settings using the layout's render context setting jobMapSettings.setTextRenderFormat( mLayout->renderContext().textRenderFormat() ); + QgsGeometry labelBoundary; if ( mEvaluatedLabelMargin.length() > 0 ) { QPolygonF visiblePoly = jobMapSettings.visiblePolygon(); @@ -1471,7 +1495,7 @@ QgsMapSettings QgsLayoutItemMap::mapSettings( const QgsRectangle &extent, QSizeF const double layoutLabelMarginInMapUnits = layoutLabelMargin / rect().width() * jobMapSettings.extent().width(); QgsGeometry mapBoundaryGeom = QgsGeometry::fromQPolygonF( visiblePoly ); mapBoundaryGeom = mapBoundaryGeom.buffer( -layoutLabelMarginInMapUnits, 0 ); - jobMapSettings.setLabelBoundaryGeometry( mapBoundaryGeom ); + labelBoundary = mapBoundaryGeom; } if ( !mBlockingLabelItems.isEmpty() ) @@ -1497,14 +1521,45 @@ QgsMapSettings QgsLayoutItemMap::mapSettings( const QgsRectangle &extent, QSizeF if ( mAtlasClippingSettings->forceLabelsInsideFeature() ) { - if ( !jobMapSettings.labelBoundaryGeometry().isEmpty() ) + if ( !labelBoundary.isEmpty() ) { - clipGeom = clipGeom.intersection( jobMapSettings.labelBoundaryGeometry() ); + labelBoundary = clipGeom.intersection( labelBoundary ); + } + else + { + labelBoundary = clipGeom; } - jobMapSettings.setLabelBoundaryGeometry( clipGeom ); } } + if ( mItemClippingSettings->isActive() ) + { + const QgsGeometry clipGeom = mItemClippingSettings->clippedMapExtent(); + if ( !clipGeom.isEmpty() ) + { + jobMapSettings.addClippingRegion( mItemClippingSettings->toMapClippingRegion() ); + + if ( mItemClippingSettings->forceLabelsInsideFeature() ) + { + const double layoutLabelMargin = mLayout->convertToLayoutUnits( mEvaluatedLabelMargin ); + const double layoutLabelMarginInMapUnits = layoutLabelMargin / rect().width() * jobMapSettings.extent().width(); + QgsGeometry mapBoundaryGeom = clipGeom; + mapBoundaryGeom = mapBoundaryGeom.buffer( -layoutLabelMarginInMapUnits, 0 ); + if ( !labelBoundary.isEmpty() ) + { + labelBoundary = mapBoundaryGeom.intersection( labelBoundary ); + } + else + { + labelBoundary = mapBoundaryGeom; + } + } + } + } + + if ( !labelBoundary.isNull() ) + jobMapSettings.setLabelBoundaryGeometry( labelBoundary ); + return jobMapSettings; } @@ -1524,6 +1579,7 @@ void QgsLayoutItemMap::finalizeRestoreFromXml() mOverviewStack->finalizeRestoreFromXml(); mGridStack->finalizeRestoreFromXml(); + mItemClippingSettings->finalizeRestoreFromXml(); } void QgsLayoutItemMap::setMoveContentPreviewOffset( double xOffset, double yOffset ) @@ -1613,7 +1669,7 @@ QPolygonF QgsLayoutItemMap::transformedMapPolygon() const double dx = mXOffset; double dy = mYOffset; transformShift( dx, dy ); - QPolygonF poly = visibleExtentPolygon(); + QPolygonF poly = calculateVisibleExtentPolygon( false ); poly.translate( -dx, -dy ); return poly; } @@ -1922,7 +1978,7 @@ void QgsLayoutItemMap::connectUpdateSlot() QTransform QgsLayoutItemMap::layoutToMapCoordsTransform() const { - QPolygonF thisExtent = visibleExtentPolygon(); + QPolygonF thisExtent = calculateVisibleExtentPolygon( false ); QTransform mapTransform; QPolygonF thisRectPoly = QPolygonF( QRectF( 0, 0, rect().width(), rect().height() ) ); //workaround QT Bug #21329 @@ -2633,6 +2689,7 @@ void QgsLayoutItemMap::createStagedRenderJob( const QgsRectangle &extent, const } + // // QgsLayoutItemMapAtlasClippingSettings // @@ -2770,3 +2827,148 @@ void QgsLayoutItemMapAtlasClippingSettings::layersAboutToBeRemoved( const QList< _qgis_removeLayers( mLayersToClip, layers ); } } + +// +// QgsLayoutItemMapItemClipPathSettings +// +QgsLayoutItemMapItemClipPathSettings::QgsLayoutItemMapItemClipPathSettings( QgsLayoutItemMap *map ) + : QObject( map ) + , mMap( map ) +{ +} + +bool QgsLayoutItemMapItemClipPathSettings::isActive() const +{ + return mEnabled && mClipPathSource; +} + +bool QgsLayoutItemMapItemClipPathSettings::enabled() const +{ + return mEnabled; +} + +void QgsLayoutItemMapItemClipPathSettings::setEnabled( bool enabled ) +{ + if ( enabled == mEnabled ) + return; + + mEnabled = enabled; + emit changed(); +} + +QgsGeometry QgsLayoutItemMapItemClipPathSettings::clippedMapExtent() const +{ + if ( isActive() ) + { + QgsGeometry clipGeom( mClipPathSource->clipPath() ); + clipGeom.transform( mMap->layoutToMapCoordsTransform() ); + return clipGeom; + } + return QgsGeometry(); +} + +QgsMapClippingRegion QgsLayoutItemMapItemClipPathSettings::toMapClippingRegion() const +{ + QgsMapClippingRegion region( clippedMapExtent() ); + region.setFeatureClip( mFeatureClippingType ); + return region; +} + +void QgsLayoutItemMapItemClipPathSettings::setSourceItem( QgsLayoutItem *item ) +{ + if ( mClipPathSource == item ) + return; + + if ( mClipPathSource ) + { + disconnect( mClipPathSource, &QgsLayoutItem::sizePositionChanged, mMap, &QgsLayoutItemMap::refresh ); + disconnect( mClipPathSource, &QgsLayoutItem::rotationChanged, mMap, &QgsLayoutItemMap::refresh ); + disconnect( mClipPathSource, &QgsLayoutItem::sizePositionChanged, mMap, &QgsLayoutItemMap::extentChanged ); + disconnect( mClipPathSource, &QgsLayoutItem::rotationChanged, mMap, &QgsLayoutItemMap::extentChanged ); + } + + mClipPathSource = item; + + if ( mClipPathSource ) + { + // if item size or rotation changes, we need to redraw this map + connect( mClipPathSource, &QgsLayoutItem::sizePositionChanged, mMap, &QgsLayoutItemMap::refresh ); + connect( mClipPathSource, &QgsLayoutItem::rotationChanged, mMap, &QgsLayoutItemMap::refresh ); + // and if clip item size or rotation changes, then effectively we've changed the visible extent of the map + connect( mClipPathSource, &QgsLayoutItem::sizePositionChanged, mMap, &QgsLayoutItemMap::extentChanged ); + connect( mClipPathSource, &QgsLayoutItem::rotationChanged, mMap, &QgsLayoutItemMap::extentChanged ); + } + + emit changed(); +} + +QgsLayoutItem *QgsLayoutItemMapItemClipPathSettings::sourceItem() +{ + return mClipPathSource; +} + +QgsMapClippingRegion::FeatureClippingType QgsLayoutItemMapItemClipPathSettings::featureClippingType() const +{ + return mFeatureClippingType; +} + +void QgsLayoutItemMapItemClipPathSettings::setFeatureClippingType( QgsMapClippingRegion::FeatureClippingType type ) +{ + if ( mFeatureClippingType == type ) + return; + + mFeatureClippingType = type; + emit changed(); +} + +bool QgsLayoutItemMapItemClipPathSettings::forceLabelsInsideFeature() const +{ + return mForceLabelsInsideClipPath; +} + +void QgsLayoutItemMapItemClipPathSettings::setForceLabelsInsideFeature( bool forceInside ) +{ + if ( forceInside == mForceLabelsInsideClipPath ) + return; + + mForceLabelsInsideClipPath = forceInside; + emit changed(); +} + +bool QgsLayoutItemMapItemClipPathSettings::writeXml( QDomElement &element, QDomDocument &document, const QgsReadWriteContext & ) const +{ + QDomElement settingsElem = document.createElement( QStringLiteral( "itemClippingSettings" ) ); + settingsElem.setAttribute( QStringLiteral( "enabled" ), mEnabled ? QStringLiteral( "1" ) : QStringLiteral( "0" ) ); + settingsElem.setAttribute( QStringLiteral( "forceLabelsInside" ), mForceLabelsInsideClipPath ? QStringLiteral( "1" ) : QStringLiteral( "0" ) ); + settingsElem.setAttribute( QStringLiteral( "clippingType" ), QString::number( static_cast( mFeatureClippingType ) ) ); + if ( mClipPathSource ) + settingsElem.setAttribute( QStringLiteral( "clipSource" ), mClipPathSource->uuid() ); + else + settingsElem.setAttribute( QStringLiteral( "clipSource" ), QString() ); + + element.appendChild( settingsElem ); + return true; +} + +bool QgsLayoutItemMapItemClipPathSettings::readXml( const QDomElement &element, const QDomDocument &, const QgsReadWriteContext & ) +{ + const QDomElement settingsElem = element.firstChildElement( QStringLiteral( "itemClippingSettings" ) ); + + mEnabled = settingsElem.attribute( QStringLiteral( "enabled" ), QStringLiteral( "0" ) ).toInt(); + mForceLabelsInsideClipPath = settingsElem.attribute( QStringLiteral( "forceLabelsInside" ), QStringLiteral( "0" ) ).toInt(); + mFeatureClippingType = static_cast< QgsMapClippingRegion::FeatureClippingType >( settingsElem.attribute( QStringLiteral( "clippingType" ), QStringLiteral( "0" ) ).toInt() ); + mClipPathUuid = settingsElem.attribute( QStringLiteral( "clipSource" ) ); + + return true; +} + +void QgsLayoutItemMapItemClipPathSettings::finalizeRestoreFromXml() +{ + if ( !mClipPathUuid.isEmpty() ) + { + if ( QgsLayoutItem *item = mMap->layout()->itemByUuid( mClipPathUuid, true ) ) + { + setSourceItem( item ); + } + } +} diff --git a/src/core/layout/qgslayoutitemmap.h b/src/core/layout/qgslayoutitemmap.h index 4f592fe6116..e7e329244e2 100644 --- a/src/core/layout/qgslayoutitemmap.h +++ b/src/core/layout/qgslayoutitemmap.h @@ -141,6 +141,141 @@ class CORE_EXPORT QgsLayoutItemMapAtlasClippingSettings : public QObject }; +/** + * \ingroup core + * \class QgsLayoutItemMapItemClipPathSettings + * \brief Contains settings relating to clipping a layout map by another layout item. + * \since QGIS 3.16 + */ +class CORE_EXPORT QgsLayoutItemMapItemClipPathSettings : public QObject +{ + Q_OBJECT + + public: + + /** + * Constructor for QgsLayoutItemMapItemClipPathSettings, with the specified \a map parent. + */ + QgsLayoutItemMapItemClipPathSettings( QgsLayoutItemMap *map SIP_TRANSFERTHIS = nullptr ); + + /** + * Returns TRUE if the item clipping is enabled and set to a valid source item. + * + * \see enabled() + * \see sourceItem() + */ + bool isActive() const; + + /** + * Returns TRUE if the map content should be clipped to the associated item. + * + * \see setEnabled() + */ + bool enabled() const; + + /** + * Sets whether the map content should be clipped to the associated item. + * + * \see enabled() + */ + void setEnabled( bool enabled ); + + /** + * Returns the geometry to use for clipping the parent map, in the map item's CRS. + */ + QgsGeometry clippedMapExtent() const; + + /** + * Returns the clip path as a map clipping region. + */ + QgsMapClippingRegion toMapClippingRegion() const; + + /** + * Sets the source \a item which will provide the clipping path for the map. + * + * The specified \a item must return the QgsLayoutItem::FlagProvidesClipPath flag. + * + * \see sourceItem() + */ + void setSourceItem( QgsLayoutItem *item ); + + /** + * Returns the source item which will provide the clipping path for the map, or NULLPTR + * if no item is set. + * + * \see setSourceItem() + */ + QgsLayoutItem *sourceItem(); + + /** + * Returns the feature clipping type to apply when clipping to the associated item. + * + * \see setFeatureClippingType() + */ + QgsMapClippingRegion::FeatureClippingType featureClippingType() const; + + /** + * Sets the feature clipping \a type to apply when clipping to the associated item. + * + * \see featureClippingType() + */ + void setFeatureClippingType( QgsMapClippingRegion::FeatureClippingType type ); + + /** + * Returns TRUE if labels should only be placed inside the clip path geometry. + * + * \see setForceLabelsInsideFeature() + */ + bool forceLabelsInsideFeature() const; + + /** + * Sets whether labels should only be placed inside the clip path geometry. + * + * \see forceLabelsInsideFeature() + */ + void setForceLabelsInsideFeature( bool forceInside ); + + /** + * Stores settings in a DOM element, where \a element is the DOM element + * corresponding to a 'LayoutMap' tag. + * \see readXml() + */ + bool writeXml( QDomElement &element, QDomDocument &document, const QgsReadWriteContext &context ) const; + + /** + * Sets the setting's state from a DOM document, where \a element is the DOM + * node corresponding to a 'LayoutMap' tag. + * \see writeXml() + * \see finalizeRestoreFromXml() + */ + bool readXml( const QDomElement &element, const QDomDocument &doc, const QgsReadWriteContext &context ); + + /** + * To be called after all pending items have been restored from XML. + * \see readXml() + */ + void finalizeRestoreFromXml(); + + signals: + + /** + * Emitted when the item clipping settings are changed. + */ + void changed(); + + private: + + QgsLayoutItemMap *mMap = nullptr; + bool mEnabled = false; + QgsMapClippingRegion::FeatureClippingType mFeatureClippingType = QgsMapClippingRegion::FeatureClippingType::ClipPainterOnly; + bool mForceLabelsInsideClipPath = false; + + QPointer< QgsLayoutItem > mClipPathSource; + QString mClipPathUuid; + +}; + + /** * \ingroup core * \class QgsLayoutItemMap @@ -677,6 +812,13 @@ class CORE_EXPORT QgsLayoutItemMap : public QgsLayoutItem, public QgsTemporalRan */ QgsLayoutItemMapAtlasClippingSettings *atlasClippingSettings() { return mAtlasClippingSettings; } + /** + * Returns the map's item based clip path settings. + * + * \since QGIS 3.16 + */ + QgsLayoutItemMapItemClipPathSettings *itemClippingSettings() { return mItemClippingSettings; } + protected: void draw( QgsLayoutItemRenderContext &context ) override; @@ -962,6 +1104,7 @@ class CORE_EXPORT QgsLayoutItemMap : public QgsLayoutItem, public QgsTemporalRan QStringList::iterator mExportThemeIt; QgsLayoutItemMapAtlasClippingSettings *mAtlasClippingSettings = nullptr; + QgsLayoutItemMapItemClipPathSettings *mItemClippingSettings = nullptr; /** * Refresh the map's extents, considering data defined extent, scale and rotation @@ -975,6 +1118,8 @@ class CORE_EXPORT QgsLayoutItemMap : public QgsLayoutItem, public QgsTemporalRan void createStagedRenderJob( const QgsRectangle &extent, const QSizeF size, double dpi ); + QPolygonF calculateVisibleExtentPolygon( bool includeClipping ) const; + friend class QgsLayoutItemMapGrid; friend class QgsLayoutItemMapOverview; friend class QgsLayoutItemLegend;