QGIS API Documentation 4.1.0-Master (9af12b5a203)
Loading...
Searching...
No Matches
qgscesiumutils.cpp
Go to the documentation of this file.
1/***************************************************************************
2 qgscesiumutils.cpp
3 --------------------
4 begin : July 2023
5 copyright : (C) 2023 by Nyall Dawson
6 email : nyall dot dawson at gmail dot com
7 ******************************************************************
8 ***************************************************************************/
9
10/***************************************************************************
11 * *
12 * This program is free software; you can redistribute it and/or modify *
13 * it under the terms of the GNU General Public License as published by *
14 * the Free Software Foundation; either version 2 of the License, or *
15 * (at your option) any later version. *
16 * *
17 ***************************************************************************/
18
19#include "qgscesiumutils.h"
20
21#include <cstring>
22#include <nlohmann/json.hpp>
23
27#include "qgsgltfutils.h"
28#include "qgsjsonutils.h"
29#include "qgslogger.h"
30#include "qgsmatrix4x4.h"
31#include "qgsorientedbox3d.h"
32#include "qgssphere.h"
34#include "tiny_gltf.h"
35
36#include <QFile>
37#include <QGenericMatrix>
38#include <QIODevice>
39#include <QMatrix3x3>
40#include <QNetworkRequest>
41#include <QString>
42#include <QUrlQuery>
43#include <QtCore/QBuffer>
44
45using namespace Qt::StringLiterals;
46
48{
49 try
50 {
51 // The latitude and longitude values are given in radians!
52 // TODO -- is this ALWAYS the case? What if there's a region root bounding volume, but a transform object present? What if there's crs metadata specifying a different crs?
53
54 const double west = region[0].get<double>() * 180 / M_PI;
55 const double south = region[1].get<double>() * 180 / M_PI;
56 const double east = region[2].get<double>() * 180 / M_PI;
57 const double north = region[3].get<double>() * 180 / M_PI;
58 double minHeight = region[4].get<double>();
59 double maxHeight = region[5].get<double>();
60
61 return QgsBox3D( west, south, minHeight, east, north, maxHeight );
62 }
63 catch ( nlohmann::json::exception & )
64 {
65 return QgsBox3D();
66 }
67}
68
69QgsBox3D QgsCesiumUtils::parseRegion( const QVariantList &region )
70{
71 if ( region.size() != 6 )
72 return QgsBox3D();
73
75}
76
78{
79 if ( box.size() != 12 )
80 return QgsOrientedBox3D();
81
82 try
83 {
85 for ( int i = 0; i < 3; ++i )
86 {
87 res.mCenter[i] = box[i].get<double>();
88 }
89 for ( int i = 0; i < 9; ++i )
90 {
91 res.mHalfAxes[i] = box[i + 3].get<double>();
92 }
93 return res;
94 }
95 catch ( nlohmann::json::exception & )
96 {
97 return QgsOrientedBox3D();
98 }
99}
100
102{
103 if ( box.size() != 12 )
104 return QgsOrientedBox3D();
105
107}
108
110{
111 if ( sphere.size() != 4 )
112 return QgsSphere();
113
114 try
115 {
116 const double centerX = sphere[0].get<double>();
117 const double centerY = sphere[1].get<double>();
118 const double centerZ = sphere[2].get<double>();
119 const double radius = sphere[3].get<double>();
120 return QgsSphere( centerX, centerY, centerZ, radius );
121 }
122 catch ( nlohmann::json::exception & )
123 {
124 return QgsSphere();
125 }
126}
127
128QgsSphere QgsCesiumUtils::parseSphere( const QVariantList &sphere )
129{
130 if ( sphere.size() != 4 )
131 return QgsSphere();
132
133 return parseSphere( QgsJsonUtils::jsonFromVariant( sphere ) );
134}
135
137{
138 if ( !transform.isIdentity() )
139 {
140 // center is transformed, radius is scaled by maximum scalar from transform
141 // see https://github.com/CesiumGS/cesium-native/blob/fd20f5e272850dde6b58c74059e6de767fe25df6/Cesium3DTilesSelection/src/BoundingVolume.cpp#L33
142 const QgsVector3D center = transform.map( sphere.centerVector() );
143 const double uniformScale = std::max(
144 std::max(
145 std::sqrt( transform.constData()[0] * transform.constData()[0] + transform.constData()[1] * transform.constData()[1] + transform.constData()[2] * transform.constData()[2] ),
146 std::sqrt( transform.constData()[4] * transform.constData()[4] + transform.constData()[5] * transform.constData()[5] + transform.constData()[6] * transform.constData()[6] )
147 ),
148 std::sqrt( transform.constData()[8] * transform.constData()[8] + transform.constData()[9] * transform.constData()[9] + transform.constData()[10] * transform.constData()[10] )
149 );
150
151 return QgsSphere( center.x(), center.y(), center.z(), sphere.radius() * uniformScale );
152 }
153 return sphere;
154}
155
157{
158 struct b3dmHeader
159 {
160 unsigned char magic[4];
161 quint32 version;
162 quint32 byteLength;
163 quint32 featureTableJsonByteLength;
164 quint32 featureTableBinaryByteLength;
165 quint32 batchTableJsonByteLength;
166 quint32 batchTableBinaryByteLength;
167 };
168
170 if ( tileContent.size() < static_cast<int>( sizeof( b3dmHeader ) ) )
171 return res;
172
173 b3dmHeader hdr;
174 memcpy( &hdr, tileContent.constData(), sizeof( b3dmHeader ) );
175
176 const QString featureTableJson( tileContent.mid( sizeof( b3dmHeader ), hdr.featureTableJsonByteLength ) );
177 if ( !featureTableJson.isEmpty() )
178 {
179 try
180 {
181 const json featureTable = json::parse( featureTableJson.toStdString() );
182 if ( featureTable.contains( "RTC_CENTER" ) )
183 {
184 const auto &rtcCenterJson = featureTable["RTC_CENTER"];
185 if ( rtcCenterJson.is_array() && rtcCenterJson.size() == 3 )
186 {
187 res.rtcCenter.setX( rtcCenterJson[0].get<double>() );
188 res.rtcCenter.setY( rtcCenterJson[1].get<double>() );
189 res.rtcCenter.setZ( rtcCenterJson[2].get<double>() );
190 }
191 else
192 {
193 QgsDebugError( u"Invalid RTC_CENTER value"_s );
194 }
195 }
196 }
197 catch ( json::parse_error &ex )
198 {
199 QgsDebugError( u"Error parsing feature table JSON: %1"_s.arg( ex.what() ) );
200 }
201 }
202
203 res.gltf = tileContent.mid( sizeof( b3dmHeader ) + hdr.featureTableJsonByteLength + hdr.featureTableBinaryByteLength + hdr.batchTableJsonByteLength + hdr.batchTableBinaryByteLength );
204 return res;
205}
206
207static QQuaternion quaternionFromNormalUpRight( const QVector3D &normalUp, const QVector3D &normalRight )
208{
209 const QVector3D right = normalRight.normalized();
210 const QVector3D up = normalUp.normalized();
211 // In a right-handed coordinate system with X=right, Z=up:
212 // Y = cross(up, right) = forward (pointing North for ENU)
213 const QVector3D forward = QVector3D::crossProduct( up, right ).normalized();
214
215 // Build rotation matrix with columns [right, forward, up]
216 // QGenericMatrix constructor takes row-major input
217 float matData[9] = { right.x(), forward.x(), up.x(), right.y(), forward.y(), up.y(), right.z(), forward.z(), up.z() };
218 QMatrix3x3 rotMatrix( matData );
219 return QQuaternion::fromRotationMatrix( rotMatrix );
220}
221
235static void computeEastNorthUpQuaternions( const QVector<QVector3D> &positions, const QgsVector3D &rtcCenter, const QgsMatrix4x4 &tileTransform, QVector<QQuaternion> &rotations )
236{
237 const int count = positions.size();
238 rotations.resize( count );
239
240 if ( count == 0 )
241 return;
242
243 const QgsCoordinateReferenceSystem ecefCrs( u"EPSG:4978"_s );
244 const QgsCoordinateReferenceSystem geodeticCrs( u"EPSG:4979"_s );
245 QgsCoordinateTransform ecefToGeodetic( ecefCrs, geodeticCrs, QgsCoordinateTransformContext() );
246 QgsCoordinateTransform geodeticToEcef( geodeticCrs, ecefCrs, QgsCoordinateTransformContext() );
247
248 constexpr double delta = 0.001; // degrees
249
250 // Batch transform: we need base points + east perturbation + north perturbation = 3*count points
251 const int totalPts = 3 * count;
252 QVector<double> gx( totalPts ), gy( totalPts ), gz( totalPts );
253
254 // First, convert tile-local positions to ECEF using the tile transform,
255 // then convert ECEF positions to geodetic
256 QVector<double> px( count ), py( count ), pz( count );
257 for ( int i = 0; i < count; ++i )
258 {
259 const QgsVector3D posLocal( static_cast<double>( positions[i].x() ) + rtcCenter.x(), static_cast<double>( positions[i].y() ) + rtcCenter.y(), static_cast<double>( positions[i].z() ) + rtcCenter.z() );
260 const QgsVector3D ecef = tileTransform.map( posLocal );
261 px[i] = ecef.x();
262 py[i] = ecef.y();
263 pz[i] = ecef.z();
264 }
265
266 try
267 {
268 ecefToGeodetic.transformCoords( count, px.data(), py.data(), pz.data() );
269 }
270 catch ( QgsCsException & )
271 {
272 // fallback to identity rotations
273 for ( int i = 0; i < count; ++i )
274 rotations[i] = QQuaternion();
275 return;
276 }
277
278 // px/py/pz are now geodetic (lon, lat, h)
279 // Build batch: base, east-perturbed, north-perturbed
280 for ( int i = 0; i < count; ++i )
281 {
282 // base point
283 gx[i] = px[i];
284 gy[i] = py[i];
285 gz[i] = pz[i];
286
287 // east-perturbed
288 gx[count + i] = px[i] + delta;
289 gy[count + i] = py[i];
290 gz[count + i] = pz[i];
291
292 // north-perturbed
293 gx[2 * count + i] = px[i];
294 gy[2 * count + i] = py[i] + delta;
295 gz[2 * count + i] = pz[i];
296 }
297
298 try
299 {
300 geodeticToEcef.transformCoords( totalPts, gx.data(), gy.data(), gz.data() );
301 }
302 catch ( QgsCsException & )
303 {
304 for ( int i = 0; i < count; ++i )
305 rotations[i] = QQuaternion();
306 return;
307 }
308
309 // gx/gy/gz are now ECEF for all 3*count points
310
311 // Extract the tile transform's 3×3 rotation part (column-major in QgsMatrix4x4).
312 // We need its transpose (= inverse for orthogonal matrices) to transform
313 // ECEF direction vectors back to tile-local space, because the instance
314 // rotation quaternion is applied in tile-local space, not ECEF.
315 const double *td = tileTransform.constData();
316 // Columns of the 3×3 rotation part (column-major: col0=[0,1,2], col1=[4,5,6], col2=[8,9,10])
317 const QVector3D tileCol0( static_cast<float>( td[0] ), static_cast<float>( td[1] ), static_cast<float>( td[2] ) );
318 const QVector3D tileCol1( static_cast<float>( td[4] ), static_cast<float>( td[5] ), static_cast<float>( td[6] ) );
319 const QVector3D tileCol2( static_cast<float>( td[8] ), static_cast<float>( td[9] ), static_cast<float>( td[10] ) );
320
321 // Normalize columns to handle any scale in the tile transform
322 const float len0 = tileCol0.length();
323 const float len1 = tileCol1.length();
324 const float len2 = tileCol2.length();
325 const bool hasTileRotation = len0 > 0 && len1 > 0 && len2 > 0 && !tileTransform.isIdentity();
326
327 for ( int i = 0; i < count; ++i )
328 {
329 const QVector3D base( static_cast<float>( gx[i] ), static_cast<float>( gy[i] ), static_cast<float>( gz[i] ) );
330 const QVector3D eastPt( static_cast<float>( gx[count + i] ), static_cast<float>( gy[count + i] ), static_cast<float>( gz[count + i] ) );
331 const QVector3D northPt( static_cast<float>( gx[2 * count + i] ), static_cast<float>( gy[2 * count + i] ), static_cast<float>( gz[2 * count + i] ) );
332
333 QVector3D east = ( eastPt - base ).normalized();
334 QVector3D north = ( northPt - base ).normalized();
335 QVector3D up = QVector3D::crossProduct( east, north ).normalized();
336
337 if ( hasTileRotation )
338 {
339 // Transform ECEF directions to tile-local space using R_tile^T
340 // (transpose of rotation = inverse for orthogonal matrices).
341 // R^T × v = (dot(col0,v)/|col0|², dot(col1,v)/|col1|², dot(col2,v)/|col2|²)
342 // With normalized columns: R^T × v = (dot(col0/|col0|, v), ...)
343 const QVector3D nc0 = tileCol0 / len0;
344 const QVector3D nc1 = tileCol1 / len1;
345 const QVector3D nc2 = tileCol2 / len2;
346
347 const QVector3D eastLocal( QVector3D::dotProduct( nc0, east ), QVector3D::dotProduct( nc1, east ), QVector3D::dotProduct( nc2, east ) );
348 const QVector3D upLocal( QVector3D::dotProduct( nc0, up ), QVector3D::dotProduct( nc1, up ), QVector3D::dotProduct( nc2, up ) );
349 east = eastLocal;
350 up = upLocal;
351 }
352
353 rotations[i] = quaternionFromNormalUpRight( up, east );
354 }
355}
356
357
358// Helper: build the axis flip matrix from gltfUpAxis
359static QMatrix4x4 axisFlipMatrix( Qgis::Axis gltfUpAxis )
360{
361 QMatrix4x4 F;
362 switch ( gltfUpAxis )
363 {
364 case Qgis::Axis::Y:
365 // Y-up → Z-up: (x,y,z) → (x,-z,y)
366 F = QMatrix4x4( 1, 0, 0, 0, 0, 0, -1, 0, 0, 1, 0, 0, 0, 0, 0, 1 );
367 break;
368 case Qgis::Axis::X:
369 case Qgis::Axis::Z:
370 // identity (already Z-up or X-up not supported yet)
371 break;
372 }
373 return F;
374}
375
376
377// Helper: parse EXT_mesh_gpu_instancing from a glTF node
378static bool parseExtMeshGpuInstancing(
379 const tinygltf::Model &model, const tinygltf::Node &node, QVector<QVector3D> &translations, QVector<QQuaternion> &rotations, QVector<QVector3D> &scales, int &instanceCount
380)
381{
382 auto extIt = node.extensions.find( "EXT_mesh_gpu_instancing" );
383 if ( extIt == node.extensions.end() )
384 return false;
385
386 const tinygltf::Value &extValue = extIt->second;
387 if ( !extValue.IsObject() || !extValue.Has( "attributes" ) )
388 return false;
389
390 const tinygltf::Value &attributes = extValue.Get( "attributes" );
391 if ( !attributes.IsObject() )
392 return false;
393
394 instanceCount = 0;
395
396 // Helper lambda: read a VEC3 FLOAT accessor into a QVector<QVector3D>
397 auto readVec3Accessor = [&model, &instanceCount]( const tinygltf::Value &attributes, const std::string &name, QVector<QVector3D> &out, const QVector3D &defaultValue ) -> bool {
398 if ( attributes.Has( name ) )
399 {
400 int accessorIdx = attributes.Get( name ).GetNumberAsInt();
401 if ( accessorIdx < 0 || accessorIdx >= static_cast<int>( model.accessors.size() ) )
402 return false;
403
404 const tinygltf::Accessor &accessor = model.accessors[accessorIdx];
405 if ( accessor.type != TINYGLTF_TYPE_VEC3 || accessor.componentType != TINYGLTF_COMPONENT_TYPE_FLOAT )
406 return false;
407
408 const int count = static_cast<int>( accessor.count );
409 if ( instanceCount == 0 )
410 instanceCount = count;
411 else if ( instanceCount != count )
412 return false;
413
414 const tinygltf::BufferView &bv = model.bufferViews[accessor.bufferView];
415 const tinygltf::Buffer &buf = model.buffers[bv.buffer];
416 const unsigned char *ptr = buf.data.data() + bv.byteOffset + accessor.byteOffset;
417 const int stride = bv.byteStride ? static_cast<int>( bv.byteStride ) : 3 * static_cast<int>( sizeof( float ) );
418
419 out.resize( count );
420 const unsigned char *row = ptr;
421 for ( int i = 0; i < count; ++i, row += stride )
422 {
423 const float *fptr = reinterpret_cast<const float *>( row );
424 out[i] = QVector3D( fptr[0], fptr[1], fptr[2] );
425 }
426 }
427 else
428 {
429 // use default
430 if ( instanceCount > 0 )
431 out.fill( defaultValue, instanceCount );
432 }
433 return true;
434 };
435
436 if ( !readVec3Accessor( attributes, "TRANSLATION", translations, QVector3D( 0, 0, 0 ) ) )
437 return false;
438
439 if ( !readVec3Accessor( attributes, "SCALE", scales, QVector3D( 1, 1, 1 ) ) )
440 return false;
441
442 // Read ROTATION: VEC4 FLOAT accessor → per-instance quaternions
443 if ( attributes.Has( "ROTATION" ) )
444 {
445 int accessorIdx = attributes.Get( "ROTATION" ).GetNumberAsInt();
446 if ( accessorIdx < 0 || accessorIdx >= static_cast<int>( model.accessors.size() ) )
447 return false;
448
449 const tinygltf::Accessor &accessor = model.accessors[accessorIdx];
450 if ( accessor.type != TINYGLTF_TYPE_VEC4 )
451 return false;
452
453 const int count = static_cast<int>( accessor.count );
454 if ( instanceCount == 0 )
455 instanceCount = count;
456 else if ( instanceCount != count )
457 return false;
458
459 const tinygltf::BufferView &bv = model.bufferViews[accessor.bufferView];
460 const tinygltf::Buffer &buf = model.buffers[bv.buffer];
461 const unsigned char *ptr = buf.data.data() + bv.byteOffset + accessor.byteOffset;
462
463 rotations.resize( count );
464
465 if ( accessor.componentType == TINYGLTF_COMPONENT_TYPE_FLOAT )
466 {
467 const int stride = bv.byteStride ? static_cast<int>( bv.byteStride ) : 4 * static_cast<int>( sizeof( float ) );
468 const unsigned char *row = ptr;
469 for ( int i = 0; i < count; ++i, row += stride )
470 {
471 const float *fptr = reinterpret_cast<const float *>( row );
472 // glTF quaternion: (x, y, z, w)
473 rotations[i] = QQuaternion( fptr[3], fptr[0], fptr[1], fptr[2] );
474 }
475 }
476 else if ( accessor.componentType == TINYGLTF_COMPONENT_TYPE_SHORT )
477 {
478 const int stride = bv.byteStride ? static_cast<int>( bv.byteStride ) : 4 * static_cast<int>( sizeof( short ) );
479 const unsigned char *row = ptr;
480 for ( int i = 0; i < count; ++i, row += stride )
481 {
482 const short *sptr = reinterpret_cast<const short *>( row );
483 // Normalized short: divide by 32767.0
484 rotations[i] = QQuaternion( static_cast<float>( sptr[3] ) / 32767.0f, static_cast<float>( sptr[0] ) / 32767.0f, static_cast<float>( sptr[1] ) / 32767.0f, static_cast<float>( sptr[2] ) / 32767.0f );
485 }
486 }
487 else if ( accessor.componentType == TINYGLTF_COMPONENT_TYPE_BYTE )
488 {
489 const int stride = bv.byteStride ? static_cast<int>( bv.byteStride ) : 4 * static_cast<int>( sizeof( char ) );
490 const unsigned char *row = ptr;
491 for ( int i = 0; i < count; ++i, row += stride )
492 {
493 const signed char *bptr = reinterpret_cast<const signed char *>( row );
494 // Normalized byte: divide by 127.0
495 rotations[i] = QQuaternion( static_cast<float>( bptr[3] ) / 127.0f, static_cast<float>( bptr[0] ) / 127.0f, static_cast<float>( bptr[1] ) / 127.0f, static_cast<float>( bptr[2] ) / 127.0f );
496 }
497 }
498 else
499 {
500 return false;
501 }
502 }
503 else
504 {
505 if ( instanceCount > 0 )
506 rotations.fill( QQuaternion(), instanceCount );
507 }
508
509 // Fill defaults if only some attributes were specified
510 if ( instanceCount > 0 )
511 {
512 if ( translations.isEmpty() )
513 translations.fill( QVector3D( 0, 0, 0 ), instanceCount );
514 if ( rotations.isEmpty() )
515 rotations.fill( QQuaternion(), instanceCount );
516 if ( scales.isEmpty() )
517 scales.fill( QVector3D( 1, 1, 1 ), instanceCount );
518 }
519
520 return instanceCount > 0;
521}
522
523
524// Helper: build a 4x4 matrix from translation, rotation, scale
525static QMatrix4x4 trsMatrix( const QVector3D &t, const QQuaternion &r, const QVector3D &s )
526{
527 QMatrix4x4 mat;
528 mat.translate( t );
529 mat.rotate( r );
530 mat.scale( s );
531 return mat;
532}
533
534
535QVector<QgsGltfUtils::InstancedPrimitive> QgsCesiumUtils::resolveInstancing(
536 const tinygltf::Model &model, const std::optional<TileI3dmData> &tileInstancing, Qgis::Axis gltfUpAxis, const QgsMatrix4x4 &tileTransform, const QgsVector3D &rtcCenter
537)
538{
539 QVector<QgsGltfUtils::InstancedPrimitive> result;
540
541 bool sceneOk = false;
542 const std::size_t sceneIndex = QgsGltfUtils::sourceSceneForModel( model, sceneOk );
543 if ( !sceneOk )
544 return result;
545
546 const tinygltf::Scene &scene = model.scenes[sceneIndex];
547 const QMatrix4x4 F = axisFlipMatrix( gltfUpAxis );
548
549 // Recursive node walker
550 std::function<void( int nodeIndex, const QMatrix4x4 &parentTransform )> walkNode;
551 walkNode = [&]( int nodeIndex, const QMatrix4x4 &parentTransform ) {
552 if ( nodeIndex < 0 || nodeIndex >= static_cast<int>( model.nodes.size() ) )
553 return;
554
555 const tinygltf::Node &node = model.nodes[nodeIndex];
556
557 // Compute accumulated local transform M for this node
558 std::unique_ptr<QMatrix4x4> localTransform = QgsGltfUtils::parseNodeTransform( node );
559 QMatrix4x4 M = parentTransform;
560 if ( localTransform )
561 M = parentTransform * *localTransform;
562
563 if ( node.mesh >= 0 )
564 {
565 const tinygltf::Mesh &mesh = model.meshes[node.mesh];
566
567 if ( tileInstancing.has_value() )
568 {
569 // i3dm path: every mesh node gets the same instances
570 // fullMatrix = T(pos_i) × R_i × S_i × F × M
571 const QMatrix4x4 FM = F * M;
572 QgsCesiumUtils::TileI3dmData inst = tileInstancing.value();
573
574 // Deferred EAST_NORTH_UP: compute ENU rotations now that the tile transform is available
575 if ( inst.eastNorthUp )
576 {
577 computeEastNorthUpQuaternions( inst.translations, rtcCenter, tileTransform, inst.rotations );
578 }
579
580 for ( int pIdx = 0; pIdx < static_cast<int>( mesh.primitives.size() ); ++pIdx )
581 {
582 QgsGltfUtils::InstancedPrimitive entry;
583 entry.meshIndex = node.mesh;
584 entry.primitiveIndex = pIdx;
585 entry.materialIndex = mesh.primitives[pIdx].material;
586 entry.instanceTransforms.resize( inst.instanceCount );
587
588 for ( int i = 0; i < inst.instanceCount; ++i )
589 {
590 entry.instanceTransforms[i] = trsMatrix( inst.translations[i], inst.rotations[i], inst.scales[i] ) * FM;
591 }
592
593 result.append( std::move( entry ) );
594 }
595 }
596 else
597 {
598 // Check for EXT_mesh_gpu_instancing on this node
599 QVector<QVector3D> translations;
600 QVector<QQuaternion> rotations;
601 QVector<QVector3D> scales;
602 int instanceCount = 0;
603
604 if ( parseExtMeshGpuInstancing( model, node, translations, rotations, scales, instanceCount ) )
605 {
606 // EXT path: fullMatrix = F × M × T_i × R_i × S_i
607 const QMatrix4x4 FM = F * M;
608
609 for ( int pIdx = 0; pIdx < static_cast<int>( mesh.primitives.size() ); ++pIdx )
610 {
611 QgsGltfUtils::InstancedPrimitive entry;
612 entry.meshIndex = node.mesh;
613 entry.primitiveIndex = pIdx;
614 entry.materialIndex = mesh.primitives[pIdx].material;
615 entry.instanceTransforms.resize( instanceCount );
616
617 for ( int i = 0; i < instanceCount; ++i )
618 {
619 entry.instanceTransforms[i] = FM * trsMatrix( translations[i], rotations[i], scales[i] );
620 }
621
622 result.append( std::move( entry ) );
623 }
624 }
625 // else: non-instanced node, skip — handled by existing code path
626 }
627 }
628
629 // Recurse to children
630 for ( int childIndex : node.children )
631 {
632 walkNode( childIndex, M );
633 }
634 };
635
636 for ( int nodeIndex : scene.nodes )
637 {
638 walkNode( nodeIndex, QMatrix4x4() );
639 }
640
641 return result;
642}
643
644
645static QgsCesiumUtils::TileContents extractGltfFromI3dm( const QByteArray &tileContent, const QString &baseUri )
646{
647 struct i3dmHeader
648 {
649 unsigned char magic[4];
650 quint32 version;
651 quint32 byteLength;
652 quint32 featureTableJsonByteLength;
653 quint32 featureTableBinaryByteLength;
654 quint32 batchTableJsonByteLength;
655 quint32 batchTableBinaryByteLength;
656 quint32 gltfFormat;
657 };
658
660
661 if ( tileContent.size() < static_cast<int>( sizeof( i3dmHeader ) ) )
662 return res;
663
664 i3dmHeader hdr;
665 memcpy( &hdr, tileContent.constData(), sizeof( i3dmHeader ) );
666
667 const int featureTableJsonOffset = sizeof( i3dmHeader );
668 const int featureTableBinaryOffset = featureTableJsonOffset + static_cast<int>( hdr.featureTableJsonByteLength );
669
670 // Parse feature table JSON
671 const QString featureTableJson( tileContent.mid( featureTableJsonOffset, hdr.featureTableJsonByteLength ) );
672 if ( featureTableJson.isEmpty() )
673 {
674 QgsDebugError( u"i3dm: empty feature table JSON"_s );
675 return res;
676 }
677
678 int instanceCount = 0;
679 bool eastNorthUp = false;
680 int positionByteOffset = -1;
681 int normalUpByteOffset = -1;
682 int normalRightByteOffset = -1;
683 int scaleByteOffset = -1;
684 int scaleNonUniformByteOffset = -1;
685
686 try
687 {
688 const json featureTable = json::parse( featureTableJson.toStdString() );
689
690 if ( !featureTable.contains( "INSTANCES_LENGTH" ) )
691 {
692 QgsDebugError( u"i3dm: INSTANCES_LENGTH not found in feature table"_s );
693 return res;
694 }
695 instanceCount = featureTable["INSTANCES_LENGTH"].get<int>();
696
697 if ( featureTable.contains( "RTC_CENTER" ) )
698 {
699 const auto &rtcCenterJson = featureTable["RTC_CENTER"];
700 if ( rtcCenterJson.is_array() && rtcCenterJson.size() == 3 )
701 {
702 res.rtcCenter.setX( rtcCenterJson[0].get<double>() );
703 res.rtcCenter.setY( rtcCenterJson[1].get<double>() );
704 res.rtcCenter.setZ( rtcCenterJson[2].get<double>() );
705 }
706 }
707
708 if ( featureTable.contains( "EAST_NORTH_UP" ) )
709 {
710 eastNorthUp = featureTable["EAST_NORTH_UP"].get<bool>();
711 }
712
713 // Get byte offsets for binary properties
714 if ( featureTable.contains( "POSITION" ) )
715 {
716 const auto &posJson = featureTable["POSITION"];
717 if ( posJson.is_object() && posJson.contains( "byteOffset" ) )
718 positionByteOffset = posJson["byteOffset"].get<int>();
719 }
720 if ( featureTable.contains( "NORMAL_UP" ) )
721 {
722 const auto &nuJson = featureTable["NORMAL_UP"];
723 if ( nuJson.is_object() && nuJson.contains( "byteOffset" ) )
724 normalUpByteOffset = nuJson["byteOffset"].get<int>();
725 }
726 if ( featureTable.contains( "NORMAL_RIGHT" ) )
727 {
728 const auto &nrJson = featureTable["NORMAL_RIGHT"];
729 if ( nrJson.is_object() && nrJson.contains( "byteOffset" ) )
730 normalRightByteOffset = nrJson["byteOffset"].get<int>();
731 }
732 if ( featureTable.contains( "SCALE" ) )
733 {
734 const auto &sJson = featureTable["SCALE"];
735 if ( sJson.is_object() && sJson.contains( "byteOffset" ) )
736 scaleByteOffset = sJson["byteOffset"].get<int>();
737 }
738 if ( featureTable.contains( "SCALE_NON_UNIFORM" ) )
739 {
740 const auto &snuJson = featureTable["SCALE_NON_UNIFORM"];
741 if ( snuJson.is_object() && snuJson.contains( "byteOffset" ) )
742 scaleNonUniformByteOffset = snuJson["byteOffset"].get<int>();
743 }
744 }
745 catch ( json::parse_error &ex )
746 {
747 QgsDebugError( u"i3dm: error parsing feature table JSON: %1"_s.arg( ex.what() ) );
748 return res;
749 }
750
751 if ( instanceCount <= 0 || positionByteOffset < 0 )
752 {
753 QgsDebugError( u"i3dm: invalid instance count or missing POSITION"_s );
754 return res;
755 }
756
757 const char *featureBinaryPtr = tileContent.constData() + featureTableBinaryOffset;
758 const int featureBinarySize = static_cast<int>( hdr.featureTableBinaryByteLength );
759
761 instancing.instanceCount = instanceCount;
762
763 // Read POSITION: instanceCount × float32[3]
764 {
765 const int requiredSize = positionByteOffset + instanceCount * 3 * static_cast<int>( sizeof( float ) );
766 if ( requiredSize > featureBinarySize )
767 {
768 QgsDebugError( u"i3dm: POSITION data exceeds feature table binary size"_s );
769 return res;
770 }
771 instancing.translations.resize( instanceCount );
772 const float *posPtr = reinterpret_cast<const float *>( featureBinaryPtr + positionByteOffset );
773 for ( int i = 0; i < instanceCount; ++i, posPtr += 3 )
774 {
775 instancing.translations[i] = QVector3D( posPtr[0], posPtr[1], posPtr[2] );
776 }
777 }
778
779 // Read rotations from NORMAL_UP + NORMAL_RIGHT, or EAST_NORTH_UP, or identity
780 if ( normalUpByteOffset >= 0 && normalRightByteOffset >= 0 )
781 {
782 const int requiredUp = normalUpByteOffset + instanceCount * 3 * static_cast<int>( sizeof( float ) );
783 const int requiredRight = normalRightByteOffset + instanceCount * 3 * static_cast<int>( sizeof( float ) );
784 if ( requiredUp > featureBinarySize || requiredRight > featureBinarySize )
785 {
786 QgsDebugError( u"i3dm: NORMAL_UP/NORMAL_RIGHT data exceeds feature table binary size"_s );
787 return res;
788 }
789 instancing.rotations.resize( instanceCount );
790 const float *upPtr = reinterpret_cast<const float *>( featureBinaryPtr + normalUpByteOffset );
791 const float *rightPtr = reinterpret_cast<const float *>( featureBinaryPtr + normalRightByteOffset );
792 for ( int i = 0; i < instanceCount; ++i, upPtr += 3, rightPtr += 3 )
793 {
794 const QVector3D normalUp( upPtr[0], upPtr[1], upPtr[2] );
795 const QVector3D normalRight( rightPtr[0], rightPtr[1], rightPtr[2] );
796 instancing.rotations[i] = quaternionFromNormalUpRight( normalUp, normalRight );
797 }
798 }
799 else if ( eastNorthUp )
800 {
801 // Defer ENU computation: the tile transform (from tileset.json) is needed
802 // to convert tile-local positions to ECEF, but is not available at parse time.
803 // Store the flag so resolveInstancing() can compute ENU rotations later.
804 instancing.eastNorthUp = true;
805 instancing.rotations.fill( QQuaternion(), instanceCount );
806 }
807 else
808 {
809 instancing.rotations.fill( QQuaternion(), instanceCount );
810 }
811
812 // Read SCALE or SCALE_NON_UNIFORM
813 if ( scaleNonUniformByteOffset >= 0 )
814 {
815 const int required = scaleNonUniformByteOffset + instanceCount * 3 * static_cast<int>( sizeof( float ) );
816 if ( required > featureBinarySize )
817 {
818 QgsDebugError( u"i3dm: SCALE_NON_UNIFORM data exceeds feature table binary size"_s );
819 return res;
820 }
821 instancing.scales.resize( instanceCount );
822 const float *scalePtr = reinterpret_cast<const float *>( featureBinaryPtr + scaleNonUniformByteOffset );
823 for ( int i = 0; i < instanceCount; ++i, scalePtr += 3 )
824 {
825 instancing.scales[i] = QVector3D( scalePtr[0], scalePtr[1], scalePtr[2] );
826 }
827 }
828 else if ( scaleByteOffset >= 0 )
829 {
830 const int required = scaleByteOffset + instanceCount * static_cast<int>( sizeof( float ) );
831 if ( required > featureBinarySize )
832 {
833 QgsDebugError( u"i3dm: SCALE data exceeds feature table binary size"_s );
834 return res;
835 }
836 instancing.scales.resize( instanceCount );
837 const float *scalePtr = reinterpret_cast<const float *>( featureBinaryPtr + scaleByteOffset );
838 for ( int i = 0; i < instanceCount; ++i )
839 {
840 const float s = scalePtr[i];
841 instancing.scales[i] = QVector3D( s, s, s );
842 }
843 }
844 else
845 {
846 instancing.scales.fill( QVector3D( 1, 1, 1 ), instanceCount );
847 }
848
849 res.instancing = std::move( instancing );
850
851 // Extract embedded glTF body
852 const int gltfOffset = featureTableBinaryOffset
853 + static_cast<int>( hdr.featureTableBinaryByteLength )
854 + static_cast<int>( hdr.batchTableJsonByteLength )
855 + static_cast<int>( hdr.batchTableBinaryByteLength );
856 QByteArray gltfContent = tileContent.mid( gltfOffset );
857
858 if ( hdr.gltfFormat == 1 ) // embedded
859 {
860 res.gltf = gltfContent;
861 }
862 else if ( hdr.gltfFormat == 0 ) // gltf is actually only a URI, not real content
863 {
864 QString gltfUri = QString::fromUtf8( gltfContent );
865 // URI may be relative to the .i3dm location
866 QUrl url = QUrl( baseUri ).resolved( gltfUri );
867
868 if ( url.scheme().startsWith( "http" ) )
869 {
870 QNetworkRequest request = QNetworkRequest( url );
871 request.setAttribute( QNetworkRequest::CacheLoadControlAttribute, QNetworkRequest::PreferCache );
872 request.setAttribute( QNetworkRequest::CacheSaveControlAttribute, true );
873 QgsBlockingNetworkRequest networkRequest;
874 // TODO: setup auth, setup headers
875 if ( networkRequest.get( request ) != QgsBlockingNetworkRequest::NoError )
876 {
877 QgsDebugError( u"i3dm: Failed to download GLTF: %1"_s.arg( url.toString() ) );
878 }
879 else
880 {
881 const QgsNetworkReplyContent content = networkRequest.reply();
882 res.gltf = content.content();
883 }
884 }
885 else if ( url.isLocalFile() )
886 {
887 QString localFilePath = url.toLocalFile();
888 if ( QFile::exists( localFilePath ) )
889 {
890 QFile f( localFilePath );
891 if ( f.open( QIODevice::ReadOnly ) )
892 {
893 res.gltf = f.readAll();
894 }
895 }
896 else
897 {
898 QgsDebugError( u"i3dm: Failed to open local GLTF: %1"_s.arg( url.toString() ) );
899 }
900 }
901 }
902 else
903 {
904 QgsDebugError( u"i3dm: Unknown gltf format: %1"_s.arg( hdr.gltfFormat ) );
905 }
906
907 return res;
908}
909
910static QVector<QgsCesiumUtils::TileContents> extractGltfFromCmpt( const QByteArray &tileContent, int depth = 0 )
911{
912 struct cmptHeader
913 {
914 unsigned char magic[4];
915 quint32 version;
916 quint32 byteLength;
917 quint32 tilesLength;
918 };
919
920 QVector<QgsCesiumUtils::TileContents> result;
921
922 if ( depth > 10 )
923 {
924 // avoid infinite recursion with badly formed tiles
925 QgsDebugError( u"cmpt recursion depth exceeded"_s );
926 return result;
927 }
928
929 if ( tileContent.size() < static_cast<int>( sizeof( cmptHeader ) ) )
930 return result;
931
932 cmptHeader hdr;
933 memcpy( &hdr, tileContent.constData(), sizeof( cmptHeader ) );
934
935 if ( hdr.version != 1 )
936 {
937 QgsDebugError( u"Unsupported cmpt version %1"_s.arg( hdr.version ) );
938 return result;
939 }
940
941 if ( static_cast<quint32>( tileContent.size() ) < hdr.byteLength )
942 return result;
943
944 int offset = static_cast<int>( sizeof( cmptHeader ) );
945 for ( quint32 i = 0; i < hdr.tilesLength; ++i )
946 {
947 // all inner tiles have the following header: magic (4 bytes), version (uint32), byteLength (uint32)
948 const quint32 innerByteLength = *reinterpret_cast<const quint32 *>( tileContent.constData() + offset + 8 );
949
950 if ( innerByteLength < 12 || offset + static_cast<int>( innerByteLength ) > static_cast<int>( hdr.byteLength ) )
951 {
952 QgsDebugError( u"cmpt with bad inner tile (at index %1)"_s.arg( i ) );
953 break;
954 }
955
956 const QByteArray innerTile = tileContent.mid( offset, innerByteLength );
957
958 if ( innerTile.startsWith( QByteArray( "cmpt" ) ) )
959 {
960 result.append( extractGltfFromCmpt( innerTile, depth + 1 ) );
961 }
962 else
963 {
964 result.append( QgsCesiumUtils::extractTileContent( innerTile ) );
965 }
966
967 offset += static_cast<int>( innerByteLength );
968 }
969
970 return result;
971}
972
973
975{
976 TileContents res;
977 if ( tileContent.startsWith( QByteArray( "b3dm" ) ) )
978 {
979 const B3DMContents b3dmContents = QgsCesiumUtils::extractGltfFromB3dm( tileContent );
980 res.gltf = b3dmContents.gltf;
981 res.rtcCenter = b3dmContents.rtcCenter;
982 return res;
983 }
984 else if ( tileContent.startsWith( QByteArray( "glTF" ) ) )
985 {
986 res.gltf = tileContent;
987 return res;
988 }
989 else
990 {
991 // unsupported tile content type
992 return res;
993 }
994}
995
996QVector<QgsCesiumUtils::TileContents> QgsCesiumUtils::extractTileContent( const QByteArray &tileContent, const QString &baseUri )
997{
998 QVector<TileContents> result;
999 if ( tileContent.startsWith( QByteArray( "b3dm" ) ) )
1000 {
1001 const B3DMContents b3dmContents = QgsCesiumUtils::extractGltfFromB3dm( tileContent );
1002 TileContents contents;
1003 contents.gltf = b3dmContents.gltf;
1004 contents.rtcCenter = b3dmContents.rtcCenter;
1005 result.append( contents );
1006 }
1007 else if ( tileContent.startsWith( QByteArray( "i3dm" ) ) )
1008 {
1009 TileContents contents = extractGltfFromI3dm( tileContent, baseUri );
1010 result.append( contents );
1011 }
1012 else if ( tileContent.startsWith( QByteArray( "glTF" ) ) )
1013 {
1014 TileContents contents;
1015 contents.gltf = tileContent;
1016 result.append( contents );
1017 }
1018 else if ( tileContent.startsWith( QByteArray( "cmpt" ) ) )
1019 {
1020 result = extractGltfFromCmpt( tileContent );
1021 }
1022 else
1023 {
1024 QgsDebugError( u"extractGltfFromTileContent: unknown tile format, size=%1, magic=%2"_s.arg( tileContent.size() ).arg( QString::fromLatin1( tileContent.left( 4 ) ) ) );
1025 }
1026 return result;
1027}
1028
1030{
1031 if ( region.width() > 20 || region.height() > 20 )
1032 {
1033 // treat very large regions as global -- these will not transform correctly to EPSG:4978
1035 }
1036
1037 // Transform the 8 corners of the region from EPSG:4979 to EPSG:4978
1038 QVector< QgsVector3D > corners = region.corners();
1039 QVector< double > x;
1040 x.reserve( 8 );
1041 QVector< double > y;
1042 y.reserve( 8 );
1043 QVector< double > z;
1044 z.reserve( 8 );
1045 for ( int i = 0; i < 8; ++i )
1046 {
1047 const QgsVector3D &corner = corners[i];
1048 x.append( corner.x() );
1049 y.append( corner.y() );
1050 z.append( corner.z() );
1051 }
1052 QgsCoordinateTransform ct( QgsCoordinateReferenceSystem( u"EPSG:4979"_s ), QgsCoordinateReferenceSystem( u"EPSG:4978"_s ), transformContext );
1054 try
1055 {
1056 ct.transformInPlace( x, y, z );
1057 }
1058 catch ( QgsCsException & )
1059 {
1060 QgsDebugError( u"Cannot transform region bounding volume"_s );
1061 }
1062
1063 const auto minMaxX = std::minmax_element( x.constBegin(), x.constEnd() );
1064 const auto minMaxY = std::minmax_element( y.constBegin(), y.constEnd() );
1065 const auto minMaxZ = std::minmax_element( z.constBegin(), z.constEnd() );
1066 // note that matrix transforms are NOT applied to region bounding volumes!
1067 return QgsTiledSceneBoundingVolume( QgsOrientedBox3D::fromBox3D( QgsBox3D( *minMaxX.first, *minMaxY.first, *minMaxZ.first, *minMaxX.second, *minMaxY.second, *minMaxZ.second ) ) );
1068}
1069
1070QString QgsCesiumUtils::appendQueryFromBaseUrl( const QString &contentUri, const QUrl &baseUrl )
1071{
1072 // This is to support a case seen with Google's tiles. Root URL is something like this:
1073 // https://tile.googleapis.com/.../root.json?key=123
1074 // The returned JSON contains relative links with "session" (e.g. "/.../abc.json?session=456")
1075 // When fetching such abc.json, we have to include also "key" from the original URL!
1076 // Then the content of abc.json contains relative links (e.g. "/.../xyz.glb") and we
1077 // need to add both "key" and "session" (otherwise requests fail).
1078
1079 QUrlQuery contentQuery( QUrl( contentUri ).query() );
1080 const QList<QPair<QString, QString>> baseUrlQueryItems = QUrlQuery( baseUrl.query() ).queryItems();
1081 for ( const QPair<QString, QString> &kv : baseUrlQueryItems )
1082 {
1083 contentQuery.addQueryItem( kv.first, kv.second );
1084 }
1085 QUrl newContentUrl( contentUri );
1086 newContentUrl.setQuery( contentQuery );
1087 return newContentUrl.toString();
1088}
Axis
Cartesian axes.
Definition qgis.h:2607
@ X
X-axis.
Definition qgis.h:2608
@ Z
Z-axis.
Definition qgis.h:2610
@ Y
Y-axis.
Definition qgis.h:2609
A thread safe class for performing blocking (sync) network requests, with full support for QGIS proxy...
ErrorCode get(QNetworkRequest &request, bool forceRefresh=false, QgsFeedback *feedback=nullptr, RequestFlags requestFlags=QgsBlockingNetworkRequest::RequestFlags())
Performs a "get" operation on the specified request.
@ NoError
No error was encountered.
QgsNetworkReplyContent reply() const
Returns the content of the network reply, after a get(), post(), head() or put() request has been mad...
A 3-dimensional box composed of x, y, z coordinates.
Definition qgsbox3d.h:45
QVector< QgsVector3D > corners() const
Returns an array of all box corners as 3D vectors.
Definition qgsbox3d.cpp:357
double width() const
Returns the width of the box.
Definition qgsbox3d.h:287
double height() const
Returns the height of the box.
Definition qgsbox3d.h:294
static QgsSphere parseSphere(const json &sphere)
Parses a sphere object from a Cesium JSON document.
static B3DMContents extractGltfFromB3dm(const QByteArray &tileContent)
Extracts GLTF binary data and other contents from the legacy b3dm (Batched 3D Model) tile format.
static QString appendQueryFromBaseUrl(const QString &contentUri, const QUrl &baseUrl)
Copies any query items from the base URL to the content URI - to replicate undocumented Cesium JS beh...
static QgsOrientedBox3D parseBox(const json &box)
Parses a box object from a Cesium JSON document to an oriented bounding box.
static QVector< QgsGltfUtils::InstancedPrimitive > resolveInstancing(const tinygltf::Model &model, const std::optional< TileI3dmData > &tileInstancing, Qgis::Axis gltfUpAxis, const QgsMatrix4x4 &tileTransform, const QgsVector3D &rtcCenter)
Resolves instancing from either i3dm data or EXT_mesh_gpu_instancing.
static QgsTiledSceneBoundingVolume boundingVolumeFromRegion(const QgsBox3D &region, const QgsCoordinateTransformContext &transformContext)
Calculates oriented bounding box in EPSG:4978 from "region" defined with min/max lat/lon coordinates ...
static QgsBox3D parseRegion(const json &region)
Parses a region object from a Cesium JSON object to a 3D box.
static QgsSphere transformSphere(const QgsSphere &sphere, const QgsMatrix4x4 &transform)
Applies a transform to a sphere.
static QVector< QgsCesiumUtils::TileContents > extractTileContent(const QByteArray &tileContent, const QString &baseUri=QString())
Parses tile content and returns a list of TileContents.
static Q_DECL_DEPRECATED TileContents extractGltfFromTileContent(const QByteArray &tileContent)
Parses tile content.
Represents a coordinate reference system (CRS).
Contains information about the context in which a coordinate transform is executed.
Handles coordinate transforms between two coordinate systems.
void setBallparkTransformsAreAppropriate(bool appropriate)
Sets whether approximate "ballpark" results are appropriate for this coordinate transform.
void transformInPlace(double &x, double &y, double &z, Qgis::TransformDirection direction=Qgis::TransformDirection::Forward) const
Transforms an array of x, y and z double coordinates in place, from the source CRS to the destination...
Custom exception class for Coordinate Reference System related exceptions.
static json jsonFromVariant(const QVariant &v)
Converts a QVariant v to a json object.
A simple 4x4 matrix implementation useful for transformation in 3D space.
bool isIdentity() const
Returns whether this matrix is an identity matrix.
QgsVector3D map(const QgsVector3D &vector) const
Matrix-vector multiplication (vector is converted to homogeneous coordinates [X,Y,...
const double * constData() const
Returns pointer to the matrix data (stored in column-major order).
Encapsulates a network reply within a container which is inexpensive to copy and safe to pass between...
QByteArray content() const
Returns the reply content.
Represents a oriented (rotated) box in 3 dimensions.
static QgsOrientedBox3D fromBox3D(const QgsBox3D &box)
Constructs an oriented box from an axis-aligned bounding box.
A spherical geometry object.
Definition qgssphere.h:46
QgsVector3D centerVector() const
Returns the vector to the center of the sphere.
Definition qgssphere.cpp:47
double radius() const
Returns the radius of the sphere.
Definition qgssphere.h:144
Represents a bounding volume for a tiled scene.
A 3D vector (similar to QVector3D) with the difference that it uses double precision instead of singl...
Definition qgsvector3d.h:33
double y() const
Returns Y coordinate.
Definition qgsvector3d.h:60
double z() const
Returns Z coordinate.
Definition qgsvector3d.h:62
void setZ(double z)
Sets Z coordinate.
Definition qgsvector3d.h:80
double x() const
Returns X coordinate.
Definition qgsvector3d.h:58
void setX(double x)
Sets X coordinate.
Definition qgsvector3d.h:68
void setY(double y)
Sets Y coordinate.
Definition qgsvector3d.h:74
bool ANALYSIS_EXPORT normalRight(Vector3D *v1, Vector3D *result, double length)
Assigns the vector 'result', which is normal to the vector 'v1', on the right side of v1 and has leng...
#define QgsDebugError(str)
Definition qgslogger.h:59
Encapsulates the contents of a B3DM file.
QByteArray gltf
GLTF binary content.
QgsVector3D rtcCenter
Optional RTC center.
Encapsulates the contents of a 3D tile.
QgsVector3D rtcCenter
Center position of relative-to-center coordinates (when used).
QByteArray gltf
GLTF binary content.
std::optional< TileI3dmData > instancing
Optional instancing data, populated for i3dm tiles.
Raw per-instance data parsed from an i3dm feature table of a single tile.
QVector< QVector3D > translations
ECEF-relative positions (Z-up), relative to RTC_CENTER.
QVector< QVector3D > scales
Per-axis scale - (1,1,1) if unspecified.
int instanceCount
Number of instances.
bool eastNorthUp
Whether EAST_NORTH_UP rotations should be computed (deferred until tile transform is available).
QVector< QQuaternion > rotations
Quaternion (x,y,z,w) - identity if unspecified.