diff --git a/src/core/3d/quick3dgeometry.cpp b/src/core/3d/quick3dgeometry.cpp index 0e19c3974e..1fcc4c3bf3 100644 --- a/src/core/3d/quick3dgeometry.cpp +++ b/src/core/3d/quick3dgeometry.cpp @@ -161,6 +161,20 @@ void Quick3DGeometry::setAltitudeClamping( AltitudeClamping clamping ) updateGeometry(); } +void Quick3DGeometry::setExtrusion( float extrusion ) +{ + extrusion = std::max( 0.0f, extrusion ); + if ( qFuzzyCompare( mExtrusion, extrusion ) ) + { + return; + } + + mExtrusion = extrusion; + mDirty = true; + emit extrusionChanged(); + updateGeometry(); +} + void Quick3DGeometry::markDirtyAndUpdate() { mDirty = true; @@ -426,6 +440,11 @@ void Quick3DGeometry::updateGeometry() } totalFillVertices += ringSize; totalFillIndices += ( ringSize - 2 ) * 3; + if ( mExtrusion > 0.0f ) + { + totalFillVertices += Quick3DGeometryUtils::polygonWallsVertexCount( ringSize ); + totalFillIndices += Quick3DGeometryUtils::polygonWallsIndexCount( ringSize ); + } } } @@ -465,7 +484,12 @@ void Quick3DGeometry::updateGeometry() { if ( path.size() >= 3 ) { - Quick3DGeometryUtils::generatePolygonFill( path, r, g, b, fillAlpha, vptr, iptr, vertexOffset, minBound, maxBound ); + if ( mExtrusion > 0.0f ) + { + Quick3DGeometryUtils::generatePolygonWalls( path, mExtrusion, r, g, b, a, vptr, iptr, vertexOffset, minBound, maxBound ); + } + const float capAlpha = mExtrusion > 0.0f ? a : fillAlpha; + Quick3DGeometryUtils::generatePolygonFill( path, r, g, b, capAlpha, vptr, iptr, vertexOffset, minBound, maxBound, mExtrusion ); } } } diff --git a/src/core/3d/quick3dgeometry.h b/src/core/3d/quick3dgeometry.h index 115accd1d5..98ced33d59 100644 --- a/src/core/3d/quick3dgeometry.h +++ b/src/core/3d/quick3dgeometry.h @@ -73,6 +73,8 @@ class Quick3DGeometry : public QQuick3DGeometry Q_PROPERTY( bool fillPolygons READ fillPolygons WRITE setFillPolygons NOTIFY fillPolygonsChanged ) //! How the geometry's Z values are combined with the terrain elevation Q_PROPERTY( AltitudeClamping altitudeClamping READ altitudeClamping WRITE setAltitudeClamping NOTIFY altitudeClampingChanged ) + //! Extrusion in scene units; when > 0, polygons are rendered as volumetric solids with walls and a roof cap + Q_PROPERTY( float extrusion READ extrusion WRITE setExtrusion NOTIFY extrusionChanged ) public: explicit Quick3DGeometry( QQuick3DObject *parent = nullptr ); @@ -101,6 +103,9 @@ class Quick3DGeometry : public QQuick3DGeometry AltitudeClamping altitudeClamping() const { return mAltitudeClamping; } void setAltitudeClamping( AltitudeClamping clamping ); + float extrusion() const { return mExtrusion; } + void setExtrusion( float extrusion ); + signals: void qgsGeometryChanged(); void crsChanged(); @@ -110,6 +115,7 @@ class Quick3DGeometry : public QQuick3DGeometry void heightOffsetChanged(); void fillPolygonsChanged(); void altitudeClampingChanged(); + void extrusionChanged(); private slots: void markDirtyAndUpdate(); @@ -139,6 +145,7 @@ class Quick3DGeometry : public QQuick3DGeometry float mLineWidth = 3.0f; float mHeightOffset = 15.0f; + float mExtrusion = 0.0f; bool mFillPolygons = true; bool mDirty = true; AltitudeClamping mAltitudeClamping = AltitudeClamping::Ignore; diff --git a/src/core/3d/quick3dgeometryutils.cpp b/src/core/3d/quick3dgeometryutils.cpp index ecd9e7875c..5d938c8d2c 100644 --- a/src/core/3d/quick3dgeometryutils.cpp +++ b/src/core/3d/quick3dgeometryutils.cpp @@ -206,11 +206,64 @@ bool Quick3DGeometryUtils::polygonIsEar( const QVector &ring, const Q return true; } +void Quick3DGeometryUtils::generatePolygonWalls( const QVector &vertices, + float extrusion, + float r, float g, float b, float a, + float *&vptr, quint32 *&iptr, + quint32 &vertexOffset, + QVector3D &minBound, QVector3D &maxBound ) +{ + QVector ring = vertices; + if ( ring.size() > 3 && ( ring.first() - ring.last() ).length() < 0.001f ) + { + ring.removeLast(); + } + + const int n = ring.size(); + if ( n < 2 ) + { + return; + } + + for ( int i = 0; i < n; ++i ) + { + const QVector3D &edgeStart = ring[i]; + const QVector3D &edgeEnd = ring[( i + 1 ) % n]; + const QVector3D edgeStartTop = edgeStart + QVector3D( 0, extrusion, 0 ); + const QVector3D edgeEndTop = edgeEnd + QVector3D( 0, extrusion, 0 ); + + const float dx = edgeEnd.x() - edgeStart.x(); + const float dz = edgeEnd.z() - edgeStart.z(); + const float len = std::sqrt( dx * dx + dz * dz ); + const QVector3D normal = len > 1e-6f ? QVector3D( dz / len, 0.0f, -dx / len ) : QVector3D( 0.0f, 0.0f, 1.0f ); + + const quint32 base = vertexOffset; + // CCW winding seen from outside (outward normal): start, startTop, endTop, end + writeVertex( vptr, edgeStart, normal, r, g, b, a ); + updateBounds( minBound, maxBound, edgeStart ); + writeVertex( vptr, edgeStartTop, normal, r, g, b, a ); + updateBounds( minBound, maxBound, edgeStartTop ); + writeVertex( vptr, edgeEndTop, normal, r, g, b, a ); + updateBounds( minBound, maxBound, edgeEndTop ); + writeVertex( vptr, edgeEnd, normal, r, g, b, a ); + updateBounds( minBound, maxBound, edgeEnd ); + vertexOffset += 4; + + *iptr++ = base + 0; + *iptr++ = base + 1; + *iptr++ = base + 2; + *iptr++ = base + 0; + *iptr++ = base + 2; + *iptr++ = base + 3; + } +} + void Quick3DGeometryUtils::generatePolygonFill( const QVector &vertices, float r, float g, float b, float a, float *&vptr, quint32 *&iptr, quint32 &vertexOffset, - QVector3D &minBound, QVector3D &maxBound ) + QVector3D &minBound, QVector3D &maxBound, + float extrusion ) { QVector ring = vertices; if ( ring.size() > 3 && ( ring.first() - ring.last() ).length() < 0.001f ) @@ -226,11 +279,13 @@ void Quick3DGeometryUtils::generatePolygonFill( const QVector &vertic const quint32 baseVertex = vertexOffset; const QVector3D upNormal( 0.0f, 1.0f, 0.0f ); + const QVector3D lift( 0.0f, extrusion, 0.0f ); for ( int i = 0; i < n; ++i ) { - writeVertex( vptr, ring[i], upNormal, r, g, b, a ); - updateBounds( minBound, maxBound, ring[i] ); + const QVector3D pos = ring[i] + lift; + writeVertex( vptr, pos, upNormal, r, g, b, a ); + updateBounds( minBound, maxBound, pos ); } vertexOffset += n; diff --git a/src/core/3d/quick3dgeometryutils.h b/src/core/3d/quick3dgeometryutils.h index a0131e5096..05a580b92b 100644 --- a/src/core/3d/quick3dgeometryutils.h +++ b/src/core/3d/quick3dgeometryutils.h @@ -69,12 +69,34 @@ class QFIELD_CORE_EXPORT Quick3DGeometryUtils * fan from vertex 0 when ear clipping cannot make further progress (which * keeps degenerate or self-touching rings from leaving uninitialised * indices in the buffer). + * + * When \a extrusion is non-zero every fill vertex is shifted upward + * by that amount so the fill becomes a roof cap on top of extruded walls. */ static void generatePolygonFill( const QVector &vertices, float r, float g, float b, float a, float *&vptr, quint32 *&iptr, quint32 &vertexOffset, - QVector3D &minBound, QVector3D &maxBound ); + QVector3D &minBound, QVector3D &maxBound, + float extrusion = 0.0f ); + + /** + * Generates vertical wall quads for each edge of a closed polygon ring, + * extruding from the base vertices upward by \a extrusion scene units. + * Each edge produces 4 vertices and 2 triangles (6 indices). + */ + static void generatePolygonWalls( const QVector &vertices, + float extrusion, + float r, float g, float b, float a, + float *&vptr, quint32 *&iptr, + quint32 &vertexOffset, + QVector3D &minBound, QVector3D &maxBound ); + + //! Returns the number of vertices produced by generatePolygonWalls() for a ring with \a ringSize unique vertices. + static int polygonWallsVertexCount( int ringSize ) { return ringSize * 4; } + + //! Returns the number of indices produced by generatePolygonWalls() for a ring with \a ringSize unique vertices. + static int polygonWallsIndexCount( int ringSize ) { return ringSize * 6; } private: //! Returns true if the triangle ( prev, cur, next ) of \a indices is an ear of \a ring on the XZ plane. diff --git a/src/core/3d/quick3dterrainprovider.cpp b/src/core/3d/quick3dterrainprovider.cpp index 6802388204..880cb5dba4 100644 --- a/src/core/3d/quick3dterrainprovider.cpp +++ b/src/core/3d/quick3dterrainprovider.cpp @@ -387,6 +387,27 @@ void Quick3DTerrainProvider::calcNormalizedData() delete terrainProvider; } + // Reject gross low outliers (DEM spikes) as missing so they don't drag the height range down. + const double outlierFence = Quick3DTerrainProvider::lowerOutlierFence( heights, 3.0 ); + lowestHeight = std::numeric_limits::max(); + for ( int index = 0; index < heights.size(); ++index ) + { + double &height = heights[index]; + if ( std::isnan( height ) ) + { + continue; + } + if ( height < outlierFence ) + { + height = std::numeric_limits::quiet_NaN(); + missingValueIndexes << index; + } + else if ( lowestHeight > height ) + { + lowestHeight = height; + } + } + if ( !missingValueIndexes.isEmpty() ) { if ( lowestHeight == std::numeric_limits::max() ) @@ -407,6 +428,25 @@ void Quick3DTerrainProvider::calcNormalizedData() mFutureWatcher->setFuture( future ); } +double Quick3DTerrainProvider::lowerOutlierFence( QVector samples, double factor ) +{ + samples.erase( std::remove_if( samples.begin(), samples.end(), []( double value ) { return std::isnan( value ); } ), samples.end() ); + if ( samples.size() < 8 ) + { + return std::numeric_limits::lowest(); + } + + const auto quantileValue = [&samples]( double quantile ) { + const int index = qBound( 0, static_cast( quantile * ( samples.size() - 1 ) ), static_cast( samples.size() ) - 1 ); + std::nth_element( samples.begin(), samples.begin() + index, samples.end() ); + return samples[index]; + }; + + const double firstQuartile = quantileValue( 0.25 ); + const double thirdQuartile = quantileValue( 0.75 ); + return firstQuartile - factor * ( thirdQuartile - firstQuartile ); +} + double Quick3DTerrainProvider::sampleHeightFromTerrainProvider( double x, double y ) const { if ( !mTerrainProvider || !mProject ) diff --git a/src/core/3d/quick3dterrainprovider.h b/src/core/3d/quick3dterrainprovider.h index 9bbaded4a4..7a60cce3d6 100644 --- a/src/core/3d/quick3dterrainprovider.h +++ b/src/core/3d/quick3dterrainprovider.h @@ -206,6 +206,9 @@ class Quick3DTerrainProvider : public QObject void onTerrainDataCalculated(); double sampleHeightFromTerrainProvider( double x, double y ) const; + //! Returns the Tukey lower fence ( Q1 - factor * IQR ) for the samples; heights below it are gross DEM spikes. + static double lowerOutlierFence( QVector samples, double factor ); + private: QgsProject *mProject = nullptr; QgsQuickMapSettings *mMapSettings = nullptr; diff --git a/src/core/multifeaturelistmodel.h b/src/core/multifeaturelistmodel.h index 58c3812d65..db2adf2003 100644 --- a/src/core/multifeaturelistmodel.h +++ b/src/core/multifeaturelistmodel.h @@ -63,6 +63,7 @@ class MultiFeatureListModel : public QSortFilterProxyModel ConditionalFontItalicRole, ConditionalFontUnderlineRole, ConditionalFontStrikeOutRole, + ExtrusionRole, }; explicit MultiFeatureListModel( QObject *parent = nullptr ); diff --git a/src/core/multifeaturelistmodelbase.cpp b/src/core/multifeaturelistmodelbase.cpp index dcc122acf8..4bb1043e89 100644 --- a/src/core/multifeaturelistmodelbase.cpp +++ b/src/core/multifeaturelistmodelbase.cpp @@ -288,6 +288,7 @@ QHash MultiFeatureListModelBase::roleNames() const roleNames[MultiFeatureListModel::ConditionalFontStrikeOutRole] = "conditionalFontStrikeOut"; roleNames[MultiFeatureListModel::ConditionalFontItalicRole] = "conditionalFontItalic"; roleNames[MultiFeatureListModel::ConditionalFontBoldRole] = "conditionalFontBold"; + roleNames[MultiFeatureListModel::ExtrusionRole] = "extrusion"; return roleNames; } @@ -466,6 +467,19 @@ QVariant MultiFeatureListModelBase::data( const QModelIndex &index, int role ) c return false; break; + + case MultiFeatureListModel::ExtrusionRole: + { + if ( vlayer ) + { + const QString heightField = LayerUtils::guessFriendlyHeightField( vlayer ); + if ( !heightField.isEmpty() ) + { + return feature->second.attribute( heightField ).toDouble(); + } + } + return 0.0; + } } return QVariant(); diff --git a/src/core/utils/layerutils.cpp b/src/core/utils/layerutils.cpp index 636f3432c7..c2501af678 100644 --- a/src/core/utils/layerutils.cpp +++ b/src/core/utils/layerutils.cpp @@ -39,6 +39,7 @@ #include #include #include +#include #include #include #include @@ -551,6 +552,46 @@ bool LayerUtils::hasMValue( QgsVectorLayer *layer ) return QgsWkbTypes::hasM( layer->wkbType() ); } +QString LayerUtils::guessFriendlyHeightField( QgsVectorLayer *layer ) +{ + if ( !layer ) + { + return QString(); + } + + const QgsFields fields = layer->fields(); + if ( fields.isEmpty() ) + { + return QString(); + } + + static const QStringList sCandidates { + QStringLiteral( "extrusion" ), + QStringLiteral( "height" ), + QStringLiteral( "hauteur" ), // French (height) + QStringLiteral( "hohe" ), // German (height) + }; + + for ( const QString &candidate : sCandidates ) + { + for ( const QgsField &field : fields ) + { + if ( !field.isNumeric() ) + { + continue; + } + + const QString fieldName = field.name(); + if ( QgsStringUtils::unaccent( fieldName ).contains( candidate, Qt::CaseInsensitive ) ) + { + return fieldName; + } + } + } + + return QString(); +} + QSet LayerUtils::uniqueValuesForVectorLayerFieldIndex( QgsVectorLayer *layer, int fieldIndex ) { if ( !layer ) diff --git a/src/core/utils/layerutils.h b/src/core/utils/layerutils.h index 4c3ba4e51e..47ca4dfaab 100644 --- a/src/core/utils/layerutils.h +++ b/src/core/utils/layerutils.h @@ -180,6 +180,12 @@ class LayerUtils : public QObject */ Q_INVOKABLE static bool hasMValue( QgsVectorLayer *layer ); + /** + * Guesses the name of the field in \a layer most likely to carry a per-feature + * height/extrusion value, or an empty string when no suitable field is found. + */ + Q_INVOKABLE static QString guessFriendlyHeightField( QgsVectorLayer *layer ); + /** * Returns a list of unique values for a given \a fieldIndex from the \a layer. */ diff --git a/src/qml/3d/FeatureListSelectionHighlight3D.qml b/src/qml/3d/FeatureListSelectionHighlight3D.qml index 2ed8ca8607..5c7e08c1c4 100644 --- a/src/qml/3d/FeatureListSelectionHighlight3D.qml +++ b/src/qml/3d/FeatureListSelectionHighlight3D.qml @@ -37,6 +37,7 @@ Node { lineWidth: featureListSelectionHighlight3D.lineWidth heightOffset: featureListSelectionHighlight3D.heightOffset altitudeClamping: featureListSelectionHighlight3D.altitudeClamping + extrusion: model.extrusion color: model.featureSelected ? featureListSelectionHighlight3D.selectedColor : featureListSelectionHighlight3D.selectionModel.model.selectedCount === 0 && index === featureListSelectionHighlight3D.selectionModel.focusedItem ? featureListSelectionHighlight3D.focusedColor : featureListSelectionHighlight3D.color } @@ -46,7 +47,8 @@ Node { metalness: 0.0 roughness: 1.0 vertexColorsEnabled: true - alphaMode: PrincipledMaterial.Blend + alphaMode: model.extrusion > 0 ? PrincipledMaterial.Opaque : PrincipledMaterial.Blend + depthDrawMode: Material.AlwaysDepthDraw cullMode: PrincipledMaterial.NoCulling } ]