diff --git a/python/core/auto_generated/layout/qgslayoutitemmap.sip.in b/python/core/auto_generated/layout/qgslayoutitemmap.sip.in index a915094f27e..f5aaa2010f1 100644 --- a/python/core/auto_generated/layout/qgslayoutitemmap.sip.in +++ b/python/core/auto_generated/layout/qgslayoutitemmap.sip.in @@ -9,6 +9,110 @@ +class QgsLayoutItemMapAtlasClippingSettings : QObject +{ +%Docstring +Contains settings relating to clipping a layout map by the current atlas feature. + +.. versionadded:: 3.16 +%End + +%TypeHeaderCode +#include "qgslayoutitemmap.h" +%End + public: + + QgsLayoutItemMapAtlasClippingSettings( QgsLayoutItemMap *map /TransferThis/ = 0 ); +%Docstring +Constructor for QgsLayoutItemMapAtlasClippingSettings, with the specified ``map`` parent. +%End + + bool enabled() const; +%Docstring +Returns ``True`` if the map content should be clipped to the current atlas feature. + +.. seealso:: :py:func:`setEnabled` +%End + + void setEnabled( bool enabled ); +%Docstring +Sets whether the map content should be clipped to the current atlas feature. + +.. seealso:: :py:func:`enabled` +%End + + QgsMapClippingRegion::FeatureClippingType featureClippingType() const; +%Docstring +Returns the feature clipping type to apply when clipping to the current atlas feature. + +.. seealso:: :py:func:`setFeatureClippingType` +%End + + void setFeatureClippingType( QgsMapClippingRegion::FeatureClippingType type ); +%Docstring +Sets the feature clipping ``type`` to apply when clipping to the current atlas feature. + +.. seealso:: :py:func:`featureClippingType` +%End + + bool forceLabelsInsideFeature() const; +%Docstring +Returns ``True`` if labels should only be placed inside the atlas feature geometry. + +.. seealso:: :py:func:`setForceLabelsInsideFeature` +%End + + void setForceLabelsInsideFeature( bool forceInside ); +%Docstring +Sets whether labels should only be placed inside the atlas feature geometry. + +.. seealso:: :py:func:`forceLabelsInsideFeature` +%End + + QList< QgsMapLayer * > layersToClip() const; +%Docstring +Returns the list of map layers to clip to the atlas feature. + +If the returned list is empty then all layers will be clipped. + +.. seealso:: :py:func:`setLayersToClip` +%End + + void setLayersToClip( const QList< QgsMapLayer * > &layers ); +%Docstring +Sets the list of map ``layers`` to clip to the atlas feature. + +If the ``layers`` list is empty then all layers will be clipped. + +.. seealso:: :py:func:`layersToClip` +%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` +%End + + signals: + + void changed(); +%Docstring +Emitted when the atlas clipping settings are changed. +%End + +}; + + class QgsLayoutItemMap : QgsLayoutItem, QgsTemporalRangeObject { %Docstring @@ -621,6 +725,13 @@ Removes a previously added rendered feature ``handler``. QTransform layoutToMapCoordsTransform() const; %Docstring Creates a transform from layout coordinates to map coordinates. +%End + + QgsLayoutItemMapAtlasClippingSettings *atlasClippingSettings(); +%Docstring +Returns the map's atlas clipping settings. + +.. versionadded:: 3.16 %End protected: diff --git a/python/core/auto_generated/qgsmaplayermodel.sip.in b/python/core/auto_generated/qgsmaplayermodel.sip.in index 295f310935f..5dd7782b47c 100644 --- a/python/core/auto_generated/qgsmaplayermodel.sip.in +++ b/python/core/auto_generated/qgsmaplayermodel.sip.in @@ -119,6 +119,12 @@ Returns ``True`` if the model includes layer's CRS in the display role. %Docstring layersChecked returns the list of layers which are checked (or unchecked) %End + + void setLayersChecked( const QList< QgsMapLayer * > &layers ); +%Docstring +Sets which layers are checked in the model. +%End + bool itemsCheckable() const; %Docstring returns if the items can be checked or not diff --git a/src/core/layout/qgslayoutitemmap.cpp b/src/core/layout/qgslayoutitemmap.cpp index f1e8e6d7da8..664045fcd61 100644 --- a/src/core/layout/qgslayoutitemmap.cpp +++ b/src/core/layout/qgslayoutitemmap.cpp @@ -39,6 +39,7 @@ QgsLayoutItemMap::QgsLayoutItemMap( QgsLayout *layout ) : QgsLayoutItem( layout ) + , mAtlasClippingSettings( new QgsLayoutItemMapAtlasClippingSettings( this ) ) { mBackgroundUpdateTimer = new QTimer( this ); mBackgroundUpdateTimer->setSingleShot( true ); @@ -56,6 +57,11 @@ QgsLayoutItemMap::QgsLayoutItemMap( QgsLayout *layout ) mGridStack = qgis::make_unique< QgsLayoutItemMapGridStack >( this ); mOverviewStack = qgis::make_unique< QgsLayoutItemMapOverviewStack >( this ); + connect( mAtlasClippingSettings, &QgsLayoutItemMapAtlasClippingSettings::changed, this, [ = ] + { + refresh(); + } ); + if ( layout ) connectUpdateSlot(); } @@ -657,6 +663,8 @@ bool QgsLayoutItemMap::writePropertiesToElement( QDomElement &mapElem, QDomDocum mapElem.setAttribute( QStringLiteral( "temporalRangeEnd" ), temporalRange().end().toString( Qt::ISODate ) ); } + mAtlasClippingSettings->writeXml( mapElem, doc, context ); + return true; } @@ -811,6 +819,8 @@ bool QgsLayoutItemMap::readPropertiesFromElement( const QDomElement &itemElem, c } } + mAtlasClippingSettings->readXml( itemElem, doc, context ); + updateBoundingRect(); //temporal settings @@ -1477,6 +1487,24 @@ QgsMapSettings QgsLayoutItemMap::mapSettings( const QgsRectangle &extent, QSizeF if ( isTemporal() ) jobMapSettings.setTemporalRange( temporalRange() ); + if ( mAtlasClippingSettings->enabled() && mLayout->reportContext().feature().isValid() ) + { + QgsGeometry clipGeom( mLayout->reportContext().currentGeometry( jobMapSettings.destinationCrs() ) ); + QgsMapClippingRegion region( clipGeom ); + region.setFeatureClip( mAtlasClippingSettings->featureClippingType() ); + region.setRestrictedLayers( mAtlasClippingSettings->layersToClip() ); + jobMapSettings.addClippingRegion( region ); + + if ( mAtlasClippingSettings->forceLabelsInsideFeature() ) + { + if ( !jobMapSettings.labelBoundaryGeometry().isEmpty() ) + { + clipGeom = clipGeom.intersection( jobMapSettings.labelBoundaryGeometry() ); + } + jobMapSettings.setLabelBoundaryGeometry( clipGeom ); + } + } + return jobMapSettings; } @@ -2606,3 +2634,140 @@ void QgsLayoutItemMap::createStagedRenderJob( const QgsRectangle &extent, const } +// +// QgsLayoutItemMapAtlasClippingSettings +// + +QgsLayoutItemMapAtlasClippingSettings::QgsLayoutItemMapAtlasClippingSettings( QgsLayoutItemMap *map ) + : QObject( map ) + , mMap( map ) +{ + if ( mMap->layout() && mMap->layout()->project() ) + { + connect( mMap->layout()->project(), static_cast < void ( QgsProject::* )( const QList& layers ) > ( &QgsProject::layersWillBeRemoved ), + this, &QgsLayoutItemMapAtlasClippingSettings::layersAboutToBeRemoved ); + } +} + +bool QgsLayoutItemMapAtlasClippingSettings::enabled() const +{ + return mClipToAtlasFeature; +} + +void QgsLayoutItemMapAtlasClippingSettings::setEnabled( bool enabled ) +{ + if ( enabled == mClipToAtlasFeature ) + return; + + mClipToAtlasFeature = enabled; + emit changed(); +} + +QgsMapClippingRegion::FeatureClippingType QgsLayoutItemMapAtlasClippingSettings::featureClippingType() const +{ + return mFeatureClippingType; +} + +void QgsLayoutItemMapAtlasClippingSettings::setFeatureClippingType( QgsMapClippingRegion::FeatureClippingType type ) +{ + if ( mFeatureClippingType == type ) + return; + + mFeatureClippingType = type; + emit changed(); +} + +bool QgsLayoutItemMapAtlasClippingSettings::forceLabelsInsideFeature() const +{ + return mForceLabelsInsideFeature; +} + +void QgsLayoutItemMapAtlasClippingSettings::setForceLabelsInsideFeature( bool forceInside ) +{ + if ( forceInside == mForceLabelsInsideFeature ) + return; + + mForceLabelsInsideFeature = forceInside; + emit changed(); +} + +QList QgsLayoutItemMapAtlasClippingSettings::layersToClip() const +{ + return _qgis_listRefToRaw( mLayersToClip ); +} + +void QgsLayoutItemMapAtlasClippingSettings::setLayersToClip( const QList< QgsMapLayer * > &layersToClip ) +{ + mLayersToClip = _qgis_listRawToRef( layersToClip ); + emit changed(); +} + +bool QgsLayoutItemMapAtlasClippingSettings::writeXml( QDomElement &element, QDomDocument &document, const QgsReadWriteContext & ) const +{ + QDomElement settingsElem = document.createElement( QStringLiteral( "atlasClippingSettings" ) ); + settingsElem.setAttribute( QStringLiteral( "enabled" ), mClipToAtlasFeature ? QStringLiteral( "1" ) : QStringLiteral( "0" ) ); + settingsElem.setAttribute( QStringLiteral( "forceLabelsInside" ), mForceLabelsInsideFeature ? QStringLiteral( "1" ) : QStringLiteral( "0" ) ); + settingsElem.setAttribute( QStringLiteral( "clippingType" ), QString::number( static_cast( mFeatureClippingType ) ) ); + + //layer set + QDomElement layerSetElem = document.createElement( QStringLiteral( "layersToClip" ) ); + for ( const QgsMapLayerRef &layerRef : mLayersToClip ) + { + if ( !layerRef ) + continue; + QDomElement layerElem = document.createElement( QStringLiteral( "Layer" ) ); + QDomText layerIdText = document.createTextNode( layerRef.layerId ); + layerElem.appendChild( layerIdText ); + + layerElem.setAttribute( QStringLiteral( "name" ), layerRef.name ); + layerElem.setAttribute( QStringLiteral( "source" ), layerRef.source ); + layerElem.setAttribute( QStringLiteral( "provider" ), layerRef.provider ); + + layerSetElem.appendChild( layerElem ); + } + settingsElem.appendChild( layerSetElem ); + + element.appendChild( settingsElem ); + return true; +} + +bool QgsLayoutItemMapAtlasClippingSettings::readXml( const QDomElement &element, const QDomDocument &, const QgsReadWriteContext & ) +{ + const QDomElement settingsElem = element.firstChildElement( QStringLiteral( "atlasClippingSettings" ) ); + + mClipToAtlasFeature = settingsElem.attribute( QStringLiteral( "enabled" ), QStringLiteral( "0" ) ).toInt(); + mForceLabelsInsideFeature = settingsElem.attribute( QStringLiteral( "forceLabelsInside" ), QStringLiteral( "0" ) ).toInt(); + mFeatureClippingType = static_cast< QgsMapClippingRegion::FeatureClippingType >( settingsElem.attribute( QStringLiteral( "clippingType" ), QStringLiteral( "0" ) ).toInt() ); + + mLayersToClip.clear(); + QDomNodeList layerSetNodeList = settingsElem.elementsByTagName( QStringLiteral( "layersToClip" ) ); + if ( !layerSetNodeList.isEmpty() ) + { + QDomElement layerSetElem = layerSetNodeList.at( 0 ).toElement(); + QDomNodeList layerIdNodeList = layerSetElem.elementsByTagName( QStringLiteral( "Layer" ) ); + mLayersToClip.reserve( layerIdNodeList.size() ); + for ( int i = 0; i < layerIdNodeList.size(); ++i ) + { + QDomElement layerElem = layerIdNodeList.at( i ).toElement(); + QString layerId = layerElem.text(); + QString layerName = layerElem.attribute( QStringLiteral( "name" ) ); + QString layerSource = layerElem.attribute( QStringLiteral( "source" ) ); + QString layerProvider = layerElem.attribute( QStringLiteral( "provider" ) ); + + QgsMapLayerRef ref( layerId, layerName, layerSource, layerProvider ); + if ( mMap->layout() && mMap->layout()->project() ) + ref.resolveWeakly( mMap->layout()->project() ); + mLayersToClip << ref; + } + } + + return true; +} + +void QgsLayoutItemMapAtlasClippingSettings::layersAboutToBeRemoved( const QList &layers ) +{ + if ( !mLayersToClip.isEmpty() ) + { + _qgis_removeLayers( mLayersToClip, layers ); + } +} diff --git a/src/core/layout/qgslayoutitemmap.h b/src/core/layout/qgslayoutitemmap.h index 6a784418c54..4f592fe6116 100644 --- a/src/core/layout/qgslayoutitemmap.h +++ b/src/core/layout/qgslayoutitemmap.h @@ -30,6 +30,117 @@ class QgsAnnotation; class QgsRenderedFeatureHandlerInterface; +/** + * \ingroup core + * \class QgsLayoutItemMapAtlasClippingSettings + * \brief Contains settings relating to clipping a layout map by the current atlas feature. + * \since QGIS 3.16 + */ +class CORE_EXPORT QgsLayoutItemMapAtlasClippingSettings : public QObject +{ + Q_OBJECT + + public: + + /** + * Constructor for QgsLayoutItemMapAtlasClippingSettings, with the specified \a map parent. + */ + QgsLayoutItemMapAtlasClippingSettings( QgsLayoutItemMap *map SIP_TRANSFERTHIS = nullptr ); + + /** + * Returns TRUE if the map content should be clipped to the current atlas feature. + * + * \see setEnabled() + */ + bool enabled() const; + + /** + * Sets whether the map content should be clipped to the current atlas feature. + * + * \see enabled() + */ + void setEnabled( bool enabled ); + + /** + * Returns the feature clipping type to apply when clipping to the current atlas feature. + * + * \see setFeatureClippingType() + */ + QgsMapClippingRegion::FeatureClippingType featureClippingType() const; + + /** + * Sets the feature clipping \a type to apply when clipping to the current atlas feature. + * + * \see featureClippingType() + */ + void setFeatureClippingType( QgsMapClippingRegion::FeatureClippingType type ); + + /** + * Returns TRUE if labels should only be placed inside the atlas feature geometry. + * + * \see setForceLabelsInsideFeature() + */ + bool forceLabelsInsideFeature() const; + + /** + * Sets whether labels should only be placed inside the atlas feature geometry. + * + * \see forceLabelsInsideFeature() + */ + void setForceLabelsInsideFeature( bool forceInside ); + + /** + * Returns the list of map layers to clip to the atlas feature. + * + * If the returned list is empty then all layers will be clipped. + * + * \see setLayersToClip() + */ + QList< QgsMapLayer * > layersToClip() const; + + /** + * Sets the list of map \a layers to clip to the atlas feature. + * + * If the \a layers list is empty then all layers will be clipped. + * + * \see layersToClip() + */ + void setLayersToClip( const QList< QgsMapLayer * > &layers ); + + /** + * 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() + */ + bool readXml( const QDomElement &element, const QDomDocument &doc, const QgsReadWriteContext &context ); + + signals: + + /** + * Emitted when the atlas clipping settings are changed. + */ + void changed(); + + private slots: + void layersAboutToBeRemoved( const QList &layers ); + + private: + + QgsLayoutItemMap *mMap = nullptr; + bool mClipToAtlasFeature = false; + QList< QgsMapLayerRef > mLayersToClip; + QgsMapClippingRegion::FeatureClippingType mFeatureClippingType = QgsMapClippingRegion::FeatureClippingType::ClipPainterOnly; + bool mForceLabelsInsideFeature = false; +}; + + /** * \ingroup core * \class QgsLayoutItemMap @@ -559,6 +670,13 @@ class CORE_EXPORT QgsLayoutItemMap : public QgsLayoutItem, public QgsTemporalRan */ QTransform layoutToMapCoordsTransform() const; + /** + * Returns the map's atlas clipping settings. + * + * \since QGIS 3.16 + */ + QgsLayoutItemMapAtlasClippingSettings *atlasClippingSettings() { return mAtlasClippingSettings; } + protected: void draw( QgsLayoutItemRenderContext &context ) override; @@ -843,6 +961,8 @@ class CORE_EXPORT QgsLayoutItemMap : public QgsLayoutItem, public QgsTemporalRan QStringList mExportThemes; QStringList::iterator mExportThemeIt; + QgsLayoutItemMapAtlasClippingSettings *mAtlasClippingSettings = nullptr; + /** * Refresh the map's extents, considering data defined extent, scale and rotation * \param context expression context for evaluating data defined map parameters diff --git a/src/core/qgsmaplayermodel.cpp b/src/core/qgsmaplayermodel.cpp index 558069c0316..1d6e5bacc1c 100644 --- a/src/core/qgsmaplayermodel.cpp +++ b/src/core/qgsmaplayermodel.cpp @@ -105,6 +105,20 @@ QList QgsMapLayerModel::layersChecked( Qt::CheckState checkState return layers; } +void QgsMapLayerModel::setLayersChecked( const QList &layers ) +{ + QMap::iterator i = mLayersChecked.begin(); + for ( ; i != mLayersChecked.end(); ++i ) + { + *i = Qt::Unchecked; + } + for ( const QgsMapLayer *layer : layers ) + { + mLayersChecked[ layer->id() ] = Qt::Checked; + } + emit dataChanged( index( 0, 0 ), index( rowCount() - 1, 0 ), QVector() << Qt::CheckStateRole ); +} + QModelIndex QgsMapLayerModel::indexFromLayer( QgsMapLayer *layer ) const { int r = mLayers.indexOf( layer ); @@ -569,7 +583,7 @@ bool QgsMapLayerModel::setData( const QModelIndex &index, const QVariant &value, { QgsMapLayer *layer = static_cast( index.internalPointer() ); mLayersChecked[layer->id()] = ( Qt::CheckState )value.toInt(); - emit dataChanged( index, index ); + emit dataChanged( index, index, QVector< int >() << Qt::CheckStateRole ); return true; } break; diff --git a/src/core/qgsmaplayermodel.h b/src/core/qgsmaplayermodel.h index 24563a62847..4a5028de895 100644 --- a/src/core/qgsmaplayermodel.h +++ b/src/core/qgsmaplayermodel.h @@ -128,6 +128,12 @@ class CORE_EXPORT QgsMapLayerModel : public QAbstractItemModel * \brief layersChecked returns the list of layers which are checked (or unchecked) */ QList layersChecked( Qt::CheckState checkState = Qt::Checked ); + + /** + * Sets which layers are checked in the model. + */ + void setLayersChecked( const QList< QgsMapLayer * > &layers ); + //! returns if the items can be checked or not bool itemsCheckable() const { return mItemCheckable; } diff --git a/src/gui/layout/qgslayoutmapwidget.cpp b/src/gui/layout/qgslayoutmapwidget.cpp index 940e8f27053..41ff69720b2 100644 --- a/src/gui/layout/qgslayoutmapwidget.cpp +++ b/src/gui/layout/qgslayoutmapwidget.cpp @@ -34,6 +34,7 @@ #include "qgsbookmarkmodel.h" #include "qgsreferencedgeometry.h" #include "qgsprojectviewsettings.h" +#include "qgsmaplayermodel.h" #include #include @@ -85,6 +86,8 @@ QgsLayoutMapWidget::QgsLayoutMapWidget( QgsLayoutItemMap *item, QgsMapCanvas *ma connect( mOverviewListWidget, &QListWidget::currentItemChanged, this, &QgsLayoutMapWidget::mOverviewListWidget_currentItemChanged ); connect( mOverviewListWidget, &QListWidget::itemChanged, this, &QgsLayoutMapWidget::mOverviewListWidget_itemChanged ); connect( mActionLabelSettings, &QAction::triggered, this, &QgsLayoutMapWidget::showLabelSettings ); + connect( mActionClipSettings, &QAction::triggered, this, &QgsLayoutMapWidget::showClipSettings ); + connect( mTemporalCheckBox, &QgsCollapsibleGroupBoxBasic::toggled, this, &QgsLayoutMapWidget::mTemporalCheckBox_toggled ); connect( mStartDateTime, &QDateTimeEdit::dateTimeChanged, this, &QgsLayoutMapWidget::updateTemporalExtent ); connect( mEndDateTime, &QDateTimeEdit::dateTimeChanged, this, &QgsLayoutMapWidget::updateTemporalExtent ); @@ -207,8 +210,14 @@ void QgsLayoutMapWidget::setMasterLayout( QgsMasterLayoutInterface *masterLayout void QgsLayoutMapWidget::setReportTypeString( const QString &string ) { + mReportTypeString = string; mAtlasCheckBox->setTitle( tr( "Controlled by %1" ).arg( string == tr( "atlas" ) ? tr( "Atlas" ) : tr( "Report" ) ) ); mAtlasPredefinedScaleRadio->setToolTip( tr( "Use one of the predefined scales of the project where the %1 feature best fits." ).arg( string ) ); + + if ( mClipWidget ) + mClipWidget->setReportTypeString( string ); + if ( mLabelWidget ) + mLabelWidget->setReportTypeString( string ); } void QgsLayoutMapWidget::setDesignerInterface( QgsLayoutDesignerInterface *iface ) @@ -233,6 +242,8 @@ bool QgsLayoutMapWidget::setNewItem( QgsLayoutItem *item ) mItemPropertiesWidget->setItem( mMapItem ); if ( mLabelWidget ) mLabelWidget->setItem( mMapItem ); + if ( mClipWidget ) + mClipWidget->setItem( mMapItem ); if ( mMapItem ) { @@ -413,9 +424,21 @@ void QgsLayoutMapWidget::overviewSymbolChanged() void QgsLayoutMapWidget::showLabelSettings() { mLabelWidget = new QgsLayoutMapLabelingWidget( mMapItem ); + + if ( !mReportTypeString.isEmpty() ) + mLabelWidget->setReportTypeString( mReportTypeString ); + openPanel( mLabelWidget ); } +void QgsLayoutMapWidget::showClipSettings() +{ + mClipWidget = new QgsLayoutMapClippingWidget( mMapItem ); + if ( !mReportTypeString.isEmpty() ) + mClipWidget->setReportTypeString( mReportTypeString ); + openPanel( mClipWidget ); +} + void QgsLayoutMapWidget::switchToMoveContentTool() { if ( mInterface ) @@ -1925,3 +1948,180 @@ bool QgsLayoutMapItemBlocksLabelsModel::filterAcceptsRow( int source_row, const return true; } + + + +// +// QgsLayoutMapClippingWidget +// + +QgsLayoutMapClippingWidget::QgsLayoutMapClippingWidget( QgsLayoutItemMap *map ) + : QgsLayoutItemBaseWidget( nullptr, map ) + , mMapItem( map ) +{ + setupUi( this ); + setPanelTitle( tr( "Clipping Settings" ) ); + + mLayerModel = new QgsMapLayerModel( this ); + mLayerModel->setItemsCheckable( true ); + mLayersTreeView->setModel( mLayerModel ); + + mAtlasClippingTypeComboBox->addItem( tr( "Clip During Render Only" ), static_cast< int >( QgsMapClippingRegion::FeatureClippingType::ClipPainterOnly ) ); + mAtlasClippingTypeComboBox->addItem( tr( "Clip Feature Before Render" ), static_cast< int >( QgsMapClippingRegion::FeatureClippingType::ClipToIntersection ) ); + mAtlasClippingTypeComboBox->addItem( tr( "Render Intersecting Features Unchanged" ), static_cast< int >( QgsMapClippingRegion::FeatureClippingType::NoClipping ) ); + + connect( mRadioClipSelectedLayers, &QRadioButton::toggled, mLayersTreeView, &QWidget::setEnabled ); + mLayersTreeView->setEnabled( false ); + mRadioClipAllLayers->setChecked( true ); + + connect( mClipToAtlasCheckBox, &QGroupBox::toggled, this, [ = ]( bool active ) + { + if ( !mBlockUpdates ) + { + mMapItem->beginCommand( tr( "Toggle Atlas Clipping" ) ); + mMapItem->atlasClippingSettings()->setEnabled( active ); + mMapItem->endCommand(); + } + } ); + connect( mForceLabelsInsideCheckBox, &QCheckBox::toggled, this, [ = ]( bool active ) + { + if ( !mBlockUpdates ) + { + mMapItem->beginCommand( tr( "Change Atlas Clipping Label Behavior" ) ); + mMapItem->atlasClippingSettings()->setForceLabelsInsideFeature( active ); + mMapItem->endCommand(); + } + } ); + connect( mAtlasClippingTypeComboBox, qgis::overload::of( &QComboBox::currentIndexChanged ), this, [ = ] + { + if ( !mBlockUpdates ) + { + mMapItem->beginCommand( tr( "Change Atlas Clipping Behavior" ) ); + mMapItem->atlasClippingSettings()->setFeatureClippingType( static_cast< QgsMapClippingRegion::FeatureClippingType >( mAtlasClippingTypeComboBox->currentData().toInt() ) ); + mMapItem->endCommand(); + } + } ); + + connect( mRadioClipSelectedLayers, &QCheckBox::toggled, this, [ = ]( bool active ) + { + if ( active && !mBlockUpdates ) + { + mBlockUpdates = true; + mMapItem->beginCommand( tr( "Change Atlas Clipping Layers" ) ); + mMapItem->atlasClippingSettings()->setLayersToClip( mLayerModel->layersChecked( ) ); + mMapItem->endCommand(); + mBlockUpdates = false; + } + } ); + connect( mRadioClipAllLayers, &QCheckBox::toggled, this, [ = ]( bool active ) + { + if ( active && !mBlockUpdates ) + { + mBlockUpdates = true; + mMapItem->beginCommand( tr( "Change Atlas Clipping Layers" ) ); + mMapItem->atlasClippingSettings()->setLayersToClip( QList< QgsMapLayer * >() ); + mMapItem->endCommand(); + mBlockUpdates = false; + } + } ); + connect( mLayerModel, &QgsMapLayerModel::dataChanged, this, [ = ]( const QModelIndex &, const QModelIndex &, const QVector &roles = QVector() ) + { + if ( !roles.contains( Qt::CheckStateRole ) ) + return; + + if ( !mBlockUpdates ) + { + mBlockUpdates = true; + mMapItem->beginCommand( tr( "Change Atlas Clipping Layers" ) ); + mMapItem->atlasClippingSettings()->setLayersToClip( mLayerModel->layersChecked() ); + mMapItem->endCommand(); + mBlockUpdates = false; + } + } ); + + setNewItem( map ); + + connect( &map->layout()->reportContext(), &QgsLayoutReportContext::layerChanged, + this, &QgsLayoutMapClippingWidget::atlasLayerChanged ); + if ( QgsLayoutAtlas *atlas = layoutAtlas() ) + { + connect( atlas, &QgsLayoutAtlas::toggled, this, &QgsLayoutMapClippingWidget::atlasToggled ); + atlasToggled( atlas->enabled() ); + } +} + +void QgsLayoutMapClippingWidget::setReportTypeString( const QString &string ) +{ + mClipToAtlasCheckBox->setTitle( tr( "Clip to %1 feature" ).arg( string ) ); + mClipToAtlasLabel->setText( tr( "When enabled, map layers will be automatically clipped to the boundary of the current %1 feature." ).arg( string ) ); + mForceLabelsInsideCheckBox->setText( tr( "Force labels inside %1 feature" ).arg( string ) ); +} + +bool QgsLayoutMapClippingWidget::setNewItem( QgsLayoutItem *item ) +{ + if ( item->type() != QgsLayoutItemRegistry::LayoutMap ) + return false; + + if ( mMapItem ) + { + disconnect( mMapItem, &QgsLayoutObject::changed, this, &QgsLayoutMapClippingWidget::updateGuiElements ); + } + + mMapItem = qobject_cast< QgsLayoutItemMap * >( item ); + + if ( mMapItem ) + { + connect( mMapItem, &QgsLayoutObject::changed, this, &QgsLayoutMapClippingWidget::updateGuiElements ); + } + + updateGuiElements(); + + return true; +} + +void QgsLayoutMapClippingWidget::updateGuiElements() +{ + if ( mBlockUpdates ) + return; + + mBlockUpdates = true; + mClipToAtlasCheckBox->setChecked( mMapItem->atlasClippingSettings()->enabled() ); + mAtlasClippingTypeComboBox->setCurrentIndex( mAtlasClippingTypeComboBox->findData( static_cast< int >( mMapItem->atlasClippingSettings()->featureClippingType() ) ) ); + mForceLabelsInsideCheckBox->setChecked( mMapItem->atlasClippingSettings()->forceLabelsInsideFeature() ); + + mRadioClipAllLayers->setChecked( mMapItem->atlasClippingSettings()->layersToClip().isEmpty() ); + mRadioClipSelectedLayers->setChecked( !mMapItem->atlasClippingSettings()->layersToClip().isEmpty() ); + mLayerModel->setLayersChecked( mMapItem->atlasClippingSettings()->layersToClip() ); + + mBlockUpdates = false; +} + +void QgsLayoutMapClippingWidget::atlasLayerChanged( QgsVectorLayer *layer ) +{ + if ( !layer || layer->geometryType() != QgsWkbTypes::PolygonGeometry ) + { + //non-polygon layer, disable atlas control + mClipToAtlasCheckBox->setChecked( false ); + mClipToAtlasCheckBox->setEnabled( false ); + return; + } + else + { + mClipToAtlasCheckBox->setEnabled( true ); + } +} + +void QgsLayoutMapClippingWidget::atlasToggled( bool atlasEnabled ) +{ + if ( atlasEnabled && + mMapItem && mMapItem->layout() && mMapItem->layout()->reportContext().layer() + && mMapItem->layout()->reportContext().layer()->geometryType() == QgsWkbTypes::PolygonGeometry ) + { + mClipToAtlasCheckBox->setEnabled( true ); + } + else + { + mClipToAtlasCheckBox->setEnabled( false ); + mClipToAtlasCheckBox->setChecked( false ); + } +} diff --git a/src/gui/layout/qgslayoutmapwidget.h b/src/gui/layout/qgslayoutmapwidget.h index cda8fb8500c..e32b7aa2cec 100644 --- a/src/gui/layout/qgslayoutmapwidget.h +++ b/src/gui/layout/qgslayoutmapwidget.h @@ -24,6 +24,7 @@ #include "qgis_gui.h" #include "ui_qgslayoutmapwidgetbase.h" #include "ui_qgslayoutmaplabelingwidgetbase.h" +#include "ui_qgslayoutmapclippingwidgetbase.h" #include "qgslayoutitemwidget.h" #include "qgslayoutitemmapgrid.h" @@ -31,6 +32,7 @@ class QgsMapLayer; class QgsLayoutItemMap; class QgsLayoutItemMapOverview; class QgsLayoutMapLabelingWidget; +class QgsLayoutMapClippingWidget; class QgsBookmarkManagerProxyModel; /** @@ -136,6 +138,7 @@ class GUI_EXPORT QgsLayoutMapWidget: public QgsLayoutItemBaseWidget, private Ui: void mapCrsChanged( const QgsCoordinateReferenceSystem &crs ); void overviewSymbolChanged(); void showLabelSettings(); + void showClipSettings(); void switchToMoveContentTool(); void aboutToShowBookmarkMenu(); @@ -145,8 +148,10 @@ class GUI_EXPORT QgsLayoutMapWidget: public QgsLayoutItemBaseWidget, private Ui: QgsLayoutItemPropertiesWidget *mItemPropertiesWidget = nullptr; QgsLayoutDesignerInterface *mInterface = nullptr; QPointer< QgsLayoutMapLabelingWidget > mLabelWidget; + QPointer< QgsLayoutMapClippingWidget > mClipWidget; QMenu *mBookmarkMenu = nullptr; QgsBookmarkManagerProxyModel *mBookmarkModel = nullptr; + QString mReportTypeString; //! Sets extent of composer map from line edits void updateComposerExtentFromGui(); @@ -242,4 +247,36 @@ class GUI_EXPORT QgsLayoutMapLabelingWidget: public QgsLayoutItemBaseWidget, pri QPointer< QgsLayoutItemMap > mMapItem; }; +/** + * \ingroup gui + * Allows configuration of layout map clipping settings. + * + * \note This class is not a part of public API + * \since QGIS 3.16 + */ +class GUI_EXPORT QgsLayoutMapClippingWidget: public QgsLayoutItemBaseWidget, private Ui::QgsLayoutMapClippingWidgetBase +{ + Q_OBJECT + + public: + //! constructor + explicit QgsLayoutMapClippingWidget( QgsLayoutItemMap *map ); + + void setReportTypeString( const QString &string ) override; + + protected: + bool setNewItem( QgsLayoutItem *item ) override; + + private slots: + void updateGuiElements(); + void atlasLayerChanged( QgsVectorLayer *layer ); + void atlasToggled( bool atlasEnabled ); + + private: + QPointer< QgsLayoutItemMap > mMapItem; + QgsMapLayerModel *mLayerModel = nullptr; + + bool mBlockUpdates = false; +}; + #endif diff --git a/src/ui/layout/qgslayoutmapclippingwidgetbase.ui b/src/ui/layout/qgslayoutmapclippingwidgetbase.ui new file mode 100644 index 00000000000..d0f4a868322 --- /dev/null +++ b/src/ui/layout/qgslayoutmapclippingwidgetbase.ui @@ -0,0 +1,127 @@ + + + QgsLayoutMapClippingWidgetBase + + + + 0 + 0 + 318 + 408 + + + + + 0 + 0 + + + + Map Options + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Clip to atlas feature + + + true + + + + + + true + + + 26 + + + + + + + + + + The clipping mode determines how features from vector layers will be clipped. + + + true + + + + + + + <b>When enabled, map layers will be automatically clipped to the boundary of the current atlas feature.</b> + + + true + + + + + + + Clip selected layers: + + + + + + + Clip all layers + + + + + + + Force labels inside atlas feature + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + QgsCollapsibleGroupBox + QGroupBox +
qgscollapsiblegroupbox.h
+ 1 +
+
+ + +
diff --git a/src/ui/layout/qgslayoutmapwidgetbase.ui b/src/ui/layout/qgslayoutmapwidgetbase.ui index 7849e6426e4..472d9448437 100644 --- a/src/ui/layout/qgslayoutmapwidgetbase.ui +++ b/src/ui/layout/qgslayoutmapwidgetbase.ui @@ -73,6 +73,7 @@ + @@ -88,8 +89,8 @@ 0 0 - 545 - 1113 + 548 + 1284 @@ -174,7 +175,7 @@ - + Qt::StrongFocus @@ -997,18 +998,59 @@ Labeling Settings + + + + :/images/themes/default/algorithms/mAlgorithmClip.svg:/images/themes/default/algorithms/mAlgorithmClip.svg + + + Clipping Settings + + + + QgsScrollArea + QScrollArea +
qgsscrollarea.h
+ 1 +
QgsDoubleSpinBox QDoubleSpinBox
qgsdoublespinbox.h
+ + QgsSpinBox + QSpinBox +
qgsspinbox.h
+
+ + QgsCollapsibleGroupBoxBasic + QGroupBox +
qgscollapsiblegroupbox.h
+ 1 +
+ + QgsLayoutItemComboBox + QComboBox +
qgslayoutitemcombobox.h
+
+ + QgsPropertyOverrideButton + QToolButton +
qgspropertyoverridebutton.h
+
QgsMapLayerComboBox QComboBox -
qgsmaplayercombobox.h
+
qgsmaplayercombobox.h
+
+ + QgsBlendModeComboBox + QComboBox +
qgsblendmodecombobox.h
QgsProjectionSelectionWidget @@ -1016,43 +1058,11 @@
qgsprojectionselectionwidget.h
1
- - QgsPropertyOverrideButton - QToolButton -
qgspropertyoverridebutton.h
-
- - QgsSpinBox - QSpinBox -
qgsspinbox.h
-
QgsSymbolButton QToolButton
qgssymbolbutton.h
- - QgsScrollArea - QScrollArea -
qgsscrollarea.h
- 1 -
- - QgsCollapsibleGroupBoxBasic - QGroupBox -
qgscollapsiblegroupbox.h
- 1 -
- - QgsBlendModeComboBox - QComboBox -
qgsblendmodecombobox.h
-
- - QgsLayoutItemComboBox - QComboBox -
qgslayoutitemcombobox.h
-
scrollArea @@ -1110,6 +1120,8 @@ + + diff --git a/tests/src/python/CMakeLists.txt b/tests/src/python/CMakeLists.txt index 5171b5b7483..d07e0d60d80 100644 --- a/tests/src/python/CMakeLists.txt +++ b/tests/src/python/CMakeLists.txt @@ -125,6 +125,7 @@ ADD_PYTHON_TEST(PyQgsLayoutItemPropertiesDialog test_qgslayoutitempropertiesdial ADD_PYTHON_TEST(PyQgsLayoutLabel test_qgslayoutlabel.py) ADD_PYTHON_TEST(PyQgsLayoutLegend test_qgslayoutlegend.py) ADD_PYTHON_TEST(PyQgsLayoutMap test_qgslayoutmap.py) +ADD_PYTHON_TEST(PyQgsLayoutItemMapAtlasClippingSettings test_qgslayoutatlasclippingsettings.py) ADD_PYTHON_TEST(PyQgsLayoutMapGrid test_qgslayoutmapgrid.py) ADD_PYTHON_TEST(PyQgsLayoutMapOverview test_qgslayoutmapoverview.py) ADD_PYTHON_TEST(PyQgsLayoutMarker test_qgslayoutmarker.py) diff --git a/tests/src/python/test_qgslayoutatlas.py b/tests/src/python/test_qgslayoutatlas.py index 44475f7adce..86bc967dfee 100644 --- a/tests/src/python/test_qgslayoutatlas.py +++ b/tests/src/python/test_qgslayoutatlas.py @@ -516,6 +516,62 @@ class TestQgsLayoutAtlas(unittest.TestCase): self.assertTrue(myTestResult, myMessage) self.atlas.endRender() + def test_clipping(self): + vectorFileInfo = QFileInfo(unitTestDataPath() + "/france_parts.shp") + vectorLayer = QgsVectorLayer(vectorFileInfo.filePath(), vectorFileInfo.completeBaseName(), "ogr") + + p = QgsProject() + p.addMapLayers([vectorLayer]) + + # create layout with layout map + + # select epsg:2154 + crs = QgsCoordinateReferenceSystem('epsg:2154') + p.setCrs(crs) + + layout = QgsPrintLayout(p) + layout.initializeDefaults() + + # fix the renderer, fill with green + props = {"color": "0,127,0", 'outline_style': 'no'} + fillSymbol = QgsFillSymbol.createSimple(props) + renderer = QgsSingleSymbolRenderer(fillSymbol) + vectorLayer.setRenderer(renderer) + + # the atlas map + atlas_map = QgsLayoutItemMap(layout) + atlas_map.attemptSetSceneRect(QRectF(20, 20, 130, 130)) + atlas_map.setFrameEnabled(False) + atlas_map.setLayers([vectorLayer]) + layout.addLayoutItem(atlas_map) + + # the atlas + atlas = layout.atlas() + atlas.setCoverageLayer(vectorLayer) + atlas.setEnabled(True) + + atlas_map.setExtent( + QgsRectangle(332719.06221504929, 6765214.5887386119, 560957.85090677091, 6993453.3774303338)) + + atlas_map.setAtlasDriven(True) + atlas_map.setAtlasScalingMode(QgsLayoutItemMap.Auto) + atlas_map.setAtlasMargin(0.10) + + atlas_map.atlasClippingSettings().setEnabled(True) + + atlas.beginRender() + + for i in range(0, 2): + atlas.seekTo(i) + + checker = QgsLayoutChecker('atlas_clipping%d' % (i + 1), layout) + checker.setControlPathPrefix("atlas") + myTestResult, myMessage = checker.testLayout(0, 200) + self.report += checker.report() + + self.assertTrue(myTestResult, myMessage) + atlas.endRender() + def legend_test(self): self.atlas_map.setAtlasDriven(True) self.atlas_map.setAtlasScalingMode(QgsLayoutItemMap.Auto) diff --git a/tests/src/python/test_qgslayoutatlasclippingsettings.py b/tests/src/python/test_qgslayoutatlasclippingsettings.py new file mode 100644 index 00000000000..2d04d57f41a --- /dev/null +++ b/tests/src/python/test_qgslayoutatlasclippingsettings.py @@ -0,0 +1,113 @@ +# -*- coding: utf-8 -*- +"""QGIS Unit tests for QgsLayoutItemMapAtlasClippingSettings. + +.. 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__ = '(C) 2020 Nyall Dawson' +__date__ = '03/07/2020' +__copyright__ = 'Copyright 2020, The QGIS Project' + +import qgis # NOQA + + +from qgis.core import (QgsLayoutItemMap, + QgsLayout, + QgsProject, + QgsLayoutItemMapAtlasClippingSettings, + QgsMapClippingRegion, + QgsVectorLayer, + QgsReadWriteContext) + +from qgis.testing import start_app, unittest +from utilities import unitTestDataPath +from qgis.PyQt.QtTest import QSignalSpy +from qgis.PyQt.QtXml import QDomDocument + + +start_app() +TEST_DATA_DIR = unitTestDataPath() + + +class TestQgsLayoutItemMapAtlasClippingSettings(unittest.TestCase): + + def testSettings(self): + p = QgsProject() + l = QgsLayout(p) + map = QgsLayoutItemMap(l) + + settings = QgsLayoutItemMapAtlasClippingSettings(map) + spy = QSignalSpy(settings.changed) + + self.assertFalse(settings.enabled()) + settings.setEnabled(True) + self.assertTrue(settings.enabled()) + self.assertEqual(len(spy), 1) + settings.setEnabled(True) + self.assertEqual(len(spy), 1) + + settings.setFeatureClippingType(QgsMapClippingRegion.FeatureClippingType.NoClipping) + self.assertEqual(settings.featureClippingType(), QgsMapClippingRegion.FeatureClippingType.NoClipping) + self.assertEqual(len(spy), 2) + settings.setFeatureClippingType(QgsMapClippingRegion.FeatureClippingType.NoClipping) + self.assertEqual(len(spy), 2) + + self.assertFalse(settings.forceLabelsInsideFeature()) + settings.setForceLabelsInsideFeature(True) + self.assertTrue(settings.forceLabelsInsideFeature()) + self.assertEqual(len(spy), 3) + settings.setForceLabelsInsideFeature(True) + self.assertEqual(len(spy), 3) + + l1 = QgsVectorLayer("Point?field=fldtxt:string&field=fldint:integer", + "addfeat", "memory") + l2 = QgsVectorLayer("Point?field=fldtxt:string&field=fldint:integer", + "addfeat", "memory") + p.addMapLayers([l1, l2]) + self.assertFalse(settings.layersToClip()) + settings.setLayersToClip([l1, l2]) + self.assertCountEqual(settings.layersToClip(), [l1, l2]) + self.assertEqual(len(spy), 4) + + p.removeMapLayer(l1.id()) + self.assertCountEqual(settings.layersToClip(), [l2]) + + def testSaveRestore(self): + p = QgsProject() + l1 = QgsVectorLayer("Point?field=fldtxt:string&field=fldint:integer", + "addfeat", "memory") + l2 = QgsVectorLayer("Point?field=fldtxt:string&field=fldint:integer", + "addfeat", "memory") + p.addMapLayers([l1, l2]) + + l = QgsLayout(p) + map = QgsLayoutItemMap(l) + + settings = map.atlasClippingSettings() + settings.setEnabled(True) + settings.setFeatureClippingType(QgsMapClippingRegion.FeatureClippingType.NoClipping) + settings.setForceLabelsInsideFeature(True) + settings.setLayersToClip([l2]) + + # save map to xml + doc = QDomDocument("testdoc") + elem = doc.createElement("test") + self.assertTrue(map.writeXml(elem, doc, QgsReadWriteContext())) + + layout2 = QgsLayout(p) + map2 = QgsLayoutItemMap(layout2) + self.assertFalse(map2.atlasClippingSettings().enabled()) + + # restore from xml + self.assertTrue(map2.readXml(elem.firstChildElement(), doc, QgsReadWriteContext())) + + self.assertTrue(map2.atlasClippingSettings().enabled()) + self.assertEqual(map2.atlasClippingSettings().featureClippingType(), QgsMapClippingRegion.FeatureClippingType.NoClipping) + self.assertTrue(map2.atlasClippingSettings().forceLabelsInsideFeature()) + self.assertEqual(map2.atlasClippingSettings().layersToClip(), [l2]) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/testdata/control_images/atlas/expected_atlas_clipping1/expected_atlas_clipping1.png b/tests/testdata/control_images/atlas/expected_atlas_clipping1/expected_atlas_clipping1.png new file mode 100644 index 00000000000..446a48d522d Binary files /dev/null and b/tests/testdata/control_images/atlas/expected_atlas_clipping1/expected_atlas_clipping1.png differ diff --git a/tests/testdata/control_images/atlas/expected_atlas_clipping2/expected_atlas_clipping2.png b/tests/testdata/control_images/atlas/expected_atlas_clipping2/expected_atlas_clipping2.png new file mode 100644 index 00000000000..4909390a346 Binary files /dev/null and b/tests/testdata/control_images/atlas/expected_atlas_clipping2/expected_atlas_clipping2.png differ