From 8b9f8fe77c7e38643a1b480023e9b72d875ad372 Mon Sep 17 00:00:00 2001 From: jmkerloch Date: Thu, 11 May 2023 14:54:57 +0200 Subject: [PATCH 01/26] feat(elevation_profile): add tolerance support for lines Add profile box geometry from profile curve buffer with tolerance for profile generation. Use this geometry to filter line geometry. For each feature intersecting profile box, define result crossSectionGeometry from each vertex for line intersection or from point coordinate for point intersection --- .../vector/qgsvectorlayerprofilegenerator.cpp | 151 ++++++++++++++---- .../vector/qgsvectorlayerprofilegenerator.h | 3 + 2 files changed, 125 insertions(+), 29 deletions(-) diff --git a/src/core/vector/qgsvectorlayerprofilegenerator.cpp b/src/core/vector/qgsvectorlayerprofilegenerator.cpp index 524e2ee4c57d..3057d848f812 100644 --- a/src/core/vector/qgsvectorlayerprofilegenerator.cpp +++ b/src/core/vector/qgsvectorlayerprofilegenerator.cpp @@ -750,6 +750,10 @@ bool QgsVectorLayerProfileGenerator::generateProfile( const QgsProfileGeneration mProfileCurveEngine.reset( new QgsGeos( mProfileCurve.get() ) ); mProfileCurveEngine->prepareGeometry(); + mProfileBox = std::unique_ptr( mProfileCurveEngine->buffer( mTolerance, 8, Qgis::EndCapStyle::Flat, Qgis::JoinStyle::Round, 2 ) ); + mProfileBoxEngine.reset( new QgsGeos( mProfileBox.get() ) ); + mProfileBoxEngine->prepareGeometry(); + mDataDefinedProperties.prepare( mExpressionContext ); if ( mFeedback->isCanceled() ) @@ -873,36 +877,73 @@ bool QgsVectorLayerProfileGenerator::generateProfileForLines() // get features from layer QgsFeatureRequest request; request.setDestinationCrs( mTargetCrs, mTransformContext ); - request.setFilterRect( mProfileCurve->boundingBox() ); + request.setFilterRect( mProfileBox->boundingBox() ); request.setSubsetOfAttributes( mDataDefinedProperties.referencedFields( mExpressionContext ), mFields ); request.setFeedback( mFeedback.get() ); auto processCurve = [this]( const QgsFeature & feature, const QgsCurve * curve ) { - QString error; - std::unique_ptr< QgsAbstractGeometry > intersection( mProfileCurveEngine->intersection( curve, &error ) ); - if ( !intersection ) - return; + auto processPoint = [this, curve]( const QgsPoint & intersectionPoint, const QgsGeos & curveGeos, const QgsFeature & feature ) + { - if ( mFeedback->isCanceled() ) - return; + QString error; - QgsGeos curveGeos( curve ); - curveGeos.prepareGeometry(); + // unfortunately we need to do some work to interpolate the z value for the line -- GEOS doesn't give us this + const double distance = curveGeos.lineLocatePoint( intersectionPoint, &error ); + std::unique_ptr< QgsPoint > interpolatedPoint( curve->interpolatePoint( distance ) ); - if ( mFeedback->isCanceled() ) - return; + const double offset = mDataDefinedProperties.valueAsDouble( QgsMapLayerElevationProperties::Property::ZOffset, mExpressionContext, mOffset ); - for ( auto it = intersection->const_parts_begin(); it != intersection->const_parts_end(); ++it ) + const double height = featureZToHeight( interpolatedPoint->x(), interpolatedPoint->y(), interpolatedPoint->z(), offset ); + mResults->mRawPoints.append( QgsPoint( interpolatedPoint->x(), interpolatedPoint->y(), height ) ); + mResults->minZ = std::min( mResults->minZ, height ); + mResults->maxZ = std::max( mResults->maxZ, height ); + + const double distanceAlongProfileCurve = mProfileCurveEngine->lineLocatePoint( *interpolatedPoint, &error ); + mResults->mDistanceToHeightMap.insert( distanceAlongProfileCurve, height ); + + QgsVectorLayerProfileResults::Feature resultFeature; + resultFeature.featureId = feature.id(); + if ( mExtrusionEnabled ) + { + const double extrusion = mDataDefinedProperties.valueAsDouble( QgsMapLayerElevationProperties::Property::ExtrusionHeight, mExpressionContext, mExtrusionHeight ); + + resultFeature.geometry = QgsGeometry( new QgsLineString( QgsPoint( interpolatedPoint->x(), interpolatedPoint->y(), height ), + QgsPoint( interpolatedPoint->x(), interpolatedPoint->y(), height + extrusion ) ) ); + resultFeature.crossSectionGeometry = QgsGeometry( new QgsLineString( QgsPoint( distanceAlongProfileCurve, height ), + QgsPoint( distanceAlongProfileCurve, height + extrusion ) ) ); + mResults->minZ = std::min( mResults->minZ, height + extrusion ); + mResults->maxZ = std::max( mResults->maxZ, height + extrusion ); + } + else + { + resultFeature.geometry = QgsGeometry( new QgsPoint( interpolatedPoint->x(), interpolatedPoint->y(), height ) ); + resultFeature.crossSectionGeometry = QgsGeometry( new QgsPoint( distanceAlongProfileCurve, height ) ); + } + mResults->features[resultFeature.featureId].append( resultFeature ); + }; + + auto processIntersectionCurve = [this, curve]( const QgsCurve & intersectionCurve, const QgsGeos & curveGeos, const QgsFeature & feature ) { - if ( mFeedback->isCanceled() ) - return; + QString error; + QVector< QgsGeometry > transformedParts; + QVector< QgsGeometry > crossSectionParts; - if ( const QgsPoint *intersectionPoint = qgsgeometry_cast< const QgsPoint * >( *it ) ) + QString lastError; + QgsVectorLayerProfileResults::Feature resultFeature; + resultFeature.featureId = feature.id(); + double lastDistanceAlongProfileCurve = 0.0; + + for ( auto it = intersectionCurve.vertices_begin(); it != intersectionCurve.vertices_end(); ++it ) { - // unfortunately we need to do some work to interpolate the z value for the line -- GEOS doesn't give us this - const double distance = curveGeos.lineLocatePoint( *intersectionPoint, &error ); - std::unique_ptr< QgsPoint > interpolatedPoint( curve->interpolatePoint( distance ) ); + const QgsPoint &intersectionPoint = ( *it ); + + + // JMK : GEOS is returning a valid height value in my current test + // Previous comment : unfortunately we need to do some work to interpolate the z value for the line -- GEOS doesn't give us this + // const double distance = curveGeos.lineLocatePoint( intersectionPoint, &error ); + // std::unique_ptr< QgsPoint > interpolatedPoint( curve->interpolatePoint( distance ) ); + const QgsPoint *interpolatedPoint = &intersectionPoint; const double offset = mDataDefinedProperties.valueAsDouble( QgsMapLayerElevationProperties::Property::ZOffset, mExpressionContext, mOffset ); @@ -912,27 +953,79 @@ bool QgsVectorLayerProfileGenerator::generateProfileForLines() mResults->maxZ = std::max( mResults->maxZ, height ); const double distanceAlongProfileCurve = mProfileCurveEngine->lineLocatePoint( *interpolatedPoint, &error ); + lastDistanceAlongProfileCurve = distanceAlongProfileCurve; mResults->mDistanceToHeightMap.insert( distanceAlongProfileCurve, height ); - QgsVectorLayerProfileResults::Feature resultFeature; - resultFeature.featureId = feature.id(); if ( mExtrusionEnabled ) { const double extrusion = mDataDefinedProperties.valueAsDouble( QgsMapLayerElevationProperties::Property::ExtrusionHeight, mExpressionContext, mExtrusionHeight ); - resultFeature.geometry = QgsGeometry( new QgsLineString( QgsPoint( interpolatedPoint->x(), interpolatedPoint->y(), height ), - QgsPoint( interpolatedPoint->x(), interpolatedPoint->y(), height + extrusion ) ) ); - resultFeature.crossSectionGeometry = QgsGeometry( new QgsLineString( QgsPoint( distanceAlongProfileCurve, height ), - QgsPoint( distanceAlongProfileCurve, height + extrusion ) ) ); + transformedParts.append( QgsGeometry( new QgsLineString( QgsPoint( interpolatedPoint->x(), interpolatedPoint->y(), height ), + QgsPoint( interpolatedPoint->x(), interpolatedPoint->y(), height + extrusion ) ) ) ); + crossSectionParts.append( QgsGeometry( new QgsLineString( QgsPoint( distanceAlongProfileCurve, height ), + QgsPoint( distanceAlongProfileCurve, height + extrusion ) ) ) ); mResults->minZ = std::min( mResults->minZ, height + extrusion ); mResults->maxZ = std::max( mResults->maxZ, height + extrusion ); } else { - resultFeature.geometry = QgsGeometry( new QgsPoint( interpolatedPoint->x(), interpolatedPoint->y(), height ) ); - resultFeature.crossSectionGeometry = QgsGeometry( new QgsPoint( distanceAlongProfileCurve, height ) ); + transformedParts.append( QgsGeometry( new QgsPoint( interpolatedPoint->x(), interpolatedPoint->y(), height ) ) ); + crossSectionParts.append( QgsGeometry( new QgsPoint( distanceAlongProfileCurve, height ) ) ); } - mResults->features[resultFeature.featureId].append( resultFeature ); + } + + mResults->mDistanceToHeightMap.insert( lastDistanceAlongProfileCurve + 0.001, qQNaN() ); + + resultFeature.geometry = transformedParts.size() > 1 ? QgsGeometry::unaryUnion( transformedParts ) : transformedParts.value( 0 ); + if ( !crossSectionParts.empty() ) + { + //Create linestring from point list + QgsLineString *line = new QgsLineString(); + for ( auto it = crossSectionParts.begin(); it != crossSectionParts.end(); ++it ) + { + line->addVertex( QgsPoint( ( *it ).asPoint() ) ); + } + resultFeature.crossSectionGeometry = QgsGeometry( line ); + } + + mResults->features[resultFeature.featureId].append( resultFeature ); + }; + + QString error; + std::unique_ptr< QgsAbstractGeometry > intersection( mProfileBoxEngine->intersection( curve, &error ) ); + if ( !intersection ) + { + return; + } + + if ( mFeedback->isCanceled() ) + return; + + + // Intersection is empty : GEOS issue for vertical intersection : use feature geometry as intersection + if ( intersection->isEmpty() ) + { + std::string wkt_std = intersection->asWkt().toStdString(); + intersection.reset( feature.geometry().constGet()->clone() ); + } + + QgsGeos curveGeos( curve ); + curveGeos.prepareGeometry(); + + if ( mFeedback->isCanceled() ) + return; + + for ( auto it = intersection->const_parts_begin(); it != intersection->const_parts_end(); ++it ) + { + if ( mFeedback->isCanceled() ) + return; + if ( const QgsPoint *intersectionPoint = qgsgeometry_cast< const QgsPoint * >( *it ) ) + { + processPoint( *intersectionPoint, curveGeos, feature ); + } + else if ( const QgsCurve *intersectionCurve = qgsgeometry_cast< const QgsCurve * >( *it ) ) + { + processIntersectionCurve( *intersectionCurve, curveGeos, feature ); } else if ( const QgsLineString *ls = qgsgeometry_cast< const QgsLineString * >( *it ) ) { @@ -1035,7 +1128,7 @@ bool QgsVectorLayerProfileGenerator::generateProfileForLines() if ( mFeedback->isCanceled() ) return false; - if ( !mProfileCurveEngine->intersects( feature.geometry().constGet() ) ) + if ( !mProfileBoxEngine->intersects( feature.geometry().constGet() ) ) continue; mExpressionContext.setFeature( feature ); @@ -1045,7 +1138,7 @@ bool QgsVectorLayerProfileGenerator::generateProfileForLines() { for ( auto it = g.const_parts_begin(); it != g.const_parts_end(); ++it ) { - if ( !mProfileCurveEngine->intersects( *it ) ) + if ( !mProfileBoxEngine->intersects( *it ) ) continue; processCurve( feature, qgsgeometry_cast< const QgsCurve * >( *it ) ); diff --git a/src/core/vector/qgsvectorlayerprofilegenerator.h b/src/core/vector/qgsvectorlayerprofilegenerator.h index e5c26dc168b1..73f1fdcb2927 100644 --- a/src/core/vector/qgsvectorlayerprofilegenerator.h +++ b/src/core/vector/qgsvectorlayerprofilegenerator.h @@ -136,6 +136,9 @@ class CORE_EXPORT QgsVectorLayerProfileGenerator : public QgsAbstractProfileSurf std::unique_ptr< QgsCurve > mProfileCurve; std::unique_ptr< QgsGeos > mProfileCurveEngine; + std::unique_ptr mProfileBox = nullptr; + std::unique_ptr< QgsGeos > mProfileBoxEngine; + std::unique_ptr< QgsAbstractTerrainProvider > mTerrainProvider; std::unique_ptr< QgsCurve > mTransformedCurve; From 61b1ad1b01616332a466a0b84866d63995b4c116 Mon Sep 17 00:00:00 2001 From: bdm-oslandia Date: Wed, 2 Aug 2023 15:49:59 +0200 Subject: [PATCH 02/26] fix(elevation_profile): improve nan values handling in distance map Fix how is drawn the above/below polygon in chart when mDistanceToHeightMap has nan values (meaning the line is stopped and a new line will start). --- .../qgsabstractprofilesurfacegenerator.cpp | 89 +++++++++---------- 1 file changed, 42 insertions(+), 47 deletions(-) diff --git a/src/core/elevation/qgsabstractprofilesurfacegenerator.cpp b/src/core/elevation/qgsabstractprofilesurfacegenerator.cpp index adf5ddeb1594..ef921bcba7eb 100644 --- a/src/core/elevation/qgsabstractprofilesurfacegenerator.cpp +++ b/src/core/elevation/qgsabstractprofilesurfacegenerator.cpp @@ -297,66 +297,61 @@ void QgsAbstractProfileSurfaceResults::renderResults( QgsProfileRenderContext &c break; } + auto checkLine = [this]( QPolygonF & currentLine, QgsProfileRenderContext & context, double minZ, double maxZ, + double prevDistance, double currentPartStartDistance ) + { + if ( currentLine.length() > 1 ) + { + switch ( symbology ) + { + case Qgis::ProfileSurfaceSymbology::Line: + mLineSymbol->renderPolyline( currentLine, nullptr, context.renderContext() ); + break; + case Qgis::ProfileSurfaceSymbology::FillBelow: + currentLine.append( context.worldTransform().map( QPointF( prevDistance, minZ ) ) ); + currentLine.append( context.worldTransform().map( QPointF( currentPartStartDistance, minZ ) ) ); + currentLine.append( currentLine.at( 0 ) ); + mFillSymbol->renderPolygon( currentLine, nullptr, nullptr, context.renderContext() ); + break; + case Qgis::ProfileSurfaceSymbology::FillAbove: + currentLine.append( context.worldTransform().map( QPointF( prevDistance, maxZ ) ) ); + currentLine.append( context.worldTransform().map( QPointF( currentPartStartDistance, maxZ ) ) ); + currentLine.append( currentLine.at( 0 ) ); + mFillSymbol->renderPolygon( currentLine, nullptr, nullptr, context.renderContext() ); + break; + } + } + }; + QPolygonF currentLine; double prevDistance = std::numeric_limits< double >::quiet_NaN(); + double prevHeight = std::numeric_limits< double >::quiet_NaN(); double currentPartStartDistance = 0; for ( auto pointIt = mDistanceToHeightMap.constBegin(); pointIt != mDistanceToHeightMap.constEnd(); ++pointIt ) { + if ( std::isnan( prevDistance ) ) + { + currentPartStartDistance = pointIt.key(); + } + else if ( currentLine.empty() ) + { + currentPartStartDistance = prevDistance; + currentLine.append( context.worldTransform().map( QPointF( prevDistance, prevHeight ) ) ); + } + if ( std::isnan( pointIt.value() ) ) { - if ( currentLine.length() > 1 ) - { - switch ( symbology ) - { - case Qgis::ProfileSurfaceSymbology::Line: - mLineSymbol->renderPolyline( currentLine, nullptr, context.renderContext() ); - break; - case Qgis::ProfileSurfaceSymbology::FillBelow: - currentLine.append( context.worldTransform().map( QPointF( prevDistance, minZ ) ) ); - currentLine.append( context.worldTransform().map( QPointF( currentPartStartDistance, minZ ) ) ); - currentLine.append( currentLine.at( 0 ) ); - mFillSymbol->renderPolygon( currentLine, nullptr, nullptr, context.renderContext() ); - break; - case Qgis::ProfileSurfaceSymbology::FillAbove: - currentLine.append( context.worldTransform().map( QPointF( prevDistance, maxZ ) ) ); - currentLine.append( context.worldTransform().map( QPointF( currentPartStartDistance, maxZ ) ) ); - currentLine.append( currentLine.at( 0 ) ); - mFillSymbol->renderPolygon( currentLine, nullptr, nullptr, context.renderContext() ); - break; - } - } - prevDistance = pointIt.key(); + checkLine( currentLine, context, minZ, maxZ, prevDistance, currentPartStartDistance ); currentLine.clear(); continue; } - if ( currentLine.length() < 1 ) - { - currentPartStartDistance = pointIt.key(); - } + currentLine.append( context.worldTransform().map( QPointF( pointIt.key(), pointIt.value() ) ) ); prevDistance = pointIt.key(); + prevHeight = pointIt.value(); } - if ( currentLine.length() > 1 ) - { - switch ( symbology ) - { - case Qgis::ProfileSurfaceSymbology::Line: - mLineSymbol->renderPolyline( currentLine, nullptr, context.renderContext() ); - break; - case Qgis::ProfileSurfaceSymbology::FillBelow: - currentLine.append( context.worldTransform().map( QPointF( prevDistance, minZ ) ) ); - currentLine.append( context.worldTransform().map( QPointF( currentPartStartDistance, minZ ) ) ); - currentLine.append( currentLine.at( 0 ) ); - mFillSymbol->renderPolygon( currentLine, nullptr, nullptr, context.renderContext() ); - break; - case Qgis::ProfileSurfaceSymbology::FillAbove: - currentLine.append( context.worldTransform().map( QPointF( prevDistance, maxZ ) ) ); - currentLine.append( context.worldTransform().map( QPointF( currentPartStartDistance, maxZ ) ) ); - currentLine.append( currentLine.at( 0 ) ); - mFillSymbol->renderPolygon( currentLine, nullptr, nullptr, context.renderContext() ); - break; - } - } + + checkLine( currentLine, context, minZ, maxZ, prevDistance, currentPartStartDistance ); switch ( symbology ) { From c633058bde0a7613f5f8285c413021966362815e Mon Sep 17 00:00:00 2001 From: bdm-oslandia Date: Wed, 2 Aug 2023 15:53:29 +0200 Subject: [PATCH 03/26] fix(elevation_profile): switch to no buffer profile computation when tolerance is 0. geos does not handle very well buffer with 0 size. --- src/core/vector/qgsvectorlayerprofilegenerator.cpp | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/core/vector/qgsvectorlayerprofilegenerator.cpp b/src/core/vector/qgsvectorlayerprofilegenerator.cpp index 3057d848f812..9e7006f2eaa1 100644 --- a/src/core/vector/qgsvectorlayerprofilegenerator.cpp +++ b/src/core/vector/qgsvectorlayerprofilegenerator.cpp @@ -750,7 +750,15 @@ bool QgsVectorLayerProfileGenerator::generateProfile( const QgsProfileGeneration mProfileCurveEngine.reset( new QgsGeos( mProfileCurve.get() ) ); mProfileCurveEngine->prepareGeometry(); - mProfileBox = std::unique_ptr( mProfileCurveEngine->buffer( mTolerance, 8, Qgis::EndCapStyle::Flat, Qgis::JoinStyle::Round, 2 ) ); + if ( mTolerance == 0.0 ) // geos does not handle very well buffer with 0 size + { + mProfileBox = std::unique_ptr( mProfileCurve->clone() ); + } + else + { + mProfileBox = std::unique_ptr( mProfileCurveEngine->buffer( mTolerance, 8, Qgis::EndCapStyle::Flat, Qgis::JoinStyle::Round, 2 ) ); + } + mProfileBoxEngine.reset( new QgsGeos( mProfileBox.get() ) ); mProfileBoxEngine->prepareGeometry(); From ea756df6011b5068d5f5199064e6878502bc20e2 Mon Sep 17 00:00:00 2001 From: bdm-oslandia Date: Wed, 2 Aug 2023 15:58:17 +0200 Subject: [PATCH 04/26] fix(elevation_profile): fix how point in mDistanceToHeightMap are generated by checking useless/too near points. --- .../vector/qgsvectorlayerprofilegenerator.cpp | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/core/vector/qgsvectorlayerprofilegenerator.cpp b/src/core/vector/qgsvectorlayerprofilegenerator.cpp index 9e7006f2eaa1..5dbda3879587 100644 --- a/src/core/vector/qgsvectorlayerprofilegenerator.cpp +++ b/src/core/vector/qgsvectorlayerprofilegenerator.cpp @@ -940,7 +940,8 @@ bool QgsVectorLayerProfileGenerator::generateProfileForLines() QString lastError; QgsVectorLayerProfileResults::Feature resultFeature; resultFeature.featureId = feature.id(); - double lastDistanceAlongProfileCurve = 0.0; + double lastDistanceAlongProfileCurve = std::numeric_limits::lowest(); + double lastHeight = std::numeric_limits::lowest(); for ( auto it = intersectionCurve.vertices_begin(); it != intersectionCurve.vertices_end(); ++it ) { @@ -954,14 +955,19 @@ bool QgsVectorLayerProfileGenerator::generateProfileForLines() const QgsPoint *interpolatedPoint = &intersectionPoint; const double offset = mDataDefinedProperties.valueAsDouble( QgsMapLayerElevationProperties::Property::ZOffset, mExpressionContext, mOffset ); - const double height = featureZToHeight( interpolatedPoint->x(), interpolatedPoint->y(), interpolatedPoint->z(), offset ); + const double distanceAlongProfileCurve = mProfileCurveEngine->lineLocatePoint( *interpolatedPoint, &error ); + + if ( qgsDoubleNear( lastHeight, height ) && qgsDoubleNear( lastDistanceAlongProfileCurve, distanceAlongProfileCurve ) ) + continue; // useless point + + lastHeight = height; + lastDistanceAlongProfileCurve = distanceAlongProfileCurve; + mResults->mRawPoints.append( QgsPoint( interpolatedPoint->x(), interpolatedPoint->y(), height ) ); mResults->minZ = std::min( mResults->minZ, height ); mResults->maxZ = std::max( mResults->maxZ, height ); - const double distanceAlongProfileCurve = mProfileCurveEngine->lineLocatePoint( *interpolatedPoint, &error ); - lastDistanceAlongProfileCurve = distanceAlongProfileCurve; mResults->mDistanceToHeightMap.insert( distanceAlongProfileCurve, height ); if ( mExtrusionEnabled ) @@ -982,6 +988,9 @@ bool QgsVectorLayerProfileGenerator::generateProfileForLines() } } + if ( mResults->mDistanceToHeightMap.empty() ) + return; // no usefull point found + mResults->mDistanceToHeightMap.insert( lastDistanceAlongProfileCurve + 0.001, qQNaN() ); resultFeature.geometry = transformedParts.size() > 1 ? QgsGeometry::unaryUnion( transformedParts ) : transformedParts.value( 0 ); From 4644266dcdf2645e74b3e87dcf42b0b0e1a80d57 Mon Sep 17 00:00:00 2001 From: bdm-oslandia Date: Wed, 2 Aug 2023 15:59:00 +0200 Subject: [PATCH 05/26] fix(elevation_profile): decrease offset position for nan (end of line) points from 0.001 to 0.000001. --- src/core/vector/qgsvectorlayerprofilegenerator.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/vector/qgsvectorlayerprofilegenerator.cpp b/src/core/vector/qgsvectorlayerprofilegenerator.cpp index 5dbda3879587..bb2bc9b2f20e 100644 --- a/src/core/vector/qgsvectorlayerprofilegenerator.cpp +++ b/src/core/vector/qgsvectorlayerprofilegenerator.cpp @@ -991,7 +991,7 @@ bool QgsVectorLayerProfileGenerator::generateProfileForLines() if ( mResults->mDistanceToHeightMap.empty() ) return; // no usefull point found - mResults->mDistanceToHeightMap.insert( lastDistanceAlongProfileCurve + 0.001, qQNaN() ); + mResults->mDistanceToHeightMap.insert( lastDistanceAlongProfileCurve + 0.000001, qQNaN() ); resultFeature.geometry = transformedParts.size() > 1 ? QgsGeometry::unaryUnion( transformedParts ) : transformedParts.value( 0 ); if ( !crossSectionParts.empty() ) From 3b161c36ab519a618e27601e43d91e151b57a828 Mon Sep 17 00:00:00 2001 From: bdm-oslandia Date: Tue, 23 May 2023 18:50:04 +0200 Subject: [PATCH 06/26] refactor(elevation_profile): simplify mFeedback->isCanceled() and g.isMultipart() checks --- .../vector/qgsvectorlayerprofilegenerator.cpp | 47 ++++--------------- 1 file changed, 10 insertions(+), 37 deletions(-) diff --git a/src/core/vector/qgsvectorlayerprofilegenerator.cpp b/src/core/vector/qgsvectorlayerprofilegenerator.cpp index bb2bc9b2f20e..8f6ba697ce4b 100644 --- a/src/core/vector/qgsvectorlayerprofilegenerator.cpp +++ b/src/core/vector/qgsvectorlayerprofilegenerator.cpp @@ -857,24 +857,14 @@ bool QgsVectorLayerProfileGenerator::generateProfileForPoints() QgsFeature feature; QgsFeatureIterator it = mSource->getFeatures( request ); - while ( it.nextFeature( feature ) ) + while ( !mFeedback->isCanceled() && it.nextFeature( feature ) ) { - if ( mFeedback->isCanceled() ) - return false; - mExpressionContext.setFeature( feature ); const QgsGeometry g = feature.geometry(); - if ( g.isMultipart() ) - { - for ( auto it = g.const_parts_begin(); it != g.const_parts_end(); ++it ) - { - processPoint( feature, qgsgeometry_cast< const QgsPoint * >( *it ) ); - } - } - else + for ( auto it = g.const_parts_begin(); !mFeedback->isCanceled() && it != g.const_parts_end(); ++it ) { - processPoint( feature, qgsgeometry_cast< const QgsPoint * >( g.constGet() ) ); + processPoint( feature, qgsgeometry_cast< const QgsPoint * >( *it ) ); } } return true; @@ -1011,9 +1001,7 @@ bool QgsVectorLayerProfileGenerator::generateProfileForLines() QString error; std::unique_ptr< QgsAbstractGeometry > intersection( mProfileBoxEngine->intersection( curve, &error ) ); if ( !intersection ) - { return; - } if ( mFeedback->isCanceled() ) return; @@ -1029,13 +1017,10 @@ bool QgsVectorLayerProfileGenerator::generateProfileForLines() QgsGeos curveGeos( curve ); curveGeos.prepareGeometry(); - if ( mFeedback->isCanceled() ) - return; - - for ( auto it = intersection->const_parts_begin(); it != intersection->const_parts_end(); ++it ) + for ( auto it = intersection->const_parts_begin(); + !mFeedback->isCanceled() && it != intersection->const_parts_end(); + ++it ) { - if ( mFeedback->isCanceled() ) - return; if ( const QgsPoint *intersectionPoint = qgsgeometry_cast< const QgsPoint * >( *it ) ) { processPoint( *intersectionPoint, curveGeos, feature ); @@ -1140,32 +1125,20 @@ bool QgsVectorLayerProfileGenerator::generateProfileForLines() QgsFeature feature; QgsFeatureIterator it = mSource->getFeatures( request ); - while ( it.nextFeature( feature ) ) + while ( !mFeedback->isCanceled() && it.nextFeature( feature ) ) { - if ( mFeedback->isCanceled() ) - return false; - - if ( !mProfileBoxEngine->intersects( feature.geometry().constGet() ) ) - continue; - mExpressionContext.setFeature( feature ); const QgsGeometry g = feature.geometry(); - if ( g.isMultipart() ) + for ( auto it = g.const_parts_begin(); !mFeedback->isCanceled() && it != g.const_parts_end(); ++it ) { - for ( auto it = g.const_parts_begin(); it != g.const_parts_end(); ++it ) + if ( mProfileBoxEngine->intersects( *it ) ) { - if ( !mProfileBoxEngine->intersects( *it ) ) - continue; - processCurve( feature, qgsgeometry_cast< const QgsCurve * >( *it ) ); } } - else - { - processCurve( feature, qgsgeometry_cast< const QgsCurve * >( g.constGet() ) ); - } } + return true; } From fa12235b5b81d0d43c2a9a88ab20440b111adfbe Mon Sep 17 00:00:00 2001 From: bdm-oslandia Date: Wed, 12 Jul 2023 12:23:25 +0200 Subject: [PATCH 07/26] refactor(elevation_profile): refactor lambda to function to reduce duplicates and increase readability --- .../vector/qgsvectorlayerprofilegenerator.cpp | 257 ++++++++---------- .../vector/qgsvectorlayerprofilegenerator.h | 3 + 2 files changed, 109 insertions(+), 151 deletions(-) diff --git a/src/core/vector/qgsvectorlayerprofilegenerator.cpp b/src/core/vector/qgsvectorlayerprofilegenerator.cpp index 8f6ba697ce4b..0c6de3a90803 100644 --- a/src/core/vector/qgsvectorlayerprofilegenerator.cpp +++ b/src/core/vector/qgsvectorlayerprofilegenerator.cpp @@ -818,56 +818,123 @@ bool QgsVectorLayerProfileGenerator::generateProfileForPoints() QgsGeos bufferedCurveEngine( bufferedCurve.get() ); bufferedCurveEngine.prepareGeometry(); - auto processPoint = [this, &bufferedCurveEngine]( const QgsFeature & feature, const QgsPoint * point ) + QgsFeature feature; + QgsFeatureIterator it = mSource->getFeatures( request ); + while ( !mFeedback->isCanceled() && it.nextFeature( feature ) ) { - if ( !bufferedCurveEngine.intersects( point ) ) - return; + mExpressionContext.setFeature( feature ); + + const QgsGeometry g = feature.geometry(); + for ( auto it = g.const_parts_begin(); !mFeedback->isCanceled() && it != g.const_parts_end(); ++it ) + { + if ( bufferedCurveEngine.intersects( *it ) ) + { + processIntersectionPoint( qgsgeometry_cast< const QgsPoint * >( *it ), feature ); + } + } + } + return true; +} + +void QgsVectorLayerProfileGenerator::processIntersectionPoint( const QgsPoint *point, const QgsFeature &feature ) +{ + QString error; + const double offset = mDataDefinedProperties.valueAsDouble( QgsMapLayerElevationProperties::Property::ZOffset, mExpressionContext, mOffset ); + + const double height = featureZToHeight( point->x(), point->y(), point->z(), offset ); + mResults->mRawPoints.append( QgsPoint( point->x(), point->y(), height ) ); + mResults->minZ = std::min( mResults->minZ, height ); + mResults->maxZ = std::max( mResults->maxZ, height ); + + const double distanceAlongProfileCurve = mProfileCurveEngine->lineLocatePoint( *point, &error ); + mResults->mDistanceToHeightMap.insert( distanceAlongProfileCurve, height ); + + QgsVectorLayerProfileResults::Feature resultFeature; + resultFeature.featureId = feature.id(); + if ( mExtrusionEnabled ) + { + const double extrusion = mDataDefinedProperties.valueAsDouble( QgsMapLayerElevationProperties::Property::ExtrusionHeight, mExpressionContext, mExtrusionHeight ); + + resultFeature.geometry = QgsGeometry( new QgsLineString( QgsPoint( point->x(), point->y(), height ), + QgsPoint( point->x(), point->y(), height + extrusion ) ) ); + resultFeature.crossSectionGeometry = QgsGeometry( new QgsLineString( QgsPoint( distanceAlongProfileCurve, height ), + QgsPoint( distanceAlongProfileCurve, height + extrusion ) ) ); + mResults->minZ = std::min( mResults->minZ, height + extrusion ); + mResults->maxZ = std::max( mResults->maxZ, height + extrusion ); + } + else + { + resultFeature.geometry = QgsGeometry( new QgsPoint( point->x(), point->y(), height ) ); + resultFeature.crossSectionGeometry = QgsGeometry( new QgsPoint( distanceAlongProfileCurve, height ) ); + } + + mResults->features[resultFeature.featureId].append( resultFeature ); +} + +void QgsVectorLayerProfileGenerator::processIntersectionCurve( const QgsLineString *intersectionCurve, const QgsFeature &feature ) +{ + QString error; + QVector< QgsGeometry > transformedParts; + QVector< QgsGeometry > crossSectionParts; + + QgsVectorLayerProfileResults::Feature resultFeature; + resultFeature.featureId = feature.id(); + double maxDistanceAlongProfileCurve = std::numeric_limits::lowest(); + + for ( auto it = intersectionCurve->vertices_begin(); + !mFeedback->isCanceled() && it != intersectionCurve->vertices_end(); + ++it ) + { + const QgsPoint &intersectionPoint = *it; const double offset = mDataDefinedProperties.valueAsDouble( QgsMapLayerElevationProperties::Property::ZOffset, mExpressionContext, mOffset ); + const double height = featureZToHeight( intersectionPoint.x(), intersectionPoint.y(), intersectionPoint.z(), offset ); + const double distanceAlongProfileCurve = mProfileCurveEngine->lineLocatePoint( intersectionPoint, &error ); - const double height = featureZToHeight( point->x(), point->y(), point->z(), offset ); - mResults->mRawPoints.append( QgsPoint( point->x(), point->y(), height ) ); + maxDistanceAlongProfileCurve = std::max( maxDistanceAlongProfileCurve, distanceAlongProfileCurve ); + + mResults->mRawPoints.append( QgsPoint( intersectionPoint.x(), intersectionPoint.y(), height ) ); mResults->minZ = std::min( mResults->minZ, height ); mResults->maxZ = std::max( mResults->maxZ, height ); - QString lastError; - const double distance = mProfileCurveEngine->lineLocatePoint( *point, &lastError ); - mResults->mDistanceToHeightMap.insert( distance, height ); + mResults->mDistanceToHeightMap.insert( distanceAlongProfileCurve, height ); - QgsVectorLayerProfileResults::Feature resultFeature; - resultFeature.featureId = feature.id(); if ( mExtrusionEnabled ) { const double extrusion = mDataDefinedProperties.valueAsDouble( QgsMapLayerElevationProperties::Property::ExtrusionHeight, mExpressionContext, mExtrusionHeight ); - resultFeature.geometry = QgsGeometry( new QgsLineString( QgsPoint( point->x(), point->y(), height ), - QgsPoint( point->x(), point->y(), height + extrusion ) ) ); - resultFeature.crossSectionGeometry = QgsGeometry( new QgsLineString( QgsPoint( distance, height ), - QgsPoint( distance, height + extrusion ) ) ); + transformedParts.append( QgsGeometry( new QgsLineString( QgsPoint( intersectionPoint.x(), intersectionPoint.y(), height ), + QgsPoint( intersectionPoint.x(), intersectionPoint.y(), height + extrusion ) ) ) ); + crossSectionParts.append( QgsGeometry( new QgsLineString( QgsPoint( distanceAlongProfileCurve, height ), + QgsPoint( distanceAlongProfileCurve, height + extrusion ) ) ) ); mResults->minZ = std::min( mResults->minZ, height + extrusion ); mResults->maxZ = std::max( mResults->maxZ, height + extrusion ); } else { - resultFeature.geometry = QgsGeometry( new QgsPoint( point->x(), point->y(), height ) ); - resultFeature.crossSectionGeometry = QgsGeometry( new QgsPoint( distance, height ) ); + transformedParts.append( QgsGeometry( new QgsPoint( intersectionPoint.x(), intersectionPoint.y(), height ) ) ); + crossSectionParts.append( QgsGeometry( new QgsPoint( distanceAlongProfileCurve, height ) ) ); } - mResults->features[resultFeature.featureId].append( resultFeature ); - }; + } - QgsFeature feature; - QgsFeatureIterator it = mSource->getFeatures( request ); - while ( !mFeedback->isCanceled() && it.nextFeature( feature ) ) - { - mExpressionContext.setFeature( feature ); + if ( mResults->mDistanceToHeightMap.empty() ) + return; // no usefull point found - const QgsGeometry g = feature.geometry(); - for ( auto it = g.const_parts_begin(); !mFeedback->isCanceled() && it != g.const_parts_end(); ++it ) + mResults->mDistanceToHeightMap.insert( maxDistanceAlongProfileCurve + 0.000001, qQNaN() ); + + resultFeature.geometry = transformedParts.size() > 1 ? QgsGeometry::unaryUnion( transformedParts ) : transformedParts.value( 0 ); + if ( !crossSectionParts.empty() ) + { + //Create linestring from point list + QgsLineString *line = new QgsLineString(); + for ( auto it = crossSectionParts.begin(); it != crossSectionParts.end(); ++it ) { - processPoint( feature, qgsgeometry_cast< const QgsPoint * >( *it ) ); + line->addVertex( QgsPoint( ( *it ).asPoint() ) ); } + resultFeature.crossSectionGeometry = QgsGeometry( line ); } - return true; + + mResults->features[resultFeature.featureId].append( resultFeature ); } bool QgsVectorLayerProfileGenerator::generateProfileForLines() @@ -879,127 +946,10 @@ bool QgsVectorLayerProfileGenerator::generateProfileForLines() request.setSubsetOfAttributes( mDataDefinedProperties.referencedFields( mExpressionContext ), mFields ); request.setFeedback( mFeedback.get() ); - auto processCurve = [this]( const QgsFeature & feature, const QgsCurve * curve ) + auto processCurve = [this]( const QgsFeature & feature, const QgsCurve * featGeomPart ) { - auto processPoint = [this, curve]( const QgsPoint & intersectionPoint, const QgsGeos & curveGeos, const QgsFeature & feature ) - { - - QString error; - - // unfortunately we need to do some work to interpolate the z value for the line -- GEOS doesn't give us this - const double distance = curveGeos.lineLocatePoint( intersectionPoint, &error ); - std::unique_ptr< QgsPoint > interpolatedPoint( curve->interpolatePoint( distance ) ); - - const double offset = mDataDefinedProperties.valueAsDouble( QgsMapLayerElevationProperties::Property::ZOffset, mExpressionContext, mOffset ); - - const double height = featureZToHeight( interpolatedPoint->x(), interpolatedPoint->y(), interpolatedPoint->z(), offset ); - mResults->mRawPoints.append( QgsPoint( interpolatedPoint->x(), interpolatedPoint->y(), height ) ); - mResults->minZ = std::min( mResults->minZ, height ); - mResults->maxZ = std::max( mResults->maxZ, height ); - - const double distanceAlongProfileCurve = mProfileCurveEngine->lineLocatePoint( *interpolatedPoint, &error ); - mResults->mDistanceToHeightMap.insert( distanceAlongProfileCurve, height ); - - QgsVectorLayerProfileResults::Feature resultFeature; - resultFeature.featureId = feature.id(); - if ( mExtrusionEnabled ) - { - const double extrusion = mDataDefinedProperties.valueAsDouble( QgsMapLayerElevationProperties::Property::ExtrusionHeight, mExpressionContext, mExtrusionHeight ); - - resultFeature.geometry = QgsGeometry( new QgsLineString( QgsPoint( interpolatedPoint->x(), interpolatedPoint->y(), height ), - QgsPoint( interpolatedPoint->x(), interpolatedPoint->y(), height + extrusion ) ) ); - resultFeature.crossSectionGeometry = QgsGeometry( new QgsLineString( QgsPoint( distanceAlongProfileCurve, height ), - QgsPoint( distanceAlongProfileCurve, height + extrusion ) ) ); - mResults->minZ = std::min( mResults->minZ, height + extrusion ); - mResults->maxZ = std::max( mResults->maxZ, height + extrusion ); - } - else - { - resultFeature.geometry = QgsGeometry( new QgsPoint( interpolatedPoint->x(), interpolatedPoint->y(), height ) ); - resultFeature.crossSectionGeometry = QgsGeometry( new QgsPoint( distanceAlongProfileCurve, height ) ); - } - mResults->features[resultFeature.featureId].append( resultFeature ); - }; - - auto processIntersectionCurve = [this, curve]( const QgsCurve & intersectionCurve, const QgsGeos & curveGeos, const QgsFeature & feature ) - { - QString error; - QVector< QgsGeometry > transformedParts; - QVector< QgsGeometry > crossSectionParts; - - QString lastError; - QgsVectorLayerProfileResults::Feature resultFeature; - resultFeature.featureId = feature.id(); - double lastDistanceAlongProfileCurve = std::numeric_limits::lowest(); - double lastHeight = std::numeric_limits::lowest(); - - for ( auto it = intersectionCurve.vertices_begin(); it != intersectionCurve.vertices_end(); ++it ) - { - const QgsPoint &intersectionPoint = ( *it ); - - - // JMK : GEOS is returning a valid height value in my current test - // Previous comment : unfortunately we need to do some work to interpolate the z value for the line -- GEOS doesn't give us this - // const double distance = curveGeos.lineLocatePoint( intersectionPoint, &error ); - // std::unique_ptr< QgsPoint > interpolatedPoint( curve->interpolatePoint( distance ) ); - const QgsPoint *interpolatedPoint = &intersectionPoint; - - const double offset = mDataDefinedProperties.valueAsDouble( QgsMapLayerElevationProperties::Property::ZOffset, mExpressionContext, mOffset ); - const double height = featureZToHeight( interpolatedPoint->x(), interpolatedPoint->y(), interpolatedPoint->z(), offset ); - const double distanceAlongProfileCurve = mProfileCurveEngine->lineLocatePoint( *interpolatedPoint, &error ); - - if ( qgsDoubleNear( lastHeight, height ) && qgsDoubleNear( lastDistanceAlongProfileCurve, distanceAlongProfileCurve ) ) - continue; // useless point - - lastHeight = height; - lastDistanceAlongProfileCurve = distanceAlongProfileCurve; - - mResults->mRawPoints.append( QgsPoint( interpolatedPoint->x(), interpolatedPoint->y(), height ) ); - mResults->minZ = std::min( mResults->minZ, height ); - mResults->maxZ = std::max( mResults->maxZ, height ); - - mResults->mDistanceToHeightMap.insert( distanceAlongProfileCurve, height ); - - if ( mExtrusionEnabled ) - { - const double extrusion = mDataDefinedProperties.valueAsDouble( QgsMapLayerElevationProperties::Property::ExtrusionHeight, mExpressionContext, mExtrusionHeight ); - - transformedParts.append( QgsGeometry( new QgsLineString( QgsPoint( interpolatedPoint->x(), interpolatedPoint->y(), height ), - QgsPoint( interpolatedPoint->x(), interpolatedPoint->y(), height + extrusion ) ) ) ); - crossSectionParts.append( QgsGeometry( new QgsLineString( QgsPoint( distanceAlongProfileCurve, height ), - QgsPoint( distanceAlongProfileCurve, height + extrusion ) ) ) ); - mResults->minZ = std::min( mResults->minZ, height + extrusion ); - mResults->maxZ = std::max( mResults->maxZ, height + extrusion ); - } - else - { - transformedParts.append( QgsGeometry( new QgsPoint( interpolatedPoint->x(), interpolatedPoint->y(), height ) ) ); - crossSectionParts.append( QgsGeometry( new QgsPoint( distanceAlongProfileCurve, height ) ) ); - } - } - - if ( mResults->mDistanceToHeightMap.empty() ) - return; // no usefull point found - - mResults->mDistanceToHeightMap.insert( lastDistanceAlongProfileCurve + 0.000001, qQNaN() ); - - resultFeature.geometry = transformedParts.size() > 1 ? QgsGeometry::unaryUnion( transformedParts ) : transformedParts.value( 0 ); - if ( !crossSectionParts.empty() ) - { - //Create linestring from point list - QgsLineString *line = new QgsLineString(); - for ( auto it = crossSectionParts.begin(); it != crossSectionParts.end(); ++it ) - { - line->addVertex( QgsPoint( ( *it ).asPoint() ) ); - } - resultFeature.crossSectionGeometry = QgsGeometry( line ); - } - - mResults->features[resultFeature.featureId].append( resultFeature ); - }; - QString error; - std::unique_ptr< QgsAbstractGeometry > intersection( mProfileBoxEngine->intersection( curve, &error ) ); + std::unique_ptr< QgsAbstractGeometry > intersection( mProfileBoxEngine->intersection( featGeomPart, &error ) ); if ( !intersection ) return; @@ -1014,8 +964,8 @@ bool QgsVectorLayerProfileGenerator::generateProfileForLines() intersection.reset( feature.geometry().constGet()->clone() ); } - QgsGeos curveGeos( curve ); - curveGeos.prepareGeometry(); + QgsGeos featGeomPartGeos( featGeomPart ); + featGeomPartGeos.prepareGeometry(); for ( auto it = intersection->const_parts_begin(); !mFeedback->isCanceled() && it != intersection->const_parts_end(); @@ -1023,11 +973,16 @@ bool QgsVectorLayerProfileGenerator::generateProfileForLines() { if ( const QgsPoint *intersectionPoint = qgsgeometry_cast< const QgsPoint * >( *it ) ) { - processPoint( *intersectionPoint, curveGeos, feature ); + // unfortunately we need to do some work to interpolate the z value for the line -- GEOS doesn't give us this + QString error; + const double distance = featGeomPartGeos.lineLocatePoint( *intersectionPoint, &error ); + std::unique_ptr< QgsPoint > interpolatedPoint( featGeomPart->interpolatePoint( distance ) ); + + processIntersectionPoint( interpolatedPoint.get(), feature ); } - else if ( const QgsCurve *intersectionCurve = qgsgeometry_cast< const QgsCurve * >( *it ) ) + else if ( const QgsLineString *intersectionCurve = qgsgeometry_cast< const QgsLineString * >( *it ) ) { - processIntersectionCurve( *intersectionCurve, curveGeos, feature ); + processIntersectionCurve( intersectionCurve, feature ); } else if ( const QgsLineString *ls = qgsgeometry_cast< const QgsLineString * >( *it ) ) { diff --git a/src/core/vector/qgsvectorlayerprofilegenerator.h b/src/core/vector/qgsvectorlayerprofilegenerator.h index 73f1fdcb2927..046c4407c99a 100644 --- a/src/core/vector/qgsvectorlayerprofilegenerator.h +++ b/src/core/vector/qgsvectorlayerprofilegenerator.h @@ -124,6 +124,9 @@ class CORE_EXPORT QgsVectorLayerProfileGenerator : public QgsAbstractProfileSurf bool generateProfileForLines(); bool generateProfileForPolygons(); + void processIntersectionPoint( const QgsPoint *intersectionPoint, const QgsFeature &feature ); + void processIntersectionCurve( const QgsLineString *intersectionCurve, const QgsFeature &feature ); + double terrainHeight( double x, double y ); double featureZToHeight( double x, double y, double z, double offset ); From 6a95a2bdf66f780038f41cd1360a0f1dd85b24bc Mon Sep 17 00:00:00 2001 From: bdm-oslandia Date: Fri, 26 May 2023 11:15:38 +0200 Subject: [PATCH 08/26] fix(elevation_profile): fix and add python tests to handle tolerance for line --- .../vector/qgsvectorlayerprofilegenerator.cpp | 5 +- .../python/test_qgslayoutelevationprofile.py | 76 +++++++ .../test_qgsvectorlayerprofilegenerator.py | 200 +++++++++++++++++- ...ected_vector_layer_map_units_tolerance.png | Bin 0 -> 10418 bytes ..._as_fill_above_surface_limit_tolerance.png | Bin 0 -> 1414 bytes ...ted_vector_profile_map_units_tolerance.png | Bin 0 -> 1010 bytes 6 files changed, 276 insertions(+), 5 deletions(-) create mode 100644 tests/testdata/control_images/layout_profile/expected_vector_layer_map_units_tolerance/expected_vector_layer_map_units_tolerance.png create mode 100644 tests/testdata/control_images/profile_chart/expected_vector_lines_as_fill_above_surface_limit_tolerance/expected_vector_lines_as_fill_above_surface_limit_tolerance.png create mode 100644 tests/testdata/control_images/profile_chart/expected_vector_profile_map_units_tolerance/expected_vector_profile_map_units_tolerance.png diff --git a/src/core/vector/qgsvectorlayerprofilegenerator.cpp b/src/core/vector/qgsvectorlayerprofilegenerator.cpp index 0c6de3a90803..ea1368700046 100644 --- a/src/core/vector/qgsvectorlayerprofilegenerator.cpp +++ b/src/core/vector/qgsvectorlayerprofilegenerator.cpp @@ -814,9 +814,6 @@ bool QgsVectorLayerProfileGenerator::generateProfileForPoints() // our feature request is using the optimised distance within check (allowing use of spatial index) // BUT this will also include points which are within the tolerance distance before/after the end of line. // So we also need to double check that they fall within the flat buffered curve too. - std::unique_ptr< QgsAbstractGeometry > bufferedCurve( mProfileCurveEngine->buffer( mTolerance, 8, Qgis::EndCapStyle::Flat, Qgis::JoinStyle::Round, 2 ) ); - QgsGeos bufferedCurveEngine( bufferedCurve.get() ); - bufferedCurveEngine.prepareGeometry(); QgsFeature feature; QgsFeatureIterator it = mSource->getFeatures( request ); @@ -827,7 +824,7 @@ bool QgsVectorLayerProfileGenerator::generateProfileForPoints() const QgsGeometry g = feature.geometry(); for ( auto it = g.const_parts_begin(); !mFeedback->isCanceled() && it != g.const_parts_end(); ++it ) { - if ( bufferedCurveEngine.intersects( *it ) ) + if ( mProfileBoxEngine->intersects( *it ) ) { processIntersectionPoint( qgsgeometry_cast< const QgsPoint * >( *it ), feature ); } diff --git a/tests/src/python/test_qgslayoutelevationprofile.py b/tests/src/python/test_qgslayoutelevationprofile.py index d0e6a5d86d31..0465eeafef41 100644 --- a/tests/src/python/test_qgslayoutelevationprofile.py +++ b/tests/src/python/test_qgslayoutelevationprofile.py @@ -795,6 +795,82 @@ def test_draw_zero_label_interval(self): 'zero_label_interval', layout )) + def test_draw_map_units_tolerance(self): + """ + Test rendering the layout profile item using symbols with map unit sizes + """ + vl = QgsVectorLayer('LineStringZ?crs=EPSG:27700', 'lines', 'memory') + vl.setCrs(QgsCoordinateReferenceSystem()) + self.assertTrue(vl.isValid()) + + for line in [ + 'LineStringZ (321829.48893365426920354 129991.38697145861806348 1, 321847.89668515208177269 129996.63588572069420479 1, 321848.97131609614007175 129979.22330882755341008 1, 321830.31725845142500475 129978.07136809575604275 1, 321829.48893365426920354 129991.38697145861806348 1)', + 'LineStringZ (321920.00953056826256216 129924.58260190498549491 2, 321924.65299345907988027 129908.43546159457764588 2, 321904.78543491888558492 129903.99811821122420952 2, 321900.80605239619035274 129931.39860145389684476 2, 321904.84799937985371798 129931.71552911199978553 2, 321908.93646715773502365 129912.90030360443051904 2, 321914.20495146053144708 129913.67693978428724222 2, 321911.30165811872575432 129923.01272751353099011 2, 321920.00953056826256216 129924.58260190498549491 2)', + 'LineStringZ (321923.10517279652412981 129919.61521573827485554 3, 321922.23537852568551898 129928.3598982143739704 3, 321928.60423935484141111 129934.22530528216157109 3, 321929.39881197665818036 129923.29054521876969375 3, 321930.55804549407912418 129916.53248518184409477 3, 321923.10517279652412981 129919.61521573827485554 3)', + 'LineStringZ (321990.47451346553862095 129909.63588680300745182 4, 321995.04325810901354998 129891.84052284323843196 4, 321989.66826330573530868 129890.5092018858413212 4, 321990.78512359503656626 129886.49917887404444627 4, 321987.37291929306229576 129885.64982962771318853 4, 321985.2254804756375961 129893.81317058412241749 4, 321987.63158903241856024 129894.41078495365218259 4, 321984.34022761805681512 129907.57450046355370432 4, 321990.47451346553862095 129909.63588680300745182 4)', + 'LineStringZ (322103.03910495212767273 129795.91051736124791205 5, 322108.25568856322206557 129804.76113295342656784 5, 322113.29666162584908307 129803.9285887333098799 5, 322117.78645010641776025 129794.48194090687320568 5, 322103.03910495212767273 129795.91051736124791205 5)']: + f = QgsFeature() + f.setGeometry(QgsGeometry.fromWkt(line)) + self.assertTrue(vl.dataProvider().addFeature(f)) + + vl.elevationProperties().setClamping(Qgis.AltitudeClamping.Absolute) + line_symbol = QgsLineSymbol.createSimple({'color': '#ff00ff', 'width': '0.8'}) + line_symbol.setWidthUnit(Qgis.RenderUnit.MapUnits) + vl.elevationProperties().setProfileLineSymbol(line_symbol) + vl.elevationProperties().setRespectLayerSymbology(False) + + p = QgsProject() + p.addMapLayer(vl) + layout = QgsLayout(p) + layout.initializeDefaults() + + profile_item = QgsLayoutItemElevationProfile(layout) + layout.addLayoutItem(profile_item) + profile_item.attemptSetSceneRect(QRectF(10, 10, 180, 180)) + + curve = QgsLineString() + curve.fromWkt( + 'LineString (321897.18831187387695536 129916.86947759155009408, 321942.11597351566888392 129924.94403429214435164)') + + profile_item.setProfileCurve(curve) + profile_item.setCrs(QgsCoordinateReferenceSystem()) + + profile_item.plot().setXMaximum(curve.length()) + profile_item.plot().setYMaximum(14) + + profile_item.plot().xAxis().setGridIntervalMajor(10) + profile_item.plot().xAxis().setGridIntervalMinor(5) + profile_item.plot().xAxis().setGridMajorSymbol(QgsLineSymbol.createSimple({'color': '#ffaaff', 'width': 2})) + profile_item.plot().xAxis().setGridMinorSymbol( + QgsLineSymbol.createSimple({'color': '#ffffaa', 'width': 2})) + + format = QgsTextFormat() + format.setFont(QgsFontUtils.getStandardTestFont("Bold")) + format.setSize(20) + format.setNamedStyle("Bold") + format.setColor(QColor(0, 0, 0)) + profile_item.plot().xAxis().setTextFormat(format) + profile_item.plot().xAxis().setLabelInterval(20) + + profile_item.plot().yAxis().setGridIntervalMajor(10) + profile_item.plot().yAxis().setGridIntervalMinor(5) + profile_item.plot().yAxis().setGridMajorSymbol(QgsLineSymbol.createSimple({'color': '#ffffaa', 'width': 2})) + profile_item.plot().yAxis().setGridMinorSymbol( + QgsLineSymbol.createSimple({'color': '#aaffaa', 'width': 2})) + + profile_item.plot().yAxis().setTextFormat(format) + profile_item.plot().yAxis().setLabelInterval(10) + + profile_item.plot().setChartBorderSymbol( + QgsFillSymbol.createSimple({'style': 'no', 'color': '#aaffaa', 'width_border': 2})) + + profile_item.setTolerance(1) + profile_item.setLayers([vl]) + + self.assertTrue(self.render_layout_check( + 'vector_layer_map_units_tolerance', layout + )) + if __name__ == '__main__': unittest.main() diff --git a/tests/src/python/test_qgsvectorlayerprofilegenerator.py b/tests/src/python/test_qgsvectorlayerprofilegenerator.py index bc3a96b883eb..afd78e0720eb 100644 --- a/tests/src/python/test_qgsvectorlayerprofilegenerator.py +++ b/tests/src/python/test_qgsvectorlayerprofilegenerator.py @@ -10,6 +10,7 @@ __copyright__ = 'Copyright 2022, The QGIS Project' import os +import math from qgis.PyQt.QtCore import QDir from qgis.core import ( @@ -54,7 +55,7 @@ def control_path_prefix(cls): @staticmethod def round_dict(val, places): - return {round(k, places): round(val[k], places) for k in sorted(val.keys())} + return {round(k, places): round(val[k], places) for k in val.keys() if not math.isnan(val[k])} def create_transform_context(self): context = QgsCoordinateTransformContext() @@ -172,6 +173,7 @@ def testPointGenerationTerrain(self): results = generator.takeResults() self.assertFalse(results.distanceToHeightMap()) + # 15 meters tolerance req.setTolerance(15) generator = vl.createProfileGenerator(req) self.assertTrue(generator.generateProfile()) @@ -188,6 +190,23 @@ def testPointGenerationTerrain(self): self.assertAlmostEqual(results.zRange().lower(), 52.25, 2) self.assertAlmostEqual(results.zRange().upper(), 67.25, 2) + # 70 meters tolerance + req.setTolerance(70) + generator = vl.createProfileGenerator(req) + self.assertTrue(generator.generateProfile()) + results = generator.takeResults() + + if QgsProjUtils.projVersionMajor() >= 8: + self.assertEqual(self.round_dict(results.distanceToHeightMap(), 1), + {175.6: 69.5, 31.2: 69.5, 1223.2: 56.8, 1242.5: 55.2}) + self.assertAlmostEqual(results.zRange().lower(), 55.249, 2) + self.assertAlmostEqual(results.zRange().upper(), 69.5, 2) + else: + self.assertEqual(self.round_dict(results.distanceToHeightMap(), 1), + {31.2: 67.2, 175.6: 65.8, 1242.5: 52.2}) + self.assertAlmostEqual(results.zRange().lower(), 52.25, 2) + self.assertAlmostEqual(results.zRange().upper(), 67.25, 2) + def testPointGenerationRelative(self): """ Points layer with relative clamping @@ -502,6 +521,106 @@ def testLineGenerationTerrain(self): self.assertAlmostEqual(results.zRange().lower(), 52.25, 2) self.assertAlmostEqual(results.zRange().upper(), 62.7499, 2) + def testLineGenerationTerrainTolerance(self): + vl = QgsVectorLayer('LineStringZ?crs=EPSG:27700', 'lines', 'memory') + self.assertTrue(vl.isValid()) + + for line in ['LineStringZ(322006 129874 12, 322008 129910 13, 322038 129909 14, 322037 129868 15)', + 'LineStringZ(322068 129900 16, 322128 129813 17)', + 'LineStringZ(321996 129914 11, 321990 129896 15)', + 'LineStringZ(321595 130176 1, 321507 130104 10)', + 'LineStringZ(321558 129930 1, 321568 130029 10, 321516 130049 5)', + 'LineStringZ(321603 129967 3, 321725 130042 9)']: + f = QgsFeature() + f.setGeometry(QgsGeometry.fromWkt(line)) + self.assertTrue(vl.dataProvider().addFeature(f)) + + vl.elevationProperties().setClamping(Qgis.AltitudeClamping.Terrain) + vl.elevationProperties().setZScale(2.5) + vl.elevationProperties().setZOffset(10) + + curve = QgsLineString() + curve.fromWkt( + 'LineString (-347692.88994020794052631 6632796.97473032586276531, -346576.99897185183363035 6632367.38372825458645821, -346396.02439485350623727 6632344.35087973903864622, -346374.34608158958144486 6632220.09952207934111357)') + req = QgsProfileRequest(curve) + req.setTransformContext(self.create_transform_context()) + + rl = QgsRasterLayer(os.path.join(unitTestDataPath(), '3d', 'dtm.tif'), 'DTM') + self.assertTrue(rl.isValid()) + terrain_provider = QgsRasterDemTerrainProvider() + terrain_provider.setLayer(rl) + terrain_provider.setScale(0.3) + terrain_provider.setOffset(-5) + req.setTerrainProvider(terrain_provider) + + req.setCrs(QgsCoordinateReferenceSystem('EPSG:3857')) + + # very small tolerance + req.setTolerance(0.1) + generator = vl.createProfileGenerator(req) + self.assertTrue(generator.generateProfile()) + results = generator.takeResults() + + if QgsProjUtils.projVersionMajor() >= 8: + self.assertEqual(self.round_dict(results.distanceToHeightMap(), 2), + {675.09: 66.5, 675.24: 66.5, 1195.73: 49.25, 1195.74: 49.25, 1223.14: 50.0, + 1223.15: 50.0, 1271.97: 53.75, 1271.99: 53.75, 1339.33: 58.25, 1339.51: 58.25, + 1444.18: 58.25, 1444.59: 58.25}) + self.assertAlmostEqual(results.zRange().lower(), 49.25, 2) + self.assertAlmostEqual(results.zRange().upper(), 66.5, 2) + else: + # TODO find a way to test with an older proj version + self.assertEqual(self.round_dict(results.distanceToHeightMap(), 2), + {675.09: 66.5, 675.24: 66.5, 1195.73: 49.25, 1195.74: 49.25, 1223.14: 50.0, + 1223.15: 50.0, 1271.97: 53.75, 1271.99: 53.75, 1339.33: 58.25, 1339.51: 58.25, + 1444.18: 58.25, 1444.59: 58.25}) + self.assertAlmostEqual(results.zRange().lower(), 52.25, 2) + self.assertAlmostEqual(results.zRange().upper(), 62.7499, 2) + + # 1 meter tolerance + req.setTolerance(1) + generator = vl.createProfileGenerator(req) + self.assertTrue(generator.generateProfile()) + results = generator.takeResults() + + if QgsProjUtils.projVersionMajor() >= 8: + self.assertEqual(self.round_dict(results.distanceToHeightMap(), 2), + {674.43: 66.5, 675.91: 66.5, 1195.73: 49.25, 1195.91: 49.25, 1223.06: 50.0, + 1223.23: 50.0, 1271.86: 53.75, 1272.1: 53.75, 1338.5: 58.25, 1340.34: 58.25, + 1442.29: 56.75, 1446.48: 57.5}) + self.assertAlmostEqual(results.zRange().lower(), 49.25, 2) + self.assertAlmostEqual(results.zRange().upper(), 66.5, 2) + else: + # TODO find a way to test with an older proj version + self.assertEqual(self.round_dict(results.distanceToHeightMap(), 2), + {675.09: 66.5, 675.24: 66.5, 1195.73: 49.25, 1195.74: 49.25, 1223.14: 50.0, + 1223.15: 50.0, 1271.97: 53.75, 1271.99: 53.75, 1339.33: 58.25, 1339.51: 58.25, + 1444.18: 58.25, 1444.59: 58.25}) + self.assertAlmostEqual(results.zRange().lower(), 52.25, 2) + self.assertAlmostEqual(results.zRange().upper(), 62.7499, 2) + + # 15 meters tolerance + req.setTolerance(15) + generator = vl.createProfileGenerator(req) + self.assertTrue(generator.generateProfile()) + results = generator.takeResults() + + if QgsProjUtils.projVersionMajor() >= 8: + self.assertEqual(self.round_dict(results.distanceToHeightMap(), 2), + {664.1: 65.75, 686.24: 67.25, 1195.73: 49.25, 1198.44: 49.25, 1221.85: 50.0, + 1224.44: 49.25, 1270.21: 54.5, 1273.75: 53.0, 1325.61: 59.0, 1353.23: 57.5, + 1412.92: 56.0, 1475.85: 57.5}) + self.assertAlmostEqual(results.zRange().lower(), 49.25, 2) + self.assertAlmostEqual(results.zRange().upper(), 67.25, 2) + else: + # TODO find a way to test with an older proj version + self.assertEqual(self.round_dict(results.distanceToHeightMap(), 2), + {675.09: 66.5, 675.24: 66.5, 1195.73: 49.25, 1195.74: 49.25, 1223.14: 50.0, + 1223.15: 50.0, 1271.97: 53.75, 1271.99: 53.75, 1339.33: 58.25, 1339.51: 58.25, + 1444.18: 58.25, 1444.59: 58.25}) + self.assertAlmostEqual(results.zRange().lower(), 52.25, 2) + self.assertAlmostEqual(results.zRange().upper(), 62.7499, 2) + def testLineGenerationRelative(self): vl = QgsVectorLayer('LineStringZ?crs=EPSG:27700', 'lines', 'memory') self.assertTrue(vl.isValid()) @@ -1752,6 +1871,48 @@ def testRenderProfileAsSurfaceFillAboveLimit(self): res = plot_renderer.renderToImage(400, 400, 0, curve.length(), 0, 14) self.assertTrue(self.image_check('vector_lines_as_fill_above_surface_limit', 'vector_lines_as_fill_above_surface_limit', res)) + def testRenderProfileAsSurfaceFillAboveLimitTolerance(self): + vl = QgsVectorLayer('LineStringZ?crs=EPSG:27700', 'lines', 'memory') + vl.setCrs(QgsCoordinateReferenceSystem()) + self.assertTrue(vl.isValid()) + + for line in [ + 'LineStringZ (321829.48893365426920354 129991.38697145861806348 1, 321847.89668515208177269 129996.63588572069420479 1, 321848.97131609614007175 129979.22330882755341008 1, 321830.31725845142500475 129978.07136809575604275 1, 321829.48893365426920354 129991.38697145861806348 1)', + 'LineStringZ (321920.00953056826256216 129924.58260190498549491 2, 321924.65299345907988027 129908.43546159457764588 2, 321904.78543491888558492 129903.99811821122420952 2, 321900.80605239619035274 129931.39860145389684476 2, 321904.84799937985371798 129931.71552911199978553 2, 321908.93646715773502365 129912.90030360443051904 2, 321914.20495146053144708 129913.67693978428724222 2, 321911.30165811872575432 129923.01272751353099011 2, 321920.00953056826256216 129924.58260190498549491 2)', + 'LineStringZ (321923.10517279652412981 129919.61521573827485554 3, 321922.23537852568551898 129928.3598982143739704 3, 321928.60423935484141111 129934.22530528216157109 3, 321929.39881197665818036 129923.29054521876969375 3, 321930.55804549407912418 129916.53248518184409477 3, 321923.10517279652412981 129919.61521573827485554 3)', + 'LineStringZ (321990.47451346553862095 129909.63588680300745182 4, 321995.04325810901354998 129891.84052284323843196 4, 321989.66826330573530868 129890.5092018858413212 4, 321990.78512359503656626 129886.49917887404444627 4, 321987.37291929306229576 129885.64982962771318853 4, 321985.2254804756375961 129893.81317058412241749 4, 321987.63158903241856024 129894.41078495365218259 4, 321984.34022761805681512 129907.57450046355370432 4, 321990.47451346553862095 129909.63588680300745182 4)', + 'LineStringZ (322103.03910495212767273 129795.91051736124791205 5, 322108.25568856322206557 129804.76113295342656784 5, 322113.29666162584908307 129803.9285887333098799 5, 322117.78645010641776025 129794.48194090687320568 5, 322103.03910495212767273 129795.91051736124791205 5)']: + f = QgsFeature() + f.setGeometry(QgsGeometry.fromWkt(line)) + self.assertTrue(vl.dataProvider().addFeature(f)) + + vl.elevationProperties().setClamping(Qgis.AltitudeClamping.Absolute) + vl.elevationProperties().setType(Qgis.VectorProfileType.ContinuousSurface) + vl.elevationProperties().setProfileSymbology(Qgis.ProfileSurfaceSymbology.FillAbove) + vl.elevationProperties().setElevationLimit( + 10) + fill_symbol = QgsFillSymbol.createSimple({'color': '#ff00ff', 'outline_style': 'no'}) + vl.elevationProperties().setRespectLayerSymbology(False) + vl.elevationProperties().setProfileFillSymbol(fill_symbol) + line_symbol = QgsLineSymbol.createSimple({'color': '#ff00ff', 'width': '0.8'}) + vl.elevationProperties().setProfileLineSymbol(line_symbol) + + curve = QgsLineString() + curve.fromWkt( + 'LineString (321897.18831187387695536 129916.86947759155009408, 321942.11597351566888392 129924.94403429214435164)') + req = QgsProfileRequest(curve) + req.setTransformContext(self.create_transform_context()) + + req.setCrs(QgsCoordinateReferenceSystem()) + req.setTolerance(20) + + plot_renderer = QgsProfilePlotRenderer([vl], req) + plot_renderer.startGeneration() + plot_renderer.waitForFinished() + + res = plot_renderer.renderToImage(400, 400, 0, curve.length(), 0, 14) + self.assertTrue(self.image_check('vector_lines_as_fill_above_surface_limit_tolerance', 'vector_lines_as_fill_above_surface_limit_tolerance', res)) + def testRenderProfileSymbolWithMapUnits(self): vl = QgsVectorLayer('LineStringZ?crs=EPSG:27700', 'lines', 'memory') vl.setCrs(QgsCoordinateReferenceSystem()) @@ -1788,6 +1949,43 @@ def testRenderProfileSymbolWithMapUnits(self): res = plot_renderer.renderToImage(400, 400, 0, curve.length(), 0, 14) self.assertTrue(self.image_check('vector_profile_map_units', 'vector_profile_map_units', res)) + def testRenderProfileSymbolWithMapUnitsTolerance(self): + vl = QgsVectorLayer('LineStringZ?crs=EPSG:27700', 'lines', 'memory') + vl.setCrs(QgsCoordinateReferenceSystem()) + self.assertTrue(vl.isValid()) + + for line in [ + 'LineStringZ (321829.48893365426920354 129991.38697145861806348 1, 321847.89668515208177269 129996.63588572069420479 1, 321848.97131609614007175 129979.22330882755341008 1, 321830.31725845142500475 129978.07136809575604275 1, 321829.48893365426920354 129991.38697145861806348 1)', + 'LineStringZ (321920.00953056826256216 129924.58260190498549491 2, 321924.65299345907988027 129908.43546159457764588 2, 321904.78543491888558492 129903.99811821122420952 2, 321900.80605239619035274 129931.39860145389684476 2, 321904.84799937985371798 129931.71552911199978553 2, 321908.93646715773502365 129912.90030360443051904 2, 321914.20495146053144708 129913.67693978428724222 2, 321911.30165811872575432 129923.01272751353099011 2, 321920.00953056826256216 129924.58260190498549491 2)', + 'LineStringZ (321923.10517279652412981 129919.61521573827485554 3, 321922.23537852568551898 129928.3598982143739704 3, 321928.60423935484141111 129934.22530528216157109 3, 321929.39881197665818036 129923.29054521876969375 3, 321930.55804549407912418 129916.53248518184409477 3, 321923.10517279652412981 129919.61521573827485554 3)', + 'LineStringZ (321990.47451346553862095 129909.63588680300745182 4, 321995.04325810901354998 129891.84052284323843196 4, 321989.66826330573530868 129890.5092018858413212 4, 321990.78512359503656626 129886.49917887404444627 4, 321987.37291929306229576 129885.64982962771318853 4, 321985.2254804756375961 129893.81317058412241749 4, 321987.63158903241856024 129894.41078495365218259 4, 321984.34022761805681512 129907.57450046355370432 4, 321990.47451346553862095 129909.63588680300745182 4)', + 'LineStringZ (322103.03910495212767273 129795.91051736124791205 5, 322108.25568856322206557 129804.76113295342656784 5, 322113.29666162584908307 129803.9285887333098799 5, 322117.78645010641776025 129794.48194090687320568 5, 322103.03910495212767273 129795.91051736124791205 5)']: + f = QgsFeature() + f.setGeometry(QgsGeometry.fromWkt(line)) + self.assertTrue(vl.dataProvider().addFeature(f)) + + vl.elevationProperties().setClamping(Qgis.AltitudeClamping.Absolute) + vl.elevationProperties().setRespectLayerSymbology(False) + line_symbol = QgsLineSymbol.createSimple({'color': '#ff00ff', 'width': '0.8'}) + line_symbol.setWidthUnit(Qgis.RenderUnit.MapUnits) + vl.elevationProperties().setProfileLineSymbol(line_symbol) + + curve = QgsLineString() + curve.fromWkt( + 'LineString (321897.18831187387695536 129916.86947759155009408, 321942.11597351566888392 129924.94403429214435164)') + req = QgsProfileRequest(curve) + req.setTransformContext(self.create_transform_context()) + + req.setCrs(QgsCoordinateReferenceSystem()) + req.setTolerance(10) + + plot_renderer = QgsProfilePlotRenderer([vl], req) + plot_renderer.startGeneration() + plot_renderer.waitForFinished() + + res = plot_renderer.renderToImage(400, 400, 0, curve.length(), 0, 14) + self.assertTrue(self.image_check('vector_profile_map_units_tolerance', 'vector_profile_map_units_tolerance', res)) + def testRenderLayerSymbology(self): vl = QgsVectorLayer('PolygonZ?crs=EPSG:27700', 'lines', 'memory') vl.setCrs(QgsCoordinateReferenceSystem()) diff --git a/tests/testdata/control_images/layout_profile/expected_vector_layer_map_units_tolerance/expected_vector_layer_map_units_tolerance.png b/tests/testdata/control_images/layout_profile/expected_vector_layer_map_units_tolerance/expected_vector_layer_map_units_tolerance.png new file mode 100644 index 0000000000000000000000000000000000000000..1f80bd61fc2af454192f6effa8d68158d1f9a7b2 GIT binary patch literal 10418 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 literal 0 HcmV?d00001 diff --git a/tests/testdata/control_images/profile_chart/expected_vector_lines_as_fill_above_surface_limit_tolerance/expected_vector_lines_as_fill_above_surface_limit_tolerance.png b/tests/testdata/control_images/profile_chart/expected_vector_lines_as_fill_above_surface_limit_tolerance/expected_vector_lines_as_fill_above_surface_limit_tolerance.png new file mode 100644 index 0000000000000000000000000000000000000000..77cd2db92a447298b8c2e78a5d1c5bf3ddce27cc GIT binary patch literal 1414 zcmeAS@N?(olHy`uVBq!ia0y~yV4MKL9Be?5hW%z|fD~teM`SSr1K(i~W;~w1B87p0 z)xy)oF{EP7+na``VnapR9+p1QpX2Do{=lKBb@GO@EOR@2JrtH0XlO8AJf!3~{aC}# z_#>rd74!bZ?R&Sk`mQ9<*dYZT{4dVs*SL57_xto`H^1@Cm>Hk;>?H60*WRB#n_lO? zt^8wo`&-6~=Ek2(s#(sQ@i+eb7A#p*%`pGl)o%<8=TsCJRG63?lt!u1AQ&2{fd4jg zLx0`;neVr`-?=dVHuDb?#>U?jb!Wdm{>Jem#xJ5kUVj_=fn*MjJM9exA|Sb)whVI4 z4iD`1I9PNw9I*XUb@uD=ZyXgojE&U=`yN>^F@1kfbF`3!<(;F0MITUsMPi{0Q0@cM zLy(+;f{eRE!u{LX>+WxJKakAH@#gb?c6T9x8_#QwPGn*E#>oNZw17F4jRh)l#3Ic)I$ztaD0e F0s!A14G{nU literal 0 HcmV?d00001 diff --git a/tests/testdata/control_images/profile_chart/expected_vector_profile_map_units_tolerance/expected_vector_profile_map_units_tolerance.png b/tests/testdata/control_images/profile_chart/expected_vector_profile_map_units_tolerance/expected_vector_profile_map_units_tolerance.png new file mode 100644 index 0000000000000000000000000000000000000000..90634607a1b700fb674cb91865843d739eacda7a GIT binary patch literal 1010 zcmeAS@N?(olHy`uVBq!ia0y~yV4MKL9Be?5hW%z|fD~teM`SSr1K(i~W;~w1B87p0 zd5NcsV@SoEx3}!GLLFsT9~di!Zs6pYbk0pNvSF*hni!{5TefB9=G<+MVBgeblC&_- z!TEqv630uUX<>GAem_6E`{ehn<=qX-8>vxWcz zTc831&!)T93^oPgo@G_-GcNY4>{p*vYJE|LP`D)oPrb(&b>+%-&u`M(=T4b&+Vazy zpkV&VGV6cenb;?Ft!{?y(P!2kn|}A_-=7p?dgfO|(79voOU*k@ z)r~5Z)3(Wfx^(CH?%t1E3jaP=;c9Wc^-7kZAvFl-v_%dK5-yDl1|3X{2@{suKCYem z#PjjC%ae+1-RFg7z51-8f35bN$NR}|{vO};S|va2+@_OZwvYeL+Lpd&zSpVgA14y> e1?ln)k$)Jk`fGYipSU#%q}$Wg&t;ucLK6UJNwx$4 literal 0 HcmV?d00001 From 3dcf388ee5dfa315cddb548320bc056fe5422203 Mon Sep 17 00:00:00 2001 From: bdm-oslandia Date: Wed, 12 Jul 2023 11:43:11 +0200 Subject: [PATCH 09/26] feat(elevation_profile): add c++ tests for line --- tests/src/core/CMakeLists.txt | 1 + tests/src/core/testqgselevationprofile.cpp | 238 +++++++++++++++++++++ 2 files changed, 239 insertions(+) create mode 100644 tests/src/core/testqgselevationprofile.cpp diff --git a/tests/src/core/CMakeLists.txt b/tests/src/core/CMakeLists.txt index 686f57293053..2274368b3669 100644 --- a/tests/src/core/CMakeLists.txt +++ b/tests/src/core/CMakeLists.txt @@ -52,6 +52,7 @@ set(TESTS testqgsdistancearea.cpp testqgsdxfexport.cpp testqgselevationmap.cpp + testqgselevationprofile.cpp testqgsellipsemarker.cpp testqgsexpression.cpp testqgsexpressioncontext.cpp diff --git a/tests/src/core/testqgselevationprofile.cpp b/tests/src/core/testqgselevationprofile.cpp new file mode 100644 index 000000000000..c72d34a5928b --- /dev/null +++ b/tests/src/core/testqgselevationprofile.cpp @@ -0,0 +1,238 @@ +/*************************************************************************** + testqgselevationProfile.cpp + -------------------------------------- +Date : August 2022 +Copyright : (C) 2022 by Martin Dobias +Email : wonder dot sk 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 "qgstest.h" + +#include "qgsapplication.h" +#include "qgsvectorlayerprofilegenerator.h" +#include "qgsprofilerequest.h" +#include "qgscurve.h" +#include "qgsvectorlayer.h" +#include "project/qgsprojectelevationproperties.h" +#include "qgsvectorlayerelevationproperties.h" +#include +#include "qgsterrainprovider.h" + +#define DEBUG 0 + +class TestQgsElevationProfile : public QgsTest +{ + Q_OBJECT + + public: + TestQgsElevationProfile() : QgsTest( QStringLiteral( "Elevation Profile Tests" ), QStringLiteral( "elevation_Profile" ) ) {} + + private: + QgsVectorLayer *mpPointsLayer = nullptr; + QgsVectorLayer *mpLinesLayer = nullptr; + QgsVectorLayer *mpPolygonsLayer = nullptr; + QgsRasterLayer *mLayerDtm = nullptr; + QString mTestDataDir; + QgsPointSequence mProfilePoints; + QgsVectorLayerProfileResults *mProfileResults = nullptr; + std::unique_ptr< QgsRasterDemTerrainProvider > mDemTerrain; + + void doCheckPoint( QgsProfileRequest &request, int tolerance, QgsVectorLayer *layer, + const QList &expectedFeatures ); + void doCheckLine( QgsProfileRequest &request, int tolerance, QgsVectorLayer *layer, + const QList &expectedFeatures, const QList &nbSubGeomPerFeature ); + + QgsVectorLayer *createVectorLayer( const QString &fileName ); + + private slots: + void initTestCase(); + void cleanupTestCase(); + + void testVectorLayerProfileForPoint(); + void testVectorLayerProfileForLine(); +}; + + +QgsVectorLayer *TestQgsElevationProfile::createVectorLayer( const QString &fileName ) +{ + const QString myFileName = mTestDataDir + fileName; + const QFileInfo myFileInfo( myFileName ); + QgsVectorLayer *layer = new QgsVectorLayer( myFileInfo.filePath(), + myFileInfo.completeBaseName(), QStringLiteral( "ogr" ) ); + + dynamic_cast( layer->elevationProperties() )->setClamping( Qgis::AltitudeClamping::Terrain ); + dynamic_cast( layer->elevationProperties() )->setBinding( Qgis::AltitudeBinding::Vertex ); + + return layer; +} + +void TestQgsElevationProfile::initTestCase() +{ + // + // Runs once before any tests are run + // + // init QGIS's paths - true means that all path will be inited from prefix + QgsApplication::init(); + QgsApplication::initQgis(); + QgsApplication::showSettings(); + + QgsProject::instance()->setCrs( QgsCoordinateReferenceSystem::fromEpsgId( 3857 ) ); + + //create some objects that will be used in all tests... + + const QString myDataDir( TEST_DATA_DIR ); //defined in CmakeLists.txt + mTestDataDir = myDataDir + "/3d/"; + + // Create a line layer that will be used in all tests... + mpLinesLayer = createVectorLayer( "lines.shp" ); + + // Create a point layer that will be used in all tests... + mpPointsLayer = createVectorLayer( "points_with_z.shp" ); + + // Create a polygon layer that will be used in all tests... + mpPolygonsLayer = createVectorLayer( "buildings.shp" ); + + // Register the layer with the registry + QgsProject::instance()->addMapLayers( + QList() << mpLinesLayer << mpPointsLayer << mpPolygonsLayer ); + + // Create a DEM layer that will be used in all tests... + const QString myDtmFileName = mTestDataDir + "dtm.tif"; + const QFileInfo myDtmFileInfo( myDtmFileName ); + mLayerDtm = new QgsRasterLayer( myDtmFileInfo.filePath(), + myDtmFileInfo.completeBaseName(), QStringLiteral( "gdal" ) ); + QVERIFY( mLayerDtm->isValid() ); + + // set dem as elevation + mDemTerrain = std::make_unique< QgsRasterDemTerrainProvider >(); + mDemTerrain->setLayer( mLayerDtm ); + + QgsProject::instance()->elevationProperties()->setTerrainProvider( mDemTerrain->clone() ); + + // profile curve + mProfilePoints << QgsPoint( Qgis::WkbType::Point, -346120, 6631840 ) + << QgsPoint( Qgis::WkbType::Point, -346550, 6632030 ) + << QgsPoint( Qgis::WkbType::Point, -346440, 6632140 ) + << QgsPoint( Qgis::WkbType::Point, -347830, 6632930 ) ; + + +} + +void TestQgsElevationProfile::cleanupTestCase() +{ + QgsApplication::exitQgis(); +} + +void TestQgsElevationProfile::doCheckPoint( QgsProfileRequest &request, int tolerance, QgsVectorLayer *layer, + const QList &expectedFeatures ) +{ + request.setTolerance( tolerance ); + + QgsAbstractProfileGenerator *profGen = layer->createProfileGenerator( request ); + QVERIFY( profGen ); + QVERIFY( profGen->generateProfile() ); + mProfileResults = dynamic_cast( profGen->takeResults() ); + QVERIFY( mProfileResults ); + QVERIFY( ! mProfileResults->features.empty() ); + + QList expected = expectedFeatures; + std::sort( expected.begin(), expected.end() ); + + QList actual = mProfileResults->features.keys(); + std::sort( actual.begin(), actual.end() ); +#if DEBUG + qDebug() << "actual sorted fid" << actual; +#endif + + QCOMPARE( actual, expected ); + + for ( auto it = mProfileResults->features.constBegin(); + it != mProfileResults->features.constEnd(); ++it ) + { + for ( const QgsVectorLayerProfileResults::Feature &feat : it.value() ) + { +#if DEBUG + qDebug() << "feat:" << feat.featureId << "geom:" << feat.geometry.asWkt(); +#endif + if ( QgsWkbTypes::hasZ( feat.geometry.wkbType() ) ) + { + bool hasValidZ = false; + for ( QgsAbstractGeometry::vertex_iterator it = feat.geometry.vertices_begin(); it != feat.geometry.vertices_end(); ++it ) + { + if ( it.operator * ().z() != 0.0 ) + { + hasValidZ = true; + break; + } + } + QVERIFY2( hasValidZ, "All vertice are on the ground!" ); + } + else + { + QVERIFY2( false, "Geometry should have z coordinates!" ); + } + } + } + +} + +void TestQgsElevationProfile::doCheckLine( QgsProfileRequest &request, int tolerance, QgsVectorLayer *layer, + const QList &expectedFeatures, const QList &nbSubGeomPerFeature ) +{ + doCheckPoint( request, tolerance, layer, expectedFeatures ); + + // check in how many geometry the feature intersects the profile curve + int i = 0; + QList actual = mProfileResults->features.keys(); + std::sort( actual.begin(), actual.end() ); + + for ( auto it = actual.constBegin(); it != actual.constEnd(); ++it, ++i ) + { + QVector< QgsVectorLayerProfileResults::Feature > feats = mProfileResults->features[*it]; +#if DEBUG + for ( const QgsVectorLayerProfileResults::Feature &feat : feats ) + { + qDebug() << "feat:" << feat.featureId << "geom:" << feat.geometry.asWkt(); + } +#endif + QCOMPARE( feats.size(), nbSubGeomPerFeature[i] ); + } +} + +void TestQgsElevationProfile::testVectorLayerProfileForPoint() +{ + QgsLineString *profileCurve = new QgsLineString ; + profileCurve->setPoints( mProfilePoints ); + + QgsProfileRequest request( profileCurve ); + request.setCrs( QgsCoordinateReferenceSystem::fromEpsgId( 3857 ) ); + request.setTerrainProvider( mDemTerrain->clone() ); + + doCheckPoint( request, 15, mpPointsLayer, { 5, 11, 12, 13, 14, 15, 18, 45, 46 } ); + doCheckPoint( request, 70, mpPointsLayer, { 0, 3, 4, 5, 6, 7, 10, 11, 12, 13, 14, 15, 16, 17, 18, 38, 45, 46, 48 } ); +} + + +void TestQgsElevationProfile::testVectorLayerProfileForLine() +{ + QgsLineString *profileCurve = new QgsLineString ; + profileCurve->setPoints( mProfilePoints ); + + QgsProfileRequest request( profileCurve ); + request.setCrs( QgsCoordinateReferenceSystem::fromEpsgId( 3857 ) ); + request.setTerrainProvider( mDemTerrain->clone() ); + + doCheckLine( request, 1, mpLinesLayer, { 0, 2 }, { 1, 5 } ); + doCheckLine( request, 20, mpLinesLayer, { 0, 2 }, { 1, 3 } ); + doCheckLine( request, 50, mpLinesLayer, { 1, 0, 2 }, { 1, 1, 1 } ); +} + + +QGSTEST_MAIN( TestQgsElevationProfile ) +#include "testqgselevationprofile.moc" From 0f203d24ad33cb5e371f968887dd05434bd70bcb Mon Sep 17 00:00:00 2001 From: bdm-oslandia Date: Wed, 12 Jul 2023 12:04:46 +0200 Subject: [PATCH 10/26] fix(elevation_profile): fix bug for line when extrusion is set The extrusion were not handled for curve when the tolerance is > 0. When extrusion is set, we need to create a polygon with all the intersection curve points plus the same points but with a vertical offset (extrusion) and then we close the polygon. --- .../vector/qgsvectorlayerprofilegenerator.cpp | 78 ++++++++++++------- tests/src/core/testqgselevationprofile.cpp | 71 +++++++++++++++-- 2 files changed, 116 insertions(+), 33 deletions(-) diff --git a/src/core/vector/qgsvectorlayerprofilegenerator.cpp b/src/core/vector/qgsvectorlayerprofilegenerator.cpp index ea1368700046..19cfcbc8b6b6 100644 --- a/src/core/vector/qgsvectorlayerprofilegenerator.cpp +++ b/src/core/vector/qgsvectorlayerprofilegenerator.cpp @@ -871,20 +871,39 @@ void QgsVectorLayerProfileGenerator::processIntersectionPoint( const QgsPoint *p void QgsVectorLayerProfileGenerator::processIntersectionCurve( const QgsLineString *intersectionCurve, const QgsFeature &feature ) { QString error; - QVector< QgsGeometry > transformedParts; - QVector< QgsGeometry > crossSectionParts; QgsVectorLayerProfileResults::Feature resultFeature; resultFeature.featureId = feature.id(); double maxDistanceAlongProfileCurve = std::numeric_limits::lowest(); + const double offset = mDataDefinedProperties.valueAsDouble( QgsMapLayerElevationProperties::Property::ZOffset, mExpressionContext, mOffset ); + const double extrusion = mDataDefinedProperties.valueAsDouble( QgsMapLayerElevationProperties::Property::ExtrusionHeight, mExpressionContext, mExtrusionHeight ); + + const int numPoints = intersectionCurve->numPoints(); + QVector< double > newX( numPoints ); + QVector< double > newY( numPoints ); + QVector< double > newZ( numPoints ); + QVector< double > newDistance( numPoints ); + + double *outX = newX.data(); + double *outY = newY.data(); + double *outZ = newZ.data(); + double *outDistance = newDistance.data(); + + QVector< double > extrudedZ; + double *extZOut = nullptr; + if ( mExtrusionEnabled ) + { + extrudedZ.resize( numPoints ); + extZOut = extrudedZ.data(); + } + for ( auto it = intersectionCurve->vertices_begin(); !mFeedback->isCanceled() && it != intersectionCurve->vertices_end(); ++it ) { const QgsPoint &intersectionPoint = *it; - const double offset = mDataDefinedProperties.valueAsDouble( QgsMapLayerElevationProperties::Property::ZOffset, mExpressionContext, mOffset ); const double height = featureZToHeight( intersectionPoint.x(), intersectionPoint.y(), intersectionPoint.z(), offset ); const double distanceAlongProfileCurve = mProfileCurveEngine->lineLocatePoint( intersectionPoint, &error ); @@ -895,40 +914,47 @@ void QgsVectorLayerProfileGenerator::processIntersectionCurve( const QgsLineStri mResults->maxZ = std::max( mResults->maxZ, height ); mResults->mDistanceToHeightMap.insert( distanceAlongProfileCurve, height ); + *outDistance++ = distanceAlongProfileCurve; + + *outX++ = intersectionPoint.x(); + *outY++ = intersectionPoint.y(); + *outZ++ = height; + if ( extZOut ) + *extZOut++ = height + extrusion; if ( mExtrusionEnabled ) { - const double extrusion = mDataDefinedProperties.valueAsDouble( QgsMapLayerElevationProperties::Property::ExtrusionHeight, mExpressionContext, mExtrusionHeight ); - - transformedParts.append( QgsGeometry( new QgsLineString( QgsPoint( intersectionPoint.x(), intersectionPoint.y(), height ), - QgsPoint( intersectionPoint.x(), intersectionPoint.y(), height + extrusion ) ) ) ); - crossSectionParts.append( QgsGeometry( new QgsLineString( QgsPoint( distanceAlongProfileCurve, height ), - QgsPoint( distanceAlongProfileCurve, height + extrusion ) ) ) ); mResults->minZ = std::min( mResults->minZ, height + extrusion ); mResults->maxZ = std::max( mResults->maxZ, height + extrusion ); } - else - { - transformedParts.append( QgsGeometry( new QgsPoint( intersectionPoint.x(), intersectionPoint.y(), height ) ) ); - crossSectionParts.append( QgsGeometry( new QgsPoint( distanceAlongProfileCurve, height ) ) ); - } } - if ( mResults->mDistanceToHeightMap.empty() ) - return; // no usefull point found - mResults->mDistanceToHeightMap.insert( maxDistanceAlongProfileCurve + 0.000001, qQNaN() ); - resultFeature.geometry = transformedParts.size() > 1 ? QgsGeometry::unaryUnion( transformedParts ) : transformedParts.value( 0 ); - if ( !crossSectionParts.empty() ) + if ( mFeedback->isCanceled() ) + return; + + // create geometries from vector data + if ( mExtrusionEnabled ) { - //Create linestring from point list - QgsLineString *line = new QgsLineString(); - for ( auto it = crossSectionParts.begin(); it != crossSectionParts.end(); ++it ) - { - line->addVertex( QgsPoint( ( *it ).asPoint() ) ); - } - resultFeature.crossSectionGeometry = QgsGeometry( line ); + std::unique_ptr< QgsLineString > ring = std::make_unique< QgsLineString >( newX, newY, newZ ); + std::unique_ptr< QgsLineString > extrudedRing = std::make_unique< QgsLineString >( newX, newY, extrudedZ ); + std::unique_ptr< QgsLineString > reversedExtrusion( extrudedRing->reversed() ); + ring->append( reversedExtrusion.get() ); + ring->close(); + resultFeature.geometry = QgsGeometry( new QgsPolygon( ring.release() ) ); + + std::unique_ptr< QgsLineString > distanceVHeightRing = std::make_unique< QgsLineString >( newDistance, newZ ); + std::unique_ptr< QgsLineString > extrudedDistanceVHeightRing = std::make_unique< QgsLineString >( newDistance, extrudedZ ); + std::unique_ptr< QgsLineString > reversedDistanceVHeightExtrusion( extrudedDistanceVHeightRing->reversed() ); + distanceVHeightRing->append( reversedDistanceVHeightExtrusion.get() ); + distanceVHeightRing->close(); + resultFeature.crossSectionGeometry = QgsGeometry( new QgsPolygon( distanceVHeightRing.release() ) ); + } + else + { + resultFeature.geometry = QgsGeometry( new QgsLineString( newX, newY, newZ ) ) ; + resultFeature.crossSectionGeometry = QgsGeometry( new QgsLineString( newDistance, newZ ) ); } mResults->features[resultFeature.featureId].append( resultFeature ); diff --git a/tests/src/core/testqgselevationprofile.cpp b/tests/src/core/testqgselevationprofile.cpp index c72d34a5928b..99648b8ada00 100644 --- a/tests/src/core/testqgselevationprofile.cpp +++ b/tests/src/core/testqgselevationprofile.cpp @@ -91,6 +91,7 @@ void TestQgsElevationProfile::initTestCase() // Create a line layer that will be used in all tests... mpLinesLayer = createVectorLayer( "lines.shp" ); + dynamic_cast( mpLinesLayer->elevationProperties() )->setExtrusionEnabled( false ); // Create a point layer that will be used in all tests... mpPointsLayer = createVectorLayer( "points_with_z.shp" ); @@ -132,6 +133,9 @@ void TestQgsElevationProfile::cleanupTestCase() void TestQgsElevationProfile::doCheckPoint( QgsProfileRequest &request, int tolerance, QgsVectorLayer *layer, const QList &expectedFeatures ) { +#if DEBUG + qDebug() << "===== checking ====="; +#endif request.setTolerance( tolerance ); QgsAbstractProfileGenerator *profGen = layer->createProfileGenerator( request ); @@ -158,7 +162,7 @@ void TestQgsElevationProfile::doCheckPoint( QgsProfileRequest &request, int tole for ( const QgsVectorLayerProfileResults::Feature &feat : it.value() ) { #if DEBUG - qDebug() << "feat:" << feat.featureId << "geom:" << feat.geometry.asWkt(); + qDebug() << "feat point:" << feat.featureId << "geom:" << feat.geometry.asWkt(); #endif if ( QgsWkbTypes::hasZ( feat.geometry.wkbType() ) ) { @@ -189,18 +193,15 @@ void TestQgsElevationProfile::doCheckLine( QgsProfileRequest &request, int toler // check in how many geometry the feature intersects the profile curve int i = 0; +#if DEBUG + qDebug() << "distanceToHeightMap:" << mProfileResults->distanceToHeightMap(); +#endif QList actual = mProfileResults->features.keys(); std::sort( actual.begin(), actual.end() ); for ( auto it = actual.constBegin(); it != actual.constEnd(); ++it, ++i ) { QVector< QgsVectorLayerProfileResults::Feature > feats = mProfileResults->features[*it]; -#if DEBUG - for ( const QgsVectorLayerProfileResults::Feature &feat : feats ) - { - qDebug() << "feat:" << feat.featureId << "geom:" << feat.geometry.asWkt(); - } -#endif QCOMPARE( feats.size(), nbSubGeomPerFeature[i] ); } } @@ -228,9 +229,65 @@ void TestQgsElevationProfile::testVectorLayerProfileForLine() request.setCrs( QgsCoordinateReferenceSystem::fromEpsgId( 3857 ) ); request.setTerrainProvider( mDemTerrain->clone() ); + // check no tolerance + doCheckLine( request, 0, mpLinesLayer, { 0, 2 }, { 1, 5 } ); + + // check increased tolerance, terrain, no extrusion doCheckLine( request, 1, mpLinesLayer, { 0, 2 }, { 1, 5 } ); + + // check increased tolerance, terrain, no extrusion doCheckLine( request, 20, mpLinesLayer, { 0, 2 }, { 1, 3 } ); + + // check increased tolerance, terrain, no extrusion + doCheckLine( request, 50, mpLinesLayer, { 1, 0, 2 }, { 1, 1, 1 } ); + QList actual = mProfileResults->features.keys(); + for ( auto it = actual.constBegin(); it != actual.constEnd(); ++it ) + { + QVector< QgsVectorLayerProfileResults::Feature > feats = mProfileResults->features[*it]; + QVERIFY2( feats[0].geometry.type() == Qgis::GeometryType::Line, "Geometry must be a line" ); + } + QMap distMap = mProfileResults->distanceToHeightMap(); + for ( QMap::iterator pair = distMap.begin(); pair != distMap.end(); pair++ ) + { + QVERIFY2( std::isnan( pair.value() ) || pair.value() > 0.0, QString( "Height must be %1 > 0.0" ).arg( pair.value() ).toStdString().c_str() ); + } + + // check terrain + extrusion + dynamic_cast( mpLinesLayer->elevationProperties() )->setClamping( Qgis::AltitudeClamping::Terrain ); + dynamic_cast( mpLinesLayer->elevationProperties() )->setExtrusionEnabled( true ); + dynamic_cast( mpLinesLayer->elevationProperties() )->setExtrusionHeight( 17 ); + doCheckLine( request, 50, mpLinesLayer, { 1, 0, 2 }, { 1, 1, 1 } ); + actual = mProfileResults->features.keys(); + for ( auto it = actual.constBegin(); it != actual.constEnd(); ++it ) + { + QVector< QgsVectorLayerProfileResults::Feature > feats = mProfileResults->features[*it]; + QVERIFY2( feats[0].geometry.type() == Qgis::GeometryType::Polygon, "Geometry must be a polygon" ); + } + distMap = mProfileResults->distanceToHeightMap(); + for ( QMap::iterator pair = distMap.begin(); pair != distMap.end(); pair++ ) + { + QVERIFY2( std::isnan( pair.value() ) || pair.value() > 0.0, QString( "Height must be %1 > 0.0" ).arg( pair.value() ).toStdString().c_str() ); + } + + // check no terrain, no extrusion + dynamic_cast( mpLinesLayer->elevationProperties() )->setClamping( Qgis::AltitudeClamping::Absolute ); + dynamic_cast( mpLinesLayer->elevationProperties() )->setZOffset( 5.0 ); + dynamic_cast( mpLinesLayer->elevationProperties() )->setExtrusionEnabled( false ); + + doCheckLine( request, 50, mpLinesLayer, { 1, 0, 2 }, { 1, 1, 1 } ); + actual = mProfileResults->features.keys(); + for ( auto it = actual.constBegin(); it != actual.constEnd(); ++it ) + { + QVector< QgsVectorLayerProfileResults::Feature > feats = mProfileResults->features[*it]; + QVERIFY2( feats[0].geometry.type() == Qgis::GeometryType::Line, "Geometry must be a line" ); + } + distMap = mProfileResults->distanceToHeightMap(); + for ( QMap::iterator pair = distMap.begin(); pair != distMap.end(); pair++ ) + { + QVERIFY2( std::isnan( pair.value() ) || pair.value() == 5.0, QString( "Height must be %1 == 5.0" ).arg( pair.value() ).toStdString().c_str() ); + } + } From 71c39f3644ad8b006e25dc2de1bf68c1139dc611 Mon Sep 17 00:00:00 2001 From: bdm-oslandia Date: Wed, 12 Jul 2023 11:35:33 +0200 Subject: [PATCH 11/26] feat(elevation_profile): add tolerance support for polygon * refactor lambda to function to reduce duplicates and increase readability * simplify mFeedback->isCanceled() and g.isMultipart() checks --- .../vector/qgsvectorlayerprofilegenerator.cpp | 426 +++++++----------- .../vector/qgsvectorlayerprofilegenerator.h | 4 + tests/src/core/testqgselevationprofile.cpp | 15 + 3 files changed, 185 insertions(+), 260 deletions(-) diff --git a/src/core/vector/qgsvectorlayerprofilegenerator.cpp b/src/core/vector/qgsvectorlayerprofilegenerator.cpp index 19cfcbc8b6b6..e6d0fc638402 100644 --- a/src/core/vector/qgsvectorlayerprofilegenerator.cpp +++ b/src/core/vector/qgsvectorlayerprofilegenerator.cpp @@ -308,7 +308,7 @@ void QgsVectorLayerProfileResults::visitFeaturesAtPoint( const QgsProfilePoint & const double snappedDistance = point.distance() < partBounds.xMinimum() ? partBounds.xMinimum() : point.distance() > partBounds.xMaximum() ? partBounds.xMaximum() : point.distance(); - const QgsGeometry cutLine( new QgsLineString( QgsPoint( snappedDistance, minZ ), QgsPoint( snappedDistance, maxZ ) ) ); + const QgsGeometry cutLine( new QgsLineString( QgsPoint( snappedDistance, qgsDoubleNear( minZ, maxZ ) ? minZ - 1 : minZ ), QgsPoint( snappedDistance, maxZ ) ) ); QgsGeos cutLineGeos( cutLine.constGet() ); const QgsGeometry points( cutLineGeos.intersection( exterior ) ); @@ -1007,97 +1007,6 @@ bool QgsVectorLayerProfileGenerator::generateProfileForLines() { processIntersectionCurve( intersectionCurve, feature ); } - else if ( const QgsLineString *ls = qgsgeometry_cast< const QgsLineString * >( *it ) ) - { - const int numPoints = ls->numPoints(); - QVector< double > newX; - newX.resize( numPoints ); - QVector< double > newY; - newY.resize( numPoints ); - QVector< double > newZ; - newZ.resize( numPoints ); - QVector< double > newDistance; - newDistance.resize( numPoints ); - - const double *inX = ls->xData(); - const double *inY = ls->yData(); - const double *inZ = ls->is3D() ? ls->zData() : nullptr; - double *outX = newX.data(); - double *outY = newY.data(); - double *outZ = newZ.data(); - double *outDistance = newDistance.data(); - - QVector< double > extrudedZ; - double *extZOut = nullptr; - double extrusion = 0; - if ( mExtrusionEnabled ) - { - extrudedZ.resize( numPoints ); - extZOut = extrudedZ.data(); - - extrusion = mDataDefinedProperties.valueAsDouble( QgsMapLayerElevationProperties::Property::ExtrusionHeight, mExpressionContext, mExtrusionHeight ); - } - - QString lastError; - for ( int i = 0 ; i < numPoints; ++i ) - { - double x = *inX++; - double y = *inY++; - - // find z value from original curve by interpolating to this point - const double distanceAlongOriginalGeometry = curveGeos.lineLocatePoint( QgsPoint( x, y ) ); - std::unique_ptr< QgsPoint > closestOriginalPoint( curve->interpolatePoint( distanceAlongOriginalGeometry ) ); - - double z = inZ ? *inZ++ : 0; - - *outX++ = x; - *outY++ = y; - *outZ++ = std::isnan( closestOriginalPoint->z() ) ? 0 : closestOriginalPoint->z(); - if ( extZOut ) - *extZOut++ = z + extrusion; - - mResults->mRawPoints.append( QgsPoint( x, y, z ) ); - mResults->minZ = std::min( mResults->minZ, z ); - mResults->maxZ = std::max( mResults->maxZ, z ); - if ( mExtrusionEnabled ) - { - mResults->minZ = std::min( mResults->minZ, z + extrusion ); - mResults->maxZ = std::max( mResults->maxZ, z + extrusion ); - } - - const double distance = mProfileCurveEngine->lineLocatePoint( QgsPoint( x, y ), &lastError ); - *outDistance++ = distance; - - mResults->mDistanceToHeightMap.insert( distance, z ); - } - - QgsVectorLayerProfileResults::Feature resultFeature; - resultFeature.featureId = feature.id(); - - if ( mExtrusionEnabled ) - { - std::unique_ptr< QgsLineString > ring = std::make_unique< QgsLineString >( newX, newY, newZ ); - std::unique_ptr< QgsLineString > extrudedRing = std::make_unique< QgsLineString >( newX, newY, extrudedZ ); - std::unique_ptr< QgsLineString > reversedExtrusion( extrudedRing->reversed() ); - ring->append( reversedExtrusion.get() ); - ring->close(); - resultFeature.geometry = QgsGeometry( new QgsPolygon( ring.release() ) ); - - - std::unique_ptr< QgsLineString > distanceVHeightRing = std::make_unique< QgsLineString >( newDistance, newZ ); - std::unique_ptr< QgsLineString > extrudedDistanceVHeightRing = std::make_unique< QgsLineString >( newDistance, extrudedZ ); - std::unique_ptr< QgsLineString > reversedDistanceVHeightExtrusion( extrudedDistanceVHeightRing->reversed() ); - distanceVHeightRing->append( reversedDistanceVHeightExtrusion.get() ); - distanceVHeightRing->close(); - resultFeature.crossSectionGeometry = QgsGeometry( new QgsPolygon( distanceVHeightRing.release() ) ); - } - else - { - resultFeature.geometry = QgsGeometry( new QgsLineString( newX, newY, newZ ) ); - resultFeature.crossSectionGeometry = QgsGeometry( new QgsLineString( newDistance, newZ ) ); - } - mResults->features[resultFeature.featureId].append( resultFeature ); - } } }; @@ -1120,166 +1029,182 @@ bool QgsVectorLayerProfileGenerator::generateProfileForLines() return true; } +QgsPoint QgsVectorLayerProfileGenerator::interpolatePointOnTriangle( const QgsPolygon *triangle, double x, double y ) const +{ + QgsPoint p1, p2, p3; + Qgis::VertexType vt; + triangle->exteriorRing()->pointAt( 0, p1, vt ); + triangle->exteriorRing()->pointAt( 1, p2, vt ); + triangle->exteriorRing()->pointAt( 2, p3, vt ); + const double z = QgsMeshLayerUtils::interpolateFromVerticesData( p1, p2, p3, p1.z(), p2.z(), p3.z(), QgsPointXY( x, y ) ); + return QgsPoint( x, y, z ); +}; + +void QgsVectorLayerProfileGenerator::processTriangleIntersectForPoint( const QgsPolygon *triangle, const QgsPoint *p, QVector< QgsGeometry > &transformedParts, QVector< QgsGeometry > &crossSectionParts ) +{ + const QgsPoint interpolatedPoint = interpolatePointOnTriangle( triangle, p->x(), p->y() ); + mResults->mRawPoints.append( interpolatedPoint ); + mResults->minZ = std::min( mResults->minZ, interpolatedPoint.z() ); + mResults->maxZ = std::max( mResults->maxZ, interpolatedPoint.z() ); + + QString lastError; + const double distance = mProfileCurveEngine->lineLocatePoint( *p, &lastError ); + mResults->mDistanceToHeightMap.insert( distance, interpolatedPoint.z() ); + + if ( mExtrusionEnabled ) + { + const double extrusion = mDataDefinedProperties.valueAsDouble( QgsMapLayerElevationProperties::Property::ExtrusionHeight, mExpressionContext, mExtrusionHeight ); + + transformedParts.append( QgsGeometry( new QgsLineString( interpolatedPoint, + QgsPoint( interpolatedPoint.x(), interpolatedPoint.y(), interpolatedPoint.z() + extrusion ) ) ) ); + crossSectionParts.append( QgsGeometry( new QgsLineString( QgsPoint( distance, interpolatedPoint.z() ), + QgsPoint( distance, interpolatedPoint.z() + extrusion ) ) ) ); + mResults->minZ = std::min( mResults->minZ, interpolatedPoint.z() + extrusion ); + mResults->maxZ = std::max( mResults->maxZ, interpolatedPoint.z() + extrusion ); + } + else + { + transformedParts.append( QgsGeometry( new QgsPoint( interpolatedPoint ) ) ); + crossSectionParts.append( QgsGeometry( new QgsPoint( distance, interpolatedPoint.z() ) ) ); + } +} + +void QgsVectorLayerProfileGenerator::processTriangleIntersectForLine( const QgsPolygon *triangle, const QgsLineString *ls, QVector< QgsGeometry > &transformedParts, QVector< QgsGeometry > &crossSectionParts ) +{ + const int numPoints = ls->numPoints(); + QVector< double > newX( numPoints ); + QVector< double > newY( numPoints ); + QVector< double > newZ( numPoints ); + QVector< double > newDistance( numPoints ); + + const double *inX = ls->xData(); + const double *inY = ls->yData(); + const double *inZ = ls->is3D() ? ls->zData() : nullptr; + double *outX = newX.data(); + double *outY = newY.data(); + double *outZ = newZ.data(); + double *outDistance = newDistance.data(); + + QVector< double > extrudedZ; + double *extZOut = nullptr; + double extrusion = 0; + if ( mExtrusionEnabled ) + { + extrudedZ.resize( numPoints ); + extZOut = extrudedZ.data(); + + extrusion = mDataDefinedProperties.valueAsDouble( QgsMapLayerElevationProperties::Property::ExtrusionHeight, mExpressionContext, mExtrusionHeight ); + } + + QString lastError; + for ( int i = 0 ; ! mFeedback->isCanceled() && i < numPoints; ++i ) + { + double x = *inX++; + double y = *inY++; + double z = inZ ? *inZ++ : 0; + + QgsPoint interpolatedPoint = interpolatePointOnTriangle( triangle, x, y ); + *outX++ = x; + *outY++ = y; + *outZ++ = std::isnan( interpolatedPoint.z() ) ? 0.0 : interpolatedPoint.z(); + if ( extZOut ) + *extZOut++ = z + extrusion; + + mResults->mRawPoints.append( interpolatedPoint ); + mResults->minZ = std::min( mResults->minZ, interpolatedPoint.z() ); + mResults->maxZ = std::max( mResults->maxZ, interpolatedPoint.z() ); + if ( mExtrusionEnabled ) + { + mResults->minZ = std::min( mResults->minZ, interpolatedPoint.z() + extrusion ); + mResults->maxZ = std::max( mResults->maxZ, interpolatedPoint.z() + extrusion ); + } + + const double distance = mProfileCurveEngine->lineLocatePoint( interpolatedPoint, &lastError ); + *outDistance++ = distance; + + mResults->mDistanceToHeightMap.insert( distance, interpolatedPoint.z() ); + } + + if ( mFeedback->isCanceled() ) + return; + + if ( mExtrusionEnabled ) + { + std::unique_ptr< QgsLineString > ring = std::make_unique< QgsLineString >( newX, newY, newZ ); + std::unique_ptr< QgsLineString > extrudedRing = std::make_unique< QgsLineString >( newX, newY, extrudedZ ); + std::unique_ptr< QgsLineString > reversedExtrusion( extrudedRing->reversed() ); + ring->append( reversedExtrusion.get() ); + ring->close(); + transformedParts.append( QgsGeometry( new QgsPolygon( ring.release() ) ) ); + + + std::unique_ptr< QgsLineString > distanceVHeightRing = std::make_unique< QgsLineString >( newDistance, newZ ); + std::unique_ptr< QgsLineString > extrudedDistanceVHeightRing = std::make_unique< QgsLineString >( newDistance, extrudedZ ); + std::unique_ptr< QgsLineString > reversedDistanceVHeightExtrusion( extrudedDistanceVHeightRing->reversed() ); + distanceVHeightRing->append( reversedDistanceVHeightExtrusion.get() ); + distanceVHeightRing->close(); + crossSectionParts.append( QgsGeometry( new QgsPolygon( distanceVHeightRing.release() ) ) ); + } + else + { + transformedParts.append( QgsGeometry( new QgsLineString( newX, newY, newZ ) ) ); + crossSectionParts.append( QgsGeometry( new QgsLineString( newDistance, newZ ) ) ); + } +}; + bool QgsVectorLayerProfileGenerator::generateProfileForPolygons() { // get features from layer QgsFeatureRequest request; request.setDestinationCrs( mTargetCrs, mTransformContext ); - request.setFilterRect( mProfileCurve->boundingBox() ); + request.setFilterRect( mProfileBox->boundingBox() ); request.setSubsetOfAttributes( mDataDefinedProperties.referencedFields( mExpressionContext ), mFields ); request.setFeedback( mFeedback.get() ); - auto interpolatePointOnTriangle = []( const QgsPolygon * triangle, double x, double y ) -> QgsPoint - { - QgsPoint p1, p2, p3; - Qgis::VertexType vt; - triangle->exteriorRing()->pointAt( 0, p1, vt ); - triangle->exteriorRing()->pointAt( 1, p2, vt ); - triangle->exteriorRing()->pointAt( 2, p3, vt ); - const double z = QgsMeshLayerUtils::interpolateFromVerticesData( p1, p2, p3, p1.z(), p2.z(), p3.z(), QgsPointXY( x, y ) ); - return QgsPoint( x, y, z ); - }; - std::function< void( const QgsPolygon *triangle, const QgsAbstractGeometry *intersect, QVector< QgsGeometry > &, QVector< QgsGeometry > & ) > processTriangleLineIntersect; - processTriangleLineIntersect = [this, &interpolatePointOnTriangle, &processTriangleLineIntersect]( const QgsPolygon * triangle, const QgsAbstractGeometry * intersect, QVector< QgsGeometry > &transformedParts, QVector< QgsGeometry > &crossSectionParts ) + processTriangleLineIntersect = [this]( const QgsPolygon * triangle, const QgsAbstractGeometry * intersection, QVector< QgsGeometry > &transformedParts, QVector< QgsGeometry > &crossSectionParts ) { - // intersect may be a (multi)point or (multi)linestring - switch ( QgsWkbTypes::geometryType( intersect->wkbType() ) ) + for ( auto it = intersection->const_parts_begin(); + ! mFeedback->isCanceled() && it != intersection->const_parts_end(); + ++it ) { - case Qgis::GeometryType::Point: - if ( const QgsMultiPoint *mp = qgsgeometry_cast< const QgsMultiPoint * >( intersect ) ) - { - const int numPoint = mp->numGeometries(); - for ( int i = 0; i < numPoint; ++i ) + // intersect may be a (multi)point or (multi)linestring + switch ( QgsWkbTypes::geometryType( ( *it )->wkbType() ) ) + { + case Qgis::GeometryType::Point: + if ( const QgsPoint *p = qgsgeometry_cast< const QgsPoint * >( *it ) ) { - processTriangleLineIntersect( triangle, mp->geometryN( i ), transformedParts, crossSectionParts ); + processTriangleIntersectForPoint( triangle, p, transformedParts, crossSectionParts ); } - } - else if ( const QgsPoint *p = qgsgeometry_cast< const QgsPoint * >( intersect ) ) - { - const QgsPoint interpolatedPoint = interpolatePointOnTriangle( triangle, p->x(), p->y() ); - mResults->mRawPoints.append( interpolatedPoint ); - mResults->minZ = std::min( mResults->minZ, interpolatedPoint.z() ); - mResults->maxZ = std::max( mResults->maxZ, interpolatedPoint.z() ); - - QString lastError; - const double distance = mProfileCurveEngine->lineLocatePoint( *p, &lastError ); - mResults->mDistanceToHeightMap.insert( distance, interpolatedPoint.z() ); + break; - if ( mExtrusionEnabled ) - { - const double extrusion = mDataDefinedProperties.valueAsDouble( QgsMapLayerElevationProperties::Property::ExtrusionHeight, mExpressionContext, mExtrusionHeight ); - - transformedParts.append( QgsGeometry( new QgsLineString( interpolatedPoint, - QgsPoint( interpolatedPoint.x(), interpolatedPoint.y(), interpolatedPoint.z() + extrusion ) ) ) ); - crossSectionParts.append( QgsGeometry( new QgsLineString( QgsPoint( distance, interpolatedPoint.z() ), - QgsPoint( distance, interpolatedPoint.z() + extrusion ) ) ) ); - mResults->minZ = std::min( mResults->minZ, interpolatedPoint.z() + extrusion ); - mResults->maxZ = std::max( mResults->maxZ, interpolatedPoint.z() + extrusion ); - } - else - { - transformedParts.append( QgsGeometry( new QgsPoint( interpolatedPoint ) ) ); - crossSectionParts.append( QgsGeometry( new QgsPoint( distance, interpolatedPoint.z() ) ) ); - } - } - break; - case Qgis::GeometryType::Line: - if ( const QgsMultiLineString *ml = qgsgeometry_cast< const QgsMultiLineString * >( intersect ) ) - { - const int numLines = ml->numGeometries(); - for ( int i = 0; i < numLines; ++i ) - { - processTriangleLineIntersect( triangle, ml->geometryN( i ), transformedParts, crossSectionParts ); - } - } - else if ( const QgsLineString *ls = qgsgeometry_cast< const QgsLineString * >( intersect ) ) - { - const int numPoints = ls->numPoints(); - QVector< double > newX; - newX.resize( numPoints ); - QVector< double > newY; - newY.resize( numPoints ); - QVector< double > newZ; - newZ.resize( numPoints ); - QVector< double > newDistance; - newDistance.resize( numPoints ); - - const double *inX = ls->xData(); - const double *inY = ls->yData(); - double *outX = newX.data(); - double *outY = newY.data(); - double *outZ = newZ.data(); - double *outDistance = newDistance.data(); - - QVector< double > extrudedZ; - double *extZOut = nullptr; - double extrusion = 0; - if ( mExtrusionEnabled ) + case Qgis::GeometryType::Line: + if ( const QgsLineString *ls = qgsgeometry_cast< const QgsLineString * >( *it ) ) { - extrudedZ.resize( numPoints ); - extZOut = extrudedZ.data(); - - extrusion = mDataDefinedProperties.valueAsDouble( QgsMapLayerElevationProperties::Property::ExtrusionHeight, mExpressionContext, mExtrusionHeight ); + processTriangleIntersectForLine( triangle, ls, transformedParts, crossSectionParts ); } + break; - QString lastError; - for ( int i = 0 ; i < numPoints; ++i ) + case Qgis::GeometryType::Polygon: + if ( const QgsPolygon *poly = qgsgeometry_cast< const QgsPolygon * >( *it ) ) { - double x = *inX++; - double y = *inY++; - - QgsPoint interpolatedPoint = interpolatePointOnTriangle( triangle, x, y ); - *outX++ = x; - *outY++ = y; - *outZ++ = interpolatedPoint.z(); - if ( extZOut ) - *extZOut++ = interpolatedPoint.z() + extrusion; - - mResults->mRawPoints.append( interpolatedPoint ); - mResults->minZ = std::min( mResults->minZ, interpolatedPoint.z() ); - mResults->maxZ = std::max( mResults->maxZ, interpolatedPoint.z() ); - if ( mExtrusionEnabled ) + if ( const QgsCurve *exterior = poly->exteriorRing() ) { - mResults->minZ = std::min( mResults->minZ, interpolatedPoint.z() + extrusion ); - mResults->maxZ = std::max( mResults->maxZ, interpolatedPoint.z() + extrusion ); + QgsLineString *ls = qgsgeometry_cast( exterior ); + processTriangleIntersectForLine( triangle, ls, transformedParts, crossSectionParts ); + } + for ( int i = 0; i < poly->numInteriorRings(); ++i ) + { + QgsLineString *ls = qgsgeometry_cast( poly->interiorRing( i ) ); + processTriangleIntersectForLine( triangle, ls, transformedParts, crossSectionParts ); } - - const double distance = mProfileCurveEngine->lineLocatePoint( interpolatedPoint, &lastError ); - *outDistance++ = distance; - - mResults->mDistanceToHeightMap.insert( distance, interpolatedPoint.z() ); - } - - if ( mExtrusionEnabled ) - { - std::unique_ptr< QgsLineString > ring = std::make_unique< QgsLineString >( newX, newY, newZ ); - std::unique_ptr< QgsLineString > extrudedRing = std::make_unique< QgsLineString >( newX, newY, extrudedZ ); - std::unique_ptr< QgsLineString > reversedExtrusion( extrudedRing->reversed() ); - ring->append( reversedExtrusion.get() ); - ring->close(); - transformedParts.append( QgsGeometry( new QgsPolygon( ring.release() ) ) ); - - - std::unique_ptr< QgsLineString > distanceVHeightRing = std::make_unique< QgsLineString >( newDistance, newZ ); - std::unique_ptr< QgsLineString > extrudedDistanceVHeightRing = std::make_unique< QgsLineString >( newDistance, extrudedZ ); - std::unique_ptr< QgsLineString > reversedDistanceVHeightExtrusion( extrudedDistanceVHeightRing->reversed() ); - distanceVHeightRing->append( reversedDistanceVHeightExtrusion.get() ); - distanceVHeightRing->close(); - crossSectionParts.append( QgsGeometry( new QgsPolygon( distanceVHeightRing.release() ) ) ); - } - else - { - transformedParts.append( QgsGeometry( new QgsLineString( newX, newY, newZ ) ) ); - crossSectionParts.append( QgsGeometry( new QgsLineString( newDistance, newZ ) ) ); } - } - break; + break; - case Qgis::GeometryType::Polygon: - case Qgis::GeometryType::Unknown: - case Qgis::GeometryType::Null: - return; + case Qgis::GeometryType::Unknown: + case Qgis::GeometryType::Null: + return; + } } }; @@ -1291,7 +1216,7 @@ bool QgsVectorLayerProfileGenerator::generateProfileForPolygons() ring->xAt( 2 ), ring->yAt( 2 ), 0.005 ); }; - auto processPolygon = [this, &triangleIsCollinearInXYPlane, &processTriangleLineIntersect]( const QgsCurvePolygon * polygon, QVector< QgsGeometry > &transformedParts, QVector< QgsGeometry > &crossSectionParts, double offset, bool & wasCollinear ) + auto processPolygon = [this, &processTriangleLineIntersect, &triangleIsCollinearInXYPlane]( const QgsCurvePolygon * polygon, QVector< QgsGeometry > &transformedParts, QVector< QgsGeometry > &crossSectionParts, double offset, bool & wasCollinear ) { std::unique_ptr< QgsPolygon > clampedPolygon; if ( const QgsPolygon *p = qgsgeometry_cast< const QgsPolygon * >( polygon ) ) @@ -1330,11 +1255,8 @@ bool QgsVectorLayerProfileGenerator::generateProfileForPolygons() // iterate through the tessellation, finding triangles which intersect the line const int numTriangles = qgsgeometry_cast< const QgsMultiPolygon * >( tessellation.constGet() )->numGeometries(); - for ( int i = 0; i < numTriangles; ++i ) + for ( int i = 0; ! mFeedback->isCanceled() && i < numTriangles; ++i ) { - if ( mFeedback->isCanceled() ) - return; - const QgsPolygon *triangle = qgsgeometry_cast< const QgsPolygon * >( qgsgeometry_cast< const QgsMultiPolygon * >( tessellation.constGet() )->geometryN( i ) ); if ( triangleIsCollinearInXYPlane( triangle ) ) @@ -1390,14 +1312,11 @@ bool QgsVectorLayerProfileGenerator::generateProfileForPolygons() } else { - if ( mProfileCurveEngine->intersects( triangle ) ) + QString error; + if ( mProfileBoxEngine->intersects( triangle, &error ) ) { - QString error; - std::unique_ptr< QgsAbstractGeometry > intersection( mProfileCurveEngine->intersection( triangle, &error ) ); - if ( intersection && !intersection->isEmpty() ) - { - processTriangleLineIntersect( triangle, intersection.get(), transformedParts, crossSectionParts ); - } + std::unique_ptr< QgsAbstractGeometry > intersection( mProfileBoxEngine->intersection( triangle, &error ) ); + processTriangleLineIntersect( triangle, intersection.get(), transformedParts, crossSectionParts ); } } } @@ -1405,39 +1324,26 @@ bool QgsVectorLayerProfileGenerator::generateProfileForPolygons() QgsFeature feature; QgsFeatureIterator it = mSource->getFeatures( request ); - while ( it.nextFeature( feature ) ) + while ( ! mFeedback->isCanceled() && it.nextFeature( feature ) ) { - if ( mFeedback->isCanceled() ) - return false; - - if ( !mProfileCurveEngine->intersects( feature.geometry().constGet() ) ) + if ( !mProfileBoxEngine->intersects( feature.geometry().constGet() ) ) continue; mExpressionContext.setFeature( feature ); const double offset = mDataDefinedProperties.valueAsDouble( QgsMapLayerElevationProperties::Property::ZOffset, mExpressionContext, mOffset ); - const QgsGeometry g = feature.geometry(); QVector< QgsGeometry > transformedParts; QVector< QgsGeometry > crossSectionParts; bool wasCollinear = false; - if ( g.isMultipart() ) + + for ( auto it = g.const_parts_begin(); ! mFeedback->isCanceled() && it != g.const_parts_end(); ++it ) { - for ( auto it = g.const_parts_begin(); it != g.const_parts_end(); ++it ) + if ( mProfileBoxEngine->intersects( *it ) ) { - if ( mFeedback->isCanceled() ) - break; - - if ( !mProfileCurveEngine->intersects( *it ) ) - continue; - processPolygon( qgsgeometry_cast< const QgsCurvePolygon * >( *it ), transformedParts, crossSectionParts, offset, wasCollinear ); } } - else - { - processPolygon( qgsgeometry_cast< const QgsCurvePolygon * >( g.constGet() ), transformedParts, crossSectionParts, offset, wasCollinear ); - } if ( mFeedback->isCanceled() ) return false; diff --git a/src/core/vector/qgsvectorlayerprofilegenerator.h b/src/core/vector/qgsvectorlayerprofilegenerator.h index 046c4407c99a..713a777e4807 100644 --- a/src/core/vector/qgsvectorlayerprofilegenerator.h +++ b/src/core/vector/qgsvectorlayerprofilegenerator.h @@ -127,6 +127,10 @@ class CORE_EXPORT QgsVectorLayerProfileGenerator : public QgsAbstractProfileSurf void processIntersectionPoint( const QgsPoint *intersectionPoint, const QgsFeature &feature ); void processIntersectionCurve( const QgsLineString *intersectionCurve, const QgsFeature &feature ); + QgsPoint interpolatePointOnTriangle( const QgsPolygon *triangle, double x, double y ) const; + void processTriangleIntersectForPoint( const QgsPolygon *triangle, const QgsPoint *intersect, QVector< QgsGeometry > &transformedParts, QVector< QgsGeometry > &crossSectionParts ); + void processTriangleIntersectForLine( const QgsPolygon *triangle, const QgsLineString *intersect, QVector< QgsGeometry > &transformedParts, QVector< QgsGeometry > &crossSectionParts ); + double terrainHeight( double x, double y ); double featureZToHeight( double x, double y, double z, double offset ); diff --git a/tests/src/core/testqgselevationprofile.cpp b/tests/src/core/testqgselevationprofile.cpp index 99648b8ada00..1b9c1eb404b0 100644 --- a/tests/src/core/testqgselevationprofile.cpp +++ b/tests/src/core/testqgselevationprofile.cpp @@ -56,6 +56,7 @@ class TestQgsElevationProfile : public QgsTest void testVectorLayerProfileForPoint(); void testVectorLayerProfileForLine(); + void testVectorLayerProfileForPolygon(); }; @@ -290,6 +291,20 @@ void TestQgsElevationProfile::testVectorLayerProfileForLine() } +void TestQgsElevationProfile::testVectorLayerProfileForPolygon() +{ + QgsLineString *profileCurve = new QgsLineString ; + profileCurve->setPoints( mProfilePoints ); + + QgsProfileRequest request( profileCurve ); + request.setCrs( QgsCoordinateReferenceSystem::fromEpsgId( 3857 ) ); + request.setTerrainProvider( mDemTerrain->clone() ); + + doCheckLine( request, 1, mpPolygonsLayer, { 168, 206, 210, 284, 306, 321 }, { 1, 1, 1, 1, 1, 1 } ); + doCheckLine( request, 10, mpPolygonsLayer, { 168, 172, 206, 210, 231, 267, 275, 282, 284, 306, 307, 319, 321 }, { 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 } ); + doCheckLine( request, 11, mpPolygonsLayer, { 168, 172, 206, 210, 231, 255, 267, 275, 282, 283, 284, 306, 307, 319, 321 }, { 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 } ); +} + QGSTEST_MAIN( TestQgsElevationProfile ) #include "testqgselevationprofile.moc" From 1b9e2ae138bd01e2f1c4383905ad7bd41d4a5027 Mon Sep 17 00:00:00 2001 From: bdm-oslandia Date: Fri, 25 Aug 2023 09:43:07 +0200 Subject: [PATCH 12/26] fix(elevation_profile): rename var to improve readability --- .../vector/qgsvectorlayerprofilegenerator.cpp | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/src/core/vector/qgsvectorlayerprofilegenerator.cpp b/src/core/vector/qgsvectorlayerprofilegenerator.cpp index e6d0fc638402..3c8ea9a9f3e8 100644 --- a/src/core/vector/qgsvectorlayerprofilegenerator.cpp +++ b/src/core/vector/qgsvectorlayerprofilegenerator.cpp @@ -1069,17 +1069,17 @@ void QgsVectorLayerProfileGenerator::processTriangleIntersectForPoint( const Qgs } } -void QgsVectorLayerProfileGenerator::processTriangleIntersectForLine( const QgsPolygon *triangle, const QgsLineString *ls, QVector< QgsGeometry > &transformedParts, QVector< QgsGeometry > &crossSectionParts ) +void QgsVectorLayerProfileGenerator::processTriangleIntersectForLine( const QgsPolygon *triangle, const QgsLineString *intersectionLine, QVector< QgsGeometry > &transformedParts, QVector< QgsGeometry > &crossSectionParts ) { - const int numPoints = ls->numPoints(); + const int numPoints = intersectionLine->numPoints(); QVector< double > newX( numPoints ); QVector< double > newY( numPoints ); QVector< double > newZ( numPoints ); QVector< double > newDistance( numPoints ); - const double *inX = ls->xData(); - const double *inY = ls->yData(); - const double *inZ = ls->is3D() ? ls->zData() : nullptr; + const double *inX = intersectionLine->xData(); + const double *inY = intersectionLine->yData(); + const double *inZ = intersectionLine->is3D() ? intersectionLine->zData() : nullptr; double *outX = newX.data(); double *outY = newY.data(); double *outZ = newZ.data(); @@ -1137,7 +1137,6 @@ void QgsVectorLayerProfileGenerator::processTriangleIntersectForLine( const QgsP ring->close(); transformedParts.append( QgsGeometry( new QgsPolygon( ring.release() ) ) ); - std::unique_ptr< QgsLineString > distanceVHeightRing = std::make_unique< QgsLineString >( newDistance, newZ ); std::unique_ptr< QgsLineString > extrudedDistanceVHeightRing = std::make_unique< QgsLineString >( newDistance, extrudedZ ); std::unique_ptr< QgsLineString > reversedDistanceVHeightExtrusion( extrudedDistanceVHeightRing->reversed() ); @@ -1179,9 +1178,9 @@ bool QgsVectorLayerProfileGenerator::generateProfileForPolygons() break; case Qgis::GeometryType::Line: - if ( const QgsLineString *ls = qgsgeometry_cast< const QgsLineString * >( *it ) ) + if ( const QgsLineString *intersectionLine = qgsgeometry_cast< const QgsLineString * >( *it ) ) { - processTriangleIntersectForLine( triangle, ls, transformedParts, crossSectionParts ); + processTriangleIntersectForLine( triangle, intersectionLine, transformedParts, crossSectionParts ); } break; @@ -1190,13 +1189,13 @@ bool QgsVectorLayerProfileGenerator::generateProfileForPolygons() { if ( const QgsCurve *exterior = poly->exteriorRing() ) { - QgsLineString *ls = qgsgeometry_cast( exterior ); - processTriangleIntersectForLine( triangle, ls, transformedParts, crossSectionParts ); + QgsLineString *intersectionLine = qgsgeometry_cast( exterior ); + processTriangleIntersectForLine( triangle, intersectionLine, transformedParts, crossSectionParts ); } for ( int i = 0; i < poly->numInteriorRings(); ++i ) { - QgsLineString *ls = qgsgeometry_cast( poly->interiorRing( i ) ); - processTriangleIntersectForLine( triangle, ls, transformedParts, crossSectionParts ); + QgsLineString *intersectionLine = qgsgeometry_cast( poly->interiorRing( i ) ); + processTriangleIntersectForLine( triangle, intersectionLine, transformedParts, crossSectionParts ); } } break; From 1151fc8663d15124aea3c9fa9ccc103c70ee034c Mon Sep 17 00:00:00 2001 From: bdm-oslandia Date: Fri, 25 Aug 2023 10:35:28 +0200 Subject: [PATCH 13/26] fix(elevation_profile): resolve bug when using tolerance and polygons --- .../vector/qgsvectorlayerprofilegenerator.cpp | 240 ++++++++++++------ .../vector/qgsvectorlayerprofilegenerator.h | 1 + .../test_qgsvectorlayerprofilegenerator.py | 72 ++++++ 3 files changed, 232 insertions(+), 81 deletions(-) diff --git a/src/core/vector/qgsvectorlayerprofilegenerator.cpp b/src/core/vector/qgsvectorlayerprofilegenerator.cpp index 3c8ea9a9f3e8..b1ba8cd70acd 100644 --- a/src/core/vector/qgsvectorlayerprofilegenerator.cpp +++ b/src/core/vector/qgsvectorlayerprofilegenerator.cpp @@ -1071,7 +1071,7 @@ void QgsVectorLayerProfileGenerator::processTriangleIntersectForPoint( const Qgs void QgsVectorLayerProfileGenerator::processTriangleIntersectForLine( const QgsPolygon *triangle, const QgsLineString *intersectionLine, QVector< QgsGeometry > &transformedParts, QVector< QgsGeometry > &crossSectionParts ) { - const int numPoints = intersectionLine->numPoints(); + int numPoints = intersectionLine->numPoints(); QVector< double > newX( numPoints ); QVector< double > newY( numPoints ); QVector< double > newZ( numPoints ); @@ -1085,9 +1085,11 @@ void QgsVectorLayerProfileGenerator::processTriangleIntersectForLine( const QgsP double *outZ = newZ.data(); double *outDistance = newDistance.data(); + double lastDistanceAlongProfileCurve = 0.0; QVector< double > extrudedZ; double *extZOut = nullptr; double extrusion = 0; + if ( mExtrusionEnabled ) { extrudedZ.resize( numPoints ); @@ -1103,12 +1105,22 @@ void QgsVectorLayerProfileGenerator::processTriangleIntersectForLine( const QgsP double y = *inY++; double z = inZ ? *inZ++ : 0; - QgsPoint interpolatedPoint = interpolatePointOnTriangle( triangle, x, y ); + QgsPoint interpolatedPoint( x, y, z ); // general case (not a triangle) + *outX++ = x; *outY++ = y; + if ( triangle->exteriorRing()->numPoints() <= 4 ) // triangle case + { + QgsPoint tmpPt = interpolatePointOnTriangle( triangle, x, y ); + if ( ! tmpPt.isEmpty() ) // point x,y inside the triangle + { + interpolatedPoint = tmpPt; + } + } *outZ++ = std::isnan( interpolatedPoint.z() ) ? 0.0 : interpolatedPoint.z(); - if ( extZOut ) - *extZOut++ = z + extrusion; + + if ( mExtrusionEnabled ) + *extZOut++ = ( std::isnan( interpolatedPoint.z() ) ? 0.0 : interpolatedPoint.z() ) + extrusion; mResults->mRawPoints.append( interpolatedPoint ); mResults->minZ = std::min( mResults->minZ, interpolatedPoint.z() ); @@ -1123,8 +1135,12 @@ void QgsVectorLayerProfileGenerator::processTriangleIntersectForLine( const QgsP *outDistance++ = distance; mResults->mDistanceToHeightMap.insert( distance, interpolatedPoint.z() ); + lastDistanceAlongProfileCurve = distance; } + // insert nan point to end the line + mResults->mDistanceToHeightMap.insert( lastDistanceAlongProfileCurve + 0.000001, qQNaN() ); + if ( mFeedback->isCanceled() ) return; @@ -1151,6 +1167,54 @@ void QgsVectorLayerProfileGenerator::processTriangleIntersectForLine( const QgsP } }; +void QgsVectorLayerProfileGenerator::processTriangleIntersectForPolygon( const QgsPolygon *sourcePolygon, const QgsPolygon *intersectionPolygon, QVector< QgsGeometry > &transformedParts, QVector< QgsGeometry > &crossSectionParts ) +{ + bool oldExtrusion = mExtrusionEnabled; + + mExtrusionEnabled = false; + if ( mProfileBoxEngine->contains( sourcePolygon ) ) // sourcePolygon is entirely inside curve buffer, we keep it as whole + { + if ( const QgsCurve *exterior = sourcePolygon->exteriorRing() ) + { + QgsLineString *exteriorLine = qgsgeometry_cast( exterior ); + processTriangleIntersectForLine( sourcePolygon, exteriorLine, transformedParts, crossSectionParts ); + } + for ( int i = 0; i < sourcePolygon->numInteriorRings(); ++i ) + { + QgsLineString *interiorLine = qgsgeometry_cast( sourcePolygon->interiorRing( i ) ); + processTriangleIntersectForLine( sourcePolygon, interiorLine, transformedParts, crossSectionParts ); + } + } + else // sourcePolygon is partially inside curve buffer, the intersectionPolygon is closed due to the intersection operation then + // it must be 'reopened' + { + if ( const QgsCurve *exterior = intersectionPolygon->exteriorRing() ) + { + QgsLineString *exteriorLine = qgsgeometry_cast( exterior )->clone(); + exteriorLine->deleteVertex( QgsVertexId( 0, 0, exteriorLine->numPoints() - 1 ) ); // open linestring + processTriangleIntersectForLine( sourcePolygon, exteriorLine, transformedParts, crossSectionParts ); + delete exteriorLine; + } + for ( int i = 0; i < intersectionPolygon->numInteriorRings(); ++i ) + { + QgsLineString *interiorLine = qgsgeometry_cast( intersectionPolygon->interiorRing( i ) ); + if ( mProfileBoxEngine->contains( interiorLine ) ) // interiorLine is entirely inside curve buffer + { + processTriangleIntersectForLine( sourcePolygon, interiorLine, transformedParts, crossSectionParts ); + } + else + { + interiorLine = qgsgeometry_cast( intersectionPolygon->interiorRing( i ) )->clone(); + interiorLine->deleteVertex( QgsVertexId( 0, 0, interiorLine->numPoints() - 1 ) ); // open linestring + processTriangleIntersectForLine( sourcePolygon, interiorLine, transformedParts, crossSectionParts ); + delete interiorLine; + } + } + } + + mExtrusionEnabled = oldExtrusion; +}; + bool QgsVectorLayerProfileGenerator::generateProfileForPolygons() { // get features from layer @@ -1187,16 +1251,7 @@ bool QgsVectorLayerProfileGenerator::generateProfileForPolygons() case Qgis::GeometryType::Polygon: if ( const QgsPolygon *poly = qgsgeometry_cast< const QgsPolygon * >( *it ) ) { - if ( const QgsCurve *exterior = poly->exteriorRing() ) - { - QgsLineString *intersectionLine = qgsgeometry_cast( exterior ); - processTriangleIntersectForLine( triangle, intersectionLine, transformedParts, crossSectionParts ); - } - for ( int i = 0; i < poly->numInteriorRings(); ++i ) - { - QgsLineString *intersectionLine = qgsgeometry_cast( poly->interiorRing( i ) ); - processTriangleIntersectForLine( triangle, intersectionLine, transformedParts, crossSectionParts ); - } + processTriangleIntersectForPolygon( triangle, poly, transformedParts, crossSectionParts ); } break; @@ -1231,96 +1286,109 @@ bool QgsVectorLayerProfileGenerator::generateProfileForPolygons() if ( mFeedback->isCanceled() ) return; - QgsGeometry tessellation; - if ( clampedPolygon->numInteriorRings() == 0 && clampedPolygon->exteriorRing() && clampedPolygon->exteriorRing()->numPoints() == 4 && clampedPolygon->exteriorRing()->isClosed() ) + if ( mTolerance > 0.0 ) // if the tolerance is not 0.0 we will have a polygon / polygon intersection, we do not need tessellation { - // special case -- polygon is already a triangle, so no need to tessellate - std::unique_ptr< QgsMultiPolygon > multiPolygon = std::make_unique< QgsMultiPolygon >(); - multiPolygon->addGeometry( clampedPolygon.release() ); - tessellation = QgsGeometry( std::move( multiPolygon ) ); + QString error; + if ( mProfileBoxEngine->intersects( clampedPolygon.get(), &error ) ) + { + std::unique_ptr< QgsAbstractGeometry > intersection( mProfileBoxEngine->intersection( clampedPolygon.get(), &error ) ); + processTriangleLineIntersect( clampedPolygon.get(), intersection.get(), transformedParts, crossSectionParts ); + } } - else + else // ie. polygon / line intersection ==> need tessellation { - const QgsRectangle bounds = clampedPolygon->boundingBox(); - QgsTessellator t( bounds, false, false, false, false ); - t.addPolygon( *clampedPolygon, 0 ); - - tessellation = QgsGeometry( t.asMultiPolygon() ); - if ( mFeedback->isCanceled() ) - return; + QgsGeometry tessellation; + if ( clampedPolygon->numInteriorRings() == 0 && clampedPolygon->exteriorRing() && clampedPolygon->exteriorRing()->numPoints() == 4 && clampedPolygon->exteriorRing()->isClosed() ) + { + // special case -- polygon is already a triangle, so no need to tessellate + std::unique_ptr< QgsMultiPolygon > multiPolygon = std::make_unique< QgsMultiPolygon >(); + multiPolygon->addGeometry( clampedPolygon.release() ); + tessellation = QgsGeometry( std::move( multiPolygon ) ); + } + else + { + const QgsRectangle bounds = clampedPolygon->boundingBox(); + QgsTessellator t( bounds, false, false, false, false ); + t.addPolygon( *clampedPolygon, 0 ); - tessellation.translate( bounds.xMinimum(), bounds.yMinimum() ); - } + tessellation = QgsGeometry( t.asMultiPolygon() ); + if ( mFeedback->isCanceled() ) + return; - // iterate through the tessellation, finding triangles which intersect the line - const int numTriangles = qgsgeometry_cast< const QgsMultiPolygon * >( tessellation.constGet() )->numGeometries(); - for ( int i = 0; ! mFeedback->isCanceled() && i < numTriangles; ++i ) - { - const QgsPolygon *triangle = qgsgeometry_cast< const QgsPolygon * >( qgsgeometry_cast< const QgsMultiPolygon * >( tessellation.constGet() )->geometryN( i ) ); + tessellation.translate( bounds.xMinimum(), bounds.yMinimum() ); + } - if ( triangleIsCollinearInXYPlane( triangle ) ) + // iterate through the tessellation, finding triangles which intersect the line + const int numTriangles = qgsgeometry_cast< const QgsMultiPolygon * >( tessellation.constGet() )->numGeometries(); + for ( int i = 0; ! mFeedback->isCanceled() && i < numTriangles; ++i ) { - wasCollinear = true; - const QgsLineString *ring = qgsgeometry_cast< const QgsLineString * >( polygon->exteriorRing() ); + const QgsPolygon *triangle = qgsgeometry_cast< const QgsPolygon * >( qgsgeometry_cast< const QgsMultiPolygon * >( tessellation.constGet() )->geometryN( i ) ); - QString lastError; - if ( const QgsLineString *ls = qgsgeometry_cast< const QgsLineString * >( mProfileCurve.get() ) ) + if ( triangleIsCollinearInXYPlane( triangle ) ) { - for ( int curveSegmentIndex = 0; curveSegmentIndex < mProfileCurve->numPoints() - 1; ++curveSegmentIndex ) + wasCollinear = true; + const QgsLineString *ring = qgsgeometry_cast< const QgsLineString * >( polygon->exteriorRing() ); + + QString lastError; + if ( const QgsLineString *ls = qgsgeometry_cast< const QgsLineString * >( mProfileCurve.get() ) ) { - const QgsPoint p1 = ls->pointN( curveSegmentIndex ); - const QgsPoint p2 = ls->pointN( curveSegmentIndex + 1 ); + for ( int curveSegmentIndex = 0; curveSegmentIndex < mProfileCurve->numPoints() - 1; ++curveSegmentIndex ) + { + const QgsPoint p1 = ls->pointN( curveSegmentIndex ); + const QgsPoint p2 = ls->pointN( curveSegmentIndex + 1 ); - QgsPoint intersectionPoint; - double minZ = std::numeric_limits< double >::max(); - double maxZ = std::numeric_limits< double >::lowest(); + QgsPoint intersectionPoint; + double minZ = std::numeric_limits< double >::max(); + double maxZ = std::numeric_limits< double >::lowest(); - for ( auto vertexPair : std::array, 3> {{ { 0, 1}, {1, 2}, {2, 0} }} ) - { - bool isIntersection = false; - if ( QgsGeometryUtils::segmentIntersection( ring->pointN( vertexPair.first ), ring->pointN( vertexPair.second ), p1, p2, intersectionPoint, isIntersection ) ) + for ( auto vertexPair : std::array, 3> {{ { 0, 1}, {1, 2}, {2, 0} }} ) { - const double fraction = QgsGeometryUtilsBase::pointFractionAlongLine( ring->xAt( vertexPair.first ), ring->yAt( vertexPair.first ), ring->xAt( vertexPair.second ), ring->yAt( vertexPair.second ), intersectionPoint.x(), intersectionPoint.y() ); - const double intersectionZ = ring->zAt( vertexPair.first ) + ( ring->zAt( vertexPair.second ) - ring->zAt( vertexPair.first ) ) * fraction; - minZ = std::min( minZ, intersectionZ ); - maxZ = std::max( maxZ, intersectionZ ); + bool isIntersection = false; + if ( QgsGeometryUtils::segmentIntersection( ring->pointN( vertexPair.first ), ring->pointN( vertexPair.second ), p1, p2, intersectionPoint, isIntersection ) ) + { + const double fraction = QgsGeometryUtilsBase::pointFractionAlongLine( ring->xAt( vertexPair.first ), ring->yAt( vertexPair.first ), ring->xAt( vertexPair.second ), ring->yAt( vertexPair.second ), intersectionPoint.x(), intersectionPoint.y() ); + const double intersectionZ = ring->zAt( vertexPair.first ) + ( ring->zAt( vertexPair.second ) - ring->zAt( vertexPair.first ) ) * fraction; + minZ = std::min( minZ, intersectionZ ); + maxZ = std::max( maxZ, intersectionZ ); + } } - } - if ( !intersectionPoint.isEmpty() ) - { - // need z? - mResults->mRawPoints.append( intersectionPoint ); - mResults->minZ = std::min( mResults->minZ, minZ ); - mResults->maxZ = std::max( mResults->maxZ, maxZ ); + if ( !intersectionPoint.isEmpty() ) + { + // need z? + mResults->mRawPoints.append( intersectionPoint ); + mResults->minZ = std::min( mResults->minZ, minZ ); + mResults->maxZ = std::max( mResults->maxZ, maxZ ); - const double distance = mProfileCurveEngine->lineLocatePoint( intersectionPoint, &lastError ); + const double distance = mProfileCurveEngine->lineLocatePoint( intersectionPoint, &lastError ); - crossSectionParts.append( QgsGeometry( new QgsLineString( QVector< double > {distance, distance}, QVector< double > {minZ, maxZ} ) ) ); + crossSectionParts.append( QgsGeometry( new QgsLineString( QVector< double > {distance, distance}, QVector< double > {minZ, maxZ} ) ) ); - mResults->mDistanceToHeightMap.insert( distance, minZ ); - mResults->mDistanceToHeightMap.insert( distance, maxZ ); + mResults->mDistanceToHeightMap.insert( distance, minZ ); + mResults->mDistanceToHeightMap.insert( distance, maxZ ); + } } } + else + { + // curved geometries, not supported yet, but not possible through the GUI anyway + QgsDebugError( QStringLiteral( "Collinear triangles with curved profile lines are not supported yet" ) ); + } } - else - { - // curved geometries, not supported yet, but not possible through the GUI anyway - QgsDebugError( QStringLiteral( "Collinear triangles with curved profile lines are not supported yet" ) ); - } - } - else - { - QString error; - if ( mProfileBoxEngine->intersects( triangle, &error ) ) + else // not collinear { - std::unique_ptr< QgsAbstractGeometry > intersection( mProfileBoxEngine->intersection( triangle, &error ) ); - processTriangleLineIntersect( triangle, intersection.get(), transformedParts, crossSectionParts ); + QString error; + if ( mProfileBoxEngine->intersects( triangle, &error ) ) + { + std::unique_ptr< QgsAbstractGeometry > intersection( mProfileBoxEngine->intersection( triangle, &error ) ); + processTriangleLineIntersect( triangle, intersection.get(), transformedParts, crossSectionParts ); + } } } } }; + // ========= MAIN JOB QgsFeature feature; QgsFeatureIterator it = mSource->getFeatures( request ); while ( ! mFeedback->isCanceled() && it.nextFeature( feature ) ) @@ -1336,6 +1404,7 @@ bool QgsVectorLayerProfileGenerator::generateProfileForPolygons() QVector< QgsGeometry > crossSectionParts; bool wasCollinear = false; + // === process intersection of geometry feature parts with the mProfileBoxEngine for ( auto it = g.const_parts_begin(); ! mFeedback->isCanceled() && it != g.const_parts_end(); ++it ) { if ( mProfileBoxEngine->intersects( *it ) ) @@ -1347,6 +1416,7 @@ bool QgsVectorLayerProfileGenerator::generateProfileForPolygons() if ( mFeedback->isCanceled() ) return false; + // === aggregate results for this feature QgsVectorLayerProfileResults::Feature resultFeature; resultFeature.featureId = feature.id(); resultFeature.geometry = transformedParts.size() > 1 ? QgsGeometry::collectGeometry( transformedParts ) : transformedParts.value( 0 ); @@ -1355,9 +1425,18 @@ bool QgsVectorLayerProfileGenerator::generateProfileForPolygons() if ( !wasCollinear ) { QgsGeometry unioned = QgsGeometry::unaryUnion( crossSectionParts ); - if ( unioned.type() == Qgis::GeometryType::Line ) - unioned = unioned.mergeLines(); - resultFeature.crossSectionGeometry = unioned; + if ( unioned.isEmpty() ) + { + resultFeature.crossSectionGeometry = QgsGeometry::collectGeometry( crossSectionParts ); + } + else + { + if ( unioned.type() == Qgis::GeometryType::Line ) + { + unioned = unioned.mergeLines(); + } + resultFeature.crossSectionGeometry = unioned; + } } else { @@ -1514,4 +1593,3 @@ bool QgsVectorLayerProfileGenerator::clampAltitudes( QgsPolygon *polygon, double } return true; } - diff --git a/src/core/vector/qgsvectorlayerprofilegenerator.h b/src/core/vector/qgsvectorlayerprofilegenerator.h index 713a777e4807..58cdf904f70a 100644 --- a/src/core/vector/qgsvectorlayerprofilegenerator.h +++ b/src/core/vector/qgsvectorlayerprofilegenerator.h @@ -130,6 +130,7 @@ class CORE_EXPORT QgsVectorLayerProfileGenerator : public QgsAbstractProfileSurf QgsPoint interpolatePointOnTriangle( const QgsPolygon *triangle, double x, double y ) const; void processTriangleIntersectForPoint( const QgsPolygon *triangle, const QgsPoint *intersect, QVector< QgsGeometry > &transformedParts, QVector< QgsGeometry > &crossSectionParts ); void processTriangleIntersectForLine( const QgsPolygon *triangle, const QgsLineString *intersect, QVector< QgsGeometry > &transformedParts, QVector< QgsGeometry > &crossSectionParts ); + void processTriangleIntersectForPolygon( const QgsPolygon *triangle, const QgsPolygon *intersectionPolygon, QVector< QgsGeometry > &transformedParts, QVector< QgsGeometry > &crossSectionParts ); double terrainHeight( double x, double y ); double featureZToHeight( double x, double y, double z, double offset ); diff --git a/tests/src/python/test_qgsvectorlayerprofilegenerator.py b/tests/src/python/test_qgsvectorlayerprofilegenerator.py index afd78e0720eb..339965de320d 100644 --- a/tests/src/python/test_qgsvectorlayerprofilegenerator.py +++ b/tests/src/python/test_qgsvectorlayerprofilegenerator.py @@ -974,6 +974,78 @@ def testPolygonGenerationRelativeExtrusion(self): 'MultiPolygonZ (((-346387.6 6632223.9 67, -346384.8 6632219 67, -346384.8 6632219 74, -346387.6 6632223.9 74, -346387.6 6632223.9 67)),((-346384.8 6632219 67, -346383.5 6632216.9 67, -346383.5 6632216.9 74, -346384.8 6632219 74, -346384.8 6632219 67)))', 'MultiPolygonZ (((-346582.6 6632371.7 62.3, -346579.7 6632370.7 62.3, -346579.7 6632370.7 69.3, -346582.6 6632371.7 69.3, -346582.6 6632371.7 62.3)),((-346579.7 6632370.7 62.3, -346577 6632369.7 62.3, -346570.8 6632367.9 62.3, -346570.8 6632367.9 69.3, -346577 6632369.7 69.3, -346579.7 6632370.7 69.3, -346579.7 6632370.7 62.3)))']) + def testPolygonGenerationRelativeExtrusionTolerance(self): + vl = QgsVectorLayer('PolygonZ?crs=EPSG:27700', 'lines', 'memory') + self.assertTrue(vl.isValid()) + + for line in [ + 'PolygonZ ((321829.48893365426920354 129991.38697145861806348 1, 321847.89668515208177269 129996.63588572069420479 1, 321848.97131609614007175 129979.22330882755341008 1, 321830.31725845142500475 129978.07136809575604275 1, 321829.48893365426920354 129991.38697145861806348 1))', + 'PolygonZ ((321920.00953056826256216 129924.58260190498549491 2, 321924.65299345907988027 129908.43546159457764588 2, 321904.78543491888558492 129903.99811821122420952 2, 321900.80605239619035274 129931.39860145389684476 2, 321904.84799937985371798 129931.71552911199978553 2, 321908.93646715773502365 129912.90030360443051904 2, 321914.20495146053144708 129913.67693978428724222 2, 321911.30165811872575432 129923.01272751353099011 2, 321920.00953056826256216 129924.58260190498549491 2))', + 'PolygonZ ((321923.10517279652412981 129919.61521573827485554 3, 321922.23537852568551898 129928.3598982143739704 3, 321928.60423935484141111 129934.22530528216157109 3, 321929.39881197665818036 129923.29054521876969375 3, 321930.55804549407912418 129916.53248518184409477 3, 321923.10517279652412981 129919.61521573827485554 3))', + 'PolygonZ ((321990.47451346553862095 129909.63588680300745182 4, 321995.04325810901354998 129891.84052284323843196 4, 321989.66826330573530868 129890.5092018858413212 4, 321990.78512359503656626 129886.49917887404444627 4, 321987.37291929306229576 129885.64982962771318853 4, 321985.2254804756375961 129893.81317058412241749 4, 321987.63158903241856024 129894.41078495365218259 4, 321984.34022761805681512 129907.57450046355370432 4, 321990.47451346553862095 129909.63588680300745182 4))', + 'PolygonZ ((322103.03910495212767273 129795.91051736124791205 5, 322108.25568856322206557 129804.76113295342656784 5, 322113.29666162584908307 129803.9285887333098799 5, 322117.78645010641776025 129794.48194090687320568 5, 322103.03910495212767273 129795.91051736124791205 5))']: + f = QgsFeature() + f.setGeometry(QgsGeometry.fromWkt(line)) + self.assertTrue(vl.dataProvider().addFeature(f)) + + vl.elevationProperties().setClamping(Qgis.AltitudeClamping.Relative) + vl.elevationProperties().setZScale(2.5) + vl.elevationProperties().setZOffset(10) + vl.elevationProperties().setExtrusionEnabled(True) + vl.elevationProperties().setExtrusionHeight(7) + + curve = QgsLineString() + curve.fromWkt( + 'LineString (-347701.59207547508412972 6632766.96282589063048363, -346577.00878971704514697 6632369.7371364813297987, -346449.93654899462126195 6632331.81857067719101906, -346383.52035177784273401 6632216.85897350125014782)') + req = QgsProfileRequest(curve) + req.setTransformContext(self.create_transform_context()) + + rl = QgsRasterLayer(os.path.join(unitTestDataPath(), '3d', 'dtm.tif'), 'DTM') + self.assertTrue(rl.isValid()) + terrain_provider = QgsRasterDemTerrainProvider() + terrain_provider.setLayer(rl) + terrain_provider.setScale(0.3) + terrain_provider.setOffset(-5) + req.setTerrainProvider(terrain_provider) + + req.setCrs(QgsCoordinateReferenceSystem('EPSG:3857')) + req.setTolerance(2.0) + + generator = vl.createProfileGenerator(req) + self.assertTrue(generator.generateProfile()) + results = generator.takeResults() + + if QgsProjUtils.projVersionMajor() >= 8: + self.assertEqual(self.round_dict(results.distanceToHeightMap(), 1), + {1041.0: 60.2, 1042.2: 60.2, 1042.9: 60.2, 1048.2: 60.2, 1050.8: 60.2, 1066.9: 60.2, + 1073.4: 60.2, 1076.2: 60.2, 1077.9: 62.0, 1079.9: 62.0, 1089.9: 62.0, 1092.2: 62.0, + 1185.4: 59.2, 1188.2: 59.2, 1192.6: 59.2, 1192.7: 59.2, 1197.9: 59.2, 1200.4: 59.2, + 1449.3: 65.5, 1450.1: 65.5, 1451.1: 65.5, 1458.1: 65.5}) + self.assertAlmostEqual(results.zRange().lower(), 59.25, 2) + self.assertAlmostEqual(results.zRange().upper(), 65.5, 2) + else: + self.assertEqual(self.round_dict(results.distanceToHeightMap(), 1), + {1041.8: 53.5, 1042.4: 53.5, 1049.5: 53.5, 1070.2: 53.5, 1073.1: 53.5, 1074.8: 53.5, + 1078.9: 56.0, 1083.9: 56.0, 1091.1: 56.0, 1186.8: 62.3, 1189.8: 62.3, 1192.7: 62.3, + 1199.2: 62.2, 1450.0: 67.0, 1455.6: 67.0, 1458.1: 67.0}) + self.assertAlmostEqual(results.zRange().lower(), 53.5, 2) + self.assertAlmostEqual(results.zRange().upper(), 74.00000, 2) + + if QgsProjUtils.projVersionMajor() >= 8: + self.assertCountEqual([g.asWkt(1) for g in results.asGeometries()], + [ + 'MultiLineStringZ ((-346696.3 6632409.8 60.2, -346688.9 6632411.2 60.2, -346687.5 6632406.6 60.2),(-346718.9 6632417.7 60.2, -346719.5 6632421.6 60.2, -346718.2 6632421.7 60.2, -346712.5 6632419.7 60.2, -346711.5 6632415.1 60.2))', + 'LineStringZ (-346684.1 6632405.4 62, -346684.6 6632409.8 62, -346673.3 6632405.9 62, -346672.4 6632401.3 62)', + 'LineStringZ (-346571.4 6632370.2 59.3, -346570.2 6632365.6 59.3, -346577.6 6632367.8 59.3, -346577.7 6632367.9 59.3, -346581.9 6632369.4 59.3, -346583.2 6632374 59.3, -346576.4 6632371.6 59.3)', + 'LineStringZ (-346381.8 6632217.9 65.5, -346385.3 6632215.9 65.5, -346388.7 6632221.9 65.5, -346387 6632224.9 65.5, -346385.8 6632224.7 65.5)']) + else: + self.assertCountEqual([g.asWkt(1) for g in results.asGeometries()], + [ + 'MultiPolygonZ (((-346684.3 6632407.6 56, -346679.6 6632406 56, -346679.6 6632406 63, -346684.3 6632407.6 63, -346684.3 6632407.6 56)),((-346679.6 6632406 56, -346672.8 6632403.6 56, -346672.8 6632403.6 63, -346679.6 6632406 63, -346679.6 6632406 56)))', + 'MultiPolygonZ (((-346718.7 6632419.8 53.5, -346712 6632417.4 53.5, -346712 6632417.4 60.5, -346718.7 6632419.8 60.5, -346718.7 6632419.8 53.5)),((-346719.3 6632420 53.5, -346718.7 6632419.8 53.5, -346718.7 6632419.8 60.5, -346719.3 6632420 60.5, -346719.3 6632420 53.5)),((-346689.7 6632409.5 53.5, -346688.2 6632409 53.5, -346688.2 6632409 60.5, -346689.7 6632409.5 60.5, -346689.7 6632409.5 53.5)),((-346692.5 6632410.5 53.5, -346689.7 6632409.5 53.5, -346689.7 6632409.5 60.5, -346692.5 6632410.5 60.5, -346692.5 6632410.5 53.5)))', + 'MultiPolygonZ (((-346387.6 6632223.9 67, -346384.8 6632219 67, -346384.8 6632219 74, -346387.6 6632223.9 74, -346387.6 6632223.9 67)),((-346384.8 6632219 67, -346383.5 6632216.9 67, -346383.5 6632216.9 74, -346384.8 6632219 74, -346384.8 6632219 67)))', + 'MultiPolygonZ (((-346582.6 6632371.7 62.3, -346579.7 6632370.7 62.3, -346579.7 6632370.7 69.3, -346582.6 6632371.7 69.3, -346582.6 6632371.7 62.3)),((-346579.7 6632370.7 62.3, -346577 6632369.7 62.3, -346570.8 6632367.9 62.3, -346570.8 6632367.9 69.3, -346577 6632369.7 69.3, -346579.7 6632370.7 69.3, -346579.7 6632370.7 62.3)))']) + def test25DPolygonGeneration(self): vl = QgsVectorLayer('PolygonZ?crs=EPSG:2056', 'lines', 'memory') self.assertTrue(vl.isValid()) From 02734b58ff74598074a6107180eee1328aa6b4b2 Mon Sep 17 00:00:00 2001 From: bdm-oslandia Date: Fri, 20 Oct 2023 14:40:04 +0200 Subject: [PATCH 14/26] fix(profileGenerator): refix #54422 and simplify algorithm --- .../qgsabstractprofilesurfacegenerator.cpp | 20 +++----- .../test_qgsvectorlayerprofilegenerator.py | 48 ++++++++++++++++++ tests/testdata/3d/dtm_with_holes.tif | Bin 0 -> 824595 bytes ..._as_fill_above_surface_limit_tolerance.png | Bin 1414 -> 1301 bytes ...s_as_fill_below_surface_with_holed_dtm.png | Bin 0 -> 9427 bytes ...ed_vector_lines_as_line_with_holed_dtm.png | Bin 0 -> 10560 bytes 6 files changed, 56 insertions(+), 12 deletions(-) create mode 100644 tests/testdata/3d/dtm_with_holes.tif create mode 100644 tests/testdata/control_images/profile_chart/expected_vector_lines_as_fill_below_surface_with_holed_dtm/expected_vector_lines_as_fill_below_surface_with_holed_dtm.png create mode 100644 tests/testdata/control_images/profile_chart/expected_vector_lines_as_line_with_holed_dtm/expected_vector_lines_as_line_with_holed_dtm.png diff --git a/src/core/elevation/qgsabstractprofilesurfacegenerator.cpp b/src/core/elevation/qgsabstractprofilesurfacegenerator.cpp index ef921bcba7eb..9ec3a9f9b0b4 100644 --- a/src/core/elevation/qgsabstractprofilesurfacegenerator.cpp +++ b/src/core/elevation/qgsabstractprofilesurfacegenerator.cpp @@ -325,30 +325,26 @@ void QgsAbstractProfileSurfaceResults::renderResults( QgsProfileRenderContext &c QPolygonF currentLine; double prevDistance = std::numeric_limits< double >::quiet_NaN(); - double prevHeight = std::numeric_limits< double >::quiet_NaN(); double currentPartStartDistance = 0; for ( auto pointIt = mDistanceToHeightMap.constBegin(); pointIt != mDistanceToHeightMap.constEnd(); ++pointIt ) { - if ( std::isnan( prevDistance ) ) + if ( currentLine.empty() ) // new part { + if ( std::isnan( pointIt.value() ) ) // skip emptiness + continue; currentPartStartDistance = pointIt.key(); } - else if ( currentLine.empty() ) - { - currentPartStartDistance = prevDistance; - currentLine.append( context.worldTransform().map( QPointF( prevDistance, prevHeight ) ) ); - } if ( std::isnan( pointIt.value() ) ) { checkLine( currentLine, context, minZ, maxZ, prevDistance, currentPartStartDistance ); currentLine.clear(); - continue; } - - currentLine.append( context.worldTransform().map( QPointF( pointIt.key(), pointIt.value() ) ) ); - prevDistance = pointIt.key(); - prevHeight = pointIt.value(); + else + { + currentLine.append( context.worldTransform().map( QPointF( pointIt.key(), pointIt.value() ) ) ); + prevDistance = pointIt.key(); + } } checkLine( currentLine, context, minZ, maxZ, prevDistance, currentPartStartDistance ); diff --git a/tests/src/python/test_qgsvectorlayerprofilegenerator.py b/tests/src/python/test_qgsvectorlayerprofilegenerator.py index 339965de320d..5b7de047d851 100644 --- a/tests/src/python/test_qgsvectorlayerprofilegenerator.py +++ b/tests/src/python/test_qgsvectorlayerprofilegenerator.py @@ -1783,6 +1783,54 @@ def testRenderProfileAsSurfaceLinesWithMarkers(self): res = plot_renderer.renderToImage(400, 400, 0, curve.length(), 0, 14) self.assertTrue(self.image_check('vector_lines_as_surface_with_markers', 'vector_lines_as_surface_with_markers', res)) + def testRenderProfileAsLineWithHoledDtm(self): + rl = QgsRasterLayer(os.path.join(unitTestDataPath(), '3d', 'dtm_with_holes.tif'), 'DTM') + self.assertTrue(rl.isValid()) + + rl.elevationProperties().setEnabled(True) + rl.elevationProperties().setProfileSymbology(Qgis.ProfileSurfaceSymbology.Line) + line_symbol = QgsLineSymbol.createSimple({'color': '#ff00ff', 'width': '0.8'}) + rl.elevationProperties().setProfileLineSymbol(line_symbol) + + curve = QgsLineString() + curve.fromWkt('LineString (320900 129000, 322900 129000)') + req = QgsProfileRequest(curve) + req.setTransformContext(self.create_transform_context()) + + req.setCrs(QgsCoordinateReferenceSystem()) + + plot_renderer = QgsProfilePlotRenderer([rl], req) + plot_renderer.startGeneration() + plot_renderer.waitForFinished() + + res = plot_renderer.renderToImage(1600, 800, 0, curve.length(), 0, 90) + self.assertTrue(self.image_check('vector_lines_as_line_with_holed_dtm', 'vector_lines_as_line_with_holed_dtm', res)) + + def testRenderProfileAsSurfaceFillBelowWithHoledDtm(self): + rl = QgsRasterLayer(os.path.join(unitTestDataPath(), '3d', 'dtm_with_holes.tif'), 'DTM') + self.assertTrue(rl.isValid()) + + rl.elevationProperties().setEnabled(True) + rl.elevationProperties().setProfileSymbology(Qgis.ProfileSurfaceSymbology.FillBelow) + fill_symbol = QgsFillSymbol.createSimple({'color': '#ff00ff', 'outline_style': 'no'}) + rl.elevationProperties().setProfileFillSymbol(fill_symbol) + line_symbol = QgsLineSymbol.createSimple({'color': '#ff00ff', 'width': '0.8'}) + rl.elevationProperties().setProfileLineSymbol(line_symbol) + + curve = QgsLineString() + curve.fromWkt('LineString (320900 129000, 322900 129000)') + req = QgsProfileRequest(curve) + req.setTransformContext(self.create_transform_context()) + + req.setCrs(QgsCoordinateReferenceSystem()) + + plot_renderer = QgsProfilePlotRenderer([rl], req) + plot_renderer.startGeneration() + plot_renderer.waitForFinished() + + res = plot_renderer.renderToImage(1600, 800, 0, curve.length(), 0, 90) + self.assertTrue(self.image_check('vector_lines_as_fill_below_surface_with_holed_dtm', 'vector_lines_as_fill_below_surface_with_holed_dtm', res)) + def testRenderProfileAsSurfaceFillBelow(self): vl = QgsVectorLayer('LineStringZ?crs=EPSG:27700', 'lines', 'memory') vl.setCrs(QgsCoordinateReferenceSystem()) diff --git a/tests/testdata/3d/dtm_with_holes.tif b/tests/testdata/3d/dtm_with_holes.tif new file mode 100644 index 0000000000000000000000000000000000000000..fe501eb6a3d0c9b06bda1a5162083e5a6d92db7e GIT binary patch literal 824595 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ÒISPEUP2wXajYho=Ty;B*v7&0yIJO#x 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 F0s!A14G{nU diff --git a/tests/testdata/control_images/profile_chart/expected_vector_lines_as_fill_below_surface_with_holed_dtm/expected_vector_lines_as_fill_below_surface_with_holed_dtm.png b/tests/testdata/control_images/profile_chart/expected_vector_lines_as_fill_below_surface_with_holed_dtm/expected_vector_lines_as_fill_below_surface_with_holed_dtm.png new file mode 100644 index 0000000000000000000000000000000000000000..9c825d3dbb26095adb12a73c4c9e8c06680f5bc6 GIT binary patch literal 9427 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 Date: Thu, 2 Nov 2023 17:49:39 +0100 Subject: [PATCH 15/26] fix(elevation_profile): add geos version verification in test --- tests/src/core/testqgselevationprofile.cpp | 37 ++++++++++++++-------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/tests/src/core/testqgselevationprofile.cpp b/tests/src/core/testqgselevationprofile.cpp index 1b9c1eb404b0..549eb0a9bd29 100644 --- a/tests/src/core/testqgselevationprofile.cpp +++ b/tests/src/core/testqgselevationprofile.cpp @@ -43,9 +43,9 @@ class TestQgsElevationProfile : public QgsTest QgsVectorLayerProfileResults *mProfileResults = nullptr; std::unique_ptr< QgsRasterDemTerrainProvider > mDemTerrain; - void doCheckPoint( QgsProfileRequest &request, int tolerance, QgsVectorLayer *layer, + void doCheckPoint( QgsProfileRequest &request, double tolerance, QgsVectorLayer *layer, const QList &expectedFeatures ); - void doCheckLine( QgsProfileRequest &request, int tolerance, QgsVectorLayer *layer, + void doCheckLine( QgsProfileRequest &request, double tolerance, QgsVectorLayer *layer, const QList &expectedFeatures, const QList &nbSubGeomPerFeature ); QgsVectorLayer *createVectorLayer( const QString &fileName ); @@ -122,8 +122,6 @@ void TestQgsElevationProfile::initTestCase() << QgsPoint( Qgis::WkbType::Point, -346550, 6632030 ) << QgsPoint( Qgis::WkbType::Point, -346440, 6632140 ) << QgsPoint( Qgis::WkbType::Point, -347830, 6632930 ) ; - - } void TestQgsElevationProfile::cleanupTestCase() @@ -131,11 +129,11 @@ void TestQgsElevationProfile::cleanupTestCase() QgsApplication::exitQgis(); } -void TestQgsElevationProfile::doCheckPoint( QgsProfileRequest &request, int tolerance, QgsVectorLayer *layer, +void TestQgsElevationProfile::doCheckPoint( QgsProfileRequest &request, double tolerance, QgsVectorLayer *layer, const QList &expectedFeatures ) { #if DEBUG - qDebug() << "===== checking ====="; + qWarning() << "===== checking ====="; #endif request.setTolerance( tolerance ); @@ -152,7 +150,7 @@ void TestQgsElevationProfile::doCheckPoint( QgsProfileRequest &request, int tole QList actual = mProfileResults->features.keys(); std::sort( actual.begin(), actual.end() ); #if DEBUG - qDebug() << "actual sorted fid" << actual; + qWarning() << "actual sorted fid" << actual; #endif QCOMPARE( actual, expected ); @@ -163,7 +161,7 @@ void TestQgsElevationProfile::doCheckPoint( QgsProfileRequest &request, int tole for ( const QgsVectorLayerProfileResults::Feature &feat : it.value() ) { #if DEBUG - qDebug() << "feat point:" << feat.featureId << "geom:" << feat.geometry.asWkt(); + qWarning() << "feat point:" << feat.featureId << "geom:" << feat.geometry.asWkt(); #endif if ( QgsWkbTypes::hasZ( feat.geometry.wkbType() ) ) { @@ -187,7 +185,7 @@ void TestQgsElevationProfile::doCheckPoint( QgsProfileRequest &request, int tole } -void TestQgsElevationProfile::doCheckLine( QgsProfileRequest &request, int tolerance, QgsVectorLayer *layer, +void TestQgsElevationProfile::doCheckLine( QgsProfileRequest &request, double tolerance, QgsVectorLayer *layer, const QList &expectedFeatures, const QList &nbSubGeomPerFeature ) { doCheckPoint( request, tolerance, layer, expectedFeatures ); @@ -195,7 +193,7 @@ void TestQgsElevationProfile::doCheckLine( QgsProfileRequest &request, int toler // check in how many geometry the feature intersects the profile curve int i = 0; #if DEBUG - qDebug() << "distanceToHeightMap:" << mProfileResults->distanceToHeightMap(); + qWarning() << "distanceToHeightMap:" << mProfileResults->distanceToHeightMap(); #endif QList actual = mProfileResults->features.keys(); std::sort( actual.begin(), actual.end() ); @@ -216,7 +214,11 @@ void TestQgsElevationProfile::testVectorLayerProfileForPoint() request.setCrs( QgsCoordinateReferenceSystem::fromEpsgId( 3857 ) ); request.setTerrainProvider( mDemTerrain->clone() ); - doCheckPoint( request, 15, mpPointsLayer, { 5, 11, 12, 13, 14, 15, 18, 45, 46 } ); + if ( ( Qgis::geosVersionMajor() == 3 && Qgis::geosVersionMinor() <= 10 ) || ( Qgis::geosVersionMajor() == 3 && Qgis::geosVersionMinor() >= 12 ) ) + doCheckPoint( request, 15, mpPointsLayer, { 5, 11, 12, 13, 14, 15, 18, 45, 46 } ); + else if ( Qgis::geosVersionMajor() == 3 && Qgis::geosVersionMinor() == 11 ) + doCheckPoint( request, 16, mpPointsLayer, { 5, 11, 12, 13, 14, 15, 18, 45, 46 } ); + doCheckPoint( request, 70, mpPointsLayer, { 0, 3, 4, 5, 6, 7, 10, 11, 12, 13, 14, 15, 16, 17, 18, 38, 45, 46, 48 } ); } @@ -301,8 +303,17 @@ void TestQgsElevationProfile::testVectorLayerProfileForPolygon() request.setTerrainProvider( mDemTerrain->clone() ); doCheckLine( request, 1, mpPolygonsLayer, { 168, 206, 210, 284, 306, 321 }, { 1, 1, 1, 1, 1, 1 } ); - doCheckLine( request, 10, mpPolygonsLayer, { 168, 172, 206, 210, 231, 267, 275, 282, 284, 306, 307, 319, 321 }, { 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 } ); - doCheckLine( request, 11, mpPolygonsLayer, { 168, 172, 206, 210, 231, 255, 267, 275, 282, 283, 284, 306, 307, 319, 321 }, { 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 } ); + + if ( ( Qgis::geosVersionMajor() == 3 && Qgis::geosVersionMinor() <= 10 ) || ( Qgis::geosVersionMajor() == 3 && Qgis::geosVersionMinor() >= 12 ) ) + { + doCheckLine( request, 10, mpPolygonsLayer, { 168, 172, 206, 210, 231, 267, 275, 282, 284, 306, 307, 319, 321 }, { 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 } ); + doCheckLine( request, 11, mpPolygonsLayer, { 168, 172, 206, 210, 231, 255, 267, 275, 282, 283, 284, 306, 307, 319, 321 }, { 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 } ); + } + else if ( Qgis::geosVersionMajor() == 3 && Qgis::geosVersionMinor() == 11 ) + { + doCheckLine( request, 9, mpPolygonsLayer, { 168, 172, 206, 210, 231, 267, 275, 282, 284, 306, 307, 319, 321 }, { 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 } ); + doCheckLine( request, 10, mpPolygonsLayer, { 168, 172, 206, 210, 231, 267, 275, 282, 283, 284, 306, 307, 319, 321 }, { 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 } ); + } } From 5c31f2660552de4ddcaac4d1871d3b0cc310eabe Mon Sep 17 00:00:00 2001 From: bdm-oslandia Date: Fri, 9 Feb 2024 09:41:15 +0100 Subject: [PATCH 16/26] fix(elevation_profile): rename mProfileBoxXX mProfileBufferedCurveXX Co-authored-by: Nyall Dawson --- .../vector/qgsvectorlayerprofilegenerator.cpp | 34 +++++++++---------- .../vector/qgsvectorlayerprofilegenerator.h | 4 +-- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/src/core/vector/qgsvectorlayerprofilegenerator.cpp b/src/core/vector/qgsvectorlayerprofilegenerator.cpp index b1ba8cd70acd..b778fbd1fbbf 100644 --- a/src/core/vector/qgsvectorlayerprofilegenerator.cpp +++ b/src/core/vector/qgsvectorlayerprofilegenerator.cpp @@ -752,15 +752,15 @@ bool QgsVectorLayerProfileGenerator::generateProfile( const QgsProfileGeneration if ( mTolerance == 0.0 ) // geos does not handle very well buffer with 0 size { - mProfileBox = std::unique_ptr( mProfileCurve->clone() ); + mProfileBufferedCurve = std::unique_ptr( mProfileCurve->clone() ); } else { - mProfileBox = std::unique_ptr( mProfileCurveEngine->buffer( mTolerance, 8, Qgis::EndCapStyle::Flat, Qgis::JoinStyle::Round, 2 ) ); + mProfileBufferedCurve = std::unique_ptr( mProfileCurveEngine->buffer( mTolerance, 8, Qgis::EndCapStyle::Flat, Qgis::JoinStyle::Round, 2 ) ); } - mProfileBoxEngine.reset( new QgsGeos( mProfileBox.get() ) ); - mProfileBoxEngine->prepareGeometry(); + mProfileBufferedCurveEngine.reset( new QgsGeos( mProfileBufferedCurve.get() ) ); + mProfileBufferedCurveEngine->prepareGeometry(); mDataDefinedProperties.prepare( mExpressionContext ); @@ -824,7 +824,7 @@ bool QgsVectorLayerProfileGenerator::generateProfileForPoints() const QgsGeometry g = feature.geometry(); for ( auto it = g.const_parts_begin(); !mFeedback->isCanceled() && it != g.const_parts_end(); ++it ) { - if ( mProfileBoxEngine->intersects( *it ) ) + if ( mProfileBufferedCurveEngine->intersects( *it ) ) { processIntersectionPoint( qgsgeometry_cast< const QgsPoint * >( *it ), feature ); } @@ -965,14 +965,14 @@ bool QgsVectorLayerProfileGenerator::generateProfileForLines() // get features from layer QgsFeatureRequest request; request.setDestinationCrs( mTargetCrs, mTransformContext ); - request.setFilterRect( mProfileBox->boundingBox() ); + request.setFilterRect( mProfileBufferedCurve->boundingBox() ); request.setSubsetOfAttributes( mDataDefinedProperties.referencedFields( mExpressionContext ), mFields ); request.setFeedback( mFeedback.get() ); auto processCurve = [this]( const QgsFeature & feature, const QgsCurve * featGeomPart ) { QString error; - std::unique_ptr< QgsAbstractGeometry > intersection( mProfileBoxEngine->intersection( featGeomPart, &error ) ); + std::unique_ptr< QgsAbstractGeometry > intersection( mProfileBufferedCurveEngine->intersection( featGeomPart, &error ) ); if ( !intersection ) return; @@ -1019,7 +1019,7 @@ bool QgsVectorLayerProfileGenerator::generateProfileForLines() const QgsGeometry g = feature.geometry(); for ( auto it = g.const_parts_begin(); !mFeedback->isCanceled() && it != g.const_parts_end(); ++it ) { - if ( mProfileBoxEngine->intersects( *it ) ) + if ( mProfileBufferedCurveEngine->intersects( *it ) ) { processCurve( feature, qgsgeometry_cast< const QgsCurve * >( *it ) ); } @@ -1172,7 +1172,7 @@ void QgsVectorLayerProfileGenerator::processTriangleIntersectForPolygon( const Q bool oldExtrusion = mExtrusionEnabled; mExtrusionEnabled = false; - if ( mProfileBoxEngine->contains( sourcePolygon ) ) // sourcePolygon is entirely inside curve buffer, we keep it as whole + if ( mProfileBufferedCurveEngine->contains( sourcePolygon ) ) // sourcePolygon is entirely inside curve buffer, we keep it as whole { if ( const QgsCurve *exterior = sourcePolygon->exteriorRing() ) { @@ -1198,7 +1198,7 @@ void QgsVectorLayerProfileGenerator::processTriangleIntersectForPolygon( const Q for ( int i = 0; i < intersectionPolygon->numInteriorRings(); ++i ) { QgsLineString *interiorLine = qgsgeometry_cast( intersectionPolygon->interiorRing( i ) ); - if ( mProfileBoxEngine->contains( interiorLine ) ) // interiorLine is entirely inside curve buffer + if ( mProfileBufferedCurveEngine->contains( interiorLine ) ) // interiorLine is entirely inside curve buffer { processTriangleIntersectForLine( sourcePolygon, interiorLine, transformedParts, crossSectionParts ); } @@ -1220,7 +1220,7 @@ bool QgsVectorLayerProfileGenerator::generateProfileForPolygons() // get features from layer QgsFeatureRequest request; request.setDestinationCrs( mTargetCrs, mTransformContext ); - request.setFilterRect( mProfileBox->boundingBox() ); + request.setFilterRect( mProfileBufferedCurve->boundingBox() ); request.setSubsetOfAttributes( mDataDefinedProperties.referencedFields( mExpressionContext ), mFields ); request.setFeedback( mFeedback.get() ); @@ -1289,9 +1289,9 @@ bool QgsVectorLayerProfileGenerator::generateProfileForPolygons() if ( mTolerance > 0.0 ) // if the tolerance is not 0.0 we will have a polygon / polygon intersection, we do not need tessellation { QString error; - if ( mProfileBoxEngine->intersects( clampedPolygon.get(), &error ) ) + if ( mProfileBufferedCurveEngine->intersects( clampedPolygon.get(), &error ) ) { - std::unique_ptr< QgsAbstractGeometry > intersection( mProfileBoxEngine->intersection( clampedPolygon.get(), &error ) ); + std::unique_ptr< QgsAbstractGeometry > intersection( mProfileBufferedCurveEngine->intersection( clampedPolygon.get(), &error ) ); processTriangleLineIntersect( clampedPolygon.get(), intersection.get(), transformedParts, crossSectionParts ); } } @@ -1378,9 +1378,9 @@ bool QgsVectorLayerProfileGenerator::generateProfileForPolygons() else // not collinear { QString error; - if ( mProfileBoxEngine->intersects( triangle, &error ) ) + if ( mProfileBufferedCurveEngine->intersects( triangle, &error ) ) { - std::unique_ptr< QgsAbstractGeometry > intersection( mProfileBoxEngine->intersection( triangle, &error ) ); + std::unique_ptr< QgsAbstractGeometry > intersection( mProfileBufferedCurveEngine->intersection( triangle, &error ) ); processTriangleLineIntersect( triangle, intersection.get(), transformedParts, crossSectionParts ); } } @@ -1393,7 +1393,7 @@ bool QgsVectorLayerProfileGenerator::generateProfileForPolygons() QgsFeatureIterator it = mSource->getFeatures( request ); while ( ! mFeedback->isCanceled() && it.nextFeature( feature ) ) { - if ( !mProfileBoxEngine->intersects( feature.geometry().constGet() ) ) + if ( !mProfileBufferedCurveEngine->intersects( feature.geometry().constGet() ) ) continue; mExpressionContext.setFeature( feature ); @@ -1407,7 +1407,7 @@ bool QgsVectorLayerProfileGenerator::generateProfileForPolygons() // === process intersection of geometry feature parts with the mProfileBoxEngine for ( auto it = g.const_parts_begin(); ! mFeedback->isCanceled() && it != g.const_parts_end(); ++it ) { - if ( mProfileBoxEngine->intersects( *it ) ) + if ( mProfileBufferedCurveEngine->intersects( *it ) ) { processPolygon( qgsgeometry_cast< const QgsCurvePolygon * >( *it ), transformedParts, crossSectionParts, offset, wasCollinear ); } diff --git a/src/core/vector/qgsvectorlayerprofilegenerator.h b/src/core/vector/qgsvectorlayerprofilegenerator.h index 58cdf904f70a..31ea86f2de90 100644 --- a/src/core/vector/qgsvectorlayerprofilegenerator.h +++ b/src/core/vector/qgsvectorlayerprofilegenerator.h @@ -144,8 +144,8 @@ class CORE_EXPORT QgsVectorLayerProfileGenerator : public QgsAbstractProfileSurf std::unique_ptr< QgsCurve > mProfileCurve; std::unique_ptr< QgsGeos > mProfileCurveEngine; - std::unique_ptr mProfileBox = nullptr; - std::unique_ptr< QgsGeos > mProfileBoxEngine; + std::unique_ptr mProfileBufferedCurve = nullptr; + std::unique_ptr< QgsGeos > mProfileBufferedCurveEngine; std::unique_ptr< QgsAbstractTerrainProvider > mTerrainProvider; From 7123f3b5a24a78c5170e59737a083e1d9de75a7a Mon Sep 17 00:00:00 2001 From: bdm-oslandia Date: Fri, 9 Feb 2024 09:56:57 +0100 Subject: [PATCH 17/26] fix(elevation_profile): generateProfileForLines and generateProfileForPoints return cancel state instead of always true value. Co-authored-by: Nyall Dawson --- src/core/vector/qgsvectorlayerprofilegenerator.cpp | 4 ++-- src/core/vector/qgsvectorlayerprofilegenerator.h | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/core/vector/qgsvectorlayerprofilegenerator.cpp b/src/core/vector/qgsvectorlayerprofilegenerator.cpp index b778fbd1fbbf..30d44aea5403 100644 --- a/src/core/vector/qgsvectorlayerprofilegenerator.cpp +++ b/src/core/vector/qgsvectorlayerprofilegenerator.cpp @@ -830,7 +830,7 @@ bool QgsVectorLayerProfileGenerator::generateProfileForPoints() } } } - return true; + return !mFeedback->isCanceled(); } void QgsVectorLayerProfileGenerator::processIntersectionPoint( const QgsPoint *point, const QgsFeature &feature ) @@ -1026,7 +1026,7 @@ bool QgsVectorLayerProfileGenerator::generateProfileForLines() } } - return true; + return !mFeedback->isCanceled(); } QgsPoint QgsVectorLayerProfileGenerator::interpolatePointOnTriangle( const QgsPolygon *triangle, double x, double y ) const diff --git a/src/core/vector/qgsvectorlayerprofilegenerator.h b/src/core/vector/qgsvectorlayerprofilegenerator.h index 31ea86f2de90..17ae6130298c 100644 --- a/src/core/vector/qgsvectorlayerprofilegenerator.h +++ b/src/core/vector/qgsvectorlayerprofilegenerator.h @@ -144,7 +144,7 @@ class CORE_EXPORT QgsVectorLayerProfileGenerator : public QgsAbstractProfileSurf std::unique_ptr< QgsCurve > mProfileCurve; std::unique_ptr< QgsGeos > mProfileCurveEngine; - std::unique_ptr mProfileBufferedCurve = nullptr; + std::unique_ptr mProfileBufferedCurve; std::unique_ptr< QgsGeos > mProfileBufferedCurveEngine; std::unique_ptr< QgsAbstractTerrainProvider > mTerrainProvider; From fd03cdff4dcaa0347a49f5b909753194fb5c2e47 Mon Sep 17 00:00:00 2001 From: bdm-oslandia Date: Fri, 9 Feb 2024 18:15:21 +0100 Subject: [PATCH 18/26] fix(elevation_profile): migrate c++ test testqgselevationprofile.cpp to python test_qgsvectorlayerprofilegenerator.py --- tests/src/core/CMakeLists.txt | 1 - tests/src/core/testqgselevationprofile.cpp | 321 ------------------ .../test_qgsvectorlayerprofilegenerator.py | 153 +++++++++ 3 files changed, 153 insertions(+), 322 deletions(-) delete mode 100644 tests/src/core/testqgselevationprofile.cpp diff --git a/tests/src/core/CMakeLists.txt b/tests/src/core/CMakeLists.txt index 2274368b3669..686f57293053 100644 --- a/tests/src/core/CMakeLists.txt +++ b/tests/src/core/CMakeLists.txt @@ -52,7 +52,6 @@ set(TESTS testqgsdistancearea.cpp testqgsdxfexport.cpp testqgselevationmap.cpp - testqgselevationprofile.cpp testqgsellipsemarker.cpp testqgsexpression.cpp testqgsexpressioncontext.cpp diff --git a/tests/src/core/testqgselevationprofile.cpp b/tests/src/core/testqgselevationprofile.cpp deleted file mode 100644 index 549eb0a9bd29..000000000000 --- a/tests/src/core/testqgselevationprofile.cpp +++ /dev/null @@ -1,321 +0,0 @@ -/*************************************************************************** - testqgselevationProfile.cpp - -------------------------------------- -Date : August 2022 -Copyright : (C) 2022 by Martin Dobias -Email : wonder dot sk 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 "qgstest.h" - -#include "qgsapplication.h" -#include "qgsvectorlayerprofilegenerator.h" -#include "qgsprofilerequest.h" -#include "qgscurve.h" -#include "qgsvectorlayer.h" -#include "project/qgsprojectelevationproperties.h" -#include "qgsvectorlayerelevationproperties.h" -#include -#include "qgsterrainprovider.h" - -#define DEBUG 0 - -class TestQgsElevationProfile : public QgsTest -{ - Q_OBJECT - - public: - TestQgsElevationProfile() : QgsTest( QStringLiteral( "Elevation Profile Tests" ), QStringLiteral( "elevation_Profile" ) ) {} - - private: - QgsVectorLayer *mpPointsLayer = nullptr; - QgsVectorLayer *mpLinesLayer = nullptr; - QgsVectorLayer *mpPolygonsLayer = nullptr; - QgsRasterLayer *mLayerDtm = nullptr; - QString mTestDataDir; - QgsPointSequence mProfilePoints; - QgsVectorLayerProfileResults *mProfileResults = nullptr; - std::unique_ptr< QgsRasterDemTerrainProvider > mDemTerrain; - - void doCheckPoint( QgsProfileRequest &request, double tolerance, QgsVectorLayer *layer, - const QList &expectedFeatures ); - void doCheckLine( QgsProfileRequest &request, double tolerance, QgsVectorLayer *layer, - const QList &expectedFeatures, const QList &nbSubGeomPerFeature ); - - QgsVectorLayer *createVectorLayer( const QString &fileName ); - - private slots: - void initTestCase(); - void cleanupTestCase(); - - void testVectorLayerProfileForPoint(); - void testVectorLayerProfileForLine(); - void testVectorLayerProfileForPolygon(); -}; - - -QgsVectorLayer *TestQgsElevationProfile::createVectorLayer( const QString &fileName ) -{ - const QString myFileName = mTestDataDir + fileName; - const QFileInfo myFileInfo( myFileName ); - QgsVectorLayer *layer = new QgsVectorLayer( myFileInfo.filePath(), - myFileInfo.completeBaseName(), QStringLiteral( "ogr" ) ); - - dynamic_cast( layer->elevationProperties() )->setClamping( Qgis::AltitudeClamping::Terrain ); - dynamic_cast( layer->elevationProperties() )->setBinding( Qgis::AltitudeBinding::Vertex ); - - return layer; -} - -void TestQgsElevationProfile::initTestCase() -{ - // - // Runs once before any tests are run - // - // init QGIS's paths - true means that all path will be inited from prefix - QgsApplication::init(); - QgsApplication::initQgis(); - QgsApplication::showSettings(); - - QgsProject::instance()->setCrs( QgsCoordinateReferenceSystem::fromEpsgId( 3857 ) ); - - //create some objects that will be used in all tests... - - const QString myDataDir( TEST_DATA_DIR ); //defined in CmakeLists.txt - mTestDataDir = myDataDir + "/3d/"; - - // Create a line layer that will be used in all tests... - mpLinesLayer = createVectorLayer( "lines.shp" ); - dynamic_cast( mpLinesLayer->elevationProperties() )->setExtrusionEnabled( false ); - - // Create a point layer that will be used in all tests... - mpPointsLayer = createVectorLayer( "points_with_z.shp" ); - - // Create a polygon layer that will be used in all tests... - mpPolygonsLayer = createVectorLayer( "buildings.shp" ); - - // Register the layer with the registry - QgsProject::instance()->addMapLayers( - QList() << mpLinesLayer << mpPointsLayer << mpPolygonsLayer ); - - // Create a DEM layer that will be used in all tests... - const QString myDtmFileName = mTestDataDir + "dtm.tif"; - const QFileInfo myDtmFileInfo( myDtmFileName ); - mLayerDtm = new QgsRasterLayer( myDtmFileInfo.filePath(), - myDtmFileInfo.completeBaseName(), QStringLiteral( "gdal" ) ); - QVERIFY( mLayerDtm->isValid() ); - - // set dem as elevation - mDemTerrain = std::make_unique< QgsRasterDemTerrainProvider >(); - mDemTerrain->setLayer( mLayerDtm ); - - QgsProject::instance()->elevationProperties()->setTerrainProvider( mDemTerrain->clone() ); - - // profile curve - mProfilePoints << QgsPoint( Qgis::WkbType::Point, -346120, 6631840 ) - << QgsPoint( Qgis::WkbType::Point, -346550, 6632030 ) - << QgsPoint( Qgis::WkbType::Point, -346440, 6632140 ) - << QgsPoint( Qgis::WkbType::Point, -347830, 6632930 ) ; -} - -void TestQgsElevationProfile::cleanupTestCase() -{ - QgsApplication::exitQgis(); -} - -void TestQgsElevationProfile::doCheckPoint( QgsProfileRequest &request, double tolerance, QgsVectorLayer *layer, - const QList &expectedFeatures ) -{ -#if DEBUG - qWarning() << "===== checking ====="; -#endif - request.setTolerance( tolerance ); - - QgsAbstractProfileGenerator *profGen = layer->createProfileGenerator( request ); - QVERIFY( profGen ); - QVERIFY( profGen->generateProfile() ); - mProfileResults = dynamic_cast( profGen->takeResults() ); - QVERIFY( mProfileResults ); - QVERIFY( ! mProfileResults->features.empty() ); - - QList expected = expectedFeatures; - std::sort( expected.begin(), expected.end() ); - - QList actual = mProfileResults->features.keys(); - std::sort( actual.begin(), actual.end() ); -#if DEBUG - qWarning() << "actual sorted fid" << actual; -#endif - - QCOMPARE( actual, expected ); - - for ( auto it = mProfileResults->features.constBegin(); - it != mProfileResults->features.constEnd(); ++it ) - { - for ( const QgsVectorLayerProfileResults::Feature &feat : it.value() ) - { -#if DEBUG - qWarning() << "feat point:" << feat.featureId << "geom:" << feat.geometry.asWkt(); -#endif - if ( QgsWkbTypes::hasZ( feat.geometry.wkbType() ) ) - { - bool hasValidZ = false; - for ( QgsAbstractGeometry::vertex_iterator it = feat.geometry.vertices_begin(); it != feat.geometry.vertices_end(); ++it ) - { - if ( it.operator * ().z() != 0.0 ) - { - hasValidZ = true; - break; - } - } - QVERIFY2( hasValidZ, "All vertice are on the ground!" ); - } - else - { - QVERIFY2( false, "Geometry should have z coordinates!" ); - } - } - } - -} - -void TestQgsElevationProfile::doCheckLine( QgsProfileRequest &request, double tolerance, QgsVectorLayer *layer, - const QList &expectedFeatures, const QList &nbSubGeomPerFeature ) -{ - doCheckPoint( request, tolerance, layer, expectedFeatures ); - - // check in how many geometry the feature intersects the profile curve - int i = 0; -#if DEBUG - qWarning() << "distanceToHeightMap:" << mProfileResults->distanceToHeightMap(); -#endif - QList actual = mProfileResults->features.keys(); - std::sort( actual.begin(), actual.end() ); - - for ( auto it = actual.constBegin(); it != actual.constEnd(); ++it, ++i ) - { - QVector< QgsVectorLayerProfileResults::Feature > feats = mProfileResults->features[*it]; - QCOMPARE( feats.size(), nbSubGeomPerFeature[i] ); - } -} - -void TestQgsElevationProfile::testVectorLayerProfileForPoint() -{ - QgsLineString *profileCurve = new QgsLineString ; - profileCurve->setPoints( mProfilePoints ); - - QgsProfileRequest request( profileCurve ); - request.setCrs( QgsCoordinateReferenceSystem::fromEpsgId( 3857 ) ); - request.setTerrainProvider( mDemTerrain->clone() ); - - if ( ( Qgis::geosVersionMajor() == 3 && Qgis::geosVersionMinor() <= 10 ) || ( Qgis::geosVersionMajor() == 3 && Qgis::geosVersionMinor() >= 12 ) ) - doCheckPoint( request, 15, mpPointsLayer, { 5, 11, 12, 13, 14, 15, 18, 45, 46 } ); - else if ( Qgis::geosVersionMajor() == 3 && Qgis::geosVersionMinor() == 11 ) - doCheckPoint( request, 16, mpPointsLayer, { 5, 11, 12, 13, 14, 15, 18, 45, 46 } ); - - doCheckPoint( request, 70, mpPointsLayer, { 0, 3, 4, 5, 6, 7, 10, 11, 12, 13, 14, 15, 16, 17, 18, 38, 45, 46, 48 } ); -} - - -void TestQgsElevationProfile::testVectorLayerProfileForLine() -{ - QgsLineString *profileCurve = new QgsLineString ; - profileCurve->setPoints( mProfilePoints ); - - QgsProfileRequest request( profileCurve ); - request.setCrs( QgsCoordinateReferenceSystem::fromEpsgId( 3857 ) ); - request.setTerrainProvider( mDemTerrain->clone() ); - - // check no tolerance - doCheckLine( request, 0, mpLinesLayer, { 0, 2 }, { 1, 5 } ); - - // check increased tolerance, terrain, no extrusion - doCheckLine( request, 1, mpLinesLayer, { 0, 2 }, { 1, 5 } ); - - // check increased tolerance, terrain, no extrusion - doCheckLine( request, 20, mpLinesLayer, { 0, 2 }, { 1, 3 } ); - - // check increased tolerance, terrain, no extrusion - doCheckLine( request, 50, mpLinesLayer, { 1, 0, 2 }, { 1, 1, 1 } ); - QList actual = mProfileResults->features.keys(); - for ( auto it = actual.constBegin(); it != actual.constEnd(); ++it ) - { - QVector< QgsVectorLayerProfileResults::Feature > feats = mProfileResults->features[*it]; - QVERIFY2( feats[0].geometry.type() == Qgis::GeometryType::Line, "Geometry must be a line" ); - } - QMap distMap = mProfileResults->distanceToHeightMap(); - for ( QMap::iterator pair = distMap.begin(); pair != distMap.end(); pair++ ) - { - QVERIFY2( std::isnan( pair.value() ) || pair.value() > 0.0, QString( "Height must be %1 > 0.0" ).arg( pair.value() ).toStdString().c_str() ); - } - - // check terrain + extrusion - dynamic_cast( mpLinesLayer->elevationProperties() )->setClamping( Qgis::AltitudeClamping::Terrain ); - dynamic_cast( mpLinesLayer->elevationProperties() )->setExtrusionEnabled( true ); - dynamic_cast( mpLinesLayer->elevationProperties() )->setExtrusionHeight( 17 ); - - doCheckLine( request, 50, mpLinesLayer, { 1, 0, 2 }, { 1, 1, 1 } ); - actual = mProfileResults->features.keys(); - for ( auto it = actual.constBegin(); it != actual.constEnd(); ++it ) - { - QVector< QgsVectorLayerProfileResults::Feature > feats = mProfileResults->features[*it]; - QVERIFY2( feats[0].geometry.type() == Qgis::GeometryType::Polygon, "Geometry must be a polygon" ); - } - distMap = mProfileResults->distanceToHeightMap(); - for ( QMap::iterator pair = distMap.begin(); pair != distMap.end(); pair++ ) - { - QVERIFY2( std::isnan( pair.value() ) || pair.value() > 0.0, QString( "Height must be %1 > 0.0" ).arg( pair.value() ).toStdString().c_str() ); - } - - // check no terrain, no extrusion - dynamic_cast( mpLinesLayer->elevationProperties() )->setClamping( Qgis::AltitudeClamping::Absolute ); - dynamic_cast( mpLinesLayer->elevationProperties() )->setZOffset( 5.0 ); - dynamic_cast( mpLinesLayer->elevationProperties() )->setExtrusionEnabled( false ); - - doCheckLine( request, 50, mpLinesLayer, { 1, 0, 2 }, { 1, 1, 1 } ); - actual = mProfileResults->features.keys(); - for ( auto it = actual.constBegin(); it != actual.constEnd(); ++it ) - { - QVector< QgsVectorLayerProfileResults::Feature > feats = mProfileResults->features[*it]; - QVERIFY2( feats[0].geometry.type() == Qgis::GeometryType::Line, "Geometry must be a line" ); - } - distMap = mProfileResults->distanceToHeightMap(); - for ( QMap::iterator pair = distMap.begin(); pair != distMap.end(); pair++ ) - { - QVERIFY2( std::isnan( pair.value() ) || pair.value() == 5.0, QString( "Height must be %1 == 5.0" ).arg( pair.value() ).toStdString().c_str() ); - } - -} - -void TestQgsElevationProfile::testVectorLayerProfileForPolygon() -{ - QgsLineString *profileCurve = new QgsLineString ; - profileCurve->setPoints( mProfilePoints ); - - QgsProfileRequest request( profileCurve ); - request.setCrs( QgsCoordinateReferenceSystem::fromEpsgId( 3857 ) ); - request.setTerrainProvider( mDemTerrain->clone() ); - - doCheckLine( request, 1, mpPolygonsLayer, { 168, 206, 210, 284, 306, 321 }, { 1, 1, 1, 1, 1, 1 } ); - - if ( ( Qgis::geosVersionMajor() == 3 && Qgis::geosVersionMinor() <= 10 ) || ( Qgis::geosVersionMajor() == 3 && Qgis::geosVersionMinor() >= 12 ) ) - { - doCheckLine( request, 10, mpPolygonsLayer, { 168, 172, 206, 210, 231, 267, 275, 282, 284, 306, 307, 319, 321 }, { 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 } ); - doCheckLine( request, 11, mpPolygonsLayer, { 168, 172, 206, 210, 231, 255, 267, 275, 282, 283, 284, 306, 307, 319, 321 }, { 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 } ); - } - else if ( Qgis::geosVersionMajor() == 3 && Qgis::geosVersionMinor() == 11 ) - { - doCheckLine( request, 9, mpPolygonsLayer, { 168, 172, 206, 210, 231, 267, 275, 282, 284, 306, 307, 319, 321 }, { 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 } ); - doCheckLine( request, 10, mpPolygonsLayer, { 168, 172, 206, 210, 231, 267, 275, 282, 283, 284, 306, 307, 319, 321 }, { 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 } ); - } -} - - -QGSTEST_MAIN( TestQgsElevationProfile ) -#include "testqgselevationprofile.moc" diff --git a/tests/src/python/test_qgsvectorlayerprofilegenerator.py b/tests/src/python/test_qgsvectorlayerprofilegenerator.py index 5b7de047d851..2f97607cba05 100644 --- a/tests/src/python/test_qgsvectorlayerprofilegenerator.py +++ b/tests/src/python/test_qgsvectorlayerprofilegenerator.py @@ -38,6 +38,7 @@ QgsRendererCategory, QgsSymbolLayer, QgsVectorLayer, + QgsWkbTypes, ) import unittest from qgis.testing import start_app, QgisTestCase @@ -2151,6 +2152,158 @@ def testRenderLayerSymbology(self): res = plot_renderer.renderToImage(400, 400, 0, curve.length(), 0, 14) self.assertTrue(self.image_check('vector_polygon_layer_symbology', 'vector_polygon_layer_symbology', res)) + def doCheckPoint(self, request: QgsProfileRequest, tolerance: float, layer: QgsVectorLayer, expectedFeatures): + request.setTolerance(tolerance) + + profGen = layer.createProfileGenerator(request) + self.assertIsNotNone(profGen) + self.assertTrue(profGen.generateProfile()) + results = profGen.takeResults() + features = results.asFeatures(Qgis.ProfileExportType.Features3D) + self.assertFalse(len(features) == 0) + + expected = sorted(expectedFeatures.copy()) + actual = [f.attributes['id'] for _, f in enumerate(features)] + actualUniqSorted = sorted(list(set(actual))) + + self.assertEqual(actualUniqSorted, expected) + + for k, feat in enumerate(features): + hasValidZ = False + if QgsWkbTypes.hasZ(feat.geometry.wkbType()): + for v in feat.geometry.vertices(): + if not math.isnan(v.z()): + hasValidZ = True + break + self.assertTrue(hasValidZ, "All vertice are on the ground!") + else: + self.assertTrue(hasValidZ, "Geometry should have z coordinates!") + + return results + + def doCheckLine(self, request: QgsProfileRequest, tolerance: float, layer: QgsVectorLayer, expectedFeatures, nbSubGeomPerFeature, geomType): + results = self.doCheckPoint(request, tolerance, layer, expectedFeatures) + features = results.asFeatures(Qgis.ProfileExportType.Features3D) + + actual = [f.attributes['id'] for _, f in enumerate(features)] + actualUniqSorted = sorted(list(set(actual))) + for idx, fid in enumerate(actualUniqSorted): + actual = [1 for _, f in enumerate(features) if f.attributes['id'] == fid] + self.assertEqual(len(actual), nbSubGeomPerFeature[idx]) + + for k, feat in enumerate(features): + self.assertEqual(feat.geometry.type(), geomType) + + for _, height in enumerate(results.distanceToHeightMap()): + self.assertTrue(math.isnan(height) or height > 0.0) + + return results + + def testPointGenerationFeature(self): + vl = QgsVectorLayer(os.path.join(unitTestDataPath(), '3d', 'points_with_z.shp')) + self.assertTrue(vl.isValid()) + + vl.elevationProperties().setClamping(Qgis.AltitudeClamping.Terrain) + vl.elevationProperties().setBinding(Qgis.AltitudeBinding.Vertex) + + dtmLayer = QgsRasterLayer(os.path.join(unitTestDataPath(), '3d', 'dtm.tif')) + self.assertTrue(dtmLayer.isValid()) + + curve = QgsLineString() + curve.fromWkt( + 'LineString (-346120 6631840, -346550 6632030, -346440 6632140, -347830 6632930)') + req = QgsProfileRequest(curve) + + terrain_provider = QgsRasterDemTerrainProvider() + terrain_provider.setLayer(dtmLayer) + + req.setTerrainProvider(terrain_provider) + req.setCrs(QgsCoordinateReferenceSystem('EPSG:3857')) + + if (Qgis.geosVersionMajor() == 3 and Qgis.geosVersionMinor() <= 10) or (Qgis.geosVersionMajor() == 3 and Qgis.geosVersionMinor() >= 12): + self.doCheckPoint(req, 15, vl, [5, 11, 12, 13, 14, 15, 18, 45, 46]) + elif Qgis.geosVersionMajor() == 3 and Qgis.geosVersionMinor() == 11: + self.doCheckPoint(req, 16, vl, [5, 11, 12, 13, 14, 15, 18, 45, 46]) + + self.doCheckPoint(req, 70, vl, [0, 3, 4, 5, 6, 7, 10, 11, 12, 13, 14, 15, 16, 17, 18, 38, 45, 46, 48]) + + def testLineGenerationFeature(self): + vl = QgsVectorLayer(os.path.join(unitTestDataPath(), '3d', 'lines.shp')) + self.assertTrue(vl.isValid()) + + vl.elevationProperties().setClamping(Qgis.AltitudeClamping.Terrain) + vl.elevationProperties().setBinding(Qgis.AltitudeBinding.Vertex) + vl.elevationProperties().setExtrusionEnabled(False) + + dtmLayer = QgsRasterLayer(os.path.join(unitTestDataPath(), '3d', 'dtm.tif')) + self.assertTrue(dtmLayer.isValid()) + + curve = QgsLineString() + curve.fromWkt( + 'LineString (-346120 6631840, -346550 6632030, -346440 6632140, -347830 6632930)') + req = QgsProfileRequest(curve) + + terrain_provider = QgsRasterDemTerrainProvider() + terrain_provider.setLayer(dtmLayer) + + req.setTerrainProvider(terrain_provider) + req.setCrs(QgsCoordinateReferenceSystem('EPSG:3857')) + + # check no tolerance + self.doCheckLine(req, 0, vl, [0, 2], [1, 5], Qgis.GeometryType.Point) + + # check increased tolerance, terrain, no extrusion + self.doCheckLine(req, 1, vl, [0, 2], [1, 5], Qgis.GeometryType.Line) + + # check increased tolerance, terrain, no extrusion + self.doCheckLine(req, 20, vl, [0, 2], [1, 3], Qgis.GeometryType.Line) + + # check increased tolerance, terrain, no extrusion + self.doCheckLine(req, 50, vl, [1, 0, 2], [1, 1, 1], Qgis.GeometryType.Line) + + # check terrain + extrusion + vl.elevationProperties().setClamping(Qgis.AltitudeClamping.Terrain) + vl.elevationProperties().setExtrusionEnabled(True) + vl.elevationProperties().setExtrusionHeight(17) + self.doCheckLine(req, 50, vl, [1, 0, 2], [1, 1, 1], Qgis.GeometryType.Polygon) + + # check no terrain + no extrusion + vl.elevationProperties().setClamping(Qgis.AltitudeClamping.Absolute) + vl.elevationProperties().setExtrusionEnabled(False) + vl.elevationProperties().setZOffset(5.0) + self.doCheckLine(req, 50, vl, [1, 0, 2], [1, 1, 1], Qgis.GeometryType.Line) + + def testPolygonGenerationFeature(self): + vl = QgsVectorLayer(os.path.join(unitTestDataPath(), '3d', 'buildings.shp')) + self.assertTrue(vl.isValid()) + + vl.elevationProperties().setClamping(Qgis.AltitudeClamping.Terrain) + vl.elevationProperties().setBinding(Qgis.AltitudeBinding.Vertex) + vl.elevationProperties().setExtrusionEnabled(False) + + dtmLayer = QgsRasterLayer(os.path.join(unitTestDataPath(), '3d', 'dtm.tif')) + self.assertTrue(dtmLayer.isValid()) + + curve = QgsLineString() + curve.fromWkt( + 'LineString (-346120 6631840, -346550 6632030, -346440 6632140, -347830 6632930)') + req = QgsProfileRequest(curve) + + terrain_provider = QgsRasterDemTerrainProvider() + terrain_provider.setLayer(dtmLayer) + + req.setTerrainProvider(terrain_provider) + req.setCrs(QgsCoordinateReferenceSystem('EPSG:3857')) + + self.doCheckLine(req, 1, vl, [168, 206, 210, 284, 306, 321], [1, 1, 1, 1, 1, 1], Qgis.GeometryType.Line) + + if (Qgis.geosVersionMajor() == 3 and Qgis.geosVersionMinor() <= 10) or (Qgis.geosVersionMajor() == 3 and Qgis.geosVersionMinor() >= 12): + self.doCheckLine(req, 10, vl, [168, 172, 206, 210, 231, 267, 275, 282, 284, 306, 307, 319, 321], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], Qgis.GeometryType.Line) + self.doCheckLine(req, 11, vl, [168, 172, 206, 210, 231, 255, 267, 275, 282, 283, 284, 306, 307, 319, 321], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], Qgis.GeometryType.Line) + elif Qgis.geosVersionMajor() == 3 and Qgis.geosVersionMinor() == 11: + self.doCheckLine(req, 9, vl, [168, 172, 206, 210, 231, 267, 275, 282, 284, 306, 307, 319, 321], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], Qgis.GeometryType.Line) + self.doCheckLine(req, 10, vl, [168, 172, 206, 210, 231, 267, 275, 282, 283, 284, 306, 307, 319, 321], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], Qgis.GeometryType.Line) + if __name__ == '__main__': unittest.main() From f1d0d7339b8f0286928f7ce2eb1deb6875a54a67 Mon Sep 17 00:00:00 2001 From: bdm-oslandia Date: Mon, 12 Feb 2024 16:56:01 +0100 Subject: [PATCH 19/26] fix(elevation_profile): remove useless test in processTriangleIntersectForLine and improve nan usage. Co-authored-by: Nyall Dawson --- .../vector/qgsvectorlayerprofilegenerator.cpp | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/core/vector/qgsvectorlayerprofilegenerator.cpp b/src/core/vector/qgsvectorlayerprofilegenerator.cpp index 30d44aea5403..e1bcebd80260 100644 --- a/src/core/vector/qgsvectorlayerprofilegenerator.cpp +++ b/src/core/vector/qgsvectorlayerprofilegenerator.cpp @@ -929,7 +929,7 @@ void QgsVectorLayerProfileGenerator::processIntersectionCurve( const QgsLineStri } } - mResults->mDistanceToHeightMap.insert( maxDistanceAlongProfileCurve + 0.000001, qQNaN() ); + mResults->mDistanceToHeightMap.insert( maxDistanceAlongProfileCurve + 0.000001, std::numeric_limits::quiet_NaN() ); if ( mFeedback->isCanceled() ) return; @@ -1111,16 +1111,13 @@ void QgsVectorLayerProfileGenerator::processTriangleIntersectForLine( const QgsP *outY++ = y; if ( triangle->exteriorRing()->numPoints() <= 4 ) // triangle case { - QgsPoint tmpPt = interpolatePointOnTriangle( triangle, x, y ); - if ( ! tmpPt.isEmpty() ) // point x,y inside the triangle - { - interpolatedPoint = tmpPt; - } + interpolatedPoint = interpolatePointOnTriangle( triangle, x, y ); } - *outZ++ = std::isnan( interpolatedPoint.z() ) ? 0.0 : interpolatedPoint.z(); + double tempOutZ = std::isnan( interpolatedPoint.z() ) ? 0.0 : interpolatedPoint.z(); + *outZ++ = tempOutZ; if ( mExtrusionEnabled ) - *extZOut++ = ( std::isnan( interpolatedPoint.z() ) ? 0.0 : interpolatedPoint.z() ) + extrusion; + *extZOut++ = tempOutZ + extrusion; mResults->mRawPoints.append( interpolatedPoint ); mResults->minZ = std::min( mResults->minZ, interpolatedPoint.z() ); @@ -1139,7 +1136,7 @@ void QgsVectorLayerProfileGenerator::processTriangleIntersectForLine( const QgsP } // insert nan point to end the line - mResults->mDistanceToHeightMap.insert( lastDistanceAlongProfileCurve + 0.000001, qQNaN() ); + mResults->mDistanceToHeightMap.insert( lastDistanceAlongProfileCurve + 0.000001, std::numeric_limits::quiet_NaN() ); if ( mFeedback->isCanceled() ) return; From ad6f3590b7d8e09efbd521eb9196afb0a72f9803 Mon Sep 17 00:00:00 2001 From: bdm-oslandia Date: Tue, 13 Feb 2024 10:52:54 +0100 Subject: [PATCH 20/26] fix(profileelevation): add comment to explain why extrusion is disabled for polygon --- .../vector/qgsvectorlayerprofilegenerator.cpp | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/core/vector/qgsvectorlayerprofilegenerator.cpp b/src/core/vector/qgsvectorlayerprofilegenerator.cpp index e1bcebd80260..f44790fa3711 100644 --- a/src/core/vector/qgsvectorlayerprofilegenerator.cpp +++ b/src/core/vector/qgsvectorlayerprofilegenerator.cpp @@ -1168,6 +1168,37 @@ void QgsVectorLayerProfileGenerator::processTriangleIntersectForPolygon( const Q { bool oldExtrusion = mExtrusionEnabled; + /* Polyone extrusion produces I or C or inverted C shapes because the starting and ending points are the same. + We observe the same case with linestrings if the starting and ending points are not at the ends. + In the case below, the Z polygon projected onto the curve produces a shape that cannot be used to represent the extrusion ==> we would obtain a 3D volume. + In order to avoid having strange shapes that cannot be understood by the end user, extrusion is deactivated in the case of polygons. + + .^.. + ./ | \.. + ../ | \... + ../ | \... + ../ | \.. ....^.. + ../ | ........\.../ \... ^ + ../ ......|......./ \... \.... .../ \ + /,........../ | \.. \... / \ + v | \... ..../ \... \ + | \ ./ \... \ + | v \.. \ + | `v + | + .^.. + ./ \.. + ../ \... + ../ \... + ../ \.. ....^.. + ../ ........\.../ \... ^ + ../ ............../ \... \.... .../ \ + /,........../ \.. \... / \ + v \... ..../ \... \ + \ ./ \... \ + v \.. \ + `v + */ mExtrusionEnabled = false; if ( mProfileBufferedCurveEngine->contains( sourcePolygon ) ) // sourcePolygon is entirely inside curve buffer, we keep it as whole { From f20543aa62179c7593c1b1d47d15bb08bd74b700 Mon Sep 17 00:00:00 2001 From: bdm-oslandia Date: Mon, 26 Feb 2024 15:46:53 +0100 Subject: [PATCH 21/26] fix(elevation_profile): revert vertice loop to pointer loop --- src/core/vector/qgsvectorlayerprofilegenerator.cpp | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/core/vector/qgsvectorlayerprofilegenerator.cpp b/src/core/vector/qgsvectorlayerprofilegenerator.cpp index f44790fa3711..41b4c1ceda20 100644 --- a/src/core/vector/qgsvectorlayerprofilegenerator.cpp +++ b/src/core/vector/qgsvectorlayerprofilegenerator.cpp @@ -885,6 +885,9 @@ void QgsVectorLayerProfileGenerator::processIntersectionCurve( const QgsLineStri QVector< double > newZ( numPoints ); QVector< double > newDistance( numPoints ); + const double *inX = intersectionCurve->xData(); + const double *inY = intersectionCurve->yData(); + const double *inZ = intersectionCurve->is3D() ? intersectionCurve->zData() : nullptr; double *outX = newX.data(); double *outY = newY.data(); double *outZ = newZ.data(); @@ -898,11 +901,9 @@ void QgsVectorLayerProfileGenerator::processIntersectionCurve( const QgsLineStri extZOut = extrudedZ.data(); } - for ( auto it = intersectionCurve->vertices_begin(); - !mFeedback->isCanceled() && it != intersectionCurve->vertices_end(); - ++it ) + for ( int i = 0 ; ! mFeedback->isCanceled() && i < numPoints; ++i ) { - const QgsPoint &intersectionPoint = *it; + QgsPoint intersectionPoint( *inX, *inY, ( inZ ? *inZ : std::numeric_limits::quiet_NaN() ) ); const double height = featureZToHeight( intersectionPoint.x(), intersectionPoint.y(), intersectionPoint.z(), offset ); const double distanceAlongProfileCurve = mProfileCurveEngine->lineLocatePoint( intersectionPoint, &error ); @@ -927,6 +928,10 @@ void QgsVectorLayerProfileGenerator::processIntersectionCurve( const QgsLineStri mResults->minZ = std::min( mResults->minZ, height + extrusion ); mResults->maxZ = std::max( mResults->maxZ, height + extrusion ); } + inX++; + inY++; + if ( inZ ) + inZ++; } mResults->mDistanceToHeightMap.insert( maxDistanceAlongProfileCurve + 0.000001, std::numeric_limits::quiet_NaN() ); From 9dc6248335501abb98dcc44c6fddb5d25779411e Mon Sep 17 00:00:00 2001 From: bdm-oslandia Date: Mon, 26 Feb 2024 15:48:48 +0100 Subject: [PATCH 22/26] fix(elevation_profile): filter polygon when vertices count < 4 --- src/core/vector/qgsvectorlayerprofilegenerator.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/core/vector/qgsvectorlayerprofilegenerator.cpp b/src/core/vector/qgsvectorlayerprofilegenerator.cpp index 41b4c1ceda20..da5c2779fb48 100644 --- a/src/core/vector/qgsvectorlayerprofilegenerator.cpp +++ b/src/core/vector/qgsvectorlayerprofilegenerator.cpp @@ -1076,6 +1076,9 @@ void QgsVectorLayerProfileGenerator::processTriangleIntersectForPoint( const Qgs void QgsVectorLayerProfileGenerator::processTriangleIntersectForLine( const QgsPolygon *triangle, const QgsLineString *intersectionLine, QVector< QgsGeometry > &transformedParts, QVector< QgsGeometry > &crossSectionParts ) { + if ( triangle->exteriorRing()->numPoints() < 4 ) // not a polygon + return; + int numPoints = intersectionLine->numPoints(); QVector< double > newX( numPoints ); QVector< double > newY( numPoints ); @@ -1114,7 +1117,7 @@ void QgsVectorLayerProfileGenerator::processTriangleIntersectForLine( const QgsP *outX++ = x; *outY++ = y; - if ( triangle->exteriorRing()->numPoints() <= 4 ) // triangle case + if ( triangle->exteriorRing()->numPoints() == 4 ) // triangle case { interpolatedPoint = interpolatePointOnTriangle( triangle, x, y ); } From a68a0ad8a568b4a6a71c01478e1fd0d8af1eeeec Mon Sep 17 00:00:00 2001 From: bdm-oslandia Date: Mon, 26 Feb 2024 15:52:50 +0100 Subject: [PATCH 23/26] fix(elevation_profile): use featGeomPart instead of feature geometry when intersection is empty as we only processing part of the feature's geometry --- src/core/vector/qgsvectorlayerprofilegenerator.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/core/vector/qgsvectorlayerprofilegenerator.cpp b/src/core/vector/qgsvectorlayerprofilegenerator.cpp index da5c2779fb48..ce91a3c71b21 100644 --- a/src/core/vector/qgsvectorlayerprofilegenerator.cpp +++ b/src/core/vector/qgsvectorlayerprofilegenerator.cpp @@ -988,8 +988,7 @@ bool QgsVectorLayerProfileGenerator::generateProfileForLines() // Intersection is empty : GEOS issue for vertical intersection : use feature geometry as intersection if ( intersection->isEmpty() ) { - std::string wkt_std = intersection->asWkt().toStdString(); - intersection.reset( feature.geometry().constGet()->clone() ); + intersection.reset( featGeomPart->clone() ); } QgsGeos featGeomPartGeos( featGeomPart ); From 6f60665cdb7ee17bb550850017ac95b5558c7b41 Mon Sep 17 00:00:00 2001 From: bdm-oslandia Date: Mon, 26 Feb 2024 17:19:09 +0100 Subject: [PATCH 24/26] fix(elevetion_profile): replace `request.setFilterRect` by `request.setDistanceWithin` when tolerance>0 as some providers as improved performances with setDistanceWithin --- .../vector/qgsvectorlayerprofilegenerator.cpp | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/core/vector/qgsvectorlayerprofilegenerator.cpp b/src/core/vector/qgsvectorlayerprofilegenerator.cpp index ce91a3c71b21..1d18e68b4c69 100644 --- a/src/core/vector/qgsvectorlayerprofilegenerator.cpp +++ b/src/core/vector/qgsvectorlayerprofilegenerator.cpp @@ -970,7 +970,14 @@ bool QgsVectorLayerProfileGenerator::generateProfileForLines() // get features from layer QgsFeatureRequest request; request.setDestinationCrs( mTargetCrs, mTransformContext ); - request.setFilterRect( mProfileBufferedCurve->boundingBox() ); + if ( mTolerance > 0 ) + { + request.setDistanceWithin( QgsGeometry( mProfileCurve->clone() ), mTolerance ); + } + else + { + request.setFilterRect( mProfileCurve->boundingBox() ); + } request.setSubsetOfAttributes( mDataDefinedProperties.referencedFields( mExpressionContext ), mFields ); request.setFeedback( mFeedback.get() ); @@ -1255,7 +1262,14 @@ bool QgsVectorLayerProfileGenerator::generateProfileForPolygons() // get features from layer QgsFeatureRequest request; request.setDestinationCrs( mTargetCrs, mTransformContext ); - request.setFilterRect( mProfileBufferedCurve->boundingBox() ); + if ( mTolerance > 0 ) + { + request.setDistanceWithin( QgsGeometry( mProfileCurve->clone() ), mTolerance ); + } + else + { + request.setFilterRect( mProfileCurve->boundingBox() ); + } request.setSubsetOfAttributes( mDataDefinedProperties.referencedFields( mExpressionContext ), mFields ); request.setFeedback( mFeedback.get() ); From 5b123f8bd336208219bce220c761a0b1d8fe4881 Mon Sep 17 00:00:00 2001 From: bdm-oslandia Date: Tue, 27 Feb 2024 09:37:52 +0100 Subject: [PATCH 25/26] fix(elevation_profile): add fix when intersecting polygons vs wall. In case the geometry is invalid, we shift all coordinates by an epsilon to ensure the 2D equivalent will be topologically correct. This is just a fix, a proper 3D geometry library is needed (like SFCGAL)! --- .../vector/qgsvectorlayerprofilegenerator.cpp | 41 ++++++++++++++++++- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/src/core/vector/qgsvectorlayerprofilegenerator.cpp b/src/core/vector/qgsvectorlayerprofilegenerator.cpp index 1d18e68b4c69..5ab958abccd5 100644 --- a/src/core/vector/qgsvectorlayerprofilegenerator.cpp +++ b/src/core/vector/qgsvectorlayerprofilegenerator.cpp @@ -1340,9 +1340,46 @@ bool QgsVectorLayerProfileGenerator::generateProfileForPolygons() QString error; if ( mProfileBufferedCurveEngine->intersects( clampedPolygon.get(), &error ) ) { - std::unique_ptr< QgsAbstractGeometry > intersection( mProfileBufferedCurveEngine->intersection( clampedPolygon.get(), &error ) ); - processTriangleLineIntersect( clampedPolygon.get(), intersection.get(), transformedParts, crossSectionParts ); + std::unique_ptr< QgsAbstractGeometry > intersection; + intersection.reset( mProfileBufferedCurveEngine->intersection( clampedPolygon.get(), &error ) ); + if ( error.isEmpty() ) + { + processTriangleLineIntersect( clampedPolygon.get(), intersection.get(), transformedParts, crossSectionParts ); + } + else + { + // this case may occur with vertical object as geos does not handle very well 3D data. + // Geos works in 2D from the 3D coordinates then re-add the Z values, but when 2D-from-3D objects are vertical, they are topologically incorrects! + // This piece of code is just a fix to handle this case, a better and real 3D capable library is needed (like SFCGAL). + QgsLineString *ring = qgsgeometry_cast< QgsLineString * >( clampedPolygon->exteriorRing() ); + int numPoints = ring->numPoints(); + QVector< double > newX( numPoints ); + QVector< double > newY( numPoints ); + QVector< double > newZ( numPoints ); + double *outX = newX.data(); + double *outY = newY.data(); + double *outZ = newZ.data(); + + const double *inX = ring->xData(); + const double *inY = ring->yData(); + const double *inZ = ring->zData(); + for ( int i = 0 ; ! mFeedback->isCanceled() && i < ring->numPoints() - 1; ++i ) + { + *outX++ = inX[i] + i * 1.0e-9; + *outY++ = inY[i] + i * 1.0e-9; + *outZ++ = inZ[i]; + } + std::unique_ptr< QgsPolygon > shiftedPoly; + shiftedPoly.reset( new QgsPolygon( new QgsLineString( newX, newY, newZ ) ) ); + + intersection.reset( mProfileBufferedCurveEngine->intersection( shiftedPoly.get(), &error ) ); + if ( intersection.get() ) + processTriangleLineIntersect( clampedPolygon.get(), intersection.get(), transformedParts, crossSectionParts ); + else + QgsDebugMsgLevel( QStringLiteral( "processPolygon after shift bad geom! error: %1" ).arg( error ), 0 ); + } } + } else // ie. polygon / line intersection ==> need tessellation { From 83a7a50c832208c112ab84dbd0e9ca57f2eb1e43 Mon Sep 17 00:00:00 2001 From: bdm-oslandia Date: Fri, 1 Mar 2024 10:45:35 +0100 Subject: [PATCH 26/26] fix(elevation_profile): adjust tolerance tests for geos 3.12 and add mask for vector_layer_map_units_tolerance --- .../test_qgsvectorlayerprofilegenerator.py | 9 +++++++-- ...ted_vector_layer_map_units_tolerance_mask.png | Bin 0 -> 8525 bytes 2 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 tests/testdata/control_images/layout_profile/expected_vector_layer_map_units_tolerance/expected_vector_layer_map_units_tolerance_mask.png diff --git a/tests/src/python/test_qgsvectorlayerprofilegenerator.py b/tests/src/python/test_qgsvectorlayerprofilegenerator.py index 2f97607cba05..2a68466da5e4 100644 --- a/tests/src/python/test_qgsvectorlayerprofilegenerator.py +++ b/tests/src/python/test_qgsvectorlayerprofilegenerator.py @@ -2220,10 +2220,12 @@ def testPointGenerationFeature(self): req.setTerrainProvider(terrain_provider) req.setCrs(QgsCoordinateReferenceSystem('EPSG:3857')) - if (Qgis.geosVersionMajor() == 3 and Qgis.geosVersionMinor() <= 10) or (Qgis.geosVersionMajor() == 3 and Qgis.geosVersionMinor() >= 12): + if Qgis.geosVersionMajor() == 3 and Qgis.geosVersionMinor() <= 10: self.doCheckPoint(req, 15, vl, [5, 11, 12, 13, 14, 15, 18, 45, 46]) elif Qgis.geosVersionMajor() == 3 and Qgis.geosVersionMinor() == 11: self.doCheckPoint(req, 16, vl, [5, 11, 12, 13, 14, 15, 18, 45, 46]) + elif Qgis.geosVersionMajor() == 3 and Qgis.geosVersionMinor() >= 12: + self.doCheckPoint(req, 15, vl, [5, 11, 12, 13, 14, 15, 18, 45]) self.doCheckPoint(req, 70, vl, [0, 3, 4, 5, 6, 7, 10, 11, 12, 13, 14, 15, 16, 17, 18, 38, 45, 46, 48]) @@ -2297,12 +2299,15 @@ def testPolygonGenerationFeature(self): self.doCheckLine(req, 1, vl, [168, 206, 210, 284, 306, 321], [1, 1, 1, 1, 1, 1], Qgis.GeometryType.Line) - if (Qgis.geosVersionMajor() == 3 and Qgis.geosVersionMinor() <= 10) or (Qgis.geosVersionMajor() == 3 and Qgis.geosVersionMinor() >= 12): + if Qgis.geosVersionMajor() == 3 and Qgis.geosVersionMinor() <= 10: self.doCheckLine(req, 10, vl, [168, 172, 206, 210, 231, 267, 275, 282, 284, 306, 307, 319, 321], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], Qgis.GeometryType.Line) self.doCheckLine(req, 11, vl, [168, 172, 206, 210, 231, 255, 267, 275, 282, 283, 284, 306, 307, 319, 321], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], Qgis.GeometryType.Line) elif Qgis.geosVersionMajor() == 3 and Qgis.geosVersionMinor() == 11: self.doCheckLine(req, 9, vl, [168, 172, 206, 210, 231, 267, 275, 282, 284, 306, 307, 319, 321], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], Qgis.GeometryType.Line) self.doCheckLine(req, 10, vl, [168, 172, 206, 210, 231, 267, 275, 282, 283, 284, 306, 307, 319, 321], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], Qgis.GeometryType.Line) + elif Qgis.geosVersionMajor() == 3 and Qgis.geosVersionMinor() >= 12: + self.doCheckLine(req, 10, vl, [168, 172, 206, 210, 231, 267, 275, 282, 283, 284, 306, 307, 319, 321], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], Qgis.GeometryType.Line) + self.doCheckLine(req, 11, vl, [168, 172, 206, 210, 231, 237, 255, 267, 275, 282, 283, 284, 306, 307, 319, 321], [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], Qgis.GeometryType.Line) if __name__ == '__main__': diff --git a/tests/testdata/control_images/layout_profile/expected_vector_layer_map_units_tolerance/expected_vector_layer_map_units_tolerance_mask.png b/tests/testdata/control_images/layout_profile/expected_vector_layer_map_units_tolerance/expected_vector_layer_map_units_tolerance_mask.png new file mode 100644 index 0000000000000000000000000000000000000000..48767757b5fc834f46d43c3de71e072b5bb98336 GIT binary patch literal 8525 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