Add writing of vector tiles to MBTiles container

This commit is contained in:
Martin Dobias 2020-04-23 23:06:08 +02:00
parent 5e70067cdf
commit 6f746b557e
11 changed files with 419 additions and 90 deletions

View File

@ -111,6 +111,16 @@ Please note that we follow the XYZ convention of X/Y axes, i.e. top-left tile ha
static QgsTileMatrix fromWebMercator( int mZoomLevel );
%Docstring
Returns a tile matrix for the usual web mercator
%End
int matrixWidth() const;
%Docstring
Returns number of columns of the tile matrix
%End
int matrixHeight() const;
%Docstring
Returns number of rows of the tile matrix
%End
QgsRectangle tileExtent( QgsTileXYZ id ) const;

View File

@ -26,8 +26,8 @@ the "url" key is normally the path. Currently supported types:
- "xyz" - tile data written as local files, using a template where {x},{y},{z}
are replaced by the actual tile column, row and zoom level numbers, e.g.:
file:///home/qgis/tiles/{z}/{x}/{y}.pbf
(More types such as "mbtiles" or "gpkg" may be added later.)
- "mbtiles" - tile data written to a new MBTiles file, the "url" key should
be ordinary file system path, e.g.: /home/qgis/output.mbtiles
Currently the writer only support MVT encoding of data.

View File

