diff --git a/python/PyQt6/core/auto_additions/qgspointclouddataprovider.py b/python/PyQt6/core/auto_additions/qgspointclouddataprovider.py index a8aa047d8dae..66c945815096 100644 --- a/python/PyQt6/core/auto_additions/qgspointclouddataprovider.py +++ b/python/PyQt6/core/auto_additions/qgspointclouddataprovider.py @@ -4,6 +4,7 @@ QgsPointCloudDataProvider.WriteLayerMetadata = QgsPointCloudDataProvider.Capability.WriteLayerMetadata QgsPointCloudDataProvider.CreateRenderer = QgsPointCloudDataProvider.Capability.CreateRenderer QgsPointCloudDataProvider.ContainSubIndexes = QgsPointCloudDataProvider.Capability.ContainSubIndexes +QgsPointCloudDataProvider.ChangeAttributeValues = QgsPointCloudDataProvider.Capability.ChangeAttributeValues QgsPointCloudDataProvider.Capabilities = lambda flags=0: QgsPointCloudDataProvider.Capability(flags) QgsPointCloudDataProvider.NotIndexed = QgsPointCloudDataProvider.PointCloudIndexGenerationState.NotIndexed QgsPointCloudDataProvider.Indexing = QgsPointCloudDataProvider.PointCloudIndexGenerationState.Indexing diff --git a/python/PyQt6/core/auto_generated/pointcloud/qgspointclouddataprovider.sip.in b/python/PyQt6/core/auto_generated/pointcloud/qgspointclouddataprovider.sip.in index ff63d96ddf56..2e981cff5380 100644 --- a/python/PyQt6/core/auto_generated/pointcloud/qgspointclouddataprovider.sip.in +++ b/python/PyQt6/core/auto_generated/pointcloud/qgspointclouddataprovider.sip.in @@ -36,6 +36,7 @@ Responsible for reading native point cloud data and returning the indexed data. WriteLayerMetadata, CreateRenderer, ContainSubIndexes, + ChangeAttributeValues, }; typedef QFlags Capabilities; diff --git a/python/PyQt6/core/auto_generated/pointcloud/qgspointcloudindex.sip.in b/python/PyQt6/core/auto_generated/pointcloud/qgspointcloudindex.sip.in index a99fbd00167c..18cbe8b42976 100644 --- a/python/PyQt6/core/auto_generated/pointcloud/qgspointcloudindex.sip.in +++ b/python/PyQt6/core/auto_generated/pointcloud/qgspointcloudindex.sip.in @@ -269,6 +269,13 @@ Returns all attributes that are stored in the file + bool updateNodeData( const QHash &data ); +%Docstring +Tries to update the data for the specified nodes. + +:return: ``True`` on success, otherwise ``False`` +%End + QgsRectangle extent() const; %Docstring Returns extent of the data @@ -346,6 +353,18 @@ Returns extra metadata that's not accessible through the other methods in an implementation-specific dynamic structure. .. seealso:: :py:func:`QgsAbstractPointCloudIndex.extraMetadata` +%End + + bool commitChanges(); +%Docstring +Tries to store pending changes to the data provider. + +:return: ``True`` on success, otherwise ``False`` +%End + + bool isModified() const; +%Docstring +Returns ``True`` if there are uncommitted changes, ``False`` otherwise %End }; diff --git a/python/PyQt6/core/auto_generated/pointcloud/qgspointcloudlayer.sip.in b/python/PyQt6/core/auto_generated/pointcloud/qgspointcloudlayer.sip.in index 93f2433a235a..090caa36c2ee 100644 --- a/python/PyQt6/core/auto_generated/pointcloud/qgspointcloudlayer.sip.in +++ b/python/PyQt6/core/auto_generated/pointcloud/qgspointcloudlayer.sip.in @@ -86,6 +86,13 @@ Constructor - creates a point cloud layer virtual QgsPointCloudDataProvider *dataProvider(); + virtual bool supportsEditing() const; + + virtual bool isEditable() const; + + virtual bool isModified() const; + + virtual bool readXml( const QDomNode &layerNode, QgsReadWriteContext &context ); @@ -201,6 +208,82 @@ Returns the status of point cloud statistics calculation .. versionadded:: 3.26 %End + + bool startEditing(); +%Docstring +Makes the layer editable. + +This starts an edit session on this layer. Changes made in this edit session will not +be made persistent until :py:func:`~QgsPointCloudLayer.commitChanges` is called, and can be reverted by calling +:py:func:`~QgsPointCloudLayer.rollBack`. + +:return: ``True`` if the layer was successfully made editable, or ``False`` if the operation + failed (e.g. due to an underlying read-only data source, or lack of edit support + by the backend data provider). + +.. seealso:: :py:func:`commitChanges` + +.. seealso:: :py:func:`rollBack` + +.. versionadded:: 3.42 +%End + + bool commitChanges( bool stopEditing = true ); +%Docstring +Attempts to commit to the underlying data provider any buffered changes made since the +last to call to :py:func:`~QgsPointCloudLayer.startEditing`. + +Returns the result of the attempt. If a commit fails (i.e. ``False`` is returned), the +in-memory changes are left untouched and are not discarded. This allows editing to +continue if the commit failed on e.g. a disallowed value for an attribute - the user +can re-edit and try again. + +If the commit failed, an error message may returned by :py:func:`~QgsPointCloudLayer.commitError`. + +By setting ``stopEditing`` to ``False``, the layer will stay in editing mode. +Otherwise the layer editing mode will be disabled if the commit is successful. + +.. seealso:: :py:func:`startEditing` + +.. seealso:: :py:func:`commitError` + +.. seealso:: :py:func:`rollBack` + +.. versionadded:: 3.42 +%End + + QString commitError() const; +%Docstring +Returns the last error message generated when attempting +to commit changes to the layer. + +.. seealso:: :py:func:`commitChanges` + +.. versionadded:: 3.42 +%End + + bool rollBack(); +%Docstring +Stops a current editing operation and discards any uncommitted edits. + +.. seealso:: :py:func:`startEditing` + +.. seealso:: :py:func:`commitChanges` + +.. versionadded:: 3.42 +%End + + + QgsPointCloudIndex index() const; +%Docstring +Returns the point cloud index associated with the layer. +If the layer is editable, its :py:class:`QgsPointCloudEditingIndex` is returned, +otherwise the index is fetched from the data provider. + +.. versionadded:: 3.42 +%End + + signals: void subsetStringChanged(); diff --git a/python/core/auto_generated/pointcloud/qgspointclouddataprovider.sip.in b/python/core/auto_generated/pointcloud/qgspointclouddataprovider.sip.in index 41323f305b8a..ad3a816b6cfb 100644 --- a/python/core/auto_generated/pointcloud/qgspointclouddataprovider.sip.in +++ b/python/core/auto_generated/pointcloud/qgspointclouddataprovider.sip.in @@ -36,6 +36,7 @@ Responsible for reading native point cloud data and returning the indexed data. WriteLayerMetadata, CreateRenderer, ContainSubIndexes, + ChangeAttributeValues, }; typedef QFlags Capabilities; diff --git a/python/core/auto_generated/pointcloud/qgspointcloudindex.sip.in b/python/core/auto_generated/pointcloud/qgspointcloudindex.sip.in index a99fbd00167c..18cbe8b42976 100644 --- a/python/core/auto_generated/pointcloud/qgspointcloudindex.sip.in +++ b/python/core/auto_generated/pointcloud/qgspointcloudindex.sip.in @@ -269,6 +269,13 @@ Returns all attributes that are stored in the file + bool updateNodeData( const QHash &data ); +%Docstring +Tries to update the data for the specified nodes. + +:return: ``True`` on success, otherwise ``False`` +%End + QgsRectangle extent() const; %Docstring Returns extent of the data @@ -346,6 +353,18 @@ Returns extra metadata that's not accessible through the other methods in an implementation-specific dynamic structure. .. seealso:: :py:func:`QgsAbstractPointCloudIndex.extraMetadata` +%End + + bool commitChanges(); +%Docstring +Tries to store pending changes to the data provider. + +:return: ``True`` on success, otherwise ``False`` +%End + + bool isModified() const; +%Docstring +Returns ``True`` if there are uncommitted changes, ``False`` otherwise %End }; diff --git a/python/core/auto_generated/pointcloud/qgspointcloudlayer.sip.in b/python/core/auto_generated/pointcloud/qgspointcloudlayer.sip.in index ca172f3f995e..d239635dce4a 100644 --- a/python/core/auto_generated/pointcloud/qgspointcloudlayer.sip.in +++ b/python/core/auto_generated/pointcloud/qgspointcloudlayer.sip.in @@ -86,6 +86,13 @@ Constructor - creates a point cloud layer virtual QgsPointCloudDataProvider *dataProvider(); + virtual bool supportsEditing() const; + + virtual bool isEditable() const; + + virtual bool isModified() const; + + virtual bool readXml( const QDomNode &layerNode, QgsReadWriteContext &context ); @@ -201,6 +208,82 @@ Returns the status of point cloud statistics calculation .. versionadded:: 3.26 %End + + bool startEditing(); +%Docstring +Makes the layer editable. + +This starts an edit session on this layer. Changes made in this edit session will not +be made persistent until :py:func:`~QgsPointCloudLayer.commitChanges` is called, and can be reverted by calling +:py:func:`~QgsPointCloudLayer.rollBack`. + +:return: ``True`` if the layer was successfully made editable, or ``False`` if the operation + failed (e.g. due to an underlying read-only data source, or lack of edit support + by the backend data provider). + +.. seealso:: :py:func:`commitChanges` + +.. seealso:: :py:func:`rollBack` + +.. versionadded:: 3.42 +%End + + bool commitChanges( bool stopEditing = true ); +%Docstring +Attempts to commit to the underlying data provider any buffered changes made since the +last to call to :py:func:`~QgsPointCloudLayer.startEditing`. + +Returns the result of the attempt. If a commit fails (i.e. ``False`` is returned), the +in-memory changes are left untouched and are not discarded. This allows editing to +continue if the commit failed on e.g. a disallowed value for an attribute - the user +can re-edit and try again. + +If the commit failed, an error message may returned by :py:func:`~QgsPointCloudLayer.commitError`. + +By setting ``stopEditing`` to ``False``, the layer will stay in editing mode. +Otherwise the layer editing mode will be disabled if the commit is successful. + +.. seealso:: :py:func:`startEditing` + +.. seealso:: :py:func:`commitError` + +.. seealso:: :py:func:`rollBack` + +.. versionadded:: 3.42 +%End + + QString commitError() const; +%Docstring +Returns the last error message generated when attempting +to commit changes to the layer. + +.. seealso:: :py:func:`commitChanges` + +.. versionadded:: 3.42 +%End + + bool rollBack(); +%Docstring +Stops a current editing operation and discards any uncommitted edits. + +.. seealso:: :py:func:`startEditing` + +.. seealso:: :py:func:`commitChanges` + +.. versionadded:: 3.42 +%End + + + QgsPointCloudIndex index() const; +%Docstring +Returns the point cloud index associated with the layer. +If the layer is editable, its :py:class:`QgsPointCloudEditingIndex` is returned, +otherwise the index is fetched from the data provider. + +.. versionadded:: 3.42 +%End + + signals: void subsetStringChanged(); diff --git a/src/3d/qgspointcloudlayer3drenderer.cpp b/src/3d/qgspointcloudlayer3drenderer.cpp index c3525d1ff073..68a6a73cc291 100644 --- a/src/3d/qgspointcloudlayer3drenderer.cpp +++ b/src/3d/qgspointcloudlayer3drenderer.cpp @@ -159,9 +159,9 @@ Qt3DCore::QEntity *QgsPointCloudLayer3DRenderer::createEntity( Qgs3DMapSettings const QgsCoordinateTransform coordinateTransform( pcl->crs3D(), map->crs(), map->transformContext() ); Qt3DCore::QEntity *entity = nullptr; - if ( pcl->dataProvider()->index() ) + if ( pcl->index() ) { - entity = new QgsPointCloudLayerChunkedEntity( map, pcl->dataProvider()->index(), coordinateTransform, dynamic_cast( mSymbol->clone() ), static_cast( maximumScreenError() ), showBoundingBoxes(), static_cast( pcl->elevationProperties() )->zScale(), static_cast( pcl->elevationProperties() )->zOffset(), mPointBudget ); + entity = new QgsPointCloudLayerChunkedEntity( map, pcl->index(), coordinateTransform, dynamic_cast( mSymbol->clone() ), static_cast( maximumScreenError() ), showBoundingBoxes(), static_cast( pcl->elevationProperties() )->zScale(), static_cast( pcl->elevationProperties() )->zOffset(), mPointBudget ); } else if ( !pcl->dataProvider()->subIndexes().isEmpty() ) { diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt index ba1a6cc0c920..c9d7fa662a2d 100644 --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -859,11 +859,13 @@ set(QGIS_CORE_SRCS pointcloud/qgspointcloudattributebyramprenderer.cpp pointcloud/qgspointcloudattributemodel.cpp pointcloud/qgspointcloudclassifiedrenderer.cpp + pointcloud/qgspointcloudeditingindex.cpp pointcloud/qgspointcloudextentrenderer.cpp pointcloud/qgspointcloudrequest.cpp pointcloud/qgspointcloudblock.cpp pointcloud/qgspointcloudblockrequest.cpp pointcloud/qgspointcloudlayer.cpp + pointcloud/qgspointcloudlayereditutils.cpp pointcloud/qgspointcloudlayerelevationproperties.cpp pointcloud/qgspointcloudlayerprofilegenerator.cpp pointcloud/qgspointcloudlayerrenderer.cpp @@ -1723,11 +1725,13 @@ set(QGIS_CORE_HDRS pointcloud/qgspointcloudattributebyramprenderer.h pointcloud/qgspointcloudattributemodel.h pointcloud/qgspointcloudclassifiedrenderer.h + pointcloud/qgspointcloudeditingindex.h pointcloud/qgspointcloudextentrenderer.h pointcloud/qgspointcloudrequest.h pointcloud/qgspointcloudblock.h pointcloud/qgspointcloudblockrequest.h pointcloud/qgspointcloudlayer.h + pointcloud/qgspointcloudlayereditutils.h pointcloud/qgspointcloudlayerelevationproperties.h pointcloud/qgspointcloudlayerprofilegenerator.h pointcloud/qgspointcloudlayerrenderer.h diff --git a/src/core/pointcloud/qgslazdecoder.cpp b/src/core/pointcloud/qgslazdecoder.cpp index f78dc5cdf0ba..ecda055d01dc 100644 --- a/src/core/pointcloud/qgslazdecoder.cpp +++ b/src/core/pointcloud/qgslazdecoder.cpp @@ -186,6 +186,11 @@ bool lazSerialize_( char *data, size_t outputPosition, QgsPointCloudAttribute::D return true; } +bool lazStoreDoubleToStream( char *s, size_t position, QgsPointCloudAttribute::DataType type, double value ) +{ + return lazStoreToStream_( s, position, type, value ); +} + // ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// std::vector< QgsLazDecoder::RequestedAttributeDetails > prepareRequestedAttributeDetails_( const QgsPointCloudAttributeCollection &requestedAttributes, QVector &extrabytesAttr ) diff --git a/src/core/pointcloud/qgslazdecoder.h b/src/core/pointcloud/qgslazdecoder.h index bb49e7703cf1..03361c949e6e 100644 --- a/src/core/pointcloud/qgslazdecoder.h +++ b/src/core/pointcloud/qgslazdecoder.h @@ -37,6 +37,7 @@ template bool lazStoreToStream_( char *s, size_t position, QgsPointCloudAttribute::DataType type, T value ); bool lazSerialize_( char *data, size_t outputPosition, QgsPointCloudAttribute::DataType outputType, const char *input, QgsPointCloudAttribute::DataType inputType, int inputSize, size_t inputPosition ); +bool lazStoreDoubleToStream( char *s, size_t position, QgsPointCloudAttribute::DataType type, double value ); class QgsLazDecoder { diff --git a/src/core/pointcloud/qgspointclouddataprovider.h b/src/core/pointcloud/qgspointclouddataprovider.h index 5d65f30e6338..c3d6ea9fee5d 100644 --- a/src/core/pointcloud/qgspointclouddataprovider.h +++ b/src/core/pointcloud/qgspointclouddataprovider.h @@ -53,6 +53,7 @@ class CORE_EXPORT QgsPointCloudDataProvider: public QgsDataProvider WriteLayerMetadata = 1 << 1, //!< Provider can write layer metadata to the data store. See QgsDataProvider::writeLayerMetadata() CreateRenderer = 1 << 2, //!< Provider can create 2D renderers using backend-specific formatting information. See QgsPointCloudDataProvider::createRenderer(). ContainSubIndexes = 1 << 3, //!< Provider can contain multiple indexes. Virtual point cloud files for example \since QGIS 3.32 + ChangeAttributeValues = 1 << 4, //!< Provider can modify the values of point attributes. \since QGIS 3.42 }; Q_DECLARE_FLAGS( Capabilities, Capability ) diff --git a/src/core/pointcloud/qgspointcloudeditingindex.cpp b/src/core/pointcloud/qgspointcloudeditingindex.cpp new file mode 100644 index 000000000000..b1fe6fd29051 --- /dev/null +++ b/src/core/pointcloud/qgspointcloudeditingindex.cpp @@ -0,0 +1,142 @@ +/*************************************************************************** + qgspointcloudeditingindex.cpp + --------------------- + begin : December 2024 + copyright : (C) 2024 by Stefanos Natsis + email : uclaros at gmail dot com + *************************************************************************** + * * + * 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. * + * * + ***************************************************************************/ + +#include "qgspointcloudeditingindex.h" +#include "qgspointcloudlayer.h" +#include "qgspointcloudlayereditutils.h" +#include "qgscoordinatereferencesystem.h" + + +QgsPointCloudEditingIndex::QgsPointCloudEditingIndex( QgsPointCloudLayer *layer ) +{ + if ( !layer || + !layer->dataProvider() || + !layer->dataProvider()->hasValidIndex() || + !( layer->dataProvider()->capabilities() & QgsPointCloudDataProvider::Capability::ChangeAttributeValues ) ) + return; + + mIndex = layer->dataProvider()->index(); + + mAttributes = mIndex.attributes(); + mScale = mIndex.scale(); + mOffset = mIndex.offset(); + mExtent = mIndex.extent(); + mZMin = mIndex.zMin(); + mZMax = mIndex.zMax(); + mRootBounds = mIndex.rootNodeBounds(); + mSpan = mIndex.span(); + mIsValid = true; +} + +std::unique_ptr QgsPointCloudEditingIndex::clone() const +{ + return nullptr; +} + +void QgsPointCloudEditingIndex::load( const QString & ) +{ + return; +} + +bool QgsPointCloudEditingIndex::isValid() const +{ + return mIsValid && mIndex.isValid(); +} + +Qgis::PointCloudAccessType QgsPointCloudEditingIndex::accessType() const +{ + return mIndex.accessType(); +} + +QgsCoordinateReferenceSystem QgsPointCloudEditingIndex::crs() const +{ + return mIndex.crs(); +} + +qint64 QgsPointCloudEditingIndex::pointCount() const +{ + return mIndex.pointCount(); +} + +QVariantMap QgsPointCloudEditingIndex::originalMetadata() const +{ + return mIndex.originalMetadata(); +} + +bool QgsPointCloudEditingIndex::hasNode( const QgsPointCloudNodeId &n ) const +{ + return mIndex.hasNode( n ); +} + +QgsPointCloudNode QgsPointCloudEditingIndex::getNode( const QgsPointCloudNodeId &id ) const +{ + return mIndex.getNode( id ); +} + +std::unique_ptr< QgsPointCloudBlock > QgsPointCloudEditingIndex::nodeData( const QgsPointCloudNodeId &n, const QgsPointCloudRequest &request ) +{ + if ( mEditedNodeData.contains( n ) ) + { + const QByteArray data = mEditedNodeData.value( n ); + int nPoints = data.size() / mIndex.attributes().pointRecordSize(); + + const QByteArray requestedData = QgsPointCloudLayerEditUtils::dataForAttributes( mIndex.attributes(), data, request ); + + std::unique_ptr block = std::make_unique< QgsPointCloudBlock >( + nPoints, + request.attributes(), + requestedData, + mIndex.scale(), + mIndex.offset() ); + return block; + } + else + { + return mIndex.nodeData( n, request ); + } +} + +QgsPointCloudBlockRequest *QgsPointCloudEditingIndex::asyncNodeData( const QgsPointCloudNodeId &, const QgsPointCloudRequest & ) +{ + Q_ASSERT( false ); + return nullptr; +} + +bool QgsPointCloudEditingIndex::commitChanges() +{ + if ( !isModified() ) + return true; + + if ( !mIndex.updateNodeData( mEditedNodeData ) ) + return false; + + mEditedNodeData.clear(); + return true; +} + +bool QgsPointCloudEditingIndex::isModified() const +{ + return !mEditedNodeData.isEmpty(); +} + +bool QgsPointCloudEditingIndex::updateNodeData( const QHash &data ) +{ + for ( auto it = data.constBegin(); it != data.constEnd(); ++it ) + { + mEditedNodeData[it.key()] = it.value(); + } + + return true; +} diff --git a/src/core/pointcloud/qgspointcloudeditingindex.h b/src/core/pointcloud/qgspointcloudeditingindex.h new file mode 100644 index 000000000000..70a892de201b --- /dev/null +++ b/src/core/pointcloud/qgspointcloudeditingindex.h @@ -0,0 +1,74 @@ +/*************************************************************************** + qgspointcloudeditingindex.h + --------------------- + begin : December 2024 + copyright : (C) 2024 by Stefanos Natsis + email : uclaros at gmail dot com + *************************************************************************** + * * + * 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. * + * * + ***************************************************************************/ + +#ifndef QGSPOINTCLOUDEDITINGINDEX_H +#define QGSPOINTCLOUDEDITINGINDEX_H + +#include "qgspointcloudindex.h" +#include "qgis_core.h" + +#define SIP_NO_FILE + +class QgsPointCloudLayer; + +/** + * \ingroup core + * + * \brief The QgsPointCloudEditingIndex class is a QgsPointCloudIndex that is used as an editing + * buffer when editing point cloud data. + * + * \note Not available in Python bindings + * + * \since QGIS 3.42 + */ +class CORE_EXPORT QgsPointCloudEditingIndex : public QgsAbstractPointCloudIndex +{ + public: + //! Ctor + explicit QgsPointCloudEditingIndex( QgsPointCloudLayer *layer ); + + std::unique_ptr clone() const override; + void load( const QString &fileName ) override; + bool isValid() const override; + Qgis::PointCloudAccessType accessType() const override; + QgsCoordinateReferenceSystem crs() const override; + qint64 pointCount() const override; + QVariantMap originalMetadata() const override; + + bool hasNode( const QgsPointCloudNodeId &n ) const override; + QgsPointCloudNode getNode( const QgsPointCloudNodeId &id ) const override; + + std::unique_ptr< QgsPointCloudBlock > nodeData( const QgsPointCloudNodeId &n, const QgsPointCloudRequest &request ) override; + QgsPointCloudBlockRequest *asyncNodeData( const QgsPointCloudNodeId &n, const QgsPointCloudRequest &request ) override; + + bool updateNodeData( const QHash &data ) override; + + /** + * Tries to store pending changes to the data provider. + * \return TRUE on success, otherwise FALSE + */ + bool commitChanges(); + + //! Returns TRUE if there are uncommitted changes, FALSE otherwise + bool isModified() const; + + + private: + QgsPointCloudIndex mIndex; + bool mIsValid = false; + QHash mEditedNodeData; +}; + +#endif // QGSPOINTCLOUDEDITINGINDEX_H diff --git a/src/core/pointcloud/qgspointcloudindex.cpp b/src/core/pointcloud/qgspointcloudindex.cpp index 4303c9ac961e..af5aa064b656 100644 --- a/src/core/pointcloud/qgspointcloudindex.cpp +++ b/src/core/pointcloud/qgspointcloudindex.cpp @@ -32,6 +32,7 @@ #include "qgstiledownloadmanager.h" #include "qgspointcloudstatistics.h" #include "qgslogger.h" +#include "qgspointcloudeditingindex.h" QgsPointCloudNodeId::QgsPointCloudNodeId(): mD( -1 ), @@ -195,6 +196,11 @@ QgsPointCloudNode QgsAbstractPointCloudIndex::getNode( const QgsPointCloudNodeId return QgsPointCloudNode( id, pointCount, children, bounds.width() / mSpan, bounds ); } +bool QgsAbstractPointCloudIndex::updateNodeData( const QHash & ) +{ + return false; +} + QgsPointCloudAttributeCollection QgsAbstractPointCloudIndex::attributes() const { return mAttributes; @@ -420,6 +426,12 @@ QgsPointCloudBlockRequest *QgsPointCloudIndex::asyncNodeData( const QgsPointClou return mIndex->asyncNodeData( n, request ); } +bool QgsPointCloudIndex::updateNodeData( const QHash &data ) +{ + Q_ASSERT( mIndex ); + return mIndex->updateNodeData( data ); +} + QgsRectangle QgsPointCloudIndex::extent() const { Q_ASSERT( mIndex ); @@ -492,3 +504,20 @@ QVariantMap QgsPointCloudIndex::extraMetadata() const return mIndex->extraMetadata(); } +bool QgsPointCloudIndex::commitChanges() +{ + Q_ASSERT( mIndex ); + if ( QgsPointCloudEditingIndex *index = dynamic_cast( mIndex.get() ) ) + return index->commitChanges(); + + return false; +} + +bool QgsPointCloudIndex::isModified() const +{ + if ( QgsPointCloudEditingIndex *index = dynamic_cast( mIndex.get() ) ) + return index->isModified(); + + return false; +} + diff --git a/src/core/pointcloud/qgspointcloudindex.h b/src/core/pointcloud/qgspointcloudindex.h index eade94d9f36c..430cff42d678 100644 --- a/src/core/pointcloud/qgspointcloudindex.h +++ b/src/core/pointcloud/qgspointcloudindex.h @@ -25,6 +25,7 @@ #include #include #include +#include #include "qgis_core.h" #include "qgspointcloudstatistics.h" @@ -298,6 +299,15 @@ class CORE_EXPORT QgsAbstractPointCloudIndex */ virtual QgsPointCloudBlockRequest *asyncNodeData( const QgsPointCloudNodeId &n, const QgsPointCloudRequest &request ) = 0; + /** + * Tries to update the data for the specified nodes. + * Subclasses that support editing should override this to handle storing the data. + * Default implementation does nothing, returns false. + * \returns TRUE on success, otherwise FALSE + * \since QGIS 3.42 + */ + virtual bool updateNodeData( const QHash &data ); + //! Returns extent of the data QgsRectangle extent() const { return mExtent; } @@ -533,6 +543,13 @@ class CORE_EXPORT QgsPointCloudIndex SIP_NODEFAULTCTORS */ QgsPointCloudBlockRequest *asyncNodeData( const QgsPointCloudNodeId &n, const QgsPointCloudRequest &request ) SIP_SKIP; + /** + * Tries to update the data for the specified nodes. + * + * \returns TRUE on success, otherwise FALSE + */ + bool updateNodeData( const QHash &data ); + /** * Returns extent of the data * @@ -624,8 +641,19 @@ class CORE_EXPORT QgsPointCloudIndex SIP_NODEFAULTCTORS */ QVariantMap extraMetadata() const; + /** + * Tries to store pending changes to the data provider. + * \return TRUE on success, otherwise FALSE + */ + bool commitChanges(); + + //! Returns TRUE if there are uncommitted changes, FALSE otherwise + bool isModified() const; + private: std::shared_ptr mIndex; + + friend class TestQgsPointCloudEditing; }; diff --git a/src/core/pointcloud/qgspointcloudlayer.cpp b/src/core/pointcloud/qgspointcloudlayer.cpp index 2cae35482674..67bffcf2f0a1 100644 --- a/src/core/pointcloud/qgspointcloudlayer.cpp +++ b/src/core/pointcloud/qgspointcloudlayer.cpp @@ -17,6 +17,8 @@ #include "qgspointcloudlayer.h" #include "moc_qgspointcloudlayer.cpp" +#include "qgspointcloudeditingindex.h" +#include "qgspointcloudlayereditutils.h" #include "qgspointcloudlayerrenderer.h" #include "qgspointcloudindex.h" #include "qgspointcloudstatistics.h" @@ -970,3 +972,114 @@ void QgsPointCloudLayer::loadIndexesForRenderContext( QgsRenderContext &renderer } } } + +bool QgsPointCloudLayer::startEditing() +{ + QGIS_PROTECT_QOBJECT_THREAD_ACCESS + if ( mEditIndex ) + return false; + + mEditIndex = QgsPointCloudIndex( new QgsPointCloudEditingIndex( this ) ); + + if ( !mEditIndex.isValid() ) + { + mEditIndex = QgsPointCloudIndex(); + return false; + } + + emit editingStarted(); + return true; +} + +bool QgsPointCloudLayer::commitChanges( bool stopEditing ) +{ + QGIS_PROTECT_QOBJECT_THREAD_ACCESS + if ( !mEditIndex || + !mEditIndex.commitChanges() ) + return false; + + if ( stopEditing ) + { + mEditIndex = QgsPointCloudIndex(); + emit editingStopped(); + } + + return true; +} + +QString QgsPointCloudLayer::commitError() const +{ + QGIS_PROTECT_QOBJECT_THREAD_ACCESS + return mCommitError; +} + +bool QgsPointCloudLayer::rollBack() +{ + QGIS_PROTECT_QOBJECT_THREAD_ACCESS + if ( !mEditIndex ) + return false; + + if ( isModified() ) + { + emit layerModified(); + triggerRepaint(); + } + + mEditIndex = QgsPointCloudIndex(); + emit editingStopped(); + + return true; +} + +bool QgsPointCloudLayer::supportsEditing() const +{ + QGIS_PROTECT_QOBJECT_THREAD_ACCESS + return mDataProvider && mDataProvider->capabilities() & QgsPointCloudDataProvider::Capability::ChangeAttributeValues; +} + +bool QgsPointCloudLayer::isEditable() const +{ + QGIS_PROTECT_QOBJECT_THREAD_ACCESS + if ( mEditIndex ) + return true; + + return false; +} + +bool QgsPointCloudLayer::isModified() const +{ + QGIS_PROTECT_QOBJECT_THREAD_ACCESS + if ( !mEditIndex ) + return false; + + return mEditIndex.isModified(); +} + +bool QgsPointCloudLayer::changeAttributeValue( const QgsPointCloudNodeId &n, const QVector &pts, const QgsPointCloudAttribute &attribute, double value ) +{ + QGIS_PROTECT_QOBJECT_THREAD_ACCESS + if ( !mEditIndex ) + return false; + + QgsPointCloudLayerEditUtils utils( this ); + + const bool success = utils.changeAttributeValue( n, pts, attribute, value ); + if ( success ) + { + emit layerModified(); + } + + return success; +} + +QgsPointCloudIndex QgsPointCloudLayer::index() const +{ + QGIS_PROTECT_QOBJECT_THREAD_ACCESS_NON_FATAL + if ( mEditIndex ) + return mEditIndex; + + if ( mDataProvider ) + return mDataProvider->index(); + + return QgsPointCloudIndex(); +} diff --git a/src/core/pointcloud/qgspointcloudlayer.h b/src/core/pointcloud/qgspointcloudlayer.h index 706e3ebbdc7a..fef96e8d1925 100644 --- a/src/core/pointcloud/qgspointcloudlayer.h +++ b/src/core/pointcloud/qgspointcloudlayer.h @@ -135,6 +135,10 @@ class CORE_EXPORT QgsPointCloudLayer : public QgsMapLayer, public QgsAbstractPro QgsPointCloudDataProvider *dataProvider() override; const QgsPointCloudDataProvider *dataProvider() const override SIP_SKIP; + bool supportsEditing() const override; + bool isEditable() const override; + bool isModified() const override; + bool readXml( const QDomNode &layerNode, QgsReadWriteContext &context ) override; bool writeXml( QDomNode &layerNode, QDomDocument &doc, const QgsReadWriteContext &context ) const override; @@ -242,6 +246,88 @@ class CORE_EXPORT QgsPointCloudLayer : public QgsMapLayer, public QgsAbstractPro * \since QGIS 3.26 */ PointCloudStatisticsCalculationState statisticsCalculationState() const { return mStatisticsCalculationState; } + + /** + * Makes the layer editable. + * + * This starts an edit session on this layer. Changes made in this edit session will not + * be made persistent until commitChanges() is called, and can be reverted by calling + * rollBack(). + * + * \returns TRUE if the layer was successfully made editable, or FALSE if the operation + * failed (e.g. due to an underlying read-only data source, or lack of edit support + * by the backend data provider). + * + * \see commitChanges() + * \see rollBack() + * \since QGIS 3.42 + */ + bool startEditing(); + + /** + * Attempts to commit to the underlying data provider any buffered changes made since the + * last to call to startEditing(). + * + * Returns the result of the attempt. If a commit fails (i.e. FALSE is returned), the + * in-memory changes are left untouched and are not discarded. This allows editing to + * continue if the commit failed on e.g. a disallowed value for an attribute - the user + * can re-edit and try again. + * + * If the commit failed, an error message may returned by commitError(). + * + * By setting \a stopEditing to FALSE, the layer will stay in editing mode. + * Otherwise the layer editing mode will be disabled if the commit is successful. + * + * \see startEditing() + * \see commitError() + * \see rollBack() + * \since QGIS 3.42 + */ + bool commitChanges( bool stopEditing = true ); + + /** + * Returns the last error message generated when attempting + * to commit changes to the layer. + * \see commitChanges() + * \since QGIS 3.42 + */ + QString commitError() const; + + /** + * Stops a current editing operation and discards any uncommitted edits. + * + * \see startEditing() + * \see commitChanges() + * \since QGIS 3.42 + */ + bool rollBack(); + + /** + * Attempts to modify attribute values for specific points in the editing buffer. + * + * \param n The point cloud node containing the points + * \param points The point ids of the points to be modified + * \param attribute The attribute whose value will be updated + * \param value The new value to set to the attribute + * \return TRUE if the editing buffer was updated successfully, FALSE otherwise + * \note Calls to changeAttributeValue() are only valid for layers in which edits have been enabled + * by a call to startEditing(). Changes made to features using this method are not committed + * to the underlying data provider until a commitChanges() call is made. Any uncommitted + * changes can be discarded by calling rollBack(). + * \since QGIS 3.42 + */ + bool changeAttributeValue( const QgsPointCloudNodeId &n, const QVector &points, const QgsPointCloudAttribute &attribute, double value ) SIP_SKIP; + + /** + * Returns the point cloud index associated with the layer. + * If the layer is editable, its QgsPointCloudEditingIndex is returned, + * otherwise the index is fetched from the data provider. + * + * \since QGIS 3.42 + */ + QgsPointCloudIndex index() const; + + signals: /** @@ -296,6 +382,9 @@ class CORE_EXPORT QgsPointCloudLayer : public QgsMapLayer, public QgsAbstractPro PointCloudStatisticsCalculationState mStatisticsCalculationState = PointCloudStatisticsCalculationState::NotStarted; long mStatsCalculationTask = 0; + QgsPointCloudIndex mEditIndex; + QString mCommitError; + friend class TestQgsVirtualPointCloudProvider; }; diff --git a/src/core/pointcloud/qgspointcloudlayereditutils.cpp b/src/core/pointcloud/qgspointcloudlayereditutils.cpp new file mode 100644 index 000000000000..28085388aa7a --- /dev/null +++ b/src/core/pointcloud/qgspointcloudlayereditutils.cpp @@ -0,0 +1,150 @@ +/*************************************************************************** + qgspointcloudlayereditutils.cpp + --------------------- + begin : December 2024 + copyright : (C) 2024 by Stefanos Natsis + email : uclaros at gmail dot com + *************************************************************************** + * * + * 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. * + * * + ***************************************************************************/ + +#include "qgspointcloudlayereditutils.h" +#include "qgspointcloudlayer.h" +#include "qgslazdecoder.h" + + +QgsPointCloudLayerEditUtils::QgsPointCloudLayerEditUtils( QgsPointCloudLayer *layer ) + : mIndex( layer->index() ) +{ +} + +bool QgsPointCloudLayerEditUtils::changeAttributeValue( const QgsPointCloudNodeId &n, const QVector &pts, const QgsPointCloudAttribute &attribute, double value ) +{ + // Cannot allow x,y,z editing as points may get moved outside the node extents + if ( attribute.name().compare( QLatin1String( "X" ), Qt::CaseInsensitive ) == 0 || + attribute.name().compare( QLatin1String( "Y" ), Qt::CaseInsensitive ) == 0 || + attribute.name().compare( QLatin1String( "Z" ), Qt::CaseInsensitive ) == 0 ) + return false; + + if ( !n.isValid() || !mIndex.hasNode( n ) ) // todo: should not have to check if n.isValid + return false; + + const QgsPointCloudAttributeCollection attributeCollection = mIndex.attributes(); + + int attributeOffset; + const QgsPointCloudAttribute *at = attributeCollection.find( attribute.name(), attributeOffset ); + + if ( !at || + at->size() != attribute.size() || + at->type() != attribute.type() ) + { + return false; + } + + if ( !isAttributeValueValid( attribute, value ) ) + { + return false; + } + + const QSet uniquePoints( pts.constBegin(), pts.constEnd() ); + QVector sortedPoints( uniquePoints.constBegin(), uniquePoints.constEnd() ); + std::sort( sortedPoints.begin(), sortedPoints.end() ); + + if ( sortedPoints.constFirst() < 0 || + sortedPoints.constLast() > mIndex.getNode( n ).pointCount() ) + return false; + + QgsPointCloudRequest req; + req.setAttributes( attributeCollection ); + + std::unique_ptr block = mIndex.nodeData( n, req ); + const int count = block->pointCount(); + const int recordSize = attributeCollection.pointRecordSize(); + + // copy data + QByteArray data( block->data(), count * recordSize ); + + char *ptr = data.data(); + + for ( int i : sortedPoints ) + { + // replace attribute for selected point + lazStoreDoubleToStream( ptr, i * recordSize + attributeOffset, attribute.type(), value ); + } + + return mIndex.updateNodeData( {{n, data}} );; +} + +QByteArray QgsPointCloudLayerEditUtils::dataForAttributes( const QgsPointCloudAttributeCollection &allAttributes, const QByteArray &data, const QgsPointCloudRequest &request ) +{ + const QVector attributes = allAttributes.attributes(); + const int nPoints = data.size() / allAttributes.pointRecordSize(); + const char *ptr = data.data(); + + QByteArray outData; + for ( int i = 0; i < nPoints; ++i ) + { + for ( const QgsPointCloudAttribute &attr : attributes ) + { + if ( request.attributes().indexOf( attr.name() ) >= 0 ) + { + outData.append( ptr, attr.size() ); + } + ptr += attr.size(); + } + } + + // + Q_ASSERT( nPoints == outData.size() / request.attributes().pointRecordSize() ); + + return outData; +} + +bool QgsPointCloudLayerEditUtils::isAttributeValueValid( const QgsPointCloudAttribute &attribute, double value ) +{ + const QString name = attribute.name().toUpper(); + + if ( name == QLatin1String( "INTENSITY" ) ) + return value >= 0 && value <= 65535; + if ( name == QLatin1String( "RETURNNUMBER" ) ) + return value >= 0 && value <= 15; + if ( name == QLatin1String( "NUMBEROFRETURNS" ) ) + return value >= 0 && value <= 15; + if ( name == QLatin1String( "SCANCHANNEL" ) ) + return value >= 0 && value <= 3; + if ( name == QLatin1String( "SCANDIRECTIONFLAG" ) ) + return value >= 0 && value <= 1; + if ( name == QLatin1String( "EDGEOFFLIGHTLINE" ) ) + return value >= 0 && value <= 1; + if ( name == QLatin1String( "CLASSIFICATION" ) ) + return value >= 0 && value <= 255; + if ( name == QLatin1String( "USERDATA" ) ) + return value >= 0 && value <= 255; + if ( name == QLatin1String( "SCANANGLE" ) ) + return value >= -30'000 && value <= 30'000; + if ( name == QLatin1String( "POINTSOURCEID" ) ) + return value >= 0 && value <= 65535; + if ( name == QLatin1String( "GPSTIME" ) ) + return value >= 0; + if ( name == QLatin1String( "SYNTHETIC" ) ) + return value >= 0 && value <= 1; + if ( name == QLatin1String( "KEYPOINT" ) ) + return value >= 0 && value <= 1; + if ( name == QLatin1String( "WITHHELD" ) ) + return value >= 0 && value <= 1; + if ( name == QLatin1String( "OVERLAP" ) ) + return value >= 0 && value <= 1; + if ( name == QLatin1String( "RED" ) ) + return value >= 0 && value <= 65535; + if ( name == QLatin1String( "GREEN" ) ) + return value >= 0 && value <= 65535; + if ( name == QLatin1String( "BLUE" ) ) + return value >= 0 && value <= 65535; + + return true; +} diff --git a/src/core/pointcloud/qgspointcloudlayereditutils.h b/src/core/pointcloud/qgspointcloudlayereditutils.h new file mode 100644 index 000000000000..4aabb116f727 --- /dev/null +++ b/src/core/pointcloud/qgspointcloudlayereditutils.h @@ -0,0 +1,69 @@ +/*************************************************************************** + qgspointcloudlayereditutils.h + --------------------- + begin : December 2024 + copyright : (C) 2024 by Stefanos Natsis + email : uclaros at gmail dot com + *************************************************************************** + * * + * 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. * + * * + ***************************************************************************/ + +#ifndef QGSPOINTCLOUDLAYEREDITUTILS_H +#define QGSPOINTCLOUDLAYEREDITUTILS_H + +#include "qgis_core.h" +#include "qgspointcloudindex.h" + +#include +#include + +#define SIP_NO_FILE + +class QgsPointCloudLayer; +class QgsPointCloudNodeId; +class QgsPointCloudAttribute; +class QgsPointCloudAttributeCollection; +class QgsPointCloudRequest; + +/** + * \ingroup core + * + * \brief Contains utility functions for editing point cloud layers. + * + * \note Not available in Python bindings + * + * \since QGIS 3.42 + */ +class CORE_EXPORT QgsPointCloudLayerEditUtils +{ + public: + //! Ctor + QgsPointCloudLayerEditUtils( QgsPointCloudLayer *layer ); + + /** + * Attempts to modify attribute values for specific points in the editing buffer. + * + * \param n The point cloud node containing the points + * \param points The point ids of the points to be modified + * \param attribute The attribute whose value will be updated + * \param value The new value to set to the attribute + * \return TRUE if the editing buffer was updated successfully, FALSE otherwise + */ + bool changeAttributeValue( const QgsPointCloudNodeId &n, const QVector &points, const QgsPointCloudAttribute &attribute, double value ); + + //! Takes \a data comprising of \a allAttributes and returns a QByteArray with data only for the attributes included in the \a request + static QByteArray dataForAttributes( const QgsPointCloudAttributeCollection &allAttributes, const QByteArray &data, const QgsPointCloudRequest &request ); + + //! Check if \a value is within proper range for the \a attribute + static bool isAttributeValueValid( const QgsPointCloudAttribute &attribute, double value ); + + private: + QgsPointCloudIndex mIndex; +}; + +#endif // QGSPOINTCLOUDLAYEREDITUTILS_H diff --git a/src/core/pointcloud/qgspointcloudlayerexporter.cpp b/src/core/pointcloud/qgspointcloudlayerexporter.cpp index c876d7e7861c..ee9241312eaa 100644 --- a/src/core/pointcloud/qgspointcloudlayerexporter.cpp +++ b/src/core/pointcloud/qgspointcloudlayerexporter.cpp @@ -55,7 +55,7 @@ QString QgsPointCloudLayerExporter::getOgrDriverName( ExportFormat format ) QgsPointCloudLayerExporter::QgsPointCloudLayerExporter( QgsPointCloudLayer *layer ) : mLayerAttributeCollection( layer->attributes() ) - , mIndex( layer->dataProvider()->index() ) + , mIndex( layer->index() ) , mSourceCrs( QgsCoordinateReferenceSystem( layer->crs() ) ) , mTargetCrs( QgsCoordinateReferenceSystem( layer->crs() ) ) { diff --git a/src/core/pointcloud/qgspointcloudlayerprofilegenerator.cpp b/src/core/pointcloud/qgspointcloudlayerprofilegenerator.cpp index 120715bc337d..7c9bee1362dc 100644 --- a/src/core/pointcloud/qgspointcloudlayerprofilegenerator.cpp +++ b/src/core/pointcloud/qgspointcloudlayerprofilegenerator.cpp @@ -361,10 +361,10 @@ QgsPointCloudLayerProfileGenerator::QgsPointCloudLayerProfileGenerator( QgsPoint , mZScale( layer->elevationProperties()->zScale() ) , mStepDistance( request.stepDistance() ) { - if ( mLayer->dataProvider()->index() ) + if ( mLayer->index() ) { - mScale = mLayer->dataProvider()->index().scale(); - mOffset = mLayer->dataProvider()->index().offset(); + mScale = mLayer->index().scale(); + mOffset = mLayer->index().offset(); } } @@ -390,7 +390,7 @@ bool QgsPointCloudLayerProfileGenerator::generateProfile( const QgsProfileGenera // TODO: fix when QgsPointCloudLayerRenderer is made thread safe to use same approach QVector indexes; - QgsPointCloudIndex mainIndex = mLayer->dataProvider()->index(); + QgsPointCloudIndex mainIndex = mLayer->index(); if ( mainIndex && mainIndex.isValid() ) indexes.append( mainIndex ); diff --git a/src/core/pointcloud/qgspointcloudlayerrenderer.cpp b/src/core/pointcloud/qgspointcloudlayerrenderer.cpp index 2528366b2e0f..4129194c84b7 100644 --- a/src/core/pointcloud/qgspointcloudlayerrenderer.cpp +++ b/src/core/pointcloud/qgspointcloudlayerrenderer.cpp @@ -65,10 +65,10 @@ QgsPointCloudLayerRenderer::QgsPointCloudLayerRenderer( QgsPointCloudLayer *laye mSubIndexExtentRenderer->setLabelTextFormat( mRenderer->labelTextFormat() ); } - if ( mLayer->dataProvider()->index() ) + if ( mLayer->index() ) { - mScale = mLayer->dataProvider()->index().scale(); - mOffset = mLayer->dataProvider()->index().offset(); + mScale = mLayer->index().scale(); + mOffset = mLayer->index().offset(); } if ( const QgsPointCloudLayerElevationProperties *elevationProps = qobject_cast< const QgsPointCloudLayerElevationProperties * >( mLayer->elevationProperties() ) ) @@ -129,7 +129,7 @@ bool QgsPointCloudLayerRenderer::render() } // TODO cache!? - QgsPointCloudIndex pc = mLayer->dataProvider()->index(); + QgsPointCloudIndex pc = mLayer->index(); if ( mSubIndexes.isEmpty() && ( !pc || !pc.isValid() ) ) { mReadyToCompose = true; diff --git a/src/core/providers/copc/qgscopcprovider.cpp b/src/core/providers/copc/qgscopcprovider.cpp index bcc1a3406c96..94c755c92481 100644 --- a/src/core/providers/copc/qgscopcprovider.cpp +++ b/src/core/providers/copc/qgscopcprovider.cpp @@ -137,6 +137,13 @@ void QgsCopcProvider::generateIndex() //no-op, index is always generated } +QgsPointCloudDataProvider::Capabilities QgsCopcProvider::capabilities() const +{ + QGIS_PROTECT_QOBJECT_THREAD_ACCESS + + return QgsPointCloudDataProvider::Capability::ChangeAttributeValues; +} + QgsCopcProviderMetadata::QgsCopcProviderMetadata(): QgsProviderMetadata( PROVIDER_KEY, PROVIDER_DESCRIPTION ) { diff --git a/src/core/providers/copc/qgscopcprovider.h b/src/core/providers/copc/qgscopcprovider.h index e1486c7c8fd5..ae686058e4cb 100644 --- a/src/core/providers/copc/qgscopcprovider.h +++ b/src/core/providers/copc/qgscopcprovider.h @@ -52,6 +52,7 @@ class QgsCopcProvider: public QgsPointCloudDataProvider void loadIndex( ) override; void generateIndex( ) override; PointCloudIndexGenerationState indexingState( ) override { return PointCloudIndexGenerationState::Indexed; } + QgsPointCloudDataProvider::Capabilities capabilities() const override; private: QgsPointCloudIndex mIndex; diff --git a/tests/src/core/CMakeLists.txt b/tests/src/core/CMakeLists.txt index 6f3638dbaecf..5ad8557ddb9a 100644 --- a/tests/src/core/CMakeLists.txt +++ b/tests/src/core/CMakeLists.txt @@ -142,6 +142,7 @@ set(TESTS testqgspainteffectregistry.cpp testqgspallabeling.cpp testqgspointcloudattribute.cpp + testqgspointcloudediting.cpp testqgspointcloudexpression.cpp testqgspointcloudlayerexporter.cpp testqgspointcloudrendererregistry.cpp diff --git a/tests/src/core/testqgspointcloudediting.cpp b/tests/src/core/testqgspointcloudediting.cpp new file mode 100644 index 000000000000..c160c9951ace --- /dev/null +++ b/tests/src/core/testqgspointcloudediting.cpp @@ -0,0 +1,413 @@ +/*************************************************************************** + testqgspointcloudediting.cpp + -------------------------------------- + Date : December 2024 + Copyright : (C) 2024 by Stefanos Natsis + Email : uclaros at gmail dot com + *************************************************************************** + * * + * 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. * + * * + ***************************************************************************/ + +#include "qgspointcloudrendererregistry.h" +#include "qgstest.h" +#include +#include + +//qgis includes... +#include "qgis.h" +#include "qgsapplication.h" +#include "qgspointcloudlayer.h" +#include "qgspointcloudindex.h" +#include "qgscopcpointcloudindex.h" +#include "qgspointcloudeditingindex.h" + +/** + * \ingroup UnitTests + * This is a unit test for point cloud editing + */ +class TestQgsPointCloudEditing : public QgsTest +{ + Q_OBJECT + + public: + TestQgsPointCloudEditing() + : QgsTest( QStringLiteral( "Point Cloud Editing Tests" ), QStringLiteral( "pointcloud_editing" ) ) {} + + private slots: + void initTestCase(); // will be called before the first testfunction is executed. + void cleanupTestCase(); // will be called after the last testfunction was executed. + void init() {} // will be called before each testfunction is executed. + void cleanup() {} // will be called after every testfunction. + + void testQgsPointCloudEditingIndex(); + void testStartStopEditing(); + void testModifyAttributeValue(); + void testModifyAttributeValueInvalid(); +}; + +//runs before all tests +void TestQgsPointCloudEditing::initTestCase() +{ + // init QGIS's paths - true means that all path will be inited from prefix + QgsApplication::init(); + QgsApplication::initQgis(); +} + +//runs after all tests +void TestQgsPointCloudEditing::cleanupTestCase() +{ + QgsApplication::exitQgis(); +} + + +void TestQgsPointCloudEditing::testQgsPointCloudEditingIndex() +{ + const QString dataPath = copyTestData( QStringLiteral( "point_clouds/copc/sunshine-coast.copc.laz" ) ); + + std::unique_ptr layer = std::make_unique( dataPath, QStringLiteral( "layer" ), QStringLiteral( "copc" ) ); + QVERIFY( layer->isValid() ); + + auto i = layer->index(); + QVERIFY( i.isValid() ); + QgsPointCloudEditingIndex e = QgsPointCloudEditingIndex( layer.get() ); + QVERIFY( e.isValid() ); + QCOMPARE( i.accessType(), e.accessType() ); + QCOMPARE( i.crs(), e.crs() ); + QCOMPARE( i.pointCount(), e.pointCount() ); + QCOMPARE( i.originalMetadata(), e.originalMetadata() ); + QCOMPARE( i.attributes().count(), e.attributes().count() ); + QCOMPARE( i.attributes().pointRecordSize(), e.attributes().pointRecordSize() ); + QCOMPARE( i.extent(), e.extent() ); + QCOMPARE( i.offset(), e.offset() ); + QCOMPARE( i.scale(), e.scale() ); + QCOMPARE( i.span(), e.span() ); + QCOMPARE( i.zMax(), e.zMax() ); + QCOMPARE( i.zMin(), e.zMin() ); + QCOMPARE( i.root(), e.root() ); + QCOMPARE( i.rootNodeBounds(), e.rootNodeBounds() ); +} + +void TestQgsPointCloudEditing::testStartStopEditing() +{ + const QString dataPath = copyTestData( QStringLiteral( "point_clouds/copc/sunshine-coast.copc.laz" ) ); + + std::unique_ptr layer = std::make_unique( dataPath, QStringLiteral( "layer" ), QStringLiteral( "copc" ) ); + QVERIFY( layer->isValid() ); + QVERIFY( !layer->isEditable() ); + QVERIFY( !layer->isModified() ); + + QVERIFY( layer->index() ); + QSignalSpy spyStart( layer.get(), &QgsMapLayer::editingStarted ); + QSignalSpy spyStop( layer.get(), &QgsMapLayer::editingStopped ); + QSignalSpy spyModify( layer.get(), &QgsMapLayer::layerModified ); + QVERIFY( layer->startEditing() ); + QVERIFY( layer->isEditable() ); + QVERIFY( !layer->isModified() ); + QCOMPARE( spyStart.size(), 1 ); + QCOMPARE( spyStop.size(), 0 ); + QCOMPARE( spyModify.size(), 0 ); + QVERIFY( dynamic_cast( layer->index().mIndex.get() ) ); + + // false if already editing + QVERIFY( !layer->startEditing() ); + QVERIFY( layer->isEditable() ); + QVERIFY( !layer->isModified() ); + QCOMPARE( spyStart.size(), 1 ); + QCOMPARE( spyStop.size(), 0 ); + QCOMPARE( spyModify.size(), 0 ); + + // stop editing + QVERIFY( layer->rollBack() ); + QVERIFY( !layer->isEditable() ); + QVERIFY( !layer->isModified() ); + QCOMPARE( spyStart.size(), 1 ); + QCOMPARE( spyStop.size(), 1 ); + QCOMPARE( spyModify.size(), 0 ); + QVERIFY( dynamic_cast( layer->index().mIndex.get() ) ); + + // false if already stopped + QVERIFY( !layer->rollBack() ); + QVERIFY( !layer->isEditable() ); + QVERIFY( !layer->isModified() ); + QCOMPARE( spyStart.size(), 1 ); + QCOMPARE( spyStop.size(), 1 ); + QCOMPARE( spyModify.size(), 0 ); + + // start again + QVERIFY( layer->startEditing() ); + QVERIFY( layer->isEditable() ); + QVERIFY( !layer->isModified() ); + QCOMPARE( spyStart.size(), 2 ); + QCOMPARE( spyStop.size(), 1 ); + QCOMPARE( spyModify.size(), 0 ); + QVERIFY( dynamic_cast( layer->index().mIndex.get() ) ); + + // commit and stop editing + QVERIFY( layer->commitChanges() ); + QVERIFY( !layer->isEditable() ); + QVERIFY( !layer->isModified() ); + QCOMPARE( spyStart.size(), 2 ); + QCOMPARE( spyStop.size(), 2 ); + QCOMPARE( spyModify.size(), 0 ); + QVERIFY( dynamic_cast( layer->index().mIndex.get() ) ); +} + +void TestQgsPointCloudEditing::testModifyAttributeValue() +{ + const QString dataPath = copyTestData( QStringLiteral( "point_clouds/copc/sunshine-coast.copc.laz" ) ); + + std::unique_ptr layer = std::make_unique( dataPath, QStringLiteral( "layer" ), QStringLiteral( "copc" ) ); + QVERIFY( layer->isValid() ); + + QSignalSpy spy( layer.get(), &QgsMapLayer::layerModified ); + + QgsPointCloudCategoryList categories = QgsPointCloudRendererRegistry::classificationAttributeCategories( layer.get() ); + QgsPointCloudClassifiedRenderer *renderer = new QgsPointCloudClassifiedRenderer( QStringLiteral( "Classification" ), categories ); + layer->setRenderer( renderer ); + + layer->renderer()->setPointSize( 2 ); + layer->renderer()->setPointSizeUnit( Qgis::RenderUnit::Millimeters ); + + QgsMapSettings mapSettings; + mapSettings.setOutputSize( QSize( 400, 400 ) ); + mapSettings.setOutputDpi( 96 ); + mapSettings.setDestinationCrs( layer->crs() ); + mapSettings.setExtent( QgsRectangle( 498061, 7050991, 498069, 7050999 ) ); + mapSettings.setLayers( { layer.get() } ); + QGSVERIFYRENDERMAPSETTINGSCHECK( "classified_render", "classified_render", mapSettings ); + + QVERIFY( layer->startEditing() ); + QVERIFY( layer->isEditable() ); + + // Change some points, point order should not matter + QgsPointCloudAttribute at( QStringLiteral( "Classification" ), QgsPointCloudAttribute::UChar ); + QgsPointCloudNodeId n( 0, 0, 0, 0 ); + QVERIFY( layer->changeAttributeValue( n, { 4, 2, 0, 1, 3, 16, 5, 13, 15, 14 }, at, 1 ) ); + QVERIFY( layer->isModified() ); + QCOMPARE( spy.size(), 1 ); + QGSVERIFYRENDERMAPSETTINGSCHECK( "classified_render_edit_1", "classified_render_edit_1", mapSettings ); + + // Change some more + QVERIFY( layer->changeAttributeValue( n, { 42, 82, 62, 52, 72 }, at, 6 ) ); + QVERIFY( layer->isModified() ); + QCOMPARE( spy.size(), 2 ); + QGSVERIFYRENDERMAPSETTINGSCHECK( "classified_render_edit_2", "classified_render_edit_2", mapSettings ); + + // Abort editing, original points should be rendered + QVERIFY( layer->rollBack() ); + QVERIFY( !layer->isEditable() ); + QVERIFY( !layer->isModified() ); + QCOMPARE( spy.size(), 3 ); + QGSVERIFYRENDERMAPSETTINGSCHECK( "classified_render", "classified_render", mapSettings ); +} + +void TestQgsPointCloudEditing::testModifyAttributeValueInvalid() +{ + const QString dataPath = copyTestData( QStringLiteral( "point_clouds/copc/sunshine-coast.copc.laz" ) ); + + std::unique_ptr layer = std::make_unique( dataPath, QStringLiteral( "layer" ), QStringLiteral( "copc" ) ); + QVERIFY( layer->isValid() ); + QVERIFY( layer->startEditing() ); + QVERIFY( layer->isEditable() ); + + QSignalSpy spy( layer.get(), &QgsMapLayer::layerModified ); + + // invalid node + QgsPointCloudAttribute at( QStringLiteral( "Classification" ), QgsPointCloudAttribute::UChar ); + QgsPointCloudNodeId n; + QVERIFY( !layer->changeAttributeValue( n, { 42 }, at, 1 ) ); + QVERIFY( !layer->isModified() ); + QCOMPARE( spy.size(), 0 ); + + // missing node + n = QgsPointCloudNodeId( 1, 1, 1, 1 ); + QVERIFY( !layer->changeAttributeValue( n, { 42 }, at, 1 ) ); + QVERIFY( !layer->isModified() ); + QCOMPARE( spy.size(), 0 ); + + // invalid point ids + n = QgsPointCloudNodeId( 0, 0, 0, 0 ); + QVERIFY( !layer->changeAttributeValue( n, { -1, 42 }, at, 1 ) ); + QVERIFY( !layer->isModified() ); + QCOMPARE( spy.size(), 0 ); + + QVERIFY( !layer->changeAttributeValue( n, { 42, 420 }, at, 1 ) ); + QVERIFY( !layer->isModified() ); + QCOMPARE( spy.size(), 0 ); + + // invalid attribute, X,Y,Z are read only + at = QgsPointCloudAttribute( QStringLiteral( "X" ), QgsPointCloudAttribute::Double ); + QVERIFY( !layer->changeAttributeValue( n, { 42 }, at, 0 ) ); + QVERIFY( !layer->isModified() ); + QCOMPARE( spy.size(), 0 ); + at = QgsPointCloudAttribute( QStringLiteral( "Y" ), QgsPointCloudAttribute::Double ); + QVERIFY( !layer->changeAttributeValue( n, { 42 }, at, 0 ) ); + QVERIFY( !layer->isModified() ); + QCOMPARE( spy.size(), 0 ); + at = QgsPointCloudAttribute( QStringLiteral( "Z" ), QgsPointCloudAttribute::Double ); + QVERIFY( !layer->changeAttributeValue( n, { 42 }, at, 0 ) ); + QVERIFY( !layer->isModified() ); + QCOMPARE( spy.size(), 0 ); + + // Wrong attribute size + at = QgsPointCloudAttribute( QStringLiteral( "Classification" ), QgsPointCloudAttribute::Double ); + QVERIFY( !layer->changeAttributeValue( n, { 42 }, at, 0 ) ); + QVERIFY( !layer->isModified() ); + QCOMPARE( spy.size(), 0 ); + + // Missing attribute + at = QgsPointCloudAttribute( QStringLiteral( "Foo" ), QgsPointCloudAttribute::Double ); + QVERIFY( !layer->changeAttributeValue( n, { 42 }, at, 0 ) ); + QVERIFY( !layer->isModified() ); + QCOMPARE( spy.size(), 0 ); + + // invalid values for standard LAZ attributes + at = QgsPointCloudAttribute( QStringLiteral( "Intensity" ), QgsPointCloudAttribute::UChar ); + QVERIFY( !layer->changeAttributeValue( n, { 42 }, at, -1 ) ); + QVERIFY( !layer->isModified() ); + QCOMPARE( spy.size(), 0 ); + QVERIFY( !layer->changeAttributeValue( n, { 42 }, at, 65536 ) ); + QVERIFY( !layer->isModified() ); + QCOMPARE( spy.size(), 0 ); + + at = QgsPointCloudAttribute( QStringLiteral( "ReturnNumber" ), QgsPointCloudAttribute::UChar ); + QVERIFY( !layer->changeAttributeValue( n, { 42 }, at, -1 ) ); + QVERIFY( !layer->isModified() ); + QCOMPARE( spy.size(), 0 ); + QVERIFY( !layer->changeAttributeValue( n, { 42 }, at, 16 ) ); + QVERIFY( !layer->isModified() ); + QCOMPARE( spy.size(), 0 ); + + at = QgsPointCloudAttribute( QStringLiteral( "NumberOfReturns" ), QgsPointCloudAttribute::UChar ); + QVERIFY( !layer->changeAttributeValue( n, { 42 }, at, -1 ) ); + QVERIFY( !layer->isModified() ); + QCOMPARE( spy.size(), 0 ); + QVERIFY( !layer->changeAttributeValue( n, { 42 }, at, 16 ) ); + QVERIFY( !layer->isModified() ); + QCOMPARE( spy.size(), 0 ); + + at = QgsPointCloudAttribute( QStringLiteral( "ScanChannel" ), QgsPointCloudAttribute::UChar ); + QVERIFY( !layer->changeAttributeValue( n, { 42 }, at, -1 ) ); + QVERIFY( !layer->isModified() ); + QCOMPARE( spy.size(), 0 ); + QVERIFY( !layer->changeAttributeValue( n, { 42 }, at, 4 ) ); + QVERIFY( !layer->isModified() ); + QCOMPARE( spy.size(), 0 ); + + at = QgsPointCloudAttribute( QStringLiteral( "ScanDirectionFlag" ), QgsPointCloudAttribute::UChar ); + QVERIFY( !layer->changeAttributeValue( n, { 42 }, at, -1 ) ); + QVERIFY( !layer->isModified() ); + QCOMPARE( spy.size(), 0 ); + QVERIFY( !layer->changeAttributeValue( n, { 42 }, at, 2 ) ); + QVERIFY( !layer->isModified() ); + QCOMPARE( spy.size(), 0 ); + + at = QgsPointCloudAttribute( QStringLiteral( "EdgeOfFlightLine" ), QgsPointCloudAttribute::UChar ); + QVERIFY( !layer->changeAttributeValue( n, { 42 }, at, -1 ) ); + QVERIFY( !layer->isModified() ); + QCOMPARE( spy.size(), 0 ); + QVERIFY( !layer->changeAttributeValue( n, { 42 }, at, 2 ) ); + QVERIFY( !layer->isModified() ); + QCOMPARE( spy.size(), 0 ); + + at = QgsPointCloudAttribute( QStringLiteral( "Classification" ), QgsPointCloudAttribute::UChar ); + QVERIFY( !layer->changeAttributeValue( n, { 42 }, at, -1 ) ); + QVERIFY( !layer->isModified() ); + QCOMPARE( spy.size(), 0 ); + QVERIFY( !layer->changeAttributeValue( n, { 42 }, at, 256 ) ); + QVERIFY( !layer->isModified() ); + QCOMPARE( spy.size(), 0 ); + + at = QgsPointCloudAttribute( QStringLiteral( "UserData" ), QgsPointCloudAttribute::UChar ); + QVERIFY( !layer->changeAttributeValue( n, { 42 }, at, -1 ) ); + QVERIFY( !layer->isModified() ); + QCOMPARE( spy.size(), 0 ); + QVERIFY( !layer->changeAttributeValue( n, { 42 }, at, 256 ) ); + QVERIFY( !layer->isModified() ); + QCOMPARE( spy.size(), 0 ); + + at = QgsPointCloudAttribute( QStringLiteral( "ScanAngle" ), QgsPointCloudAttribute::UChar ); + QVERIFY( !layer->changeAttributeValue( n, { 42 }, at, -30'001 ) ); + QVERIFY( !layer->isModified() ); + QCOMPARE( spy.size(), 0 ); + QVERIFY( !layer->changeAttributeValue( n, { 42 }, at, 30'001 ) ); + QVERIFY( !layer->isModified() ); + QCOMPARE( spy.size(), 0 ); + + at = QgsPointCloudAttribute( QStringLiteral( "PointSourceId" ), QgsPointCloudAttribute::UChar ); + QVERIFY( !layer->changeAttributeValue( n, { 42 }, at, -1 ) ); + QVERIFY( !layer->isModified() ); + QCOMPARE( spy.size(), 0 ); + QVERIFY( !layer->changeAttributeValue( n, { 42 }, at, 65536 ) ); + QVERIFY( !layer->isModified() ); + QCOMPARE( spy.size(), 0 ); + + at = QgsPointCloudAttribute( QStringLiteral( "GpsTime" ), QgsPointCloudAttribute::UChar ); + QVERIFY( !layer->changeAttributeValue( n, { 42 }, at, -1 ) ); + QVERIFY( !layer->isModified() ); + QCOMPARE( spy.size(), 0 ); + + at = QgsPointCloudAttribute( QStringLiteral( "Synthetic" ), QgsPointCloudAttribute::UChar ); + QVERIFY( !layer->changeAttributeValue( n, { 42 }, at, -1 ) ); + QVERIFY( !layer->isModified() ); + QCOMPARE( spy.size(), 0 ); + QVERIFY( !layer->changeAttributeValue( n, { 42 }, at, 2 ) ); + QVERIFY( !layer->isModified() ); + QCOMPARE( spy.size(), 0 ); + + at = QgsPointCloudAttribute( QStringLiteral( "Keypoint" ), QgsPointCloudAttribute::UChar ); + QVERIFY( !layer->changeAttributeValue( n, { 42 }, at, -1 ) ); + QVERIFY( !layer->isModified() ); + QCOMPARE( spy.size(), 0 ); + QVERIFY( !layer->changeAttributeValue( n, { 42 }, at, 2 ) ); + QVERIFY( !layer->isModified() ); + QCOMPARE( spy.size(), 0 ); + + at = QgsPointCloudAttribute( QStringLiteral( "Withheld" ), QgsPointCloudAttribute::UChar ); + QVERIFY( !layer->changeAttributeValue( n, { 42 }, at, -1 ) ); + QVERIFY( !layer->isModified() ); + QCOMPARE( spy.size(), 0 ); + QVERIFY( !layer->changeAttributeValue( n, { 42 }, at, 2 ) ); + QVERIFY( !layer->isModified() ); + QCOMPARE( spy.size(), 0 ); + + at = QgsPointCloudAttribute( QStringLiteral( "Overlap" ), QgsPointCloudAttribute::UChar ); + QVERIFY( !layer->changeAttributeValue( n, { 42 }, at, -1 ) ); + QVERIFY( !layer->isModified() ); + QCOMPARE( spy.size(), 0 ); + QVERIFY( !layer->changeAttributeValue( n, { 42 }, at, 2 ) ); + QVERIFY( !layer->isModified() ); + QCOMPARE( spy.size(), 0 ); + + at = QgsPointCloudAttribute( QStringLiteral( "Red" ), QgsPointCloudAttribute::UChar ); + QVERIFY( !layer->changeAttributeValue( n, { 42 }, at, -1 ) ); + QVERIFY( !layer->isModified() ); + QCOMPARE( spy.size(), 0 ); + QVERIFY( !layer->changeAttributeValue( n, { 42 }, at, 65536 ) ); + QVERIFY( !layer->isModified() ); + QCOMPARE( spy.size(), 0 ); + + at = QgsPointCloudAttribute( QStringLiteral( "Green" ), QgsPointCloudAttribute::UChar ); + QVERIFY( !layer->changeAttributeValue( n, { 42 }, at, -1 ) ); + QVERIFY( !layer->isModified() ); + QCOMPARE( spy.size(), 0 ); + QVERIFY( !layer->changeAttributeValue( n, { 42 }, at, 65536 ) ); + QVERIFY( !layer->isModified() ); + QCOMPARE( spy.size(), 0 ); + + at = QgsPointCloudAttribute( QStringLiteral( "Blue" ), QgsPointCloudAttribute::UChar ); + QVERIFY( !layer->changeAttributeValue( n, { 42 }, at, -1 ) ); + QVERIFY( !layer->isModified() ); + QCOMPARE( spy.size(), 0 ); + QVERIFY( !layer->changeAttributeValue( n, { 42 }, at, 65536 ) ); + QVERIFY( !layer->isModified() ); + QCOMPARE( spy.size(), 0 ); +} + +QGSTEST_MAIN( TestQgsPointCloudEditing ) +#include "testqgspointcloudediting.moc" diff --git a/tests/testdata/control_images/pointcloud_editing/expected_classified_render/expected_classified_render.png b/tests/testdata/control_images/pointcloud_editing/expected_classified_render/expected_classified_render.png new file mode 100644 index 000000000000..3063f20e7a81 Binary files /dev/null and b/tests/testdata/control_images/pointcloud_editing/expected_classified_render/expected_classified_render.png differ diff --git a/tests/testdata/control_images/pointcloud_editing/expected_classified_render_edit_1/expected_classified_render_edit_1.png b/tests/testdata/control_images/pointcloud_editing/expected_classified_render_edit_1/expected_classified_render_edit_1.png new file mode 100644 index 000000000000..790254ee8762 Binary files /dev/null and b/tests/testdata/control_images/pointcloud_editing/expected_classified_render_edit_1/expected_classified_render_edit_1.png differ diff --git a/tests/testdata/control_images/pointcloud_editing/expected_classified_render_edit_2/expected_classified_render_edit_2.png b/tests/testdata/control_images/pointcloud_editing/expected_classified_render_edit_2/expected_classified_render_edit_2.png new file mode 100644 index 000000000000..9ac93303df00 Binary files /dev/null and b/tests/testdata/control_images/pointcloud_editing/expected_classified_render_edit_2/expected_classified_render_edit_2.png differ