From fca90a7bfbf49a96a73613706adda11b30803aee Mon Sep 17 00:00:00 2001 From: Vincent Cloarec Date: Mon, 11 May 2020 01:19:22 -0400 Subject: [PATCH] [MESH] scalar color settings depending on classification (#36313) * [MESH] [FEATURE] Sets meh color ramp classification from metadata read by MDAL driver. Some mesh layer formats can provide values that can be compressed by categorizing values in consecutive intervals, each represent by an integer or byte. MDAL has the capabilities to recognize this dataset type and store the bounds of each class an the units in the metadata. QGIS uses this metadata to setup adapted color ramp shader. * [MDAL] update to pre-release 0.5.92 --- external/mdal/frmts/mdal_3di.cpp | 16 +- external/mdal/frmts/mdal_3di.hpp | 9 +- external/mdal/frmts/mdal_cf.cpp | 258 +++++++++++++++--- external/mdal/frmts/mdal_cf.hpp | 17 +- external/mdal/frmts/mdal_esri_tin.cpp | 10 +- external/mdal/frmts/mdal_tuflowfv.cpp | 22 +- external/mdal/frmts/mdal_tuflowfv.hpp | 11 +- external/mdal/frmts/mdal_ugrid.cpp | 62 ++++- external/mdal/frmts/mdal_ugrid.hpp | 8 +- external/mdal/frmts/mdal_xms_tin.cpp | 4 +- external/mdal/mdal.cpp | 2 +- external/mdal/mdal_data_model.cpp | 26 ++ external/mdal/mdal_data_model.hpp | 10 + external/mdal/mdal_utils.hpp | 4 + .../qgsmeshrendereractivedatasetwidget.cpp | 5 + .../qgsmeshrendererscalarsettingswidget.cpp | 1 - .../mesh/qgsrenderermeshpropertieswidget.cpp | 5 + src/core/mesh/qgsmeshlayer.cpp | 92 +++++++ src/core/mesh/qgsmeshlayer.h | 3 + src/core/mesh/qgsmeshlayerutils.cpp | 1 - tests/src/core/testqgsmeshlayerrenderer.cpp | 15 +- .../expected_classified_values.png | Bin 0 -> 80307 bytes tests/testdata/mesh/simplebox_clm.nc | Bin 0 -> 68940 bytes 23 files changed, 519 insertions(+), 62 deletions(-) create mode 100644 tests/testdata/control_images/mesh/expected_classified_values/expected_classified_values.png create mode 100644 tests/testdata/mesh/simplebox_clm.nc diff --git a/external/mdal/frmts/mdal_3di.cpp b/external/mdal/frmts/mdal_3di.cpp index c5d42d88cfe..a7ee2a97861 100644 --- a/external/mdal/frmts/mdal_3di.cpp +++ b/external/mdal/frmts/mdal_3di.cpp @@ -194,10 +194,16 @@ std::set MDAL::Driver3Di::ignoreNetCDFVariables() return ignore_variables; } -void MDAL::Driver3Di::parseNetCDFVariableMetadata( int varid, const std::string &variableName, std::string &name, bool *is_vector, bool *is_x ) +void MDAL::Driver3Di::parseNetCDFVariableMetadata( int varid, + std::string &variableName, + std::string &name, + bool *is_vector, + bool *isPolar, + bool *is_x ) { *is_vector = false; *is_x = true; + *isPolar = false; std::string long_name = mNcFile->getAttrStr( "long_name", varid ); if ( long_name.empty() ) @@ -209,6 +215,7 @@ void MDAL::Driver3Di::parseNetCDFVariableMetadata( int varid, const std::string } else { + variableName = standard_name; if ( MDAL::contains( standard_name, "_x_" ) ) { *is_vector = true; @@ -228,6 +235,7 @@ void MDAL::Driver3Di::parseNetCDFVariableMetadata( int varid, const std::string } else { + variableName = long_name; if ( MDAL::contains( long_name, " in x direction" ) ) { *is_vector = true; @@ -245,3 +253,9 @@ void MDAL::Driver3Di::parseNetCDFVariableMetadata( int varid, const std::string } } } + +std::vector> MDAL::Driver3Di::parseClassification( int varid ) const +{ + MDAL_UNUSED( varid ); + return std::vector>(); +} diff --git a/external/mdal/frmts/mdal_3di.hpp b/external/mdal/frmts/mdal_3di.hpp index 6d3d8643db2..4b261c330ce 100644 --- a/external/mdal/frmts/mdal_3di.hpp +++ b/external/mdal/frmts/mdal_3di.hpp @@ -50,8 +50,13 @@ namespace MDAL std::string getCoordinateSystemVariableName() override; std::string getTimeVariableName() const override; std::set ignoreNetCDFVariables() override; - void parseNetCDFVariableMetadata( int varid, const std::string &variableName, - std::string &name, bool *is_vector, bool *is_x ) override; + void parseNetCDFVariableMetadata( int varid, + std::string &variableName, + std::string &name, + bool *is_vector, + bool *isPolar, + bool *is_x ) override; + std::vector> parseClassification( int varid ) const override; //! Returns number of vertices size_t parse2DMesh(); diff --git a/external/mdal/frmts/mdal_cf.cpp b/external/mdal/frmts/mdal_cf.cpp index ddfe79ced9a..a7e7fb87470 100644 --- a/external/mdal/frmts/mdal_cf.cpp +++ b/external/mdal/frmts/mdal_cf.cpp @@ -6,7 +6,7 @@ #include #include #include -#include "math.h" +#include #include #include #include @@ -16,6 +16,28 @@ #include "mdal_utils.hpp" #include "mdal_logger.hpp" +static std::pair metadataFromClassification( const MDAL::Classification &classes ) +{ + std::pair classificationMeta; + classificationMeta.first = "classification"; + std::string classification; + for ( const auto boundValues : classes ) + { + if ( boundValues.first != NC_FILL_DOUBLE ) + classification.append( MDAL::doubleToString( boundValues.first ) ); + if ( boundValues.second != NC_FILL_DOUBLE ) + { + classification.append( "," ); + classification.append( MDAL::doubleToString( boundValues.second ) ); + } + if ( boundValues != classes.back() ) + classification.append( ";;" ); + } + classificationMeta.second = classification; + + return classificationMeta; +} + MDAL::cfdataset_info_map MDAL::DriverCF::parseDatasetGroupInfo() { /* @@ -101,63 +123,188 @@ MDAL::cfdataset_info_map MDAL::DriverCF::parseDatasetGroupInfo() continue; // Get name, if it is vector and if it is x or y - std::string name; + std::string vectorName; bool is_vector = true; + bool is_polar = false; bool is_x = false; + Classification classes = parseClassification( varid ); + bool isClassified = !classes.empty(); + parseNetCDFVariableMetadata( varid, variable_name, vectorName, &is_vector, &is_polar, &is_x ); - parseNetCDFVariableMetadata( varid, variable_name, name, &is_vector, &is_x ); - - // Add it to the map - auto it = dsinfo_map.find( name ); - if ( it != dsinfo_map.end() ) + Metadata meta; + // check for units + std::string units; + try { + units = mNcFile->getAttrStr( "units", varid ); + std::pair unitMeta; + unitMeta.first = "units"; + unitMeta.second = units; + meta.push_back( unitMeta ); + } + catch ( MDAL::Error & ) + {} + + //construct classification metadata + if ( isClassified && !is_vector ) + { + + meta.push_back( metadataFromClassification( classes ) ); + } + + // Add dsinfo to the map + auto it = dsinfo_map.find( vectorName ); + if ( it != dsinfo_map.end() && is_vector ) + { + // this dataset is already existing and it is a vector dataset + if ( is_x ) { it->second.ncid_x = varid; + it->second.classification_x = classes; } else { it->second.ncid_y = varid; + it->second.classification_y = classes; } + + // If it is classified, we want to keep each component as scalar + // So create two scalar dataset groups + if ( isClassified ) + { + CFDatasetGroupInfo scalarDsInfoX; + scalarDsInfoX = it->second; + scalarDsInfoX.is_vector = false; + scalarDsInfoX.is_polar = false; + CFDatasetGroupInfo scalarDsInfoY; + scalarDsInfoY = it->second; + scalarDsInfoY.is_vector = false; + scalarDsInfoX.is_polar = false; + scalarDsInfoX.ncid_x = it->second.ncid_x; + scalarDsInfoY.ncid_x = it->second.ncid_y; + + if ( is_x ) + { + scalarDsInfoX.name = variable_name; + scalarDsInfoX.classification_x = classes; + scalarDsInfoX.metadata = meta; + } + else + { + scalarDsInfoY.name = variable_name; + scalarDsInfoY.classification_x = classes; + scalarDsInfoY.metadata = meta; + } + + scalarDsInfoX.metadata.push_back( metadataFromClassification( scalarDsInfoX.classification_x ) ); + scalarDsInfoY.metadata.push_back( metadataFromClassification( scalarDsInfoY.classification_y ) ); + + dsinfo_map[scalarDsInfoX.name] = scalarDsInfoX; + dsinfo_map[scalarDsInfoY.name] = scalarDsInfoY; + } + + it->second.name = vectorName; } - else + else if ( it == dsinfo_map.end() || ( isClassified && is_vector ) ) { CFDatasetGroupInfo dsInfo; dsInfo.nTimesteps = nTimesteps; - dsInfo.is_vector = is_vector; + dsInfo.ncid_x = -1; + dsInfo.ncid_y = -1; if ( is_x ) { dsInfo.ncid_x = varid; + dsInfo.classification_x = classes; } else { dsInfo.ncid_y = varid; + dsInfo.classification_y = classes; } + dsInfo.outputType = mDimensions.type( dimid ); - dsInfo.name = name; + dsInfo.is_vector = is_vector; + dsInfo.is_polar = is_polar; dsInfo.nValues = mDimensions.size( mDimensions.type( dimid ) ); dsInfo.timeLocation = timeLocation; - dsinfo_map[name] = dsInfo; + dsInfo.metadata = meta; + if ( is_vector && !isClassified ) + dsInfo.name = vectorName; + else + dsInfo.name = variable_name; + dsinfo_map[vectorName] = dsInfo; //if is not vector, vectorName=variableName } } } while ( true ); + // check the dsinfo if there dataset group defined as vector without valid ncid_y + // if ncid_y<0 set the datasetinfo to scalar + for ( auto &it : dsinfo_map ) + { + if ( it.second.is_vector && it.second.ncid_y < 0 ) + it.second.is_vector = false; + } + + return dsinfo_map; } -static void populate_vals( bool is_vector, double *vals, size_t i, - const std::vector &vals_x, const std::vector &vals_y, - size_t idx, double fill_val_x, double fill_val_y ) +static void populate_vector_vals( double *vals, size_t i, + const std::vector &vals_x, const std::vector &vals_y, + size_t idx, double fill_val_x, double fill_val_y ) { - if ( is_vector ) + vals[2 * i] = MDAL::safeValue( vals_x[idx], fill_val_x ); + vals[2 * i + 1] = MDAL::safeValue( vals_y[idx], fill_val_y ); +} + +static void populate_polar_vector_vals( double *vals, size_t i, + const std::vector &vals_x, const std::vector &vals_y, + size_t idx, double fill_val_x, double fill_val_y, std::pair referenceAngles ) +{ + double magnitude = MDAL::safeValue( vals_x[idx], fill_val_x ); + double direction = MDAL::safeValue( vals_y[idx], fill_val_y ); + + direction = 2 * M_PI * ( ( direction - referenceAngles.second ) / referenceAngles.first ); + + vals[2 * i] = magnitude * cos( direction ); + vals[2 * i + 1] = magnitude * sin( direction ); +} + +static void populate_scalar_vals( double *vals, size_t i, + const std::vector &rawVals, + size_t idx, + double fill_val ) +{ + vals[i] = MDAL::safeValue( rawVals[idx], fill_val ); +} + +static void fromClassificationToValue( const MDAL::Classification &classification, std::vector &values, size_t classStartAt = 0 ) +{ + for ( size_t i = 0; i < values.size(); ++i ) { - vals[2 * i] = MDAL::safeValue( vals_x[idx], fill_val_x ); - vals[2 * i + 1] = MDAL::safeValue( vals_y[idx], fill_val_y ); - } - else - { - vals[i] = MDAL::safeValue( vals_x[idx], fill_val_x ); + if ( std::isnan( values[i] ) ) + continue; + + size_t boundIndex = size_t( values[i] ) - classStartAt; + if ( boundIndex >= classification.size() ) + { + values[i] = std::numeric_limits::quiet_NaN(); + continue; + } + + std::pair bounds = classification.at( boundIndex ); + double bound1 = bounds.first; + double bound2 = bounds.second; + if ( bound1 == NC_FILL_DOUBLE ) + bound1 = bound2; + if ( bound2 == NC_FILL_DOUBLE ) + bound2 = bound1; + if ( bound1 == NC_FILL_DOUBLE || bound2 == NC_FILL_DOUBLE ) + values[i] = std::numeric_limits::quiet_NaN(); + else + values[i] = ( bound1 + bound2 ) / 2; } } @@ -175,6 +322,8 @@ void MDAL::DriverCF::addDatasetGroups( MDAL::Mesh *mesh, const std::vectorsetIsScalar( !dsi.is_vector ); + group->setIsPolar( dsi.is_polar ); + group->setMetadata( dsi.metadata ); if ( dsi.outputType == CFDimensions::Vertex ) group->setDataLocation( MDAL_DataLocation::DataOnVertices ); @@ -275,6 +424,8 @@ std::shared_ptr MDAL::DriverCF::create2DDataset( std::shared_ptr< fill_val_y, dsi.ncid_x, dsi.ncid_y, + dsi.classification_x, + dsi.classification_y, dsi.timeLocation, dsi.nTimesteps, dsi.nValues, @@ -491,14 +642,24 @@ bool MDAL::CFDimensions::isDatasetType( MDAL::CFDimensions::Type type ) const ////////////////////////////////////////////////////////////////////////////////////// MDAL::CFDataset2D::CFDataset2D( MDAL::DatasetGroup *parent, - double fill_val_x, double fill_val_y, - int ncid_x, int ncid_y, CFDatasetGroupInfo::TimeLocation timeLocation, - size_t timesteps, size_t values, size_t ts, std::shared_ptr ncFile ) + double fill_val_x, + double fill_val_y, + int ncid_x, + int ncid_y, + Classification classification_x, + Classification classification_y, + CFDatasetGroupInfo::TimeLocation timeLocation, + size_t timesteps, + size_t values, + size_t ts, + std::shared_ptr ncFile ) : Dataset2D( parent ) , mFillValX( fill_val_x ) , mFillValY( fill_val_y ) , mNcidX( ncid_x ) , mNcidY( ncid_y ) + , mClassificationX( classification_x ) + , mClassificationY( classification_y ) , mTimeLocation( timeLocation ) , mTimesteps( timesteps ) , mValues( values ) @@ -547,14 +708,11 @@ size_t MDAL::CFDataset2D::scalarData( size_t indexStart, size_t count, double *b for ( size_t i = 0; i < copyValues; ++i ) { - populate_vals( false, - buffer, - i, - values_x, - std::vector(), - i, - mFillValX, - mFillValY ); + populate_scalar_vals( buffer, + i, + values_x, + i, + mFillValX ); } return copyValues; } @@ -611,16 +769,36 @@ size_t MDAL::CFDataset2D::vectorData( size_t indexStart, size_t count, double *b ); } + //if values component are classified convert from index to value + if ( !mClassificationX.empty() ) + { + fromClassificationToValue( mClassificationX, values_x, 1 ); + } + + if ( !mClassificationY.empty() ) + { + fromClassificationToValue( mClassificationY, values_y, 1 ); + } + for ( size_t i = 0; i < copyValues; ++i ) { - populate_vals( true, - buffer, - i, - values_x, - values_y, - i, - mFillValX, - mFillValY ); + if ( group()->isPolar() ) + populate_polar_vector_vals( buffer, + i, + values_x, + values_y, + i, + mFillValX, + mFillValY, + group()->referenceAngles() ); + else + populate_vector_vals( buffer, + i, + values_x, + values_y, + i, + mFillValX, + mFillValY ); } return copyValues; diff --git a/external/mdal/frmts/mdal_cf.hpp b/external/mdal/frmts/mdal_cf.hpp index 087506e0b82..bccc8d1479b 100644 --- a/external/mdal/frmts/mdal_cf.hpp +++ b/external/mdal/frmts/mdal_cf.hpp @@ -61,11 +61,15 @@ namespace MDAL std::string name; //!< Dataset group name CFDimensions::Type outputType; bool is_vector; + bool is_polar; TimeLocation timeLocation; size_t nTimesteps; size_t nValues; int ncid_x; //!< NetCDF variable id int ncid_y; //!< NetCDF variable id + Metadata metadata; + Classification classification_x; + Classification classification_y; }; typedef std::map cfdataset_info_map; // name -> DatasetInfo @@ -77,6 +81,8 @@ namespace MDAL double fill_val_y, int ncid_x, int ncid_y, + Classification classification_x, + Classification classification_y, CFDatasetGroupInfo::TimeLocation timeLocation, size_t timesteps, size_t values, @@ -93,6 +99,8 @@ namespace MDAL double mFillValY; int mNcidX; //!< NetCDF variable id int mNcidY; //!< NetCDF variable id + Classification mClassificationX; //!< Classification, void if not classified + Classification mClassificationY; //!< Classification, void if not classified CFDatasetGroupInfo::TimeLocation mTimeLocation; size_t mTimesteps; size_t mValues; @@ -120,8 +128,13 @@ namespace MDAL virtual void addBedElevation( MDAL::MemoryMesh *mesh ) = 0; virtual std::string getCoordinateSystemVariableName() = 0; virtual std::set ignoreNetCDFVariables() = 0; - virtual void parseNetCDFVariableMetadata( int varid, const std::string &variableName, - std::string &name, bool *is_vector, bool *is_x ) = 0; + virtual void parseNetCDFVariableMetadata( int varid, + std::string &variableName, + std::string &name, + bool *is_vector, + bool *isPolar, + bool *is_x ) = 0; + virtual std::vector > parseClassification( int varid ) const = 0; virtual std::string getTimeVariableName() const = 0; virtual std::shared_ptr create2DDataset( std::shared_ptr group, diff --git a/external/mdal/frmts/mdal_esri_tin.cpp b/external/mdal/frmts/mdal_esri_tin.cpp index 84a0f76082e..e8e50f095c0 100644 --- a/external/mdal/frmts/mdal_esri_tin.cpp +++ b/external/mdal/frmts/mdal_esri_tin.cpp @@ -61,17 +61,17 @@ std::unique_ptr MDAL::DriverEsriTin::load( const std::string &uri, c inMsx.seekg( -4, std::ios::end ); int32_t mskBegin; if ( ! readValue( mskBegin, inMsx, true ) ) - throw MDAL::Error( MDAL_Status::Err_UnknownFormat, "Unable to find the beginning of data in msk file" ); + throw MDAL::Error( MDAL_Status::Err_UnknownFormat, "Unable to find the beginning of data in tmsx.adf file" ); //read information in mskFile inMsk.seekg( -mskBegin * 2, std::ios::end ); int32_t maskIntergerCount; if ( ! readValue( maskIntergerCount, inMsk, true ) ) - throw MDAL::Error( MDAL_Status::Err_UnknownFormat, "Unable to read information in msk file" ); + throw MDAL::Error( MDAL_Status::Err_UnknownFormat, "Unable to read information in tmsk.adf file" ); inMsk.ignore( 4 ); //unused 4 bytes int32_t maskBitsCount; if ( ! readValue( maskBitsCount, inMsk, true ) ) - throw MDAL::Error( MDAL_Status::Err_UnknownFormat, "Unable to read information in msk file" ); + throw MDAL::Error( MDAL_Status::Err_UnknownFormat, "Unable to read information in tmsk.adf file" ); int c = 0; int32_t maskInt = 0; @@ -80,7 +80,7 @@ std::unique_ptr MDAL::DriverEsriTin::load( const std::string &uri, c //read mask file if ( c % 32 == 0 && c < maskBitsCount ) //first bit in the mask array have to be used-->read next maskInt if ( ! readValue( maskInt, inMsk, true ) ) - throw MDAL::Error( MDAL_Status::Err_UnknownFormat, "Unable to read information in msk file" ); + throw MDAL::Error( MDAL_Status::Err_UnknownFormat, "Unable to read information in tmsk.adf file" ); Face f; for ( int i = 0; i < 3; ++i ) @@ -96,7 +96,7 @@ std::unique_ptr MDAL::DriverEsriTin::load( const std::string &uri, c break; if ( f.size() < 3 ) //that's mean the face is not complete - throw MDAL::Error( MDAL_Status::Err_UnknownFormat, "Unable to read information in mask file, face is not complete" ); + throw MDAL::Error( MDAL_Status::Err_UnknownFormat, "Unable to read information in tnod.adf file, face is not complete" ); //exclude masked face if ( !( maskInt & 0x01 ) ) diff --git a/external/mdal/frmts/mdal_tuflowfv.cpp b/external/mdal/frmts/mdal_tuflowfv.cpp index b66d793eca3..aaba109a819 100644 --- a/external/mdal/frmts/mdal_tuflowfv.cpp +++ b/external/mdal/frmts/mdal_tuflowfv.cpp @@ -54,6 +54,8 @@ MDAL::TuflowFVDataset2D::TuflowFVDataset2D( double fillValY, int ncidX, int ncidY, + Classification classificationX, + Classification classificationY, int ncidActive, CFDatasetGroupInfo::TimeLocation timeLocation, size_t timesteps, @@ -67,6 +69,8 @@ MDAL::TuflowFVDataset2D::TuflowFVDataset2D( fillValY, ncidX, ncidY, + classificationX, + classificationY, timeLocation, timesteps, values, @@ -456,10 +460,16 @@ std::set MDAL::DriverTuflowFV::ignoreNetCDFVariables() return ignore_variables; } -void MDAL::DriverTuflowFV::parseNetCDFVariableMetadata( int varid, const std::string &variableName, std::string &name, bool *is_vector, bool *is_x ) +void MDAL::DriverTuflowFV::parseNetCDFVariableMetadata( int varid, + std::string &variableName, + std::string &name, + bool *is_vector, + bool *isPolar, + bool *is_x ) { *is_vector = false; *is_x = true; + *isPolar = false; std::string long_name = mNcFile->getAttrStr( "long_name", varid ); if ( long_name.empty() || ( long_name == "??????" ) ) @@ -480,6 +490,8 @@ void MDAL::DriverTuflowFV::parseNetCDFVariableMetadata( int varid, const std::st if ( MDAL::startsWith( long_name, "time at minimum value of " ) ) long_name = MDAL::replace( long_name, "time at minimum value of ", "" ) + "/Time at Minimums"; + variableName = long_name; + if ( MDAL::startsWith( long_name, "x_" ) ) { *is_vector = true; @@ -515,6 +527,8 @@ std::shared_ptr MDAL::DriverTuflowFV::create2DDataset( fill_val_y, dsi.ncid_x, dsi.ncid_y, + dsi.classification_x, + dsi.classification_y, mNcFile->arrId( "stat" ), dsi.timeLocation, dsi.nTimesteps, @@ -551,3 +565,9 @@ std::shared_ptr MDAL::DriverTuflowFV::create3DDataset( std::share dataset->setStatistics( MDAL::calculateStatistics( dataset ) ); return std::move( dataset ); } + +std::vector> MDAL::DriverTuflowFV::parseClassification( int varid ) const +{ + MDAL_UNUSED( varid ); + return std::vector>(); +} diff --git a/external/mdal/frmts/mdal_tuflowfv.hpp b/external/mdal/frmts/mdal_tuflowfv.hpp index 393b94e2b74..4e24773257b 100644 --- a/external/mdal/frmts/mdal_tuflowfv.hpp +++ b/external/mdal/frmts/mdal_tuflowfv.hpp @@ -40,6 +40,8 @@ namespace MDAL double fillValY, int ncidX, int ncidY, + Classification classificationX, + Classification classificationY, int ncidActive, CFDatasetGroupInfo::TimeLocation timeLocation, size_t timesteps, @@ -122,8 +124,13 @@ namespace MDAL void addBedElevation( MemoryMesh *mesh ) override; std::string getCoordinateSystemVariableName() override; std::set ignoreNetCDFVariables() override; - void parseNetCDFVariableMetadata( int varid, const std::string &variableName, - std::string &name, bool *is_vector, bool *is_x ) override; + void parseNetCDFVariableMetadata( int varid, + std::string &variableName, + std::string &name, + bool *is_vector, + bool *isPolar, + bool *is_x ) override; + std::vector> parseClassification( int varid ) const override; std::string getTimeVariableName() const override; std::shared_ptr create2DDataset( std::shared_ptr group, diff --git a/external/mdal/frmts/mdal_ugrid.cpp b/external/mdal/frmts/mdal_ugrid.cpp index 072f6095ab2..e101086ecbe 100644 --- a/external/mdal/frmts/mdal_ugrid.cpp +++ b/external/mdal/frmts/mdal_ugrid.cpp @@ -491,10 +491,17 @@ void MDAL::DriverUgrid::ignore2DMeshVariables( const std::string &mesh, std::set ignoreVariables.insert( mNcFile->getAttrStr( mesh, "edge_face_connectivity" ) ); } -void MDAL::DriverUgrid::parseNetCDFVariableMetadata( int varid, const std::string &variableName, std::string &name, bool *isVector, bool *isX ) +void MDAL::DriverUgrid::parseNetCDFVariableMetadata( int varid, + std::string &variableName, + std::string &name, + bool *isVector, + bool *isPolar, + bool *isX ) { + *isVector = false; *isX = true; + *isPolar = false; std::string longName = mNcFile->getAttrStr( "long_name", varid ); if ( longName.empty() ) @@ -506,6 +513,7 @@ void MDAL::DriverUgrid::parseNetCDFVariableMetadata( int varid, const std::strin } else { + variableName = standardName; if ( MDAL::contains( standardName, "_x_" ) ) { *isVector = true; @@ -525,6 +533,7 @@ void MDAL::DriverUgrid::parseNetCDFVariableMetadata( int varid, const std::strin } else { + variableName = longName; if ( MDAL::contains( longName, ", x-component" ) || MDAL::contains( longName, "u component of " ) ) { *isVector = true; @@ -538,6 +547,20 @@ void MDAL::DriverUgrid::parseNetCDFVariableMetadata( int varid, const std::strin name = MDAL::replace( longName, ", y-component", "" ); name = MDAL::replace( name, "v component of ", "" ); } + else if ( MDAL::contains( longName, "velocity magnitude" ) ) + { + *isVector = true; + *isPolar = true; + *isX = true; + name = MDAL::replace( longName, " magnitude", "" ); + } + else if ( MDAL::contains( longName, "velocity direction" ) ) + { + *isVector = true; + *isPolar = true; + *isX = false; + name = MDAL::replace( longName, " direction", "" ); + } else { name = longName; @@ -807,3 +830,40 @@ void MDAL::DriverUgrid::writeGlobals() mNcFile->putAttrStr( NC_GLOBAL, "date_created", MDAL::getCurrentTimeStamp() ); mNcFile->putAttrStr( NC_GLOBAL, "Conventions", "CF-1.6 UGRID-1.0" ); } + +std::vector> MDAL::DriverUgrid::parseClassification( int varid ) const +{ + std::vector> classes; + std::string flagBoundVarName = mNcFile->getAttrStr( "flag_bounds", varid ); + if ( !flagBoundVarName.empty() ) + { + try + { + int boundsVarId = mNcFile->getVarId( flagBoundVarName ); + std::vector classDims; + std::vector classDimIds; + mNcFile->getDimensions( flagBoundVarName, classDims, classDimIds ); + std::vector boundValues = mNcFile->readDoubleArr( boundsVarId, 0, 0, classDims[0], classDims[1] ); + + if ( classDims[1] != 2 || classDims[0] <= 0 ) + throw MDAL::Error( MDAL_Status::Err_UnknownFormat, "Invalid classification dimension" ); + + std::pair classificationMeta; + classificationMeta.first = "classification"; + std::string classification; + for ( size_t i = 0; i < classDims[0]; ++i ) + { + std::pair classBound; + classBound.first = boundValues[i * 2]; + classBound.second = boundValues[i * 2 + 1]; + classes.push_back( classBound ); + } + } + catch ( MDAL::Error &err ) + { + MDAL::Log::warning( err.status, err.driver, "Error when parsing class bounds: " + err.mssg + ", classification ignored" ); + } + } + + return classes; +} diff --git a/external/mdal/frmts/mdal_ugrid.hpp b/external/mdal/frmts/mdal_ugrid.hpp index be442b0059e..c3eed4d6bee 100644 --- a/external/mdal/frmts/mdal_ugrid.hpp +++ b/external/mdal/frmts/mdal_ugrid.hpp @@ -38,8 +38,12 @@ namespace MDAL void addBedElevation( MemoryMesh *mesh ) override; std::string getCoordinateSystemVariableName() override; std::set ignoreNetCDFVariables() override; - void parseNetCDFVariableMetadata( int varid, const std::string &variableName, - std::string &name, bool *is_vector, bool *is_x ) override; + void parseNetCDFVariableMetadata( int varid, + std::string &variableName, + std::string &name, + bool *is_vector, bool *isPolar, + bool *is_x ) override; + std::vector> parseClassification( int varid ) const override; std::string getTimeVariableName() const override; void parse2VariablesFromAttribute( const std::string &name, const std::string &attr_name, diff --git a/external/mdal/frmts/mdal_xms_tin.cpp b/external/mdal/frmts/mdal_xms_tin.cpp index 16f17650aa3..ce9bd41fab4 100644 --- a/external/mdal/frmts/mdal_xms_tin.cpp +++ b/external/mdal/frmts/mdal_xms_tin.cpp @@ -105,7 +105,7 @@ std::unique_ptr MDAL::DriverXmsTin::load( const std::string &meshFil // Read triangles if ( !std::getline( in, line ) ) { - MDAL::Log::error( MDAL_Status::Err_IncompatibleMesh, name(), meshFile + " does not contain valid triangle definitions" ); + MDAL::Log::error( MDAL_Status::Err_IncompatibleMesh, name(), meshFile + " does not contain valid triangle definition" ); return nullptr; } chunks = split( line, ' ' ); @@ -127,7 +127,7 @@ std::unique_ptr MDAL::DriverXmsTin::load( const std::string &meshFil if ( chunks.size() != 3 ) { // should have 3 indexes - MDAL::Log::error( MDAL_Status::Err_IncompatibleMesh, name(), meshFile + " does not contain valid triangle definitions" ); + MDAL::Log::error( MDAL_Status::Err_IncompatibleMesh, name(), meshFile + " does not contain valid triangle defintion" ); return nullptr; } diff --git a/external/mdal/mdal.cpp b/external/mdal/mdal.cpp index ff21322dcc0..d5ea75c8694 100644 --- a/external/mdal/mdal.cpp +++ b/external/mdal/mdal.cpp @@ -21,7 +21,7 @@ static const char *EMPTY_STR = ""; const char *MDAL_Version() { - return "0.5.91"; + return "0.5.92"; } MDAL_Status MDAL_LastStatus() diff --git a/external/mdal/mdal_data_model.cpp b/external/mdal/mdal_data_model.cpp index a3d24c8e8ad..91319a6d309 100644 --- a/external/mdal/mdal_data_model.cpp +++ b/external/mdal/mdal_data_model.cpp @@ -187,6 +187,12 @@ void MDAL::DatasetGroup::setMetadata( const std::string &key, const std::string metadata.push_back( std::make_pair( key, val ) ); } +void MDAL::DatasetGroup::setMetadata( const MDAL::Metadata &metadata ) +{ + for ( const auto &meta : metadata ) + setMetadata( meta.first, meta.second ); +} + std::string MDAL::DatasetGroup::name() { return getMetadata( "name" ); @@ -254,6 +260,26 @@ void MDAL::DatasetGroup::stopEditing() mInEditMode = false; } +void MDAL::DatasetGroup::setReferenceAngles( const std::pair &referenceAngle ) +{ + mReferenceAngles = referenceAngle; +} + +bool MDAL::DatasetGroup::isPolar() const +{ + return mIsPolar; +} + +void MDAL::DatasetGroup::setIsPolar( bool isPolar ) +{ + mIsPolar = isPolar; +} + +std::pair MDAL::DatasetGroup::referenceAngles() const +{ + return mReferenceAngles; +} + MDAL_DataLocation MDAL::DatasetGroup::dataLocation() const { return mDataLocation; diff --git a/external/mdal/mdal_data_model.hpp b/external/mdal/mdal_data_model.hpp index 63a3d611cd9..74968216750 100644 --- a/external/mdal/mdal_data_model.hpp +++ b/external/mdal/mdal_data_model.hpp @@ -38,6 +38,7 @@ namespace MDAL } Statistics; typedef std::vector< std::pair< std::string, std::string > > Metadata; + typedef std::vector> Classification; class Dataset { @@ -150,6 +151,7 @@ namespace MDAL std::string getMetadata( const std::string &key ); void setMetadata( const std::string &key, const std::string &val ); + void setMetadata( const Metadata &metadata ); std::string name(); void setName( const std::string &name ); @@ -179,12 +181,20 @@ namespace MDAL void startEditing(); void stopEditing(); + //! First value is the angle for full rotation and second value is the start angle + void setReferenceAngles( const std::pair &referenceAngle ); + std::pair referenceAngles() const; + + bool isPolar() const; + void setIsPolar( bool isPolar ); private: bool mInEditMode = false; const std::string mDriverName; Mesh *mParent = nullptr; bool mIsScalar = true; + bool mIsPolar = true; + std::pair mReferenceAngles = {360, 0}; MDAL_DataLocation mDataLocation = MDAL_DataLocation::DataOnVertices; std::string mUri; // file/uri from where it came Statistics mStatistics; diff --git a/external/mdal/mdal_utils.hpp b/external/mdal/mdal_utils.hpp index 0f24114c4e4..c6721a1de8e 100644 --- a/external/mdal/mdal_utils.hpp +++ b/external/mdal/mdal_utils.hpp @@ -23,6 +23,10 @@ #define MDAL_UNUSED(x) (void)x; #define MDAL_NAN std::numeric_limits::quiet_NaN() +#ifndef M_PI +#define M_PI 3.14159265358979323846264338327 +#endif + namespace MDAL { // endianness diff --git a/src/app/mesh/qgsmeshrendereractivedatasetwidget.cpp b/src/app/mesh/qgsmeshrendereractivedatasetwidget.cpp index 777f448c262..45f8cfb3850 100644 --- a/src/app/mesh/qgsmeshrendereractivedatasetwidget.cpp +++ b/src/app/mesh/qgsmeshrendereractivedatasetwidget.cpp @@ -185,6 +185,11 @@ QString QgsMeshRendererActiveDatasetWidget::metadata( QgsMeshDatasetIndex datase const auto options = gmeta.extraOptions(); for ( auto it = options.constBegin(); it != options.constEnd(); ++it ) { + if ( it.key() == QStringLiteral( "classification" ) ) + { + msg += QStringLiteral( "%1" ).arg( tr( "Classified values" ) ); + continue; + } msg += QStringLiteral( "%1%2" ).arg( it.key() ).arg( it.value() ); } diff --git a/src/app/mesh/qgsmeshrendererscalarsettingswidget.cpp b/src/app/mesh/qgsmeshrendererscalarsettingswidget.cpp index b78b7cd9549..c2485546c36 100644 --- a/src/app/mesh/qgsmeshrendererscalarsettingswidget.cpp +++ b/src/app/mesh/qgsmeshrendererscalarsettingswidget.cpp @@ -112,7 +112,6 @@ void QgsMeshRendererScalarSettingsWidget::syncToLayer( ) const double min = settings.classificationMinimum(); const double max = settings.classificationMaximum(); - whileBlocking( mScalarMinLineEdit )->setText( QString::number( min ) ); whileBlocking( mScalarMaxLineEdit )->setText( QString::number( max ) ); whileBlocking( mScalarColorRampShaderWidget )->setFromShader( shader ); diff --git a/src/app/mesh/qgsrenderermeshpropertieswidget.cpp b/src/app/mesh/qgsrenderermeshpropertieswidget.cpp index 3c7b9d46502..71d60a90a56 100644 --- a/src/app/mesh/qgsrenderermeshpropertieswidget.cpp +++ b/src/app/mesh/qgsrenderermeshpropertieswidget.cpp @@ -116,6 +116,11 @@ void QgsRendererMeshPropertiesWidget::apply() if ( activeVectorDatasetGroupIndex > -1 ) settings.setVectorSettings( activeVectorDatasetGroupIndex, mMeshRendererVectorSettingsWidget->settings() ); + QgsMeshDatasetIndex staticScalarDatasetIndex( activeScalarDatasetGroupIndex, mMeshLayer->staticScalarDatasetIndex().dataset() ); + QgsMeshDatasetIndex staticVectorDatasetIndex( activeVectorDatasetGroupIndex, mMeshLayer->staticVectorDatasetIndex().dataset() ); + mMeshLayer->setStaticScalarDatasetIndex( staticScalarDatasetIndex ); + mMeshLayer->setStaticVectorDatasetIndex( staticVectorDatasetIndex ); + //set the blend mode for the layer mMeshLayer->setBlendMode( mBlendModeComboBox->blendMode() ); //set the averaging method for the layer diff --git a/src/core/mesh/qgsmeshlayer.cpp b/src/core/mesh/qgsmeshlayer.cpp index bc1ecc8e63b..7aa444fc462 100644 --- a/src/core/mesh/qgsmeshlayer.cpp +++ b/src/core/mesh/qgsmeshlayer.cpp @@ -108,6 +108,10 @@ void QgsMeshLayer::setDefaultRendererSettings() case QgsMeshDatasetGroupMetadata::DataOnEdges: break; } + + //override color ramp if the values in the dataset group are classified + applyClassificationOnScalarSettings( meta, scalarSettings ); + mRendererSettings.setScalarSettings( i, scalarSettings ); } @@ -407,6 +411,94 @@ QgsMeshDatasetIndex QgsMeshLayer::datasetIndexAtTime( const QgsDateTimeRange &ti return QgsMeshDatasetIndex(); } +void QgsMeshLayer::applyClassificationOnScalarSettings( const QgsMeshDatasetGroupMetadata &meta, QgsMeshRendererScalarSettings &scalarSettings ) const +{ + if ( meta.extraOptions().contains( QStringLiteral( "classification" ) ) ) + { + QgsColorRampShader colorRampShader = scalarSettings.colorRampShader(); + QgsColorRamp *colorRamp = colorRampShader.sourceColorRamp(); + QStringList classes = meta.extraOptions()[QStringLiteral( "classification" )].split( QStringLiteral( ";;" ) ); + + QString units; + if ( meta.extraOptions().contains( QStringLiteral( "units" ) ) ) + units = meta.extraOptions()[ QStringLiteral( "units" )]; + + QVector> bounds; + for ( const QString classe : classes ) + { + QStringList boundsStr = classe.split( ',' ); + QVector bound; + for ( const QString boundStr : boundsStr ) + bound.append( boundStr.toDouble() ); + bounds.append( bound ); + } + + if ( ( bounds.count() == 1 && bounds.first().count() > 2 ) || // at least a class with two value + ( bounds.count() > 1 ) ) // or at least two classes + { + const QVector firstClass = bounds.first(); + const QVector lastClass = bounds.last(); + double minValue = firstClass.count() > 1 ? ( firstClass.first() + firstClass.last() ) / 2 : firstClass.first(); + double maxValue = lastClass.count() > 1 ? ( lastClass.first() + lastClass.last() ) / 2 : lastClass.first(); + double diff = maxValue - minValue; + QList colorRampItemlist; + for ( int i = 0; i < bounds.count(); ++i ) + { + const QVector &boundClass = bounds.at( i ); + QgsColorRampShader::ColorRampItem item; + item.value = i + 1; + if ( !boundClass.isEmpty() ) + { + double scalarValue = ( boundClass.first() + boundClass.last() ) / 2; + item.color = colorRamp->color( ( scalarValue - minValue ) / diff ); + if ( i != 0 && i < bounds.count() - 1 ) //The first and last labels are treated after + { + item.label = QString( ( "%1 - %2 %3" ) ). + arg( QString::number( boundClass.first() ) ). + arg( QString::number( boundClass.last() ) ). + arg( units ); + } + } + colorRampItemlist.append( item ); + } + //treat first and last labels + if ( firstClass.count() == 1 ) + colorRampItemlist.first().label = QObject::tr( "below %1 %2" ). + arg( QString::number( firstClass.first() ) ). + arg( units ); + else + { + colorRampItemlist.first().label = QString( ( "%1 - %2 %3" ) ). + arg( QString::number( firstClass.first() ) ). + arg( QString::number( firstClass.last() ) ). + arg( units ); + } + + if ( lastClass.count() == 1 ) + colorRampItemlist.last().label = QObject::tr( "above %1 %2" ). + arg( QString::number( lastClass.first() ) ). + arg( units ); + else + { + colorRampItemlist.last().label = QString( ( "%1 - %2 %3" ) ). + arg( QString::number( lastClass.first() ) ). + arg( QString::number( lastClass.last() ) ). + arg( units ); + } + + colorRampShader.setMinimumValue( 0 ); + colorRampShader.setMaximumValue( colorRampItemlist.count() - 1 ); + scalarSettings.setClassificationMinimumMaximum( 0, colorRampItemlist.count() - 1 ); + colorRampShader.setColorRampItemList( colorRampItemlist ); + colorRampShader.setColorRampType( QgsColorRampShader::Exact ); + colorRampShader.setClassificationMode( QgsColorRampShader::EqualInterval ); + } + + scalarSettings.setColorRampShader( colorRampShader ); + scalarSettings.setDataResamplingMethod( QgsMeshRendererScalarSettings::None ); + } +} + QgsMeshDatasetIndex QgsMeshLayer::activeScalarDatasetAtTime( const QgsDateTimeRange &timeRange ) const { if ( mTemporalProperties->isActive() ) diff --git a/src/core/mesh/qgsmeshlayer.h b/src/core/mesh/qgsmeshlayer.h index 60973deb582..cf33be33c59 100644 --- a/src/core/mesh/qgsmeshlayer.h +++ b/src/core/mesh/qgsmeshlayer.h @@ -461,6 +461,9 @@ class CORE_EXPORT QgsMeshLayer : public QgsMapLayer QgsMeshDatasetIndex datasetIndexAtTime( const QgsDateTimeRange &timeRange, int datasetGroupIndex ) const; + //! Changes scalar settings for classified scalar value (information about is in the metadata + void applyClassificationOnScalarSettings( const QgsMeshDatasetGroupMetadata &meta, QgsMeshRendererScalarSettings &scalarSettings ) const; + private slots: void onDatasetGroupsAdded( int count ); diff --git a/src/core/mesh/qgsmeshlayerutils.cpp b/src/core/mesh/qgsmeshlayerutils.cpp index 654208069b9..ef5e36005f2 100644 --- a/src/core/mesh/qgsmeshlayerutils.cpp +++ b/src/core/mesh/qgsmeshlayerutils.cpp @@ -587,5 +587,4 @@ QVector QgsMeshLayerUtils::calculateNormals( const QgsTriangularMesh return normals; } - ///@endcond diff --git a/tests/src/core/testqgsmeshlayerrenderer.cpp b/tests/src/core/testqgsmeshlayerrenderer.cpp index 95cbc403d86..7cd2dee7175 100644 --- a/tests/src/core/testqgsmeshlayerrenderer.cpp +++ b/tests/src/core/testqgsmeshlayerrenderer.cpp @@ -94,6 +94,7 @@ class TestQgsMeshRenderer : public QObject void test_vertex_vector_traces_colorRamp(); void test_stacked_3d_mesh_single_level_averaging(); void test_simplified_triangular_mesh_rendering(); + void test_classified_values(); void test_signals(); }; @@ -681,7 +682,19 @@ void TestQgsMeshRenderer::test_simplified_triangular_mesh_rendering() QVERIFY( imageCheck( "simplified_triangular_mesh", mMdal3DLayer ) ); } -// TODO test edge mesh rendering! +void TestQgsMeshRenderer::test_classified_values() +{ + QgsMeshLayer classifiedMesh( mDataDir + "/simplebox_clm.nc", "Mesh with classified values", "mdal" ); + QVERIFY( classifiedMesh.isValid() ); + + QgsProject::instance()->addMapLayer( &classifiedMesh ); + mMapSettings->setLayers( QList() << &classifiedMesh ); + + classifiedMesh.temporalProperties()->setIsActive( false ); + classifiedMesh.setStaticScalarDatasetIndex( QgsMeshDatasetIndex( 3, 4 ) ); + + QVERIFY( imageCheck( "classified_values", &classifiedMesh ) ); +} QGSTEST_MAIN( TestQgsMeshRenderer ) #include "testqgsmeshlayerrenderer.moc" diff --git a/tests/testdata/control_images/mesh/expected_classified_values/expected_classified_values.png b/tests/testdata/control_images/mesh/expected_classified_values/expected_classified_values.png new file mode 100644 index 0000000000000000000000000000000000000000..76349cb557b219e9ba54b7b6cb98a6bcd35a32a6 GIT binary patch literal 80307 zcmeI&zl&W(6bJD8Qb>$!&`MbYn!-p}ThY>l4eo1#gn$MtEVNHZBMD)Hf|4dkKujN! zCW%<3v@!Uju?rUY4%)BW*y+z_ z!~F8S?b-E*JC7HC=!XD-B?9}FeC8tp1fB>8h@(KDA|N0Y98&}Y#2Fw^5fG3Hjwu2H z;tUX|2na|8#}okpaRvxf1O%jlV~W6RZU5K*>;I&`^Y_c6E*<~*_r+-MS%A^U|M7C; zpWVk0+Woxw(oVZ#i%N%I`?G1ijkg~(t=GEPp^txSU9xFC-zH5t73)nvKzi$_{WKL2 zkfxl9^(G)7y>--nnhFR=Q%=Qt6A+NzI%+>n1q7rir((Sc2uN=owV$Q}0@9RIvEBp( zWCb1l?%LL^cFzK=V8L#&R6tmkviOj|o`8Tj2?Qzv0#d;-MLAe_cK5S%5K)u#7ca6PO4H$OOcYz*s;) z#u}~(OaugE0%AyDEFd6b4c7!F0s=AtF(fb+5RkEkYXTDi0hxdp5*P~z$XLTQfr)^C zOh62C3%qye!JV*Y0p^$QZA(n<17h{T0s;~YtbA?(0m2mlDmIaA1ojs!NAJr76=c>?Za0-40{$Jx4qRT z69^AWG7rloPzVS}!C^q46cCWo!-zm3ARq;Y0fACLKuQlI0)>Ep6dVQwN&x{WJ&Xtx z0s>NS7!W801f=vZB2WkjNWt-RaOudo`_>!-YRS8-`bQ=9|Y(r`?x@6KF25BrMJEp&)^@0s@k@br!w~0RdTsvU{Wz z5RkO3v+z|22*@gw-6O4lfTV4mg|9+DKvtpb9%%(0-F@-PBi~%Rxx0b+iIbb(AOGZw Fe*qSVMil@6 literal 0 HcmV?d00001 diff --git a/tests/testdata/mesh/simplebox_clm.nc b/tests/testdata/mesh/simplebox_clm.nc new file mode 100644 index 0000000000000000000000000000000000000000..40f2d14ec4314ceba1e1ab22cd88116dd050cb59 GIT binary patch literal 68940 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