@ -18,8 +18,11 @@
#include "qgslogger.h"
#include "qgsrectangle.h"
#include <QFile>
#include <QImage>
#include <zlib.h>
QgsMBTilesReader::QgsMBTilesReader( const QString &filename )
: mFilename( filename )
@ -46,6 +49,37 @@ bool QgsMBTilesReader::isOpen() const
return bool( mDatabase );
}
bool QgsMBTilesReader::create()
{
if ( mDatabase )
return false;
if ( QFile::exists( mFilename ) )
return false;
sqlite3_database_unique_ptr database;
int result = mDatabase.open_v2( mFilename, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE, nullptr );
if ( result != SQLITE_OK )
{
QgsDebugMsg( QStringLiteral( "Can't create MBTiles database: %1" ).arg( database.errorMessage() ) );
return false;
}
QString sql = \
"CREATE TABLE metadata (name text, value text);" \
"CREATE TABLE tiles (zoom_level integer, tile_column integer, tile_row integer, tile_data blob);" \
"CREATE UNIQUE INDEX tile_index on tiles (zoom_level, tile_column, tile_row);";
QString errorMessage;
result = mDatabase.exec( sql, errorMessage );
if ( result != SQLITE_OK )
{
QgsDebugMsg( QStringLiteral( "Failed to initialize MBTiles database: " ) + errorMessage );
return false;
}
return true;
}
QString QgsMBTilesReader::metadataValue( const QString &key )
{
if ( !mDatabase )
@ -72,6 +106,30 @@ QString QgsMBTilesReader::metadataValue( const QString &key )
return preparedStatement.columnAsText( 0 );
}
void QgsMBTilesReader::setMetadataValue( const QString &key, const QString &value )
{
if ( !mDatabase )
{
QgsDebugMsg( QStringLiteral( "MBTiles database not open: " ) + mFilename );
return;
}
int result;
QString sql = QStringLiteral( "insert into metadata values (%1, %2)" ).arg( QgsSqliteUtils::quotedValue( key ), QgsSqliteUtils::quotedValue( value ) );
sqlite3_statement_unique_ptr preparedStatement = mDatabase.prepare( sql, result );
if ( result != SQLITE_OK )
{
QgsDebugMsg( QStringLiteral( "MBTile failed to prepare statement: " ) + sql );
return;
}
if ( preparedStatement.step() != SQLITE_DONE )
{
QgsDebugMsg( QStringLiteral( "MBTile metadata value failed to be set: " ) + key );
return;
}
}
QgsRectangle QgsMBTilesReader::extent()
{
QString boundsStr = metadataValue( "bounds" );
@ -122,3 +180,127 @@ QImage QgsMBTilesReader::tileDataAsImage( int z, int x, int y )
}
return tileImage;
}
void QgsMBTilesReader::setTileData( int z, int x, int y, const QByteArray &data )
{
if ( !mDatabase )
{
QgsDebugMsg( QStringLiteral( "MBTiles database not open: " ) + mFilename );
return;
}
int result;
QString sql = QStringLiteral( "insert into tiles values (%1, %2, %3, ?)" ).arg( z ).arg( x ).arg( y );
sqlite3_statement_unique_ptr preparedStatement = mDatabase.prepare( sql, result );
if ( result != SQLITE_OK )
{
QgsDebugMsg( QStringLiteral( "MBTile failed to prepare statement: " ) + sql );
return;
}
sqlite3_bind_blob( preparedStatement.get(), 1, data.constData(), data.size(), SQLITE_TRANSIENT );
if ( preparedStatement.step() != SQLITE_DONE )
{
QgsDebugMsg( QStringLiteral( "MBTile tile failed to be set: %1,%2,%3" ).arg( z ).arg( x ).arg( y ) );
return;
}
}
bool QgsMBTilesReader::decodeGzip( const QByteArray &bytesIn, QByteArray &bytesOut )
{
unsigned char *bytesInPtr = reinterpret_cast<unsigned char *>( const_cast<char *>( bytesIn.constData() ) );
uint bytesInLeft = static_cast<uint>( bytesIn.count() );
const uint CHUNK = 16384;
unsigned char out[CHUNK];
const int DEC_MAGIC_NUM_FOR_GZIP = 16;
// allocate inflate state
z_stream strm;
strm.zalloc = Z_NULL;
strm.zfree = Z_NULL;
strm.opaque = Z_NULL;
strm.avail_in = 0;
strm.next_in = Z_NULL;
int ret = inflateInit2( &strm, MAX_WBITS + DEC_MAGIC_NUM_FOR_GZIP );
if ( ret != Z_OK )
return false;
while ( ret != Z_STREAM_END ) // done when inflate() says it's done
{
// prepare next chunk
uint bytesToProcess = std::min( CHUNK, bytesInLeft );
strm.next_in = bytesInPtr;
strm.avail_in = bytesToProcess;
bytesInPtr += bytesToProcess;
bytesInLeft -= bytesToProcess;
if ( bytesToProcess == 0 )
break; // we end with an error - no more data but inflate() wants more data
// run inflate() on input until output buffer not full
do
{
strm.avail_out = CHUNK;
strm.next_out = out;
ret = inflate( &strm, Z_NO_FLUSH );
Q_ASSERT( ret != Z_STREAM_ERROR ); // state not clobbered
if ( ret == Z_NEED_DICT || ret == Z_DATA_ERROR || ret == Z_MEM_ERROR )
{
inflateEnd( &strm );
return false;
}
unsigned have = CHUNK - strm.avail_out;
bytesOut.append( QByteArray::fromRawData( reinterpret_cast<const char *>( out ), static_cast<int>( have ) ) );
}
while ( strm.avail_out == 0 );
}
inflateEnd( &strm );
return ret == Z_STREAM_END;
}
bool QgsMBTilesReader::encodeGzip( const QByteArray &bytesIn, QByteArray &bytesOut )
{
unsigned char *bytesInPtr = reinterpret_cast<unsigned char *>( const_cast<char *>( bytesIn.constData() ) );
uint bytesInLeft = static_cast<uint>( bytesIn.count() );
const uint CHUNK = 16384;
unsigned char out[CHUNK];
const int DEC_MAGIC_NUM_FOR_GZIP = 16;
// allocate deflate state
z_stream strm;
strm.zalloc = Z_NULL;
strm.zfree = Z_NULL;
strm.opaque = Z_NULL;
int ret = deflateInit2( &strm, Z_DEFAULT_COMPRESSION, Z_DEFLATED, MAX_WBITS + DEC_MAGIC_NUM_FOR_GZIP, 8, Z_DEFAULT_STRATEGY );
if ( ret != Z_OK )
return false;
strm.avail_in = bytesInLeft;
strm.next_in = bytesInPtr;
// run deflate() on input until output buffer not full, finish
// compression if all of source has been read in
do
{
strm.avail_out = CHUNK;
strm.next_out = out;
ret = deflate( &strm, Z_FINISH ); // no bad return value
Q_ASSERT( ret != Z_STREAM_ERROR ); // state not clobbered
unsigned have = CHUNK - strm.avail_out;
bytesOut.append( QByteArray::fromRawData( reinterpret_cast<const char *>( out ), static_cast<int>( have ) ) );
}
while ( strm.avail_out == 0 );
Q_ASSERT( ret == Z_STREAM_END ); // stream will be complete
// clean up and return
deflateEnd( &strm );
return true;
}

