From a41f6f433388b0e2e74dbfe7a0b2d0bb0f95a3d9 Mon Sep 17 00:00:00 2001 From: Nyall Dawson Date: Mon, 29 Apr 2024 12:08:25 +1000 Subject: [PATCH] Copy project vertical/3d crs logic to QgsMapLayer This adds a new verticalCrs property for map layers, and a corresponding crs3D() property. Logic is the same as that for QgsProject. --- .../core/auto_generated/qgsmaplayer.sip.in | 145 ++++++++- python/core/auto_generated/qgsmaplayer.sip.in | 145 ++++++++- src/core/qgsmaplayer.cpp | 227 +++++++++++++++ src/core/qgsmaplayer.h | 123 +++++++- tests/src/python/test_qgsmaplayer.py | 274 ++++++++++++++++++ 5 files changed, 908 insertions(+), 6 deletions(-) diff --git a/python/PyQt6/core/auto_generated/qgsmaplayer.sip.in b/python/PyQt6/core/auto_generated/qgsmaplayer.sip.in index e7509e7753b4..1ee48acc14c3 100644 --- a/python/PyQt6/core/auto_generated/qgsmaplayer.sip.in +++ b/python/PyQt6/core/auto_generated/qgsmaplayer.sip.in @@ -808,11 +808,110 @@ after accessing data by :py:func:`~QgsMapLayer.draw` etc. QgsCoordinateReferenceSystem crs() const; %Docstring Returns the layer's spatial reference system. + +.. warning:: + + Since QGIS 3.38, consider using :py:func:`~QgsMapLayer.crs3D` whenever transforming 3D data or whenever + z/elevation value handling is important. + +.. seealso:: :py:func:`setCrs` + +.. seealso:: :py:func:`crs3D` + +.. seealso:: :py:func:`verticalCrs` + +.. seealso:: :py:func:`crsChanged` +%End + + QgsCoordinateReferenceSystem verticalCrs() const; +%Docstring +Returns the layer's vertical coordinate reference system. + +If the layer :py:func:`~QgsMapLayer.crs` is a compound CRS, then the CRS returned will +be the vertical component of :py:func:`~QgsMapLayer.crs`. Otherwise it will be the value +explicitly set by a call to :py:func:`~QgsMapLayer.setVerticalCrs`. + +The returned CRS will be invalid if the layer has no vertical CRS. + +.. note:: + + Consider also using :py:func:`~QgsMapLayer.crs3D`, which will return a CRS which takes into account + both :py:func:`~QgsMapLayer.crs` and :py:func:`~QgsMapLayer.verticalCrs`. + +.. seealso:: :py:func:`crs` + +.. seealso:: :py:func:`crs3D` + +.. seealso:: :py:func:`setVerticalCrs` + + +.. versionadded:: 3.38 +%End + + QgsCoordinateReferenceSystem crs3D() const; +%Docstring +Returns the CRS to use for the layer when transforming 3D data, or when z/elevation +value handling is important. + +The returned CRS will take into account :py:func:`~QgsMapLayer.verticalCrs` when appropriate, e.g. it may return a compound +CRS consisting of :py:func:`~QgsMapLayer.crs` + :py:func:`~QgsMapLayer.verticalCrs`. This method may still return a 2D CRS, e.g in the +case that :py:func:`~QgsMapLayer.crs` is a 2D CRS and no :py:func:`~QgsMapLayer.verticalCrs` has been set for the layer. Check :py:func:`QgsCoordinateReferenceSystem.type()` +on the returned value to determine the type of CRS returned by this method. + +.. warning:: + + It is NOT guaranteed that the returned CRS will actually be a 3D CRS, but rather + it is guaranteed that the returned CRS is ALWAYS the most appropriate CRS to use when handling 3D data. + +.. seealso:: :py:func:`crs` + +.. seealso:: :py:func:`verticalCrs` + +.. seealso:: :py:func:`crs3DChanged` + + +.. versionadded:: 3.38 %End void setCrs( const QgsCoordinateReferenceSystem &srs, bool emitSignal = true ); %Docstring -Sets layer's spatial reference system +Sets layer's spatial reference system. + +If ``emitSignal`` is ``True``, changing the CRS will trigger a :py:func:`~QgsMapLayer.crsChanged` signal. Additionally, if ``crs`` is a compound +CRS, then the :py:func:`~QgsMapLayer.verticalCrsChanged` signal will also be emitted. + +.. seealso:: :py:func:`crs` + +.. seealso:: :py:func:`crsChanged` + +.. seealso:: :py:func:`setVerticalCrs` +%End + + bool setVerticalCrs( const QgsCoordinateReferenceSystem &crs, QString *errorMessage /Out/ = 0 ); +%Docstring +Sets the layer's vertical coordinate reference system. + +The :py:func:`~QgsMapLayer.verticalCrsChanged` signal will be raised if the vertical CRS is changed. + +.. note:: + + If the layer :py:func:`~QgsMapLayer.crs` is a compound CRS, then the CRS returned for + :py:func:`~QgsMapLayer.verticalCrs` will be the vertical component of :py:func:`~QgsMapLayer.crs`. Otherwise it will be the value + explicitly set by this call. + +:param crs: the vertical CRS + + +:return: - ``True`` if vertical CRS was successfully set + - errorMessage: will be set to a descriptive message if the vertical CRS could not be set + + +.. seealso:: :py:func:`verticalCrs` + +.. seealso:: :py:func:`setCrs` + + +.. versionadded:: 3.38 %End QgsCoordinateTransformContext transformContext( ) const; @@ -1823,7 +1922,49 @@ Emitted when the name has been changed void crsChanged(); %Docstring -Emit a signal that layer's CRS has been reset +Emitted when the :py:func:`~QgsMapLayer.crs` of the layer has changed. + +.. seealso:: :py:func:`crs` + +.. seealso:: :py:func:`setCrs` + +.. seealso:: :py:func:`verticalCrsChanged` + +.. seealso:: :py:func:`crs3DChanged` +%End + + void crs3DChanged(); +%Docstring +Emitted when the :py:func:`~QgsMapLayer.crs3D` of the layer has changed. + +.. seealso:: :py:func:`crs3D` + +.. seealso:: :py:func:`crsChanged` + +.. seealso:: :py:func:`verticalCrsChanged` + +.. versionadded:: 3.38 +%End + + void verticalCrsChanged(); +%Docstring +Emitted when the :py:func:`~QgsMapLayer.verticalCrs` of the layer has changed. + +This signal will be emitted whenever the vertical CRS of the layer is changed, either +as a direct result of a call to :py:func:`~QgsMapLayer.setVerticalCrs` or when :py:func:`~QgsMapLayer.setCrs` is called with a compound +CRS. + +.. seealso:: :py:func:`crsChanged` + +.. seealso:: :py:func:`crs3DChanged` + +.. seealso:: :py:func:`setCrs` + +.. seealso:: :py:func:`setVerticalCrs` + +.. seealso:: :py:func:`verticalCrs` + +.. versionadded:: 3.38 %End void repaintRequested( bool deferredUpdate = false ); diff --git a/python/core/auto_generated/qgsmaplayer.sip.in b/python/core/auto_generated/qgsmaplayer.sip.in index de3e0845e3f1..556795b43f58 100644 --- a/python/core/auto_generated/qgsmaplayer.sip.in +++ b/python/core/auto_generated/qgsmaplayer.sip.in @@ -808,11 +808,110 @@ after accessing data by :py:func:`~QgsMapLayer.draw` etc. QgsCoordinateReferenceSystem crs() const; %Docstring Returns the layer's spatial reference system. + +.. warning:: + + Since QGIS 3.38, consider using :py:func:`~QgsMapLayer.crs3D` whenever transforming 3D data or whenever + z/elevation value handling is important. + +.. seealso:: :py:func:`setCrs` + +.. seealso:: :py:func:`crs3D` + +.. seealso:: :py:func:`verticalCrs` + +.. seealso:: :py:func:`crsChanged` +%End + + QgsCoordinateReferenceSystem verticalCrs() const; +%Docstring +Returns the layer's vertical coordinate reference system. + +If the layer :py:func:`~QgsMapLayer.crs` is a compound CRS, then the CRS returned will +be the vertical component of :py:func:`~QgsMapLayer.crs`. Otherwise it will be the value +explicitly set by a call to :py:func:`~QgsMapLayer.setVerticalCrs`. + +The returned CRS will be invalid if the layer has no vertical CRS. + +.. note:: + + Consider also using :py:func:`~QgsMapLayer.crs3D`, which will return a CRS which takes into account + both :py:func:`~QgsMapLayer.crs` and :py:func:`~QgsMapLayer.verticalCrs`. + +.. seealso:: :py:func:`crs` + +.. seealso:: :py:func:`crs3D` + +.. seealso:: :py:func:`setVerticalCrs` + + +.. versionadded:: 3.38 +%End + + QgsCoordinateReferenceSystem crs3D() const; +%Docstring +Returns the CRS to use for the layer when transforming 3D data, or when z/elevation +value handling is important. + +The returned CRS will take into account :py:func:`~QgsMapLayer.verticalCrs` when appropriate, e.g. it may return a compound +CRS consisting of :py:func:`~QgsMapLayer.crs` + :py:func:`~QgsMapLayer.verticalCrs`. This method may still return a 2D CRS, e.g in the +case that :py:func:`~QgsMapLayer.crs` is a 2D CRS and no :py:func:`~QgsMapLayer.verticalCrs` has been set for the layer. Check :py:func:`QgsCoordinateReferenceSystem.type()` +on the returned value to determine the type of CRS returned by this method. + +.. warning:: + + It is NOT guaranteed that the returned CRS will actually be a 3D CRS, but rather + it is guaranteed that the returned CRS is ALWAYS the most appropriate CRS to use when handling 3D data. + +.. seealso:: :py:func:`crs` + +.. seealso:: :py:func:`verticalCrs` + +.. seealso:: :py:func:`crs3DChanged` + + +.. versionadded:: 3.38 %End void setCrs( const QgsCoordinateReferenceSystem &srs, bool emitSignal = true ); %Docstring -Sets layer's spatial reference system +Sets layer's spatial reference system. + +If ``emitSignal`` is ``True``, changing the CRS will trigger a :py:func:`~QgsMapLayer.crsChanged` signal. Additionally, if ``crs`` is a compound +CRS, then the :py:func:`~QgsMapLayer.verticalCrsChanged` signal will also be emitted. + +.. seealso:: :py:func:`crs` + +.. seealso:: :py:func:`crsChanged` + +.. seealso:: :py:func:`setVerticalCrs` +%End + + bool setVerticalCrs( const QgsCoordinateReferenceSystem &crs, QString *errorMessage /Out/ = 0 ); +%Docstring +Sets the layer's vertical coordinate reference system. + +The :py:func:`~QgsMapLayer.verticalCrsChanged` signal will be raised if the vertical CRS is changed. + +.. note:: + + If the layer :py:func:`~QgsMapLayer.crs` is a compound CRS, then the CRS returned for + :py:func:`~QgsMapLayer.verticalCrs` will be the vertical component of :py:func:`~QgsMapLayer.crs`. Otherwise it will be the value + explicitly set by this call. + +:param crs: the vertical CRS + + +:return: - ``True`` if vertical CRS was successfully set + - errorMessage: will be set to a descriptive message if the vertical CRS could not be set + + +.. seealso:: :py:func:`verticalCrs` + +.. seealso:: :py:func:`setCrs` + + +.. versionadded:: 3.38 %End QgsCoordinateTransformContext transformContext( ) const; @@ -1823,7 +1922,49 @@ Emitted when the name has been changed void crsChanged(); %Docstring -Emit a signal that layer's CRS has been reset +Emitted when the :py:func:`~QgsMapLayer.crs` of the layer has changed. + +.. seealso:: :py:func:`crs` + +.. seealso:: :py:func:`setCrs` + +.. seealso:: :py:func:`verticalCrsChanged` + +.. seealso:: :py:func:`crs3DChanged` +%End + + void crs3DChanged(); +%Docstring +Emitted when the :py:func:`~QgsMapLayer.crs3D` of the layer has changed. + +.. seealso:: :py:func:`crs3D` + +.. seealso:: :py:func:`crsChanged` + +.. seealso:: :py:func:`verticalCrsChanged` + +.. versionadded:: 3.38 +%End + + void verticalCrsChanged(); +%Docstring +Emitted when the :py:func:`~QgsMapLayer.verticalCrs` of the layer has changed. + +This signal will be emitted whenever the vertical CRS of the layer is changed, either +as a direct result of a call to :py:func:`~QgsMapLayer.setVerticalCrs` or when :py:func:`~QgsMapLayer.setCrs` is called with a compound +CRS. + +.. seealso:: :py:func:`crsChanged` + +.. seealso:: :py:func:`crs3DChanged` + +.. seealso:: :py:func:`setCrs` + +.. seealso:: :py:func:`setVerticalCrs` + +.. seealso:: :py:func:`verticalCrs` + +.. versionadded:: 3.38 %End void repaintRequested( bool deferredUpdate = false ); diff --git a/src/core/qgsmaplayer.cpp b/src/core/qgsmaplayer.cpp index cdfc40277ef8..dd969da983c9 100644 --- a/src/core/qgsmaplayer.cpp +++ b/src/core/qgsmaplayer.cpp @@ -624,12 +624,27 @@ bool QgsMapLayer::readLayerXml( const QDomElement &layerElement, QgsReadWriteCon // now let the children grab what they need from the Dom node. layerError = !readXml( layerElement, context ); + const QgsCoordinateReferenceSystem oldVerticalCrs = verticalCrs(); + const QgsCoordinateReferenceSystem oldCrs3D = mCrs3D; + // overwrite CRS with what we read from project file before the raster/vector // file reading functions changed it. They will if projections is specified in the file. // FIXME: is this necessary? Yes, it is (autumn 2019) QgsCoordinateReferenceSystem::setCustomCrsValidation( savedValidation ); mCRS = savedCRS; + //vertical CRS + { + QgsCoordinateReferenceSystem verticalCrs; + const QDomNode verticalCrsNode = layerElement.firstChildElement( QStringLiteral( "verticalCrs" ) ); + if ( !verticalCrsNode.isNull() ) + { + verticalCrs.readXml( verticalCrsNode ); + } + mVerticalCrs = verticalCrs; + } + rebuildCrs3D(); + //legendUrl const QDomElement legendUrlElem = layerElement.firstChildElement( QStringLiteral( "legendUrl" ) ); if ( !legendUrlElem.isNull() ) @@ -681,6 +696,11 @@ bool QgsMapLayer::readLayerXml( const QDomElement &layerElement, QgsReadWriteCon mLegendPlaceholderImage = layerElement.attribute( QStringLiteral( "legendPlaceholderImage" ) ); + if ( verticalCrs() != oldVerticalCrs ) + emit verticalCrsChanged(); + if ( mCrs3D != oldCrs3D ) + emit crs3DChanged(); + return ! layerError; } // bool QgsMapLayer::readLayerXML @@ -741,6 +761,12 @@ bool QgsMapLayer::writeLayerXml( QDomElement &layerElement, QDomDocument &docume layerElement.appendChild( layerId ); + { + QDomElement verticalSrsNode = document.createElement( QStringLiteral( "verticalCrs" ) ); + mVerticalCrs.writeXml( verticalSrsNode, document ); + layerElement.appendChild( verticalSrsNode ); + } + // data source QDomElement dataSource = document.createElement( QStringLiteral( "datasource" ) ); const QgsDataProvider *provider = dataProvider(); @@ -1274,9 +1300,50 @@ QgsCoordinateReferenceSystem QgsMapLayer::crs() const return mCRS; } +QgsCoordinateReferenceSystem QgsMapLayer::verticalCrs() const +{ + QGIS_PROTECT_QOBJECT_THREAD_ACCESS + + switch ( mCRS.type() ) + { + case Qgis::CrsType::Vertical: // would hope this never happens! + QgsDebugError( QStringLiteral( "Layer has a vertical CRS set as the horizontal CRS!" ) ); + return mCRS; + + case Qgis::CrsType::Compound: + return mCRS.verticalCrs(); + + case Qgis::CrsType::Unknown: + case Qgis::CrsType::Geodetic: + case Qgis::CrsType::Geocentric: + case Qgis::CrsType::Geographic2d: + case Qgis::CrsType::Geographic3d: + case Qgis::CrsType::Projected: + case Qgis::CrsType::Temporal: + case Qgis::CrsType::Engineering: + case Qgis::CrsType::Bound: + case Qgis::CrsType::Other: + case Qgis::CrsType::DerivedProjected: + break; + } + return mVerticalCrs; +} + +QgsCoordinateReferenceSystem QgsMapLayer::crs3D() const +{ + QGIS_PROTECT_QOBJECT_THREAD_ACCESS + + return mCrs3D.isValid() ? mCrs3D : mCRS; +} + void QgsMapLayer::setCrs( const QgsCoordinateReferenceSystem &srs, bool emitSignal ) { QGIS_PROTECT_QOBJECT_THREAD_ACCESS + if ( mCRS == srs ) + return; + + const QgsCoordinateReferenceSystem oldVerticalCrs = verticalCrs(); + const QgsCoordinateReferenceSystem oldCrs3D = mCrs3D; mCRS = srs; @@ -1286,8 +1353,114 @@ void QgsMapLayer::setCrs( const QgsCoordinateReferenceSystem &srs, bool emitSign mCRS.validate(); } + rebuildCrs3D(); + if ( emitSignal ) emit crsChanged(); + + // Did vertical crs also change as a result of this? If so, emit signal + if ( oldVerticalCrs != verticalCrs() ) + emit verticalCrsChanged(); + if ( oldCrs3D != mCrs3D ) + emit crs3DChanged(); +} + +bool QgsMapLayer::setVerticalCrs( const QgsCoordinateReferenceSystem &crs, QString *errorMessage ) +{ + QGIS_PROTECT_QOBJECT_THREAD_ACCESS + bool res = true; + if ( crs.isValid() ) + { + // validate that passed crs is a vertical crs + switch ( crs.type() ) + { + case Qgis::CrsType::Vertical: + break; + + case Qgis::CrsType::Unknown: + case Qgis::CrsType::Compound: + case Qgis::CrsType::Geodetic: + case Qgis::CrsType::Geocentric: + case Qgis::CrsType::Geographic2d: + case Qgis::CrsType::Geographic3d: + case Qgis::CrsType::Projected: + case Qgis::CrsType::Temporal: + case Qgis::CrsType::Engineering: + case Qgis::CrsType::Bound: + case Qgis::CrsType::Other: + case Qgis::CrsType::DerivedProjected: + if ( errorMessage ) + *errorMessage = QObject::tr( "Specified CRS is a %1 CRS, not a Vertical CRS" ).arg( qgsEnumValueToKey( crs.type() ) ); + return false; + } + } + + if ( crs != mVerticalCrs ) + { + const QgsCoordinateReferenceSystem oldVerticalCrs = verticalCrs(); + const QgsCoordinateReferenceSystem oldCrs3D = mCrs3D; + + switch ( mCRS.type() ) + { + case Qgis::CrsType::Compound: + if ( crs != oldVerticalCrs ) + { + if ( errorMessage ) + *errorMessage = QObject::tr( "Layer CRS is a Compound CRS, specified Vertical CRS will be ignored" ); + return false; + } + break; + + case Qgis::CrsType::Geographic3d: + if ( crs != oldVerticalCrs ) + { + if ( errorMessage ) + *errorMessage = QObject::tr( "Layer CRS is a Geographic 3D CRS, specified Vertical CRS will be ignored" ); + return false; + } + break; + + case Qgis::CrsType::Geocentric: + if ( crs != oldVerticalCrs ) + { + if ( errorMessage ) + *errorMessage = QObject::tr( "Layer CRS is a Geocentric CRS, specified Vertical CRS will be ignored" ); + return false; + } + break; + + case Qgis::CrsType::Projected: + if ( mCRS.hasVerticalAxis() && crs != oldVerticalCrs ) + { + if ( errorMessage ) + *errorMessage = QObject::tr( "Layer CRS is a Projected 3D CRS, specified Vertical CRS will be ignored" ); + return false; + } + break; + + case Qgis::CrsType::Unknown: + case Qgis::CrsType::Geodetic: + case Qgis::CrsType::Geographic2d: + case Qgis::CrsType::Temporal: + case Qgis::CrsType::Engineering: + case Qgis::CrsType::Bound: + case Qgis::CrsType::Other: + case Qgis::CrsType::Vertical: + case Qgis::CrsType::DerivedProjected: + break; + } + + mVerticalCrs = crs; + res = rebuildCrs3D( errorMessage ); + + // only emit signal if vertical crs was actually changed, so eg if mCrs is compound + // then we haven't actually changed the vertical crs by this call! + if ( verticalCrs() != oldVerticalCrs ) + emit verticalCrsChanged(); + if ( mCrs3D != oldCrs3D ) + emit crs3DChanged(); + } + return res; } QgsCoordinateTransformContext QgsMapLayer::transformContext() const @@ -2933,6 +3106,60 @@ void QgsMapLayer::updateExtent( const QgsBox3D &extent ) const } } +bool QgsMapLayer::rebuildCrs3D( QString *error ) +{ + bool res = true; + if ( !mCRS.isValid() ) + { + mCrs3D = QgsCoordinateReferenceSystem(); + } + else if ( !mVerticalCrs.isValid() ) + { + mCrs3D = mCRS; + } + else + { + switch ( mCRS.type() ) + { + case Qgis::CrsType::Compound: + case Qgis::CrsType::Geographic3d: + case Qgis::CrsType::Geocentric: + mCrs3D = mCRS; + break; + + case Qgis::CrsType::Projected: + { + QString tempError; + mCrs3D = mCRS.hasVerticalAxis() ? mCRS : QgsCoordinateReferenceSystem::createCompoundCrs( mCRS, mVerticalCrs, error ? *error : tempError ); + res = mCrs3D.isValid(); + break; + } + + case Qgis::CrsType::Vertical: + // nonsense situation + mCrs3D = QgsCoordinateReferenceSystem(); + res = false; + break; + + case Qgis::CrsType::Unknown: + case Qgis::CrsType::Geodetic: + case Qgis::CrsType::Geographic2d: + case Qgis::CrsType::Temporal: + case Qgis::CrsType::Engineering: + case Qgis::CrsType::Bound: + case Qgis::CrsType::Other: + case Qgis::CrsType::DerivedProjected: + { + QString tempError; + mCrs3D = QgsCoordinateReferenceSystem::createCompoundCrs( mCRS, mVerticalCrs, error ? *error : tempError ); + res = mCrs3D.isValid(); + break; + } + } + } + return res; +} + void QgsMapLayer::invalidateWgs84Extent() { QGIS_PROTECT_QOBJECT_THREAD_ACCESS diff --git a/src/core/qgsmaplayer.h b/src/core/qgsmaplayer.h index 9001af953261..8447fc761772 100644 --- a/src/core/qgsmaplayer.h +++ b/src/core/qgsmaplayer.h @@ -80,6 +80,8 @@ class CORE_EXPORT QgsMapLayer : public QObject Q_PROPERTY( int autoRefreshInterval READ autoRefreshInterval WRITE setAutoRefreshInterval NOTIFY autoRefreshIntervalChanged ) Q_PROPERTY( QgsLayerMetadata metadata READ metadata WRITE setMetadata NOTIFY metadataChanged ) Q_PROPERTY( QgsCoordinateReferenceSystem crs READ crs WRITE setCrs NOTIFY crsChanged ) + Q_PROPERTY( QgsCoordinateReferenceSystem verticalCrs READ verticalCrs WRITE setVerticalCrs NOTIFY verticalCrsChanged ) + Q_PROPERTY( QgsCoordinateReferenceSystem crs3D READ crs3D NOTIFY crs3DChanged ) Q_PROPERTY( Qgis::LayerType type READ type CONSTANT ) Q_PROPERTY( bool isValid READ isValid NOTIFY isValidChanged ) Q_PROPERTY( double opacity READ opacity WRITE setOpacity NOTIFY opacityChanged ) @@ -966,12 +968,90 @@ class CORE_EXPORT QgsMapLayer : public QObject /** * Returns the layer's spatial reference system. + * + * \warning Since QGIS 3.38, consider using crs3D() whenever transforming 3D data or whenever + * z/elevation value handling is important. + * + * \see setCrs() + * \see crs3D() + * \see verticalCrs() + * \see crsChanged() */ QgsCoordinateReferenceSystem crs() const; - //! Sets layer's spatial reference system + /** + * Returns the layer's vertical coordinate reference system. + * + * If the layer crs() is a compound CRS, then the CRS returned will + * be the vertical component of crs(). Otherwise it will be the value + * explicitly set by a call to setVerticalCrs(). + * + * The returned CRS will be invalid if the layer has no vertical CRS. + * + * \note Consider also using crs3D(), which will return a CRS which takes into account + * both crs() and verticalCrs(). + * + * \see crs() + * \see crs3D() + * \see setVerticalCrs() + * + * \since QGIS 3.38 + */ + QgsCoordinateReferenceSystem verticalCrs() const; + + /** + * Returns the CRS to use for the layer when transforming 3D data, or when z/elevation + * value handling is important. + * + * The returned CRS will take into account verticalCrs() when appropriate, e.g. it may return a compound + * CRS consisting of crs() + verticalCrs(). This method may still return a 2D CRS, e.g in the + * case that crs() is a 2D CRS and no verticalCrs() has been set for the layer. Check QgsCoordinateReferenceSystem::type() + * on the returned value to determine the type of CRS returned by this method. + * + * \warning It is NOT guaranteed that the returned CRS will actually be a 3D CRS, but rather + * it is guaranteed that the returned CRS is ALWAYS the most appropriate CRS to use when handling 3D data. + * + * \see crs() + * \see verticalCrs() + * \see crs3DChanged() + * + * \since QGIS 3.38 + */ + QgsCoordinateReferenceSystem crs3D() const; + + /** + * Sets layer's spatial reference system. + * + * If \a emitSignal is TRUE, changing the CRS will trigger a crsChanged() signal. Additionally, if \a crs is a compound + * CRS, then the verticalCrsChanged() signal will also be emitted. + * + * \see crs() + * \see crsChanged() + * \see setVerticalCrs() + */ void setCrs( const QgsCoordinateReferenceSystem &srs, bool emitSignal = true ); + /** + * Sets the layer's vertical coordinate reference system. + * + * The verticalCrsChanged() signal will be raised if the vertical CRS is changed. + * + * \note If the layer crs() is a compound CRS, then the CRS returned for + * verticalCrs() will be the vertical component of crs(). Otherwise it will be the value + * explicitly set by this call. + * + * \param crs the vertical CRS + * \param errorMessage will be set to a descriptive message if the vertical CRS could not be set + * + * \returns TRUE if vertical CRS was successfully set + * + * \see verticalCrs() + * \see setCrs() + * + * \since QGIS 3.38 + */ + bool setVerticalCrs( const QgsCoordinateReferenceSystem &crs, QString *errorMessage SIP_OUT = nullptr ); + /** * Returns the layer data provider coordinate transform context * or a default transform context if the layer does not have a valid data provider. @@ -1829,9 +1909,44 @@ class CORE_EXPORT QgsMapLayer : public QObject */ void nameChanged(); - //! Emit a signal that layer's CRS has been reset + /** + * Emitted when the crs() of the layer has changed. + * + * \see crs() + * \see setCrs() + * \see verticalCrsChanged() + * \see crs3DChanged() + */ void crsChanged(); + /** + * Emitted when the crs3D() of the layer has changed. + * + * \see crs3D() + * \see crsChanged() + * \see verticalCrsChanged() + * + * \since QGIS 3.38 + */ + void crs3DChanged(); + + /** + * Emitted when the verticalCrs() of the layer has changed. + * + * This signal will be emitted whenever the vertical CRS of the layer is changed, either + * as a direct result of a call to setVerticalCrs() or when setCrs() is called with a compound + * CRS. + * + * \see crsChanged() + * \see crs3DChanged() + * \see setCrs() + * \see setVerticalCrs() + * \see verticalCrs() + * + * \since QGIS 3.38 + */ + void verticalCrsChanged(); + /** * By emitting this signal the layer tells that either appearance or content have been changed * and any view showing the rendered layer should refresh itself. @@ -2236,6 +2351,8 @@ class CORE_EXPORT QgsMapLayer : public QObject void updateExtent( const QgsRectangle &extent ) const; void updateExtent( const QgsBox3D &extent ) const; + bool rebuildCrs3D( QString *error = nullptr ); + /** * This method returns TRUE by default but can be overwritten to specify * that a certain layer is writable. @@ -2247,6 +2364,8 @@ class CORE_EXPORT QgsMapLayer : public QObject * private to make sure setCrs must be used and crsChanged() is emitted. */ QgsCoordinateReferenceSystem mCRS; + QgsCoordinateReferenceSystem mVerticalCrs; + QgsCoordinateReferenceSystem mCrs3D; //! Unique ID of this layer - used to refer to this layer in map layer registry QString mID; diff --git a/tests/src/python/test_qgsmaplayer.py b/tests/src/python/test_qgsmaplayer.py index 9bf6d4864f23..a9a7bfc35ea6 100644 --- a/tests/src/python/test_qgsmaplayer.py +++ b/tests/src/python/test_qgsmaplayer.py @@ -13,17 +13,23 @@ import os import shutil import tempfile +from tempfile import TemporaryDirectory from qgis.PyQt import sip from qgis.PyQt.QtCore import QTemporaryDir from qgis.PyQt.QtXml import QDomDocument +from qgis.PyQt.QtTest import QSignalSpy + from qgis.core import ( + Qgis, + QgsMapLayer, QgsLayerMetadata, QgsLayerNotesUtils, QgsProject, QgsRasterLayer, QgsReadWriteContext, QgsVectorLayer, + QgsCoordinateReferenceSystem ) import unittest from qgis.testing import start_app, QgisTestCase @@ -80,6 +86,274 @@ def testGettersSetters(self): self.assertFalse(layer.hasAutoRefreshEnabled()) self.assertEqual(layer.autoRefreshInterval(), 0) + def test_crs(self): + layer = QgsVectorLayer("Point?field=fldtxt:string", "layer", "memory") + spy = QSignalSpy(layer.crsChanged) + layer.setCrs(QgsCoordinateReferenceSystem()) + self.assertEqual(len(spy), 1) + self.assertFalse(layer.crs().isValid()) + layer.setCrs(QgsCoordinateReferenceSystem()) + self.assertEqual(len(spy), 1) + + layer.setCrs(QgsCoordinateReferenceSystem('EPSG:3111')) + self.assertEqual(layer.crs().authid(), 'EPSG:3111') + self.assertEqual(len(spy), 2) + layer.setCrs(QgsCoordinateReferenceSystem('EPSG:3111')) + self.assertEqual(len(spy), 2) + + layer2 = QgsVectorLayer("Point?field=fldtxt:string", + "layer", "memory") + self.copyLayerViaXmlReadWrite(layer, layer2) + self.assertEqual(layer2.crs().authid(), 'EPSG:3111') + + def test_vertical_crs(self): + layer = QgsVectorLayer("Point?field=fldtxt:string", "layer", "memory") + layer.setCrs(QgsCoordinateReferenceSystem()) + self.assertFalse(layer.verticalCrs().isValid()) + + spy = QSignalSpy(layer.verticalCrsChanged) + # not a vertical crs + ok, err = layer.setVerticalCrs( + QgsCoordinateReferenceSystem('EPSG:3111')) + self.assertFalse(ok) + self.assertEqual(err, 'Specified CRS is a Projected CRS, not a Vertical CRS') + self.assertFalse(layer.verticalCrs().isValid()) + + ok, err = layer.setVerticalCrs(QgsCoordinateReferenceSystem('EPSG:5703')) + self.assertTrue(ok) + self.assertEqual(layer.verticalCrs().authid(), 'EPSG:5703') + self.assertEqual(len(spy), 1) + # try overwriting with same crs, should be no new signal + ok, err = layer.setVerticalCrs(QgsCoordinateReferenceSystem('EPSG:5703')) + self.assertTrue(ok) + self.assertEqual(len(spy), 1) + + # check that vertical crs is saved/restored + layer2 = QgsVectorLayer("Point?field=fldtxt:string", + "layer", "memory") + spy2 = QSignalSpy(layer2.verticalCrsChanged) + self.copyLayerViaXmlReadWrite(layer, layer2) + + self.assertEqual(layer2.verticalCrs().authid(), 'EPSG:5703') + self.assertEqual(len(spy2), 1) + self.copyLayerViaXmlReadWrite(layer, layer2) + self.assertEqual(layer2.verticalCrs().authid(), 'EPSG:5703') + self.assertEqual(len(spy2), 1) + + layer.setVerticalCrs(QgsCoordinateReferenceSystem()) + self.assertEqual(len(spy), 2) + self.assertFalse(layer.verticalCrs().isValid()) + + def test_vertical_crs_with_compound_project_crs(self): + """ + Test vertical crs logic when layer has a compound crs set + """ + layer = QgsVectorLayer("Point?field=fldtxt:string", "layer", "memory") + layer.setCrs(QgsCoordinateReferenceSystem()) + self.assertFalse(layer.crs().isValid()) + self.assertFalse(layer.verticalCrs().isValid()) + + spy = QSignalSpy(layer.verticalCrsChanged) + layer.setCrs(QgsCoordinateReferenceSystem('EPSG:5500')) + self.assertEqual(layer.crs().authid(), 'EPSG:5500') + # verticalCrs() should return the vertical part of the + # compound CRS + self.assertEqual(layer.verticalCrs().authid(), 'EPSG:5703') + self.assertEqual(len(spy), 1) + other_vert_crs = QgsCoordinateReferenceSystem('ESRI:115700') + self.assertTrue(other_vert_crs.isValid()) + self.assertEqual(other_vert_crs.type(), Qgis.CrsType.Vertical) + + # if we explicitly set a vertical crs now, it should be ignored + # because the main project crs is a compound crs and that takes + # precedence + ok, err = layer.setVerticalCrs(other_vert_crs) + self.assertFalse(ok) + self.assertEqual(err, 'Layer CRS is a Compound CRS, specified Vertical CRS will be ignored') + self.assertEqual(layer.verticalCrs().authid(), 'EPSG:5703') + self.assertEqual(len(spy), 1) + # setting the vertical crs to the vertical component of the compound crs + # IS permitted, even though it effectively has no impact... + ok, err = layer.setVerticalCrs(QgsCoordinateReferenceSystem('EPSG:5703')) + self.assertTrue(ok) + self.assertEqual(layer.verticalCrs().authid(), 'EPSG:5703') + self.assertEqual(len(spy), 1) + + # reset horizontal crs to a non-compound crs, now the manually + # specified vertical crs should take precedence + layer.setCrs(QgsCoordinateReferenceSystem('EPSG:3111')) + self.assertEqual(layer.verticalCrs().authid(), 'EPSG:5703') + self.assertEqual(len(spy), 1) + + # invalid combinations + layer.setCrs(QgsCoordinateReferenceSystem('EPSG:4979')) + ok, err = layer.setVerticalCrs(QgsCoordinateReferenceSystem('EPSG:5711')) + self.assertFalse(ok) + self.assertEqual(err, 'Layer CRS is a Geographic 3D CRS, specified Vertical CRS will be ignored') + self.assertEqual(layer.crs3D().authid(), 'EPSG:4979') + + layer.setCrs(QgsCoordinateReferenceSystem('EPSG:4978')) + ok, err = layer.setVerticalCrs(QgsCoordinateReferenceSystem('EPSG:5711')) + self.assertFalse(ok) + self.assertEqual(err, 'Layer CRS is a Geocentric CRS, specified Vertical CRS will be ignored') + self.assertEqual(layer.crs3D().authid(), 'EPSG:4978') + + def test_vertical_crs_with_projected3d_project_crs(self): + """ + Test vertical crs logic when layer has a projected 3d crs set + """ + layer = QgsVectorLayer("Point?field=fldtxt:string", "layer", "memory") + layer.setCrs(QgsCoordinateReferenceSystem()) + self.assertFalse(layer.crs().isValid()) + self.assertFalse(layer.verticalCrs().isValid()) + + spy = QSignalSpy(layer.verticalCrsChanged) + + projected3d_crs = QgsCoordinateReferenceSystem.fromWkt("PROJCRS[\"NAD83(HARN) / Oregon GIC Lambert (ft)\",\n" + " BASEGEOGCRS[\"NAD83(HARN)\",\n" + " DATUM[\"NAD83 (High Accuracy Reference Network)\",\n" + " ELLIPSOID[\"GRS 1980\",6378137,298.257222101,\n" + " LENGTHUNIT[\"metre\",1]]],\n" + " PRIMEM[\"Greenwich\",0,\n" + " ANGLEUNIT[\"degree\",0.0174532925199433]],\n" + " ID[\"EPSG\",4957]],\n" + " CONVERSION[\"unnamed\",\n" + " METHOD[\"Lambert Conic Conformal (2SP)\",\n" + " ID[\"EPSG\",9802]],\n" + " PARAMETER[\"Latitude of false origin\",41.75,\n" + " ANGLEUNIT[\"degree\",0.0174532925199433],\n" + " ID[\"EPSG\",8821]],\n" + " PARAMETER[\"Longitude of false origin\",-120.5,\n" + " ANGLEUNIT[\"degree\",0.0174532925199433],\n" + " ID[\"EPSG\",8822]],\n" + " PARAMETER[\"Latitude of 1st standard parallel\",43,\n" + " ANGLEUNIT[\"degree\",0.0174532925199433],\n" + " ID[\"EPSG\",8823]],\n" + " PARAMETER[\"Latitude of 2nd standard parallel\",45.5,\n" + " ANGLEUNIT[\"degree\",0.0174532925199433],\n" + " ID[\"EPSG\",8824]],\n" + " PARAMETER[\"Easting at false origin\",1312335.958,\n" + " LENGTHUNIT[\"foot\",0.3048],\n" + " ID[\"EPSG\",8826]],\n" + " PARAMETER[\"Northing at false origin\",0,\n" + " LENGTHUNIT[\"foot\",0.3048],\n" + " ID[\"EPSG\",8827]]],\n" + " CS[Cartesian,3],\n" + " AXIS[\"easting\",east,\n" + " ORDER[1],\n" + " LENGTHUNIT[\"foot\",0.3048]],\n" + " AXIS[\"northing\",north,\n" + " ORDER[2],\n" + " LENGTHUNIT[\"foot\",0.3048]],\n" + " AXIS[\"ellipsoidal height (h)\",up,\n" + " ORDER[3],\n" + " LENGTHUNIT[\"foot\",0.3048]]]") + self.assertTrue(projected3d_crs.isValid()) + layer.setCrs(projected3d_crs) + self.assertEqual(layer.crs().toWkt(), projected3d_crs.toWkt()) + # layer 3d crs should be projected 3d crs + self.assertEqual(layer.crs3D().toWkt(), projected3d_crs.toWkt()) + # verticalCrs() should return invalid crs + self.assertFalse(layer.verticalCrs().isValid()) + self.assertEqual(len(spy), 0) + other_vert_crs = QgsCoordinateReferenceSystem('ESRI:115700') + self.assertTrue(other_vert_crs.isValid()) + self.assertEqual(other_vert_crs.type(), Qgis.CrsType.Vertical) + + # if we explicitly set a vertical crs now, it should be ignored + # because the main layer crs is already 3d and that takes + # precedence + ok, err = layer.setVerticalCrs(other_vert_crs) + self.assertFalse(ok) + self.assertEqual(err, 'Layer CRS is a Projected 3D CRS, specified Vertical CRS will be ignored') + self.assertFalse(layer.verticalCrs().isValid()) + self.assertEqual(len(spy), 0) + self.assertEqual(layer.crs3D().toWkt(), projected3d_crs.toWkt()) + + def test_crs_3d(self): + layer = QgsVectorLayer("Point?field=fldtxt:string", "layer", "memory") + layer.setCrs(QgsCoordinateReferenceSystem()) + self.assertFalse(layer.crs3D().isValid()) + + spy = QSignalSpy(layer.crs3DChanged) + + # set layer crs to a 2d crs + layer.setCrs(QgsCoordinateReferenceSystem('EPSG:3111')) + + self.assertEqual(layer.crs3D().authid(), 'EPSG:3111') + self.assertEqual(len(spy), 1) + + # don't change, no new signals + layer.setCrs(QgsCoordinateReferenceSystem('EPSG:3111')) + self.assertEqual(layer.crs3D().authid(), 'EPSG:3111') + self.assertEqual(len(spy), 1) + + # change 2d crs, should be new signals + layer.setCrs(QgsCoordinateReferenceSystem('EPSG:3113')) + self.assertEqual(layer.crs3D().authid(), 'EPSG:3113') + self.assertEqual(len(spy), 2) + + # change vertical crs: + + # not a vertical crs, no change + ok, err = layer.setVerticalCrs( + QgsCoordinateReferenceSystem('EPSG:3111')) + self.assertFalse(ok) + self.assertEqual(layer.crs3D().authid(), 'EPSG:3113') + self.assertEqual(len(spy), 2) + + # valid vertical crs + ok, err = layer.setVerticalCrs(QgsCoordinateReferenceSystem('EPSG:5703')) + self.assertTrue(ok) + self.assertEqual(layer.crs3D().type(), Qgis.CrsType.Compound) + # crs3D should be a compound crs + self.assertEqual(layer.crs3D().horizontalCrs().authid(), 'EPSG:3113') + self.assertEqual(layer.crs3D().verticalCrs().authid(), 'EPSG:5703') + self.assertEqual(len(spy), 3) + # try overwriting with same crs, should be no new signal + ok, err = layer.setVerticalCrs(QgsCoordinateReferenceSystem('EPSG:5703')) + self.assertTrue(ok) + self.assertEqual(len(spy), 3) + + # set 2d crs to a compound crs + layer.setCrs(QgsCoordinateReferenceSystem('EPSG:5500')) + self.assertEqual(layer.crs().authid(), 'EPSG:5500') + self.assertEqual(layer.crs3D().authid(), 'EPSG:5500') + self.assertEqual(len(spy), 4) + + layer.setCrs(QgsCoordinateReferenceSystem('EPSG:5500')) + self.assertEqual(layer.crs().authid(), 'EPSG:5500') + self.assertEqual(layer.crs3D().authid(), 'EPSG:5500') + self.assertEqual(len(spy), 4) + + # remove vertical crs, should be no change because compound crs is causing vertical crs to be ignored + layer.setVerticalCrs(QgsCoordinateReferenceSystem()) + self.assertEqual(layer.crs3D().authid(), 'EPSG:5500') + self.assertEqual(len(spy), 4) + + layer.setVerticalCrs(QgsCoordinateReferenceSystem('EPSG:5703')) + self.assertEqual(layer.crs3D().authid(), 'EPSG:5500') + self.assertEqual(len(spy), 4) + + # set crs back to 2d crs, should be new signal + layer.setCrs(QgsCoordinateReferenceSystem('EPSG:3111')) + self.assertEqual(layer.crs3D().horizontalCrs().authid(), 'EPSG:3111') + self.assertEqual(layer.crs3D().verticalCrs().authid(), 'EPSG:5703') + self.assertEqual(len(spy), 5) + + # check that crs3D is handled correctly during save/restore + layer2 = QgsVectorLayer("Point?field=fldtxt:string", "layer", "memory") + + spy2 = QSignalSpy(layer2.crs3DChanged) + self.copyLayerViaXmlReadWrite(layer, layer2) + self.assertEqual(layer2.crs3D().horizontalCrs().authid(), 'EPSG:3111') + self.assertEqual(layer2.crs3D().verticalCrs().authid(), 'EPSG:5703') + self.assertEqual(len(spy2), 1) + self.copyLayerViaXmlReadWrite(layer, layer2) + self.assertEqual(layer2.crs3D().horizontalCrs().authid(), 'EPSG:3111') + self.assertEqual(layer2.crs3D().verticalCrs().authid(), 'EPSG:5703') + self.assertEqual(len(spy2), 1) + def testLayerNotes(self): """ Test layer notes