Revision control
++ // Copyright (C) 2026 The Qt Company Ltd.
++ // SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only
++
++ #include <QtTest/QtTest>
++
++ #include <QtQuick3DGltf/private/qssggltfaccessorreader_p.h>
++ #include <QtQuick3DGltf/private/qssggltfparser_p.h>
++ #include <QtQuick3DGltf/private/qssggltfresourceresolver_p.h>
++
++ #include <QtCore/qendian.h>
++ #include <QtCore/qmath.h>
++ #include <QtCore/qfile.h>
++
++ class tst_qssggltfparser : public QObject
++ {
++ Q_OBJECT
++
++ private slots:
++ void externalBuffer();
++ void embeddedBuffer();
++ void glbContainer();
++ void glbBadMagic();
++ void glbTruncated();
++ void glbUnsupportedVersion();
++ void dataUriDecoding();
++ void requiredExtensionGate();
++ void materials();
++ void lightsAndCameras();
++ void sparseAccessorStructure();
++ void structuralValidation();
++ void hostileInputValidation();
++ void nodeVisibilityAndWebp();
++ void meshoptCompression();
++ void meshGpuInstancing();
++ void materialVariants();
++ void readAccessorData();
++ void readSparseAccessor();
++ void readInterleavedAccessor();
++ void readNormalizedAccessor();
++
++ private:
++ QString testFile(const QString &name) const
++ {
++ return QFINDTESTDATA(QStringLiteral("data/") + name);
++ }
++
++ QByteArray makeGlb(const QByteArray &json, const QByteArray &bin = {}) const;
++ };
++
++ // Builds a GLB container from a JSON chunk and an optional BIN chunk
++ QByteArray tst_qssggltfparser::makeGlb(const QByteArray &json, const QByteArray &bin) const
++ {
++ const auto appendUInt32 = [](QByteArray &out, quint32 value) {
++ quint32 le;
++ qToLittleEndian(value, &le);
++ out.append(reinterpret_cast<const char *>(&le), 4);
++ };
++ const auto padded = [](QByteArray chunk, char padByte) {
++ while (chunk.size() % 4)
++ chunk.append(padByte);
++ return chunk;
++ };
++
++ const QByteArray jsonChunk = padded(json, ' ');
++ const QByteArray binChunk = padded(bin, '\0');
++
++ QByteArray out;
++ appendUInt32(out, 0x46546C67); // magic
++ appendUInt32(out, 2); // version
++ quint32 total = 12 + 8 + jsonChunk.size();
++ if (!bin.isEmpty())
++ total += 8 + binChunk.size();
++ appendUInt32(out, total);
++
++ appendUInt32(out, jsonChunk.size());
++ appendUInt32(out, 0x4E4F534A); // 'JSON'
++ out += jsonChunk;
++
++ if (!bin.isEmpty()) {
++ appendUInt32(out, binChunk.size());
++ appendUInt32(out, 0x004E4942); // 'BIN\0'
++ out += binChunk;
++ }
++ return out;
++ }
++
++ void tst_qssggltfparser::externalBuffer()
++ {
++ QSSGGltfParser parser;
++ QSSGGltfDocument document;
++ QVERIFY2(parser.parseFile(testFile(QStringLiteral("triangle.gltf")), &document),
++ qPrintable(parser.errorMessage()));
++
++ QCOMPARE(document.asset.version, QStringLiteral("2.0"));
++ QCOMPARE(document.buffers.size(), 1);
++ QCOMPARE(document.buffers.first().data.size(), 44);
++ QCOMPARE(document.bufferViews.size(), 2);
++ QCOMPARE(document.accessors.size(), 2);
++ QCOMPARE(document.accessors.at(0).type, QSSGGltf::Accessor::Type::Vec3);
++ QCOMPARE(document.accessors.at(0).componentType, QSSGGltf::Accessor::ComponentType::Float);
++ QCOMPARE(document.accessors.at(0).count, 3);
++ QCOMPARE(document.accessors.at(1).componentType, QSSGGltf::Accessor::ComponentType::UnsignedShort);
++ QCOMPARE(document.meshes.size(), 1);
++ QCOMPARE(document.meshes.first().primitives.size(), 1);
++ QCOMPARE(document.meshes.first().primitives.first().attributes.value("POSITION"), 0);
++ QCOMPARE(document.meshes.first().primitives.first().indices, 1);
++ QCOMPARE(document.nodes.size(), 1);
++ QCOMPARE(document.nodes.first().translation, QVector3D(1, 2, 3));
++ QCOMPARE(document.scene, 0);
++ QCOMPARE(document.scenes.first().nodes, QList<int>({ 0 }));
++
++ // Buffer contents: second vertex is (1, 0, 0)
++ const float *positions = reinterpret_cast<const float *>(document.buffers.first().data.constData());
++ QCOMPARE(positions[3], 1.0f);
++ }
++
++ void tst_qssggltfparser::embeddedBuffer()
++ {
++ QSSGGltfParser parser;
++ QSSGGltfDocument document;
++ QVERIFY2(parser.parseFile(testFile(QStringLiteral("triangle_embedded.gltf")), &document),
++ qPrintable(parser.errorMessage()));
++ QCOMPARE(document.buffers.first().data.size(), 44);
++
++ // The embedded buffer must decode to the same bytes as the external one
++ QFile binFile(testFile(QStringLiteral("triangle.bin")));
++ QVERIFY(binFile.open(QIODevice::ReadOnly));
++ QCOMPARE(document.buffers.first().data, binFile.readAll());
++ }
++
++ void tst_qssggltfparser::glbContainer()
++ {
++ QFile jsonFile(testFile(QStringLiteral("triangle_embedded.gltf")));
++ QVERIFY(jsonFile.open(QIODevice::ReadOnly));
++ const QByteArray glb = makeGlb(jsonFile.readAll());
++
++ QSSGGltfParser parser;
++ QSSGGltfDocument document;
++ QVERIFY2(parser.parse(glb, QString(), &document), qPrintable(parser.errorMessage()));
++ QCOMPARE(document.meshes.size(), 1);
++
++ // GLB with the buffer moved into the BIN chunk
++ QFile binFile(testFile(QStringLiteral("triangle.bin")));
++ QVERIFY(binFile.open(QIODevice::ReadOnly));
++ const QByteArray bin = binFile.readAll();
++ const QByteArray json = QByteArrayLiteral(
++ "{ \"asset\": { \"version\": \"2.0\" },"
++ " \"buffers\": [ { \"byteLength\": 44 } ],"
++ " \"bufferViews\": [ { \"buffer\": 0, \"byteOffset\": 0, \"byteLength\": 36 } ],"
++ " \"accessors\": [ { \"bufferView\": 0, \"componentType\": 5126, \"count\": 3, \"type\": \"VEC3\" } ] }");
++ QVERIFY2(parser.parse(makeGlb(json, bin), QString(), &document), qPrintable(parser.errorMessage()));
++ QCOMPARE(document.buffers.first().data, bin);
++ }
++
++ void tst_qssggltfparser::glbBadMagic()
++ {
++ QByteArray glb = makeGlb(QByteArrayLiteral("{ \"asset\": { \"version\": \"2.0\" } }"));
++ glb[0] = 'X';
++
++ // Not GLB magic, so it falls through to JSON parsing and fails there
++ QSSGGltfParser parser;
++ QSSGGltfDocument document;
++ QVERIFY(!parser.parse(glb, QString(), &document));
++ QVERIFY(parser.errorMessage().contains(QStringLiteral("Invalid glTF JSON")));
++ }
++
++ void tst_qssggltfparser::glbTruncated()
++ {
++ const QByteArray glb = makeGlb(QByteArrayLiteral("{ \"asset\": { \"version\": \"2.0\" } }"));
++
++ QSSGGltfParser parser;
++ QSSGGltfDocument document;
++ QVERIFY(!parser.parse(glb.left(glb.size() - 8), QString(), &document));
++ QVERIFY2(parser.errorMessage().contains(QStringLiteral("Truncated")), qPrintable(parser.errorMessage()));
++ }
++
++ void tst_qssggltfparser::glbUnsupportedVersion()
++ {
++ QByteArray glb = makeGlb(QByteArrayLiteral("{ \"asset\": { \"version\": \"2.0\" } }"));
++ glb[4] = 3; // container version
++
++ QSSGGltfParser parser;
++ QSSGGltfDocument document;
++ QVERIFY(!parser.parse(glb, QString(), &document));
++ QVERIFY(parser.errorMessage().contains(QStringLiteral("version")));
++ }
++
++ void tst_qssggltfparser::dataUriDecoding()
++ {
++ QCOMPARE(QSSGGltfResourceResolver::decodeDataUri(u"data:application/octet-stream;base64,SGVsbG8="),
++ QByteArrayLiteral("Hello"));
++ QCOMPARE(QSSGGltfResourceResolver::decodeDataUri(u"data:,Hello%20World"),
++ QByteArrayLiteral("Hello World"));
++
++ // A non-base64 payload carries arbitrary octets, not text. Decoding it via
++ // QString would turn every sequence that is not valid UTF-8 into U+FFFD,
++ // which changes the length as well as the contents, and a buffer whose
++ // length still passes its size check then loads silently corrupted.
++ QCOMPARE(QSSGGltfResourceResolver::decodeDataUri(u"data:,%FF%FE%00A"),
++ QByteArrayLiteral("\xff\xfe\x00" "A"));
++
++ QString error;
++ QVERIFY(QSSGGltfResourceResolver::decodeDataUri(u"data:application/octet-stream;base64", &error).isEmpty());
++ QVERIFY(!error.isEmpty());
++
++ QVERIFY(QSSGGltfResourceResolver::isDataUri(u"data:,x"));
++ QVERIFY(!QSSGGltfResourceResolver::isDataUri(u"buffer.bin"));
++ }
++
++ void tst_qssggltfparser::requiredExtensionGate()
++ {
++ QSSGGltfParser parser;
++ QSSGGltfDocument document;
++ QVERIFY(!parser.parseFile(testFile(QStringLiteral("draco_required.gltf")), &document));
++ QCOMPARE(parser.errorMessage(),
++ QStringLiteral("Asset requires unsupported glTF extension 'KHR_draco_mesh_compression'"));
++
++ // The same extension only listed in extensionsUsed parses with a warning
++ const QByteArray json = QByteArrayLiteral(
++ "{ \"asset\": { \"version\": \"2.0\" },"
++ " \"extensionsUsed\": [ \"KHR_texture_basisu\" ] }");
++ QVERIFY2(parser.parse(json, QString(), &document), qPrintable(parser.errorMessage()));
++ }
++
++ static bool specGlossOrUnlitHasBrdfExtensions(const QSSGGltf::Material &material)
++ {
++ return material.sheen.has_value() || material.anisotropy.has_value() || material.iridescence.has_value()
++ || material.dispersion.has_value();
++ }
++
++ void tst_qssggltfparser::materials()
++ {
++ QSSGGltfParser parser;
++ QSSGGltfDocument document;
++ QVERIFY2(parser.parseFile(testFile(QStringLiteral("material_extensions.gltf")), &document),
++ qPrintable(parser.errorMessage()));
++
++ QCOMPARE(document.materials.size(), 3);
++
++ const QSSGGltf::Material &full = document.materials.at(0);
++ QCOMPARE(full.name, QStringLiteral("full"));
++ QCOMPARE(full.baseColorFactor, QVector4D(0.8f, 0.6f, 0.4f, 1.0f));
++ QCOMPARE(full.metallicFactor, 0.7f);
++ QCOMPARE(full.roughnessFactor, 0.3f);
++ QCOMPARE(full.baseColorTexture.index, 0);
++ QVERIFY(full.baseColorTexture.transform.has_value());
++ QCOMPARE(full.baseColorTexture.transform->offset, QVector2D(0.25f, 0.5f));
++ QCOMPARE(full.baseColorTexture.transform->scale, QVector2D(2.0f, 3.0f));
++ QCOMPARE(full.normalTexture.scaleOrStrength, 0.9f);
++ QCOMPARE(full.occlusionTexture.scaleOrStrength, 0.8f);
++ QCOMPARE(full.emissiveFactor, QVector3D(0.1f, 0.2f, 0.3f));
++ QCOMPARE(full.alphaMode, QSSGGltf::Material::AlphaMode::Mask);
++ QCOMPARE(full.alphaCutoff, 0.25f);
++ QVERIFY(full.doubleSided);
++
++ QVERIFY(full.clearcoat.has_value());
++ QCOMPARE(full.clearcoat->clearcoatFactor, 0.6f);
++ QCOMPARE(full.clearcoat->clearcoatRoughnessFactor, 0.2f);
++ QVERIFY(full.transmission.has_value());
++ QCOMPARE(full.transmission->transmissionFactor, 0.75f);
++ QVERIFY(full.volume.has_value());
++ QCOMPARE(full.volume->thicknessFactor, 1.2f);
++ QCOMPARE(full.volume->attenuationDistance, 0.5f);
++ QCOMPARE(full.volume->attenuationColor, QVector3D(0.9f, 0.8f, 0.7f));
++ QVERIFY(full.ior.has_value());
++ QCOMPARE(*full.ior, 1.4f);
++ QVERIFY(full.emissiveStrength.has_value());
++ QCOMPARE(*full.emissiveStrength, 5.0f);
++ QVERIFY(full.specular.has_value());
++ QCOMPARE(full.specular->specularFactor, 0.5f);
++ QCOMPARE(full.specular->specularColorFactor, QVector3D(0.2f, 0.4f, 0.6f));
++
++ QVERIFY(full.sheen.has_value());
++ QCOMPARE(full.sheen->sheenColorFactor, QVector3D(0.3f, 0.5f, 0.7f));
++ QCOMPARE(full.sheen->sheenRoughnessFactor, 0.45f);
++ QCOMPARE(full.sheen->sheenColorTexture.index, 0);
++ QCOMPARE(full.sheen->sheenRoughnessTexture.index, 0);
++
++ QVERIFY(full.anisotropy.has_value());
++ QCOMPARE(full.anisotropy->anisotropyStrength, 0.65f);
++ // The document keeps the extension's radians; the converter is what turns
++ // it into the degrees the QML property uses
++ QCOMPARE(full.anisotropy->anisotropyRotation, qDegreesToRadians(90.0f));
++ QCOMPARE(full.anisotropy->anisotropyTexture.index, 0);
++
++ QVERIFY(full.iridescence.has_value());
++ QCOMPARE(full.iridescence->iridescenceFactor, 0.85f);
++ QCOMPARE(full.iridescence->iridescenceIor, 1.8f);
++ QCOMPARE(full.iridescence->iridescenceThicknessMinimum, 250.0f);
++ QCOMPARE(full.iridescence->iridescenceThicknessMaximum, 900.0f);
++ QCOMPARE(full.iridescence->iridescenceTexture.index, 0);
++ QCOMPARE(full.iridescence->iridescenceThicknessTexture.index, 0);
++
++ QVERIFY(full.dispersion.has_value());
++ QCOMPARE(*full.dispersion, 0.4f);
++
++ // A material without the extensions must leave every optional unset rather
++ // than defaulting the effects on
++ QVERIFY(!specGlossOrUnlitHasBrdfExtensions(document.materials.at(1)));
++ QVERIFY(!specGlossOrUnlitHasBrdfExtensions(document.materials.at(2)));
++
++ const QSSGGltf::Material &specGloss = document.materials.at(1);
++ QVERIFY(specGloss.specularGlossiness.has_value());
++ QCOMPARE(specGloss.specularGlossiness->diffuseFactor, QVector4D(0.5f, 0.5f, 0.5f, 1.0f));
++ QCOMPARE(specGloss.specularGlossiness->specularFactor, QVector3D(0.1f, 0.2f, 0.3f));
++ QCOMPARE(specGloss.specularGlossiness->glossinessFactor, 0.4f);
++
++ QVERIFY(document.materials.at(2).unlit);
++
++ // Sampler values
++ QCOMPARE(document.samplers.size(), 1);
++ QCOMPARE(document.samplers.first().magFilter, int(QSSGGltf::Sampler::Nearest));
++ QCOMPARE(document.samplers.first().minFilter, int(QSSGGltf::Sampler::LinearMipMapNearest));
++ QCOMPARE(document.samplers.first().wrapS, int(QSSGGltf::Sampler::ClampToEdge));
++ QCOMPARE(document.samplers.first().wrapT, int(QSSGGltf::Sampler::MirroredRepeat));
++ }
++
++ void tst_qssggltfparser::lightsAndCameras()
++ {
++ QSSGGltfParser parser;
++ QSSGGltfDocument document;
++ QVERIFY2(parser.parseFile(testFile(QStringLiteral("material_extensions.gltf")), &document),
++ qPrintable(parser.errorMessage()));
++
++ QCOMPARE(document.lights.size(), 3);
++ QCOMPARE(document.lights.at(0).type, QSSGGltf::Light::Type::Directional);
++ QCOMPARE(document.lights.at(0).color, QVector3D(1.0f, 0.9f, 0.8f));
++ QCOMPARE(document.lights.at(0).intensity, 3.0f);
++ QCOMPARE(document.lights.at(1).type, QSSGGltf::Light::Type::Point);
++ QCOMPARE(document.lights.at(1).range, 10.0f);
++ QCOMPARE(document.lights.at(2).type, QSSGGltf::Light::Type::Spot);
++ QCOMPARE(document.lights.at(2).innerConeAngle, 0.2f);
++ QCOMPARE(document.lights.at(2).outerConeAngle, 0.5f);
++
++ QCOMPARE(document.nodes.at(0).light, 2);
++ QCOMPARE(document.nodes.at(1).camera, 0);
++
++ QCOMPARE(document.cameras.size(), 2);
++ QCOMPARE(document.cameras.at(0).type, QSSGGltf::Camera::Type::Perspective);
++ QCOMPARE(document.cameras.at(0).yfov, 0.66f);
++ QCOMPARE(document.cameras.at(0).zfar, 1000.0f);
++ QCOMPARE(document.cameras.at(1).type, QSSGGltf::Camera::Type::Orthographic);
++ QCOMPARE(document.cameras.at(1).xmag, 2.0f);
++ QCOMPARE(document.cameras.at(1).ymag, 1.5f);
++ }
++
++ void tst_qssggltfparser::sparseAccessorStructure()
++ {
++ QSSGGltfParser parser;
++ QSSGGltfDocument document;
++ QVERIFY2(parser.parseFile(testFile(QStringLiteral("sparse.gltf")), &document),
++ qPrintable(parser.errorMessage()));
++
++ QCOMPARE(document.accessors.size(), 1);
++ const QSSGGltf::Accessor &accessor = document.accessors.first();
++ QVERIFY(accessor.sparse.has_value());
++ QCOMPARE(accessor.sparse->count, 2);
++ QCOMPARE(accessor.sparse->indicesBufferView, 1);
++ QCOMPARE(accessor.sparse->indicesComponentType, QSSGGltf::Accessor::ComponentType::UnsignedShort);
++ QCOMPARE(accessor.sparse->valuesBufferView, 2);
++ }
++
++ void tst_qssggltfparser::structuralValidation()
++ {
++ QSSGGltfParser parser;
++ QSSGGltfDocument document;
++
++ // Accessor past the end of its buffer view
++ QVERIFY(!parser.parse(QByteArrayLiteral(
++ "{ \"asset\": { \"version\": \"2.0\" },"
++ " \"buffers\": [ { \"uri\": \"data:,0123456789\", \"byteLength\": 10 } ],"
++ " \"bufferViews\": [ { \"buffer\": 0, \"byteLength\": 10 } ],"
++ " \"accessors\": [ { \"bufferView\": 0, \"componentType\": 5126, \"count\": 3, \"type\": \"VEC3\" } ] }"),
++ QString(), &document));
++ QVERIFY2(parser.errorMessage().contains(QStringLiteral("extends past")), qPrintable(parser.errorMessage()));
++
++ // Buffer view past the end of its buffer
++ QVERIFY(!parser.parse(QByteArrayLiteral(
++ "{ \"asset\": { \"version\": \"2.0\" },"
++ " \"buffers\": [ { \"uri\": \"data:,0123456789\", \"byteLength\": 10 } ],"
++ " \"bufferViews\": [ { \"buffer\": 0, \"byteOffset\": 8, \"byteLength\": 10 } ] }"),
++ QString(), &document));
++
++ // Node referencing an out-of-range mesh
++ QVERIFY(!parser.parse(QByteArrayLiteral(
++ "{ \"asset\": { \"version\": \"2.0\" }, \"nodes\": [ { \"mesh\": 7 } ] }"),
++ QString(), &document));
++
++ // Scene referencing an out-of-range node
++ QVERIFY(!parser.parse(QByteArrayLiteral(
++ "{ \"asset\": { \"version\": \"2.0\" }, \"scenes\": [ { \"nodes\": [3] } ], \"scene\": 0 }"),
++ QString(), &document));
++
++ // glTF 1.x is rejected
++ QVERIFY(!parser.parse(QByteArrayLiteral("{ \"asset\": { \"version\": \"1.0\" } }"), QString(), &document));
++ QVERIFY(parser.errorMessage().contains(QStringLiteral("version")));
++
++ // Missing asset object
++ QVERIFY(!parser.parse(QByteArrayLiteral("{ }"), QString(), &document));
++ }
++
++ // Inputs a well-meaning exporter would never produce, but a hostile file
++ // can: each of these used to be able to reach out-of-bounds reads or
++ // writes, oversized allocations, or infinite recursion in consumers.
++ void tst_qssggltfparser::hostileInputValidation()
++ {
++ QSSGGltfParser parser;
++ QSSGGltfDocument document;
++
++ // Negative buffer view byte offset (would address before the buffer)
++ QVERIFY(!parser.parse(QByteArrayLiteral(
++ "{ \"asset\": { \"version\": \"2.0\" },"
++ " \"buffers\": [ { \"uri\": \"data:,0123456789\", \"byteLength\": 10 } ],"
++ " \"bufferViews\": [ { \"buffer\": 0, \"byteOffset\": -1000000, \"byteLength\": 10 } ] }"),
++ QString(), &document));
++
++ // Buffer view byte stride beyond the specification maximum of 252
++ QVERIFY(!parser.parse(QByteArrayLiteral(
++ "{ \"asset\": { \"version\": \"2.0\" },"
++ " \"buffers\": [ { \"uri\": \"data:,0123456789\", \"byteLength\": 10 } ],"
++ " \"bufferViews\": [ { \"buffer\": 0, \"byteLength\": 10, \"byteStride\": 1000000000 } ] }"),
++ QString(), &document));
++
++ // Buffer view byte offset and length that are each positive but whose sum
++ // overflows to a negative value, which would otherwise read as fitting
++ // inside a ten-byte buffer
++ QVERIFY(!parser.parse(QByteArrayLiteral(
++ "{ \"asset\": { \"version\": \"2.0\" },"
++ " \"buffers\": [ { \"uri\": \"data:,0123456789\", \"byteLength\": 10 } ],"
++ " \"bufferViews\": [ { \"buffer\": 0, \"byteOffset\": 5000000000000000000,"
++ " \"byteLength\": 5000000000000000000 } ] }"),
++ QString(), &document));
++ QVERIFY2(parser.errorMessage().contains(QStringLiteral("extends past")), qPrintable(parser.errorMessage()));
++
++ // Negative accessor byte offset and negative count
++ QVERIFY(!parser.parse(QByteArrayLiteral(
++ "{ \"asset\": { \"version\": \"2.0\" },"
++ " \"buffers\": [ { \"uri\": \"data:,0123456789\", \"byteLength\": 10 } ],"
++ " \"bufferViews\": [ { \"buffer\": 0, \"byteLength\": 10 } ],"
++ " \"accessors\": [ { \"bufferView\": 0, \"byteOffset\": -8, \"componentType\": 5126,"
++ " \"count\": 1, \"type\": \"SCALAR\" } ] }"),
++ QString(), &document));
++ QVERIFY(!parser.parse(QByteArrayLiteral(
++ "{ \"asset\": { \"version\": \"2.0\" },"
++ " \"accessors\": [ { \"componentType\": 5126, \"count\": -5, \"type\": \"SCALAR\" } ] }"),
++ QString(), &document));
++
++ // Accessor without a buffer view reads as zeros, so a huge count is an
++ // allocation bomb rather than a data reference
++ QVERIFY(!parser.parse(QByteArrayLiteral(
++ "{ \"asset\": { \"version\": \"2.0\" },"
++ " \"accessors\": [ { \"componentType\": 5126, \"count\": 4000000000, \"type\": \"VEC4\" } ] }"),
++ QString(), &document));
++ QVERIFY2(parser.errorMessage().contains(QStringLiteral("unreasonably large")),
++ qPrintable(parser.errorMessage()));
++
++ // A count of 2^62 is exactly representable as a double, so it survives JSON
++ // parsing intact. Without an element cap, (count - 1) * stride + elementSize
++ // overflows to 0, the bounds check below it reads as satisfied, and a
++ // ten-line document yields an accessor claiming 2^62 elements.
++ QVERIFY(!parser.parse(QByteArrayLiteral(
++ "{ \"asset\": { \"version\": \"2.0\" },"
++ " \"buffers\": [ { \"uri\": \"data:,0123456789\", \"byteLength\": 10 } ],"
++ " \"bufferViews\": [ { \"buffer\": 0, \"byteLength\": 10 } ],"
++ " \"accessors\": [ { \"bufferView\": 0, \"componentType\": 5126,"
++ " \"count\": 4611686018427387904, \"type\": \"SCALAR\" } ] }"),
++ QString(), &document));
++ QVERIFY2(parser.errorMessage().contains(QStringLiteral("unreasonably large")),
++ qPrintable(parser.errorMessage()));
++
++ // The same cap has to apply to the sparse count, which is multiplied by a
++ // component size while validating the sparse buffer views
++ QVERIFY(!parser.parse(QByteArrayLiteral(
++ "{ \"asset\": { \"version\": \"2.0\" },"
++ " \"buffers\": [ { \"uri\": \"data:,0123456789\", \"byteLength\": 10 } ],"
++ " \"bufferViews\": [ { \"buffer\": 0, \"byteLength\": 10 } ],"
++ " \"accessors\": [ { \"bufferView\": 0, \"componentType\": 5126, \"count\": 1,"
++ " \"type\": \"SCALAR\","
++ " \"sparse\": { \"count\": 4611686018427387904,"
++ " \"indices\": { \"bufferView\": 0, \"componentType\": 5123 },"
++ " \"values\": { \"bufferView\": 0 } } } ] }"),
++ QString(), &document));
++
++ // A near-2^63 offset only overflows a span of 1025 bytes or more, and a span
++ // that large has to fit its buffer view, hence the 2048-byte buffer
++ const auto withLargeBuffer = [](const QByteArray &accessor) {
++ return QByteArrayLiteral("{ \"asset\": { \"version\": \"2.0\" },"
++ " \"buffers\": [ { \"uri\": \"data:,")
++ + QByteArray(2048, 'A')
++ + QByteArrayLiteral("\", \"byteLength\": 2048 } ],"
++ " \"bufferViews\": [ { \"buffer\": 0, \"byteLength\": 2048 } ],"
++ " \"accessors\": [ ")
++ + accessor + QByteArrayLiteral(" ] }");
++ };
++
++ // Accessor byte offset that overflows once its span is added
++ QVERIFY(!parser.parse(withLargeBuffer(QByteArrayLiteral(
++ "{ \"bufferView\": 0, \"byteOffset\": 9223372036854774784,"
++ " \"componentType\": 5126, \"count\": 300, \"type\": \"SCALAR\" }")),
++ QString(), &document));
++ QVERIFY2(parser.errorMessage().contains(QStringLiteral("extends past")),
++ qPrintable(parser.errorMessage()));
++
++ // The same overflow in each of the two sparse byte offsets
++ QVERIFY(!parser.parse(withLargeBuffer(QByteArrayLiteral(
++ "{ \"componentType\": 5123, \"count\": 600, \"type\": \"SCALAR\","
++ " \"sparse\": { \"count\": 600,"
++ " \"indices\": { \"bufferView\": 0, \"componentType\": 5123,"
++ " \"byteOffset\": 9223372036854774784 },"
++ " \"values\": { \"bufferView\": 0 } } }")),
++ QString(), &document));
++ QVERIFY2(parser.errorMessage().contains(QStringLiteral("sparse")), qPrintable(parser.errorMessage()));
++ QVERIFY(!parser.parse(withLargeBuffer(QByteArrayLiteral(
++ "{ \"componentType\": 5123, \"count\": 600, \"type\": \"SCALAR\","
++ " \"sparse\": { \"count\": 600,"
++ " \"indices\": { \"bufferView\": 0, \"componentType\": 5123 },"
++ " \"values\": { \"bufferView\": 0,"
++ " \"byteOffset\": 9223372036854774784 } } }")),
++ QString(), &document));
++ QVERIFY2(parser.errorMessage().contains(QStringLiteral("sparse")), qPrintable(parser.errorMessage()));
++
++ // A material naming a texture that does not exist
++ QVERIFY(!parser.parse(QByteArrayLiteral(
++ "{ \"asset\": { \"version\": \"2.0\" },"
++ " \"materials\": [ { \"pbrMetallicRoughness\":"
++ " { \"baseColorTexture\": { \"index\": 3 } } } ] }"),
++ QString(), &document));
++ QVERIFY2(parser.errorMessage().contains(QStringLiteral("invalid texture")),
++ qPrintable(parser.errorMessage()));
++
++ // A buffer URI is a relative reference; one that climbs out of the asset
++ // directory must not be resolved and loaded
++ QVERIFY(QSSGGltfResourceResolver::resolveFilePath(u"../../../etc/passwd",
++ QStringLiteral("/tmp/assets")).isEmpty());
++ QVERIFY(QSSGGltfResourceResolver::resolveFilePath(u"/etc/passwd",
++ QStringLiteral("/tmp/assets")).isEmpty());
++ QVERIFY(QSSGGltfResourceResolver::resolveFilePath(u":/secret.bin",
++ QStringLiteral("/tmp/assets")).isEmpty());
++ QCOMPARE(QSSGGltfResourceResolver::resolveFilePath(u"textures/wood.png",
++ QStringLiteral("/tmp/assets")),
++ QStringLiteral("/tmp/assets/textures/wood.png"));
++
++ // minVersion above what is implemented has to fail rather than load
++ QVERIFY(!parser.parse(QByteArrayLiteral(
++ "{ \"asset\": { \"version\": \"2.0\", \"minVersion\": \"2.1\" } }"),
++ QString(), &document));
++
++ // Sparse indices extending past their buffer view
++ QVERIFY(!parser.parse(QByteArrayLiteral(
++ "{ \"asset\": { \"version\": \"2.0\" },"
++ " \"buffers\": [ { \"uri\": \"data:,0123456789\", \"byteLength\": 10 } ],"
++ " \"bufferViews\": [ { \"buffer\": 0, \"byteLength\": 10 } ],"
++ " \"accessors\": [ { \"componentType\": 5126, \"count\": 1000, \"type\": \"SCALAR\","
++ " \"sparse\": { \"count\": 1000,"
++ " \"indices\": { \"bufferView\": 0, \"componentType\": 5125 },"
++ " \"values\": { \"bufferView\": 0 } } } ] }"),
++ QString(), &document));
++ QVERIFY2(parser.errorMessage().contains(QStringLiteral("sparse")), qPrintable(parser.errorMessage()));
++
++ // A node hierarchy cycle would recurse forever in consumers
++ QVERIFY(!parser.parse(QByteArrayLiteral(
++ "{ \"asset\": { \"version\": \"2.0\" },"
++ " \"nodes\": [ { \"children\": [1] }, { \"children\": [0] } ],"
++ " \"scenes\": [ { \"nodes\": [] } ], \"scene\": 0 }"),
++ QString(), &document));
++
++ // A node with two parents cannot be represented, and emitting its subtree
++ // twice is not an option either, so the second edge is dropped and the
++ // rest of the asset still loads. The first parent keeps the child.
++ QTest::ignoreMessage(QtWarningMsg, "Node 2 has more than one parent; ignoring the edge from node 1");
++ QVERIFY2(parser.parse(QByteArrayLiteral(
++ "{ \"asset\": { \"version\": \"2.0\" },"
++ " \"nodes\": [ { \"children\": [2] }, { \"children\": [2] }, { } ],"
++ " \"scenes\": [ { \"nodes\": [0, 1] } ], \"scene\": 0 }"),
++ QString(), &document), qPrintable(parser.errorMessage()));
++ QCOMPARE(document.nodes.at(0).children, QList<int>({ 2 }));
++ QCOMPARE(document.nodes.at(1).children, QList<int>());
++
++ // The same node listed twice as a child of one parent
++ QTest::ignoreMessage(QtWarningMsg, "Node 1 has more than one parent; ignoring the edge from node 0");
++ QVERIFY2(parser.parse(QByteArrayLiteral(
++ "{ \"asset\": { \"version\": \"2.0\" },"
++ " \"nodes\": [ { \"children\": [1, 1] }, { } ] }"),
++ QString(), &document), qPrintable(parser.errorMessage()));
++ QCOMPARE(document.nodes.at(0).children, QList<int>({ 1 }));
++
++ // Scene nodes must be unique root nodes, so a repeated entry, or one that
++ // is somebody's child, is dropped from the scene rather than failing it
++ QTest::ignoreMessage(QtWarningMsg, "Scene 0 references node 0 which is not a unique root node; ignoring it");
++ QVERIFY2(parser.parse(QByteArrayLiteral(
++ "{ \"asset\": { \"version\": \"2.0\" },"
++ " \"nodes\": [ { }, { } ],"
++ " \"scenes\": [ { \"nodes\": [0, 0] } ], \"scene\": 0 }"),
++ QString(), &document), qPrintable(parser.errorMessage()));
++ QCOMPARE(document.scenes.at(0).nodes, QList<int>({ 0 }));
++
++ QTest::ignoreMessage(QtWarningMsg, "Scene 0 references node 1 which is not a unique root node; ignoring it");
++ QVERIFY2(parser.parse(QByteArrayLiteral(
++ "{ \"asset\": { \"version\": \"2.0\" },"
++ " \"nodes\": [ { \"children\": [1] }, { } ],"
++ " \"scenes\": [ { \"nodes\": [0, 1] } ], \"scene\": 0 }"),
++ QString(), &document), qPrintable(parser.errorMessage()));
++ QCOMPARE(document.scenes.at(0).nodes, QList<int>({ 0 }));
++
++ // An absurdly deep hierarchy would exhaust the stack in recursive
++ // consumers; a moderately deep one must keep working
++ const auto chainOfNodes = [](int count) {
++ QByteArray json = QByteArrayLiteral("{ \"asset\": { \"version\": \"2.0\" }, \"nodes\": [");
++ for (int i = 0; i < count; ++i) {
++ if (i)
++ json += ',';
++ json += i + 1 < count ? QByteArray("{ \"children\": [" + QByteArray::number(i + 1) + "] }")
++ : QByteArray("{ }");
++ }
++ json += QByteArrayLiteral("], \"scenes\": [ { \"nodes\": [0] } ], \"scene\": 0 }");
++ return json;
++ };
++ QVERIFY2(parser.parse(chainOfNodes(512), QString(), &document), qPrintable(parser.errorMessage()));
++ QVERIFY(!parser.parse(chainOfNodes(100000), QString(), &document));
++ QVERIFY2(parser.errorMessage().contains(QStringLiteral("deeper")), qPrintable(parser.errorMessage()));
++ }
++
++ void tst_qssggltfparser::nodeVisibilityAndWebp()
++ {
++ QSSGGltfParser parser;
++ QSSGGltfDocument document;
++
++ // KHR_node_visibility: visible false parsed, absence defaults to true.
++ // KHR_xmp_json_ld: accepted even when required; metadata stays raw.
++ // EXT_texture_webp: alternate image source preferred over the fallback.
++ QVERIFY2(parser.parse(QByteArrayLiteral(
++ "{ \"asset\": { \"version\": \"2.0\" },"
++ " \"extensionsUsed\": [ \"KHR_node_visibility\", \"EXT_texture_webp\", \"KHR_xmp_json_ld\" ],"
++ " \"extensionsRequired\": [ \"KHR_node_visibility\", \"KHR_xmp_json_ld\" ],"
++ " \"nodes\": [ { \"extensions\": { \"KHR_node_visibility\": { \"visible\": false } } }, { } ],"
++ " \"images\": [ { \"uri\": \"fallback.png\" }, { \"uri\": \"image.webp\" } ],"
++ " \"textures\": [ { \"source\": 0, \"extensions\": { \"EXT_texture_webp\": { \"source\": 1 } } },"
++ " { \"source\": 0 } ] }"),
++ QString(), &document),
++ qPrintable(parser.errorMessage()));
++ QCOMPARE(document.nodes.size(), 2);
++ QCOMPARE(document.nodes.at(0).visible, false);
++ QCOMPARE(document.nodes.at(1).visible, true);
++ QCOMPARE(document.textures.at(0).webpSource, 1);
++ QCOMPARE(document.textures.at(0).source, 0);
++ QCOMPARE(document.textures.at(1).webpSource, -1);
++
++ // An out-of-range EXT_texture_webp source is structurally invalid
++ QVERIFY(!parser.parse(QByteArrayLiteral(
++ "{ \"asset\": { \"version\": \"2.0\" },"
++ " \"images\": [ { \"uri\": \"fallback.png\" } ],"
++ " \"textures\": [ { \"source\": 0, \"extensions\": { \"EXT_texture_webp\": { \"source\": 7 } } } ] }"),
++ QString(), &document));
++ QVERIFY2(parser.errorMessage().contains(QStringLiteral("invalid image")), qPrintable(parser.errorMessage()));
++ }
++
++ void tst_qssggltfparser::meshoptCompression()
++ {
++ QSSGGltfParser parser;
++ QSSGGltfDocument document;
++
++ // Payloads created with meshopt_encodeVertexBuffer/encodeIndexBuffer
++ // (and meshopt_encodeFilterExp with 15-bit shared-vector mode for the
++ // filtered variant) from 4 vec3 positions and 6 quad indices
++ const QByteArray json = QByteArrayLiteral(
++ "{ \"asset\": { \"version\": \"2.0\" },"
++ " \"extensionsUsed\": [ \"EXT_meshopt_compression\" ],"
++ " \"extensionsRequired\": [ \"EXT_meshopt_compression\" ],"
++ " \"buffers\": ["
++ " { \"uri\": \"data:application/octet-stream;base64,ofrq6gAAAP8AfwB+AAB/AAAAAH4AAAAAAAAAAAAAAAAA"
++ "AAAAAAAAAAABAQA=\", \"byteLength\": 44 },"
++ " { \"uri\": \"data:application/octet-stream;base64,4fAAAHaHVmd4qYZliWiYAWkAAA==\","
++ " \"byteLength\": 19 },"
++ " { \"byteLength\": 48, \"extensions\": { \"EXT_meshopt_compression\": { \"fallback\": true } } },"
++ " { \"uri\": \"data:application/octet-stream;base64,oe7u7gBAAD8AAgAAAABAAAACAAAAAAAgAAIAAAAAAAAA"
++ "AAAAAAAAAPIAAADyAAAA8gAAAA==\", \"byteLength\": 52 } ],"
++ " \"bufferViews\": ["
++ " { \"buffer\": 2, \"byteLength\": 48, \"byteStride\": 12, \"extensions\": {"
++ " \"EXT_meshopt_compression\": { \"buffer\": 0, \"byteLength\": 44, \"byteStride\": 12,"
++ " \"count\": 4, \"mode\": \"ATTRIBUTES\" } } },"
++ " { \"buffer\": 2, \"byteLength\": 24, \"extensions\": {"
++ " \"EXT_meshopt_compression\": { \"buffer\": 1, \"byteLength\": 19, \"byteStride\": 4,"
++ " \"count\": 6, \"mode\": \"TRIANGLES\" } } },"
++ " { \"buffer\": 2, \"byteLength\": 48, \"byteStride\": 12, \"extensions\": {"
++ " \"EXT_meshopt_compression\": { \"buffer\": 3, \"byteLength\": 52, \"byteStride\": 12,"
++ " \"count\": 4, \"mode\": \"ATTRIBUTES\","
++ " \"filter\": \"EXPONENTIAL\" } } } ],"
++ " \"accessors\": ["
++ " { \"bufferView\": 0, \"componentType\": 5126, \"count\": 4, \"type\": \"VEC3\" },"
++ " { \"bufferView\": 1, \"componentType\": 5125, \"count\": 6, \"type\": \"SCALAR\" },"
++ " { \"bufferView\": 2, \"componentType\": 5126, \"count\": 4, \"type\": \"VEC3\" } ] }");
++
++ QVERIFY2(parser.parse(json, QString(), &document), qPrintable(parser.errorMessage()));
++
++ const QList<float> expected = { 0.0f, 0.0f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f, 0.0f, 0.0f, 1.0f, 0.5f };
++ QCOMPARE(QSSGGltfAccessorReader::readAsFloats(document, 0), expected);
++ // The index codec preserves triangles and winding but may rotate the
++ // vertex order within a triangle: the source was 0,1,2 / 2,3,0
++ QCOMPARE(QSSGGltfAccessorReader::readIndices(document, 1), (QList<quint32> { 0, 1, 2, 0, 2, 3 }));
++ // 0.0, 1.0, and 0.5 survive the exponential filter exactly
++ QCOMPARE(QSSGGltfAccessorReader::readAsFloats(document, 2), expected);
++
++ // Corrupt compressed payload (truncated to 10 bytes)
++ QByteArray corrupt = json;
++ corrupt.replace("\"byteLength\": 44, \"byteStride\": 12", "\"byteLength\": 10, \"byteStride\": 12");
++ QVERIFY(!parser.parse(corrupt, QString(), &document));
++ QVERIFY2(parser.errorMessage().contains(QStringLiteral("corrupt")), qPrintable(parser.errorMessage()));
++
++ // A regular buffer view cannot read from an unloaded fallback buffer
++ QVERIFY(!parser.parse(QByteArrayLiteral(
++ "{ \"asset\": { \"version\": \"2.0\" },"
++ " \"buffers\": [ { \"byteLength\": 48,"
++ " \"extensions\": { \"EXT_meshopt_compression\": { \"fallback\": true } } } ],"
++ " \"bufferViews\": [ { \"buffer\": 0, \"byteLength\": 48 } ] }"),
++ QString(), &document));
++ QVERIFY2(parser.errorMessage().contains(QStringLiteral("fallback")), qPrintable(parser.errorMessage()));
++
++ // Hostile decoded sizes and invalid mode/stride combinations
++ QByteArray bomb = json;
++ bomb.replace("\"count\": 4, \"mode\": \"ATTRIBUTES\" }", "\"count\": 400000000, \"mode\": \"ATTRIBUTES\" }");
++ QVERIFY(!parser.parse(bomb, QString(), &document));
++
++ // A count of 2^62 is exactly representable as a double, so it survives JSON
++ // parsing; multiplied by a valid stride of 12 the qint64 product wraps, and
++ // an unbounded check would read as satisfied and leave the decoder writing
++ // into a destination allocated from the wrapped size.
++ QByteArray wrap = json;
++ wrap.replace("\"count\": 4, \"mode\": \"ATTRIBUTES\" }",
++ "\"count\": 4611686018427387904, \"mode\": \"ATTRIBUTES\" }");
++ QVERIFY(!parser.parse(wrap, QString(), &document));
++ QVERIFY2(parser.errorMessage().contains(QStringLiteral("unreasonable")), qPrintable(parser.errorMessage()));
++
++ // Compressed byte offset and length that are each positive but whose sum
++ // wraps negative, which would otherwise read as inside the source buffer
++ QByteArray offsetWrap = json;
++ offsetWrap.replace("\"buffer\": 0, \"byteLength\": 44",
++ "\"buffer\": 0, \"byteOffset\": 5000000000000000000, \"byteLength\": 5000000000000000000");
++ QVERIFY(!parser.parse(offsetWrap, QString(), &document));
++ QVERIFY2(parser.errorMessage().contains(QStringLiteral("outside buffer")), qPrintable(parser.errorMessage()));
++
++ // Without a valid stride the size check must still reject a count that
++ // cannot be converted to a 64-bit integer
++ QByteArray hugeCountNoStride = json;
++ hugeCountNoStride.replace("\"byteLength\": 44, \"byteStride\": 12,", "\"byteLength\": 44,");
++ hugeCountNoStride.replace("\"count\": 4, \"mode\": \"ATTRIBUTES\" }", "\"count\": 1e300, \"mode\": \"ATTRIBUTES\" }");
++ QVERIFY(!parser.parse(hugeCountNoStride, QString(), &document));
++ QVERIFY2(parser.errorMessage().contains(QStringLiteral("unreasonable")), qPrintable(parser.errorMessage()));
++
++ // A byte offset too large for a 64-bit integer must be rejected rather
++ // than converted, which would be undefined
++ QByteArray hugeOffset = json;
++ hugeOffset.replace("\"buffer\": 0, \"byteLength\": 44", "\"buffer\": 0, \"byteOffset\": 1e30, \"byteLength\": 44");
++ QVERIFY(!parser.parse(hugeOffset, QString(), &document));
++ QVERIFY2(parser.errorMessage().contains(QStringLiteral("outside buffer")), qPrintable(parser.errorMessage()));
++
++ // Views decoding the same compressed range share one decoded buffer, so
++ // aliasing cannot multiply the memory a small file demands
++ QByteArray aliased = json;
++ aliased.replace("\"filter\": \"EXPONENTIAL\" } } } ],",
++ "\"filter\": \"EXPONENTIAL\" } } },"
++ " { \"buffer\": 2, \"byteLength\": 48, \"byteStride\": 12, \"extensions\": {"
++ " \"EXT_meshopt_compression\": { \"buffer\": 0, \"byteLength\": 44, \"byteStride\": 12,"
++ " \"count\": 4, \"mode\": \"ATTRIBUTES\" } } } ],");
++ aliased.replace("\"count\": 4, \"type\": \"VEC3\" } ] }",
++ "\"count\": 4, \"type\": \"VEC3\" },"
++ " { \"bufferView\": 3, \"componentType\": 5126, \"count\": 4, \"type\": \"VEC3\" } ] }");
++ QVERIFY2(parser.parse(aliased, QString(), &document), qPrintable(parser.errorMessage()));
++ QCOMPARE(document.bufferViews.size(), 4);
++ QCOMPARE(document.bufferViews.at(3).buffer, document.bufferViews.at(0).buffer);
++ QCOMPARE(document.buffers.size(), 4 + 3);
++ QCOMPARE(QSSGGltfAccessorReader::readAsFloats(document, 3), expected);
++
++ QByteArray badMode = json;
++ badMode.replace("\"mode\": \"TRIANGLES\"", "\"mode\": \"LINES\"");
++ QVERIFY(!parser.parse(badMode, QString(), &document));
++ QByteArray badStride = json;
++ badStride.replace("\"byteLength\": 19, \"byteStride\": 4", "\"byteLength\": 19, \"byteStride\": 8");
++ QVERIFY(!parser.parse(badStride, QString(), &document));
++
++ // In a GLB the compressed data goes in the BIN chunk, and the extension
++ // requires the fallback placeholder to sit at index 1 or above "to avoid
++ // conflicts with GLB binary buffer". Check that layout end to end, since
++ // only the first buffer may claim the BIN chunk.
++ const QByteArray compressed = QByteArray::fromBase64(
++ "ofrq6gAAAP8AfwB+AAB/AAAAAH4AAAAAAAAAAAAAAAAAAAAAAAAAAAABAQA=");
++ QCOMPARE(compressed.size(), 44);
++ const QByteArray glbJson = QByteArrayLiteral(
++ "{ \"asset\": { \"version\": \"2.0\" },"
++ " \"extensionsUsed\": [ \"EXT_meshopt_compression\" ],"
++ " \"extensionsRequired\": [ \"EXT_meshopt_compression\" ],"
++ " \"buffers\": ["
++ " { \"byteLength\": 44 },"
++ " { \"byteLength\": 48, \"extensions\": { \"EXT_meshopt_compression\": { \"fallback\": true } } } ],"
++ " \"bufferViews\": ["
++ " { \"buffer\": 1, \"byteLength\": 48, \"byteStride\": 12, \"extensions\": {"
++ " \"EXT_meshopt_compression\": { \"buffer\": 0, \"byteLength\": 44, \"byteStride\": 12,"
++ " \"count\": 4, \"mode\": \"ATTRIBUTES\" } } } ],"
++ " \"accessors\": ["
++ " { \"bufferView\": 0, \"componentType\": 5126, \"count\": 4, \"type\": \"VEC3\" } ] }");
++ QVERIFY2(parser.parse(makeGlb(glbJson, compressed), QString(), &document),
++ qPrintable(parser.errorMessage()));
++ QCOMPARE(QSSGGltfAccessorReader::readAsFloats(document, 0), expected);
++
++ // The reverse layout puts the uri-less fallback first, which leaves the
++ // compressed buffer wanting the BIN chunk from index 1. That is what the
++ // extension's index requirement exists to prevent, so it is rejected with a
++ // clear message rather than silently mis-read.
++ QByteArray glbSwapped = glbJson;
++ glbSwapped.replace("{ \"byteLength\": 44 },"
++ " { \"byteLength\": 48, \"extensions\": { \"EXT_meshopt_compression\": "
++ "{ \"fallback\": true } } } ]",
++ "{ \"byteLength\": 48, \"extensions\": { \"EXT_meshopt_compression\": "
++ "{ \"fallback\": true } } },"
++ " { \"byteLength\": 44 } ]");
++ glbSwapped.replace("\"buffer\": 1, \"byteLength\": 48, \"byteStride\": 12",
++ "\"buffer\": 0, \"byteLength\": 48, \"byteStride\": 12");
++ glbSwapped.replace("\"buffer\": 0, \"byteLength\": 44", "\"buffer\": 1, \"byteLength\": 44");
++ QVERIFY(!parser.parse(makeGlb(glbSwapped, compressed), QString(), &document));
++ QVERIFY2(parser.errorMessage().contains(QStringLiteral("first buffer")), qPrintable(parser.errorMessage()));
++ }
++
++ void tst_qssggltfparser::meshGpuInstancing()
++ {
++ QSSGGltfParser parser;
++ QSSGGltfDocument document;
++
++ QVERIFY2(parser.parse(QByteArrayLiteral(
++ "{ \"asset\": { \"version\": \"2.0\" },"
++ " \"extensionsUsed\": [ \"EXT_mesh_gpu_instancing\" ],"
++ " \"accessors\": ["
++ " { \"componentType\": 5126, \"count\": 3, \"type\": \"VEC3\" },"
++ " { \"componentType\": 5126, \"count\": 3, \"type\": \"VEC4\" } ],"
++ " \"nodes\": [ { \"extensions\": { \"EXT_mesh_gpu_instancing\": {"
++ " \"attributes\": { \"TRANSLATION\": 0, \"ROTATION\": 1, \"SCALE\": 0 } } } },"
++ " { } ] }"),
++ QString(), &document),
++ qPrintable(parser.errorMessage()));
++ QCOMPARE(document.nodes.at(0).hasInstancing, true);
++ QCOMPARE(document.nodes.at(0).instanceTranslation, 0);
++ QCOMPARE(document.nodes.at(0).instanceRotation, 1);
++ QCOMPARE(document.nodes.at(0).instanceScale, 0);
++ QCOMPARE(document.nodes.at(1).hasInstancing, false);
++
++ // Instance attributes referencing an invalid accessor are structural
++ QVERIFY(!parser.parse(QByteArrayLiteral(
++ "{ \"asset\": { \"version\": \"2.0\" },"
++ " \"nodes\": [ { \"extensions\": { \"EXT_mesh_gpu_instancing\": {"
++ " \"attributes\": { \"TRANSLATION\": 9 } } } } ] }"),
++ QString(), &document));
++ QVERIFY2(parser.errorMessage().contains(QStringLiteral("instance attribute")),
++ qPrintable(parser.errorMessage()));
++ }
++
++ void tst_qssggltfparser::materialVariants()
++ {
++ QSSGGltfParser parser;
++ QSSGGltfDocument document;
++
++ QVERIFY2(parser.parse(QByteArrayLiteral(
++ "{ \"asset\": { \"version\": \"2.0\" },"
++ " \"extensionsUsed\": [ \"KHR_materials_variants\" ],"
++ " \"extensions\": { \"KHR_materials_variants\": {"
++ " \"variants\": [ { \"name\": \"Red\" }, { \"name\": \"Blue\" } ] } },"
++ " \"materials\": [ { \"name\": \"default\" }, { \"name\": \"red\" }, { \"name\": \"blue\" } ],"
++ " \"accessors\": [ { \"componentType\": 5126, \"count\": 3, \"type\": \"VEC3\" } ],"
++ " \"meshes\": [ { \"primitives\": [ {"
++ " \"attributes\": { \"POSITION\": 0 }, \"material\": 0,"
++ " \"extensions\": { \"KHR_materials_variants\": { \"mappings\": ["
++ " { \"material\": 1, \"variants\": [0] },"
++ " { \"material\": 2, \"variants\": [1] } ] } } } ] } ] }"),
++ QString(), &document),
++ qPrintable(parser.errorMessage()));
++ QCOMPARE(document.materialVariants, (QStringList { QStringLiteral("Red"), QStringLiteral("Blue") }));
++ const QSSGGltf::MeshPrimitive &primitive = document.meshes.at(0).primitives.at(0);
++ QCOMPARE(primitive.variantMappings.size(), 2);
++ QCOMPARE(primitive.effectiveMaterial(-1), 0); // default
++ QCOMPARE(primitive.effectiveMaterial(0), 1); // Red
++ QCOMPARE(primitive.effectiveMaterial(1), 2); // Blue
++ QCOMPARE(primitive.effectiveMaterial(5), 0); // unmapped variant -> default
++
++ // Mappings referencing invalid materials or variants are structural
++ QVERIFY(!parser.parse(QByteArrayLiteral(
++ "{ \"asset\": { \"version\": \"2.0\" },"
++ " \"materials\": [ { } ],"
++ " \"accessors\": [ { \"componentType\": 5126, \"count\": 3, \"type\": \"VEC3\" } ],"
++ " \"meshes\": [ { \"primitives\": [ {"
++ " \"attributes\": { \"POSITION\": 0 },"
++ " \"extensions\": { \"KHR_materials_variants\": { \"mappings\": ["
++ " { \"material\": 7, \"variants\": [0] } ] } } } ] } ] }"),
++ QString(), &document));
++ QVERIFY(!parser.parse(QByteArrayLiteral(
++ "{ \"asset\": { \"version\": \"2.0\" },"
++ " \"materials\": [ { } ],"
++ " \"accessors\": [ { \"componentType\": 5126, \"count\": 3, \"type\": \"VEC3\" } ],"
++ " \"meshes\": [ { \"primitives\": [ {"
++ " \"attributes\": { \"POSITION\": 0 },"
++ " \"extensions\": { \"KHR_materials_variants\": { \"mappings\": ["
++ " { \"material\": 0, \"variants\": [3] } ] } } } ] } ] }"),
++ QString(), &document));
++ }
++
++ void tst_qssggltfparser::readAccessorData()
++ {
++ QSSGGltfParser parser;
++ QSSGGltfDocument document;
++ QVERIFY2(parser.parseFile(testFile(QStringLiteral("triangle.gltf")), &document),
++ qPrintable(parser.errorMessage()));
++
++ const QList<float> positions = QSSGGltfAccessorReader::readAsFloats(document, 0);
++ QCOMPARE(positions.size(), 9);
++ QCOMPARE(positions.at(3), 1.0f); // second vertex x
++ QCOMPARE(positions.at(7), 1.0f); // third vertex y
++
++ const QList<quint32> indices = QSSGGltfAccessorReader::readIndices(document, 1);
++ QCOMPARE(indices, QList<quint32>({ 0, 1, 2 }));
++
++ // Out-of-range accessor indices are safe
++ QVERIFY(QSSGGltfAccessorReader::readAsFloats(document, -1).isEmpty());
++ QVERIFY(QSSGGltfAccessorReader::readAsFloats(document, 7).isEmpty());
++ }
++
++ void tst_qssggltfparser::readSparseAccessor()
++ {
++ QSSGGltfParser parser;
++ QSSGGltfDocument document;
++ QVERIFY2(parser.parseFile(testFile(QStringLiteral("sparse.gltf")), &document),
++ qPrintable(parser.errorMessage()));
++
++ // The base data is all zeros; sparse substitution replaces elements 2 and 4
++ const QList<float> positions = QSSGGltfAccessorReader::readAsFloats(document, 0);
++ QCOMPARE(positions.size(), 18);
++ QCOMPARE(positions.mid(0, 3), QList<float>({ 0, 0, 0 }));
++ QCOMPARE(positions.mid(6, 3), QList<float>({ 1, 2, 3 }));
++ QCOMPARE(positions.mid(9, 3), QList<float>({ 0, 0, 0 }));
++ QCOMPARE(positions.mid(12, 3), QList<float>({ 4, 5, 6 }));
++ }
++
++ void tst_qssggltfparser::readInterleavedAccessor()
++ {
++ // Two vec3 attributes interleaved in one buffer view with byteStride 24:
++ // element i of the first attribute at offset i*24, of the second at i*24+12
++ QByteArray bin;
++ const float data[] = { 1, 2, 3, 10, 20, 30, 4, 5, 6, 40, 50, 60 };
++ bin.append(reinterpret_cast<const char *>(data), sizeof(data));
++
++ const QByteArray json = QByteArrayLiteral(
++ "{ \"asset\": { \"version\": \"2.0\" },"
++ " \"buffers\": [ { \"byteLength\": 48 } ],"
++ " \"bufferViews\": [ { \"buffer\": 0, \"byteLength\": 48, \"byteStride\": 24 } ],"
++ " \"accessors\": ["
++ " { \"bufferView\": 0, \"byteOffset\": 0, \"componentType\": 5126, \"count\": 2, \"type\": \"VEC3\" },"
++ " { \"bufferView\": 0, \"byteOffset\": 12, \"componentType\": 5126, \"count\": 2, \"type\": \"VEC3\" } ] }");
++
++ QSSGGltfParser parser;
++ QSSGGltfDocument document;
++ QVERIFY2(parser.parse(makeGlb(json, bin), QString(), &document), qPrintable(parser.errorMessage()));
++
++ QCOMPARE(QSSGGltfAccessorReader::readAsFloats(document, 0), QList<float>({ 1, 2, 3, 4, 5, 6 }));
++ QCOMPARE(QSSGGltfAccessorReader::readAsFloats(document, 1), QList<float>({ 10, 20, 30, 40, 50, 60 }));
++ }
++
++ void tst_qssggltfparser::readNormalizedAccessor()
++ {
++ // Normalized u8 and s16 component data (as KHR_mesh_quantization produces)
++ QByteArray bin;
++ const quint8 u8data[] = { 0, 51, 255, 128 };
++ bin.append(reinterpret_cast<const char *>(u8data), sizeof(u8data));
++ const qint16 s16data[] = { -32768, -16384, 16384, 32767 };
++ bin.append(reinterpret_cast<const char *>(s16data), sizeof(s16data));
++
++ const QByteArray json = QByteArrayLiteral(
++ "{ \"asset\": { \"version\": \"2.0\" },"
++ " \"buffers\": [ { \"byteLength\": 12 } ],"
++ " \"bufferViews\": ["
++ " { \"buffer\": 0, \"byteOffset\": 0, \"byteLength\": 4 },"
++ " { \"buffer\": 0, \"byteOffset\": 4, \"byteLength\": 8 } ],"
++ " \"accessors\": ["
++ " { \"bufferView\": 0, \"componentType\": 5121, \"count\": 1, \"type\": \"VEC4\", \"normalized\": true },"
++ " { \"bufferView\": 1, \"componentType\": 5122, \"count\": 1, \"type\": \"VEC4\", \"normalized\": true },"
++ " { \"bufferView\": 0, \"componentType\": 5121, \"count\": 1, \"type\": \"VEC4\" } ] }");
++
++ QSSGGltfParser parser;
++ QSSGGltfDocument document;
++ QVERIFY2(parser.parse(makeGlb(json, bin), QString(), &document), qPrintable(parser.errorMessage()));
++
++ const QList<float> u8normalized = QSSGGltfAccessorReader::readAsFloats(document, 0);
++ QCOMPARE(u8normalized.at(0), 0.0f);
++ QCOMPARE(u8normalized.at(1), 51.0f / 255.0f);
++ QCOMPARE(u8normalized.at(2), 1.0f);
++
++ const QList<float> s16normalized = QSSGGltfAccessorReader::readAsFloats(document, 1);
++ QCOMPARE(s16normalized.at(0), -1.0f); // clamped per spec: max(c/32767, -1)
++ QCOMPARE(s16normalized.at(3), 1.0f);
++
++ // Non-normalized integers are cast, not scaled
++ const QList<float> u8plain = QSSGGltfAccessorReader::readAsFloats(document, 2);
++ QCOMPARE(u8plain.at(1), 51.0f);
++ QCOMPARE(u8plain.at(2), 255.0f);
++ }
++
++ QTEST_APPLESS_MAIN(tst_qssggltfparser)
++ #include "tst_qssggltfparser.moc"