View File

@ -44,9 +44,22 @@ class CORE_EXPORT QgsMBTilesReader
//! Returns whether the MBTiles file is currently opened
bool isOpen() const;
/**
* Creates a new MBTiles file and initializes it with metadata and tiles tables.
* It is up to the caller to set appropriate metadata entries and add tiles afterwards.
* Returns true on success. If the file exists already, returns false.
*/
bool create();
//! Requests metadata value for the given key
QString metadataValue( const QString &key );
/**
* Sets metadata value for the given key. Does not overwrite existing entries.
* \note the database has to be opened in read-write mode (currently only when opened with create()
*/
void setMetadataValue( const QString &key, const QString &value );
//! Returns bounding box from metadata, given in WGS 84 (if available)
QgsRectangle extent();
@ -56,6 +69,17 @@ class CORE_EXPORT QgsMBTilesReader
//! Returns tile decoded as a raster image (if stored in a known format like JPG or PNG)
QImage tileDataAsImage( int z, int x, int y );
/**
* Adds tile data for the given tile coordinates. Does not overwrite existing entries.
* \note the database has to be opened in read-write mode (currently only when opened with create()
*/
void setTileData( int z, int x, int y, const QByteArray &data );
//! Decodes gzip byte stream, returns true on success. Useful for reading vector tiles.
static bool decodeGzip( const QByteArray &bytesIn, QByteArray &bytesOut );
//! Encodes gzip byte stream, returns true on success. Useful for writing vector tiles.
static bool encodeGzip( const QByteArray &bytesIn, QByteArray &bytesOut );
private:
QString mFilename;
sqlite3_database_unique_ptr mDatabase;

View File

@ -106,6 +106,12 @@ class CORE_EXPORT QgsTileMatrix
//! Returns a tile matrix for the usual web mercator
static QgsTileMatrix fromWebMercator( int mZoomLevel );
//! Returns number of columns of the tile matrix
int matrixWidth() const { return mMatrixWidth; }
//! Returns number of rows of the tile matrix
int matrixHeight() const { return mMatrixHeight; }
//! Returns extent of the given tile in this matrix
QgsRectangle tileExtent( QgsTileXYZ id ) const;

View File

@ -68,6 +68,13 @@ bool QgsVectorTileLayer::loadDataSource()
return false;
}
QString format = reader.metadataValue( QStringLiteral( "format" ) );
if ( format != QStringLiteral( "pbf" ) )
{
QgsDebugMsg( QStringLiteral( "Cannot open MBTiles for vector tiles. Format = " ) + format );
return false;
}
QgsDebugMsgLevel( QStringLiteral( "name: " ) + reader.metadataValue( QStringLiteral( "name" ) ), 2 );
bool minZoomOk, maxZoomOk;
int minZoom = reader.metadataValue( QStringLiteral( "minzoom" ) ).toInt( &minZoomOk );

View File

@ -17,8 +17,6 @@
#include <QEventLoop>
#include <zlib.h>
#include "qgsblockingnetworkrequest.h"
#include "qgslogger.h"
#include "qgsmbtilesreader.h"
@ -200,10 +198,8 @@ QByteArray QgsVectorTileLoader::loadFromMBTiles( const QgsTileXYZ &id, QgsMBTile
return QByteArray();
}
// TODO: check format is "pbf"
QByteArray data;
if ( !decodeGzip( gzippedTileData, data ) )
if ( !QgsMBTilesReader::decodeGzip( gzippedTileData, data ) )
{
QgsDebugMsg( QStringLiteral( "Failed to decompress tile " ) + id.toString() );
return QByteArray();
@ -212,59 +208,3 @@ QByteArray QgsVectorTileLoader::loadFromMBTiles( const QgsTileXYZ &id, QgsMBTile
QgsDebugMsgLevel( QStringLiteral( "Tile blob size %1 -> uncompressed size %2" ).arg( gzippedTileData.size() ).arg( data.size() ), 2 );
return data;
}
bool QgsVectorTileLoader::decodeGzip( const QByteArray &bytesIn, QByteArray &bytesOut )
{
unsigned char *bytesInPtr = reinterpret_cast<unsigned char *>( const_cast<char *>( bytesIn.constData() ) );
uint bytesInLeft = static_cast<uint>( bytesIn.count() );
const uint CHUNK = 16384;
unsigned char out[CHUNK];
const int DEC_MAGIC_NUM_FOR_GZIP = 16;
// allocate inflate state
z_stream strm;
strm.zalloc = Z_NULL;
strm.zfree = Z_NULL;
strm.opaque = Z_NULL;
strm.avail_in = 0;
strm.next_in = Z_NULL;
int ret = inflateInit2( &strm, MAX_WBITS + DEC_MAGIC_NUM_FOR_GZIP );
if ( ret != Z_OK )
return false;
while ( ret != Z_STREAM_END ) // done when inflate() says it's done
{
// prepare next chunk
uint bytesToProcess = std::min( CHUNK, bytesInLeft );
strm.next_in = bytesInPtr;
strm.avail_in = bytesToProcess;
bytesInPtr += bytesToProcess;
bytesInLeft -= bytesToProcess;
if ( bytesToProcess == 0 )
break; // we end with an error - no more data but inflate() wants more data
// run inflate() on input until output buffer not full
do
{
strm.avail_out = CHUNK;
strm.next_out = out;
ret = inflate( &strm, Z_NO_FLUSH );
Q_ASSERT( ret != Z_STREAM_ERROR ); // state not clobbered
if ( ret == Z_NEED_DICT || ret == Z_DATA_ERROR || ret == Z_MEM_ERROR )
{
inflateEnd( &strm );
return false;
}
unsigned have = CHUNK - strm.avail_out;
bytesOut.append( QByteArray::fromRawData( reinterpret_cast<const char *>( out ), static_cast<int>( have ) ) );
}
while ( strm.avail_out == 0 );
}
inflateEnd( &strm );
return ret == Z_STREAM_END;
}

View File

@ -65,8 +65,6 @@ class QgsVectorTileLoader : public QObject
static QByteArray loadFromNetwork( const QgsTileXYZ &id, const QString &requestUrl );
//! Returns raw tile data for a single tile loaded from MBTiles file
static QByteArray loadFromMBTiles( const QgsTileXYZ &id, QgsMBTilesReader &mbTileReader );
//! Decodes gzip byte stream, returns true on success
static bool decodeGzip( const QByteArray &bytesIn, QByteArray &bytesOut );
//
// non-static stuff

View File

@ -17,13 +17,17 @@
#include "qgsdatasourceuri.h"
#include "qgsfeedback.h"
#include "qgsmbtilesreader.h"
#include "qgstiles.h"
#include "qgsvectorlayer.h"
#include "qgsvectortilemvtencoder.h"
#include "qgsvectortileutils.h"
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QJsonDocument>
#include <QJsonArray>
#include <QUrl>
@ -46,20 +50,28 @@ bool QgsVectorTileWriter::writeTiles( QgsFeedback *feedback )
return false;
}
std::unique_ptr<QgsMBTilesReader> mbtiles;
QgsDataSourceUri dsUri;
dsUri.setEncodedUri( mDestinationUri );
QString sourceType = dsUri.param( QStringLiteral( "type" ) );
QString sourcePath = dsUri.param( QStringLiteral( "url" ) );
if ( sourceType != QStringLiteral( "xyz" ) )
if ( sourceType == QStringLiteral( "xyz" ) )
{
// remove the initial file:// scheme
sourcePath = QUrl( sourcePath ).toLocalFile();
}
else if ( sourceType == QStringLiteral( "mbtiles" ) )
{
mbtiles.reset( new QgsMBTilesReader( sourcePath ) );
}
else
{
mErrorMessage = tr( "Unsupported source type for writing: " ) + sourceType;
return false;
}
// remove the initial file:// scheme
sourcePath = QUrl( sourcePath ).toLocalFile();
// figure out how many tiles we will need to do
int tilesToCreate = 0;
for ( int zoomLevel = mMinZoom; zoomLevel <= mMaxZoom; ++zoomLevel )
@ -77,6 +89,28 @@ bool QgsVectorTileWriter::writeTiles( QgsFeedback *feedback )
return false;
}
if ( mbtiles )
{
if ( !mbtiles->create() )
{
mErrorMessage = tr( "Failed to create MBTiles file: " ) + sourcePath;
return false;
}
// required metadata
mbtiles->setMetadataValue( "name", "???" ); // TODO: custom name?
mbtiles->setMetadataValue( "format", "pbf" );
// optional metadata
mbtiles->setMetadataValue( "bounds", "-180.0,-85,180,85" );
mbtiles->setMetadataValue( "minzoom", QString::number( mMinZoom ) );
mbtiles->setMetadataValue( "maxzoom", QString::number( mMaxZoom ) );
// TODO: "center"? initial view with [lon,lat,zoom]
// required metadata for vector tiles: "json" with schema of layers
mbtiles->setMetadataValue( "json", mbtilesJsonSchema() );
}
int tilesCreated = 0;
for ( int zoomLevel = mMinZoom; zoomLevel <= mMaxZoom; ++zoomLevel )
{
@ -115,32 +149,85 @@ bool QgsVectorTileWriter::writeTiles( QgsFeedback *feedback )
continue;
}
QString filePath = QgsVectorTileUtils::formatXYZUrlTemplate( sourcePath, tileID );
// make dirs if needed
QFileInfo fi( filePath );
QDir fileDir = fi.dir();
if ( !fileDir.exists() )
if ( sourceType == QStringLiteral( "xyz" ) )
{
if ( !fileDir.mkpath( "." ) )
{
mErrorMessage = tr( "Cannot create directory " ) + fileDir.path();
return false;
}
if ( !writeTileFileXYZ( sourcePath, tileID, tileData ) )
return false; // error message already set
}
QFile f( filePath );
if ( !f.open( QIODevice::WriteOnly ) )
else // mbtiles
{
mErrorMessage = tr( "Cannot open file for writing " ) + filePath;
return false;
QByteArray gzipTileData;
QgsMBTilesReader::encodeGzip( tileData, gzipTileData );
int rowTMS = pow( 2, tileID.zoomLevel() ) - tileID.row() - 1;
mbtiles->setTileData( tileID.zoomLevel(), tileID.column(), rowTMS, gzipTileData );
}
f.write( tileData );
f.close();
}
}
}
return true;
}
bool QgsVectorTileWriter::writeTileFileXYZ( const QString &sourcePath, QgsTileXYZ tileID, const QByteArray &tileData )
{
QString filePath = QgsVectorTileUtils::formatXYZUrlTemplate( sourcePath, tileID );
// make dirs if needed
QFileInfo fi( filePath );
QDir fileDir = fi.dir();
if ( !fileDir.exists() )
{
if ( !fileDir.mkpath( "." ) )
{
mErrorMessage = tr( "Cannot create directory " ) + fileDir.path();
return false;
}
}
QFile f( filePath );
if ( !f.open( QIODevice::WriteOnly ) )
{
mErrorMessage = tr( "Cannot open file for writing " ) + filePath;
return false;
}
f.write( tileData );
f.close();
return true;
}
QString QgsVectorTileWriter::mbtilesJsonSchema()
{
QJsonArray arrayLayers;
for ( const Layer &layer : qgis::as_const( mLayers ) )
{
QgsVectorLayer *vl = layer.layer();
const QgsFields fields = vl->fields();
QJsonObject fieldsObj;
for ( const QgsField &field : fields )
{
QString fieldTypeStr;
if ( field.type() == QVariant::Bool )
fieldTypeStr = "Boolean";
else if ( field.type() == QVariant::Int || field.type() == QVariant::Double )
fieldTypeStr = "Number";
else
fieldTypeStr = "String";
fieldsObj["field"] = fieldTypeStr;
}
QJsonObject layerObj;
layerObj["id"] = vl->name();
layerObj["fields"] = fieldsObj;
arrayLayers.append( layerObj );
}
QJsonObject rootObj;
rootObj["vector_layers"] = arrayLayers;
QJsonDocument doc;
doc.setObject( rootObj );
return doc.toJson();
}

View File

@ -20,6 +20,7 @@
#include "qgsrectangle.h"
class QgsFeedback;
class QgsTileXYZ;
class QgsVectorLayer;
@ -38,8 +39,8 @@ class QgsVectorLayer;
* - "xyz" - tile data written as local files, using a template where {x},{y},{z}
* are replaced by the actual tile column, row and zoom level numbers, e.g.:
* file:///home/qgis/tiles/{z}/{x}/{y}.pbf
*
* (More types such as "mbtiles" or "gpkg" may be added later.)
* - "mbtiles" - tile data written to a new MBTiles file, the "url" key should
* be ordinary file system path, e.g.: /home/qgis/output.mbtiles
*
* Currently the writer only support MVT encoding of data.
*
@ -110,6 +111,10 @@ class CORE_EXPORT QgsVectorTileWriter
*/
QString errorMessage() const { return mErrorMessage; }
private:
bool writeTileFileXYZ( const QString &sourcePath, QgsTileXYZ tileID, const QByteArray &tileData );
QString mbtilesJsonSchema();
private:
QgsRectangle mExtent;
int mMinZoom = 0;

View File

@ -50,6 +50,7 @@ class TestQgsVectorTileWriter : public QObject
void cleanup() {} // will be called after every testfunction.
void test_basic();
void test_mbtiles();
};
@ -141,5 +142,74 @@ void TestQgsVectorTileWriter::test_basic()
}
void TestQgsVectorTileWriter::test_mbtiles()
{
QString fileName = QDir::tempPath() + "/test_qgsvectortilewriter.mbtiles";
if ( QFile::exists( fileName ) )
QFile::remove( fileName );
QgsDataSourceUri ds;
ds.setParam( "type", "mbtiles" );
ds.setParam( "url", fileName );
QgsVectorLayer *vlPoints = new QgsVectorLayer( mDataDir + "/points.shp", "points", "ogr" );
QgsVectorLayer *vlLines = new QgsVectorLayer( mDataDir + "/lines.shp", "lines", "ogr" );
QgsVectorLayer *vlPolys = new QgsVectorLayer( mDataDir + "/polys.shp", "polys", "ogr" );
QList<QgsVectorTileWriter::Layer> layers;
layers << QgsVectorTileWriter::Layer( vlPoints );
layers << QgsVectorTileWriter::Layer( vlLines );
layers << QgsVectorTileWriter::Layer( vlPolys );
QgsVectorTileWriter writer;
writer.setDestinationUri( ds.encodedUri() );
writer.setMaxZoom( 3 );
writer.setLayers( layers );
bool res = writer.writeTiles();
QVERIFY( res );
QVERIFY( writer.errorMessage().isEmpty() );
delete vlPoints;
delete vlLines;
delete vlPolys;
// do some checks on the output
QgsVectorTileLayer *vtLayer = new QgsVectorTileLayer( ds.encodedUri(), "output" );
QByteArray tile0 = vtLayer->getRawTile( QgsTileXYZ( 0, 0, 0 ) );
QgsVectorTileMVTDecoder decoder;
bool resDecode0 = decoder.decode( QgsTileXYZ( 0, 0, 0 ), tile0 );
QVERIFY( resDecode0 );
QStringList layerNames = decoder.layers();
QCOMPARE( layerNames, QStringList() << "points" << "lines" << "polys" );
QStringList fieldNamesLines = decoder.layerFieldNames( "lines" );
QCOMPARE( fieldNamesLines, QStringList() << "Name" << "Value" );
QgsFields fieldsPolys;
fieldsPolys.append( QgsField( "Name", QVariant::String ) );
QMap<QString, QgsFields> perLayerFields;
perLayerFields["polys"] = fieldsPolys;
perLayerFields["lines"] = QgsFields();
perLayerFields["points"] = QgsFields();
QgsVectorTileFeatures features0 = decoder.layerFeatures( perLayerFields, QgsCoordinateTransform() );
QCOMPARE( features0["points"].count(), 17 );
QCOMPARE( features0["lines"].count(), 6 );
QCOMPARE( features0["polys"].count(), 10 );
QCOMPARE( features0["points"][0].geometry().wkbType(), QgsWkbTypes::Point );
QCOMPARE( features0["lines"][0].geometry().wkbType(), QgsWkbTypes::LineString );
QCOMPARE( features0["polys"][0].geometry().wkbType(), QgsWkbTypes::MultiPolygon ); // source geoms in shp are multipolygons
QgsAttributes attrsPolys0_0 = features0["polys"][0].attributes();
QCOMPARE( attrsPolys0_0.count(), 1 );
QString attrNamePolys0_0 = attrsPolys0_0[0].toString();
QVERIFY( attrNamePolys0_0 == "Dam" || attrNamePolys0_0 == "Lake" );
delete vtLayer;
}
QGSTEST_MAIN( TestQgsVectorTileWriter )
#include "testqgsvectortilewriter.moc"