From 2401d3aa90916d19c40c492a7907907e52b4c2d0 Mon Sep 17 00:00:00 2001 From: uclaros Date: Thu, 19 Dec 2024 23:10:51 +0200 Subject: [PATCH] Point cloud editing part 1 --- .../qgspointclouddataprovider.py | 1 + .../qgspointclouddataprovider.sip.in | 1 + .../pointcloud/qgspointcloudlayer.sip.in | 75 ++++ .../qgspointclouddataprovider.sip.in | 1 + .../pointcloud/qgspointcloudlayer.sip.in | 75 ++++ src/3d/qgspointcloudlayer3drenderer.cpp | 4 +- src/core/CMakeLists.txt | 4 + .../pointcloud/qgspointclouddataprovider.h | 1 + .../pointcloud/qgspointcloudeditingindex.cpp | 141 ++++++ .../pointcloud/qgspointcloudeditingindex.h | 70 +++ src/core/pointcloud/qgspointcloudindex.cpp | 5 + src/core/pointcloud/qgspointcloudindex.h | 9 + src/core/pointcloud/qgspointcloudlayer.cpp | 113 +++++ src/core/pointcloud/qgspointcloudlayer.h | 83 ++++ .../qgspointcloudlayereditutils.cpp | 151 +++++++ .../pointcloud/qgspointcloudlayereditutils.h | 57 +++ .../pointcloud/qgspointcloudlayerexporter.cpp | 2 +- .../qgspointcloudlayerprofilegenerator.cpp | 8 +- .../pointcloud/qgspointcloudlayerrenderer.cpp | 8 +- src/core/providers/copc/qgscopcprovider.cpp | 7 + src/core/providers/copc/qgscopcprovider.h | 1 + tests/src/core/CMakeLists.txt | 1 + tests/src/core/testqgspointcloudediting.cpp | 414 ++++++++++++++++++ .../expected_classified_render.png | Bin 0 -> 471523 bytes .../expected_classified_render_edit_1.png | Bin 0 -> 471523 bytes .../expected_classified_render_edit_2.png | Bin 0 -> 471523 bytes 26 files changed, 1221 insertions(+), 11 deletions(-) create mode 100644 src/core/pointcloud/qgspointcloudeditingindex.cpp create mode 100644 src/core/pointcloud/qgspointcloudeditingindex.h create mode 100644 src/core/pointcloud/qgspointcloudlayereditutils.cpp create mode 100644 src/core/pointcloud/qgspointcloudlayereditutils.h create mode 100644 tests/src/core/testqgspointcloudediting.cpp create mode 100644 tests/testdata/control_images/pointcloud_editing/expected_classified_render/expected_classified_render.png create mode 100644 tests/testdata/control_images/pointcloud_editing/expected_classified_render_edit_1/expected_classified_render_edit_1.png create mode 100644 tests/testdata/control_images/pointcloud_editing/expected_classified_render_edit_2/expected_classified_render_edit_2.png diff --git a/python/PyQt6/core/auto_additions/qgspointclouddataprovider.py b/python/PyQt6/core/auto_additions/qgspointclouddataprovider.py index a8aa047d8daed..66c9458150964 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 c1bd9092a6261..8a0a6bc739891 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/qgspointcloudlayer.sip.in b/python/PyQt6/core/auto_generated/pointcloud/qgspointcloudlayer.sip.in index 93f2433a235ad..a008b4a5eb4e2 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,74 @@ 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 + + + + signals: void subsetStringChanged(); diff --git a/python/core/auto_generated/pointcloud/qgspointclouddataprovider.sip.in b/python/core/auto_generated/pointcloud/qgspointclouddataprovider.sip.in index 4155ab4d36f6a..7e0a3b45ddb35 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/qgspointcloudlayer.sip.in b/python/core/auto_generated/pointcloud/qgspointcloudlayer.sip.in index ca172f3f995ed..5d512a751e5f4 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,74 @@ 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 + + + + signals: void subsetStringChanged(); diff --git a/src/3d/qgspointcloudlayer3drenderer.cpp b/src/3d/qgspointcloudlayer3drenderer.cpp index c3525d1ff0733..68a6a73cc2912 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 a97f8f43aa749..17eb37ab1770a 100644 --- a/src/core/CMakeLists.txt +++ b/src/core/CMakeLists.txt @@ -858,11 +858,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 @@ -1726,11 +1728,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/qgspointclouddataprovider.h b/src/core/pointcloud/qgspointclouddataprovider.h index 0df928b8267b8..7f69631b5d232 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 0000000000000..b3079eef91410 --- /dev/null +++ b/src/core/pointcloud/qgspointcloudeditingindex.cpp @@ -0,0 +1,141 @@ +/*************************************************************************** + 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 ) + : mIndex( layer ? layer->index() : nullptr ) +{ + if ( !layer || + !layer->dataProvider() || + !layer->dataProvider()->hasValidIndex() || + !( layer->dataProvider()->capabilities() & QgsPointCloudDataProvider::Capability::ChangeAttributeValues ) ) + return; + + 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(); +} + +QgsPointCloudIndex::AccessType 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 0000000000000..a600ef8b4ef9f --- /dev/null +++ b/src/core/pointcloud/qgspointcloudeditingindex.h @@ -0,0 +1,70 @@ +/*************************************************************************** + 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; + +/** + * The QgsPointCloudEditingIndex class is a QgsPointCloudIndex that is used as an editing + * buffer when editing point cloud data. + * + * \since QGIS 3.42 + */ +class CORE_EXPORT QgsPointCloudEditingIndex : public QgsPointCloudIndex +{ + public: + //! Ctor + explicit QgsPointCloudEditingIndex( QgsPointCloudLayer *layer ); + + std::unique_ptr clone() const override; + void load( const QString &fileName ) override; + bool isValid() const override; + AccessType 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; + + /** + * Try 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 = nullptr; + bool mIsValid = false; + QHash mEditedNodeData; +}; + +#endif // QGSPOINTCLOUDEDITINGINDEX_H diff --git a/src/core/pointcloud/qgspointcloudindex.cpp b/src/core/pointcloud/qgspointcloudindex.cpp index dc12a79f74e0f..2cdcf15beec29 100644 --- a/src/core/pointcloud/qgspointcloudindex.cpp +++ b/src/core/pointcloud/qgspointcloudindex.cpp @@ -193,6 +193,11 @@ QgsPointCloudNode QgsPointCloudIndex::getNode( const QgsPointCloudNodeId &id ) c return QgsPointCloudNode( id, pointCount, children, bounds.width() / mSpan, bounds ); } +bool QgsPointCloudIndex::updateNodeData( const QHash & ) +{ + return false; +} + QgsPointCloudAttributeCollection QgsPointCloudIndex::attributes() const { return mAttributes; diff --git a/src/core/pointcloud/qgspointcloudindex.h b/src/core/pointcloud/qgspointcloudindex.h index 2c038a5fe4412..6fc0fad94b687 100644 --- a/src/core/pointcloud/qgspointcloudindex.h +++ b/src/core/pointcloud/qgspointcloudindex.h @@ -269,6 +269,15 @@ class CORE_EXPORT QgsPointCloudIndex //! Returns object for a given node virtual QgsPointCloudNode getNode( const QgsPointCloudNodeId &id ) const; + /** + * 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 all attributes that are stored in the file QgsPointCloudAttributeCollection attributes() const; diff --git a/src/core/pointcloud/qgspointcloudlayer.cpp b/src/core/pointcloud/qgspointcloudlayer.cpp index e2288ad901d87..297068058bd7f 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" @@ -973,3 +975,114 @@ void QgsPointCloudLayer::loadIndexesForRenderContext( QgsRenderContext &renderer } } } + +bool QgsPointCloudLayer::startEditing() +{ + QGIS_PROTECT_QOBJECT_THREAD_ACCESS + if ( mEditIndex ) + return false; + + mEditIndex = std::make_unique( this ); + + if ( !mEditIndex->isValid() ) + { + mEditIndex.reset(); + 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.reset(); + 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.reset(); + 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 + if ( mEditIndex ) + return mEditIndex.get(); + + if ( mDataProvider ) + return mDataProvider->index(); + + return nullptr; +} diff --git a/src/core/pointcloud/qgspointcloudlayer.h b/src/core/pointcloud/qgspointcloudlayer.h index 706e3ebbdc7a0..11d986f6d1e82 100644 --- a/src/core/pointcloud/qgspointcloudlayer.h +++ b/src/core/pointcloud/qgspointcloudlayer.h @@ -32,6 +32,7 @@ class QgsPointCloudLayerRenderer; class QgsPointCloudRenderer; class QgsPointCloudLayerElevationProperties; class QgsAbstractPointCloud3DRenderer; +class QgsPointCloudEditingIndex; /** * \ingroup core @@ -135,6 +136,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 +247,81 @@ 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; + + QgsPointCloudIndex *index() const SIP_SKIP; + + signals: /** @@ -285,6 +365,8 @@ class CORE_EXPORT QgsPointCloudLayer : public QgsMapLayer, public QgsAbstractPro std::unique_ptr mDataProvider; + std::unique_ptr mEditIndex; + std::unique_ptr mRenderer; QgsPointCloudLayerElevationProperties *mElevationProperties = nullptr; @@ -295,6 +377,7 @@ class CORE_EXPORT QgsPointCloudLayer : public QgsMapLayer, public QgsAbstractPro QgsPointCloudStatistics mStatistics; PointCloudStatisticsCalculationState mStatisticsCalculationState = PointCloudStatisticsCalculationState::NotStarted; long mStatsCalculationTask = 0; + QString mCommitError; friend class TestQgsVirtualPointCloudProvider; }; diff --git a/src/core/pointcloud/qgspointcloudlayereditutils.cpp b/src/core/pointcloud/qgspointcloudlayereditutils.cpp new file mode 100644 index 0000000000000..d24f3f42861f3 --- /dev/null +++ b/src/core/pointcloud/qgspointcloudlayereditutils.cpp @@ -0,0 +1,151 @@ +/*************************************************************************** + 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 "qgspointcloudeditingindex.h" +#include "qgspointcloudlayer.h" +#include "qgslazdecoder.h" + + +QgsPointCloudLayerEditUtils::QgsPointCloudLayerEditUtils( QgsPointCloudLayer *layer ) + : mIndex( dynamic_cast( 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 + lazStoreToStream_( 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 0000000000000..acaaf796ad4e0 --- /dev/null +++ b/src/core/pointcloud/qgspointcloudlayereditutils.h @@ -0,0 +1,57 @@ +/*************************************************************************** + 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 +#include + +#define SIP_NO_FILE + +class QgsPointCloudLayer; +class QgsPointCloudEditingIndex; +class QgsPointCloudNodeId; +class QgsPointCloudAttribute; +class QgsPointCloudAttributeCollection; +class QgsPointCloudRequest; + +class QgsPointCloudLayerEditUtils +{ + public: + 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: + QgsPointCloudEditingIndex *mIndex = nullptr; +}; + +#endif // QGSPOINTCLOUDLAYEREDITUTILS_H diff --git a/src/core/pointcloud/qgspointcloudlayerexporter.cpp b/src/core/pointcloud/qgspointcloudlayerexporter.cpp index ba5896c58d173..30a0ecd27403e 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()->clone() ) + , mIndex( layer->index()->clone() ) , mSourceCrs( QgsCoordinateReferenceSystem( layer->crs() ) ) , mTargetCrs( QgsCoordinateReferenceSystem( layer->crs() ) ) { diff --git a/src/core/pointcloud/qgspointcloudlayerprofilegenerator.cpp b/src/core/pointcloud/qgspointcloudlayerprofilegenerator.cpp index a8d706b0e14e7..2c401de13b47f 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 71a2043f82a33..b7b1531f023a0 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() ) ) { diff --git a/src/core/providers/copc/qgscopcprovider.cpp b/src/core/providers/copc/qgscopcprovider.cpp index b033c7850246d..6b8d3dff9bfe1 100644 --- a/src/core/providers/copc/qgscopcprovider.cpp +++ b/src/core/providers/copc/qgscopcprovider.cpp @@ -143,6 +143,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 1eb7308d16a9a..7c1adf1cccf17 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: std::unique_ptr mIndex; diff --git a/tests/src/core/CMakeLists.txt b/tests/src/core/CMakeLists.txt index 6f3638dbaecfc..5ad8557ddb9a6 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 0000000000000..885ad5de2a248 --- /dev/null +++ b/tests/src/core/testqgspointcloudediting.cpp @@ -0,0 +1,414 @@ +/*************************************************************************** + 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() ); + + QgsCopcPointCloudIndex *i = dynamic_cast( layer->index() ); + QVERIFY( i ); + std::unique_ptr e = std::make_unique( layer.get() ); + QVERIFY( e ); + 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( dynamic_cast( 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() ) ); + + // 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() ) ); + + // 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() ) ); + + // 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() ) ); +} + +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 0000000000000000000000000000000000000000..3063f20e7a81675eeefbc380fd3e767afd676284 GIT binary patch literal 471523 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

D2$5((&}DbFAgiEp}d! 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..790254ee8762d3f722c49776935872a1e3d9be00 GIT binary patch literal 471523 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..9ac93303df004509f519fe8dcb53b369e0b2a389 GIT binary patch literal 471523 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 AUH||9 literal 0 HcmV?d00001