Revision control
Copy as Markdown
// Copyright (C) 2019 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only
#include <QtTest>
#include <QDebug>
#include <QtQuick3DAssetImport/private/qssgassetimportmanager_p.h>
#include <QtQuick3DAssetUtils/private/qssgscenedesc_p.h>
#include <QtQuick3DUtils/private/qssgmesh_p.h>
#include <QDir>
#include <QByteArray>
#include <QJsonObject>
#include <QTemporaryDir>
#include <QUrl>
#include <QImageReader>
#include <QRegularExpression>
// add necessary includes here
class tst_assetimport : public QObject
{
Q_OBJECT
public:
tst_assetimport();
~tst_assetimport();
private slots:
void initTestCase();
void cleanupTestCase();
void importFile_data();
void importFile();
void meshLevelsOfDetailForUnweldedMesh_data();
void meshLevelsOfDetailForUnweldedMesh();
void idsDoNotLeakBetweenAssets();
void importUrl_data();
void importUrl();
void nativeGltfRouting();
void gltfNodeVisibilityAndWebp();
void gltfMaterialVariant_data();
void gltfMaterialVariant();
void generatedVariantBinding();
void materialExtensionMapping();
};
tst_assetimport::tst_assetimport()
{
}
tst_assetimport::~tst_assetimport()
{
}
void tst_assetimport::initTestCase()
{
}
void tst_assetimport::cleanupTestCase()
{
}
void tst_assetimport::importFile_data()
{
QTest::addColumn<QString>("extension");
QTest::addColumn<bool>("result");
QTest::newRow("fbx") << QString("fbx") << true;
QTest::newRow("dae") << QString("dae") << true;
QTest::newRow("obj") << QString("obj") << true;
QTest::newRow("gltf") << QString("gltf") << true;
QTest::newRow("glb") << QString("glb") << true;
QTest::newRow("stl") << QString("stl") << true;
QTest::newRow("ply") << QString("ply") << true;
}
void tst_assetimport::importFile()
{
QFETCH(QString, extension);
QFETCH(bool, result);
QSSGAssetImportManager importManager;
QString file = "resources/cube_scene." + extension;
QString error;
// Should return "true" if there were no errors opening the source or creating the exported object.
auto importState = importManager.importFile(QFINDTESTDATA(file), QDir("./"), &error);
const bool realResult = (importState == QSSGAssetImportManager::ImportState::Success);
if(!error.isEmpty()) {
if (importState == QSSGAssetImportManager::ImportState::Unsupported) {
QEXPECT_FAIL("", "Unsupported format!", Continue);
} else {
qDebug() << "Error message:" << error;
QFAIL(error.toStdString().c_str());
}
}
QCOMPARE(realResult, result);
}
// Simplification can only collapse an edge when the faces around it share its
// vertices, so a mesh stored with one vertex per triangle corner has no
// collapsible edge and used to come out of --generateMeshLevelsOfDetail with no
// levels at all. Both assets here are such a mesh - a displaced 8x8 grid whose
// 81 grid points are stored as 384 corner vertices, each carrying its own
// texture coordinate so that nothing upstream welds them first - and they differ
// only in their normals, which is what decides whether welding by position alone
// is safe.
void tst_assetimport::meshLevelsOfDetailForUnweldedMesh_data()
{
QTest::addColumn<QString>("asset");
QTest::addColumn<bool>("recalculateLodNormals");
QTest::addColumn<bool>("expectLevels");
const QString smooth = QStringLiteral("lod_unwelded_smooth.gltf");
const QString faceted = QStringLiteral("lod_unwelded_faceted.gltf");
// Welding by position recovers the real topology, so levels are produced
// where there were none before
QTest::newRow("smooth, normals recalculated") << smooth << true << true;
// Every vertex at a given position carries the same normal here, so welding
// them together cannot change the shading, whether or not the level's
// normals are recalculated afterwards
QTest::newRow("smooth, normals preserved") << smooth << false << true;
// Faceted normals are safe to weld across as long as the level recalculates
// them, which is what the default asks for
QTest::newRow("faceted, normals recalculated") << faceted << true << true;
// ...but with the stored normals preserved, welding a faceted position would
// leave every face around it reading one arbitrary face's normal. Those
// vertices have to stay pinned instead, even though it means no levels.
QTest::newRow("faceted, normals preserved") << faceted << false << false;
}
void tst_assetimport::meshLevelsOfDetailForUnweldedMesh()
{
QFETCH(QString, asset);
QFETCH(bool, recalculateLodNormals);
QFETCH(bool, expectLevels);
const QString file = QFINDTESTDATA(QStringLiteral("resources/") + asset);
QVERIFY(!file.isEmpty());
const auto option = [](QJsonValue value) {
QJsonObject option;
option[QStringLiteral("value")] = value;
return option;
};
QJsonObject options;
options[QStringLiteral("generateMeshLevelsOfDetail")] = option(true);
options[QStringLiteral("recalculateLodNormals")] = option(recalculateLodNormals);
// Spelled out rather than left to the plugin, since an importer that finds
// no angle in the options is free to fall back to 0.0, which would disable
// the recalculation the row above just asked for
options[QStringLiteral("recalculateLodNormalsMergeAngle")] = option(60.0);
options[QStringLiteral("recalculateLodNormalsSplitAngle")] = option(25.0);
QTemporaryDir outDir;
QVERIFY(outDir.isValid());
const QDir out(outDir.path());
QSSGAssetImportManager manager;
QString error;
if (manager.importFile(file, out, options, &error) != QSSGAssetImportManager::ImportState::Success)
QSKIP(qPrintable(QStringLiteral("glTF asset could not be converted: ") + error));
// Each asset holds a single mesh, so the one file written is the one to
// look at, whatever the generator decided to call it
const QDir meshDir(out.filePath(QStringLiteral("meshes")));
const QStringList written = meshDir.entryList({ QStringLiteral("*.mesh") }, QDir::Files);
QCOMPARE(written.size(), 1);
QFile meshFile(meshDir.filePath(written.first()));
QVERIFY(meshFile.open(QIODevice::ReadOnly));
const QSSGMesh::Mesh loaded = QSSGMesh::Mesh::loadMesh(&meshFile);
QVERIFY(loaded.isValid());
const QVector<QSSGMesh::Mesh::Subset> subsets = loaded.subsets();
QCOMPARE(subsets.size(), 1);
const QVector<QSSGMesh::Mesh::Lod> lods = subsets.first().lods;
if (!expectLevels) {
QCOMPARE(lods.size(), 0);
return;
}
QVERIFY2(!lods.isEmpty(), "welding by position should have made the mesh simplifiable");
// Subset lods run from the highest level of detail to the lowest, and every
// one of them has to be a whole number of triangles and coarser than the
// full resolution mesh the subset itself covers
const quint32 indexCount = subsets.first().count;
quint32 previous = indexCount;
for (const QSSGMesh::Mesh::Lod &lod : lods) {
QCOMPARE(lod.count % 3, 0u);
QVERIFY2(lod.count > 0 && lod.count < previous,
qPrintable(QStringLiteral("level %1 following %2, full resolution %3")
.arg(lod.count).arg(previous).arg(indexCount)));
previous = lod.count;
}
}
// balsam converts every positional argument in one process, and the QML id
// allocator is a process-wide static keyed by node pointer. Each scene frees
// its nodes before the next is built, so the next scene's nodes land on the
// same addresses and used to inherit the previous asset's ids - the second
// component's root could come out named after a material from the first.
void tst_assetimport::idsDoNotLeakBetweenAssets()
{
const QString file = QFINDTESTDATA(QStringLiteral("resources/cube_scene.gltf"));
QVERIFY(!file.isEmpty());
const auto convertAndRead = [&file](const QDir &outdir) {
QSSGAssetImportManager manager;
QString error;
if (manager.importFile(file, outdir, &error) != QSSGAssetImportManager::ImportState::Success)
return QString();
const QStringList generated = outdir.entryList({ QStringLiteral("*.qml") }, QDir::Files);
if (generated.size() != 1)
return QString();
QFile qml(outdir.filePath(generated.first()));
if (!qml.open(QIODevice::ReadOnly))
return QString();
return QString::fromUtf8(qml.readAll());
};
QTemporaryDir firstDir;
QVERIFY(firstDir.isValid());
QTemporaryDir secondDir;
QVERIFY(secondDir.isValid());
const QString first = convertAndRead(QDir(firstDir.path()));
if (first.isEmpty())
QSKIP("Asset could not be converted");
const QString second = convertAndRead(QDir(secondDir.path()));
QVERIFY(!second.isEmpty());
// The same input converted twice has to produce the same component
QCOMPARE(second, first);
QVERIFY2(first.contains(QStringLiteral("id: root")), qPrintable(first));
}
// The runtime overload picks the importer from the URL rather than from a
// filename, so it has its own path from URL to extension to importer.
void tst_assetimport::importUrl_data()
{
QTest::addColumn<QUrl>("url");
QTest::addColumn<QSSGAssetImportManager::ImportState>("result");
QTest::newRow("local file") << QUrl::fromLocalFile(QFINDTESTDATA("resources/cube_scene.gltf"))
<< QSSGAssetImportManager::ImportState::Success;
QTest::newRow("qrc") << QUrl("qrc:/resources/cube_scene.glb") << QSSGAssetImportManager::ImportState::Success;
QTest::newRow("unsupported extension") << QUrl::fromLocalFile(QFINDTESTDATA("resources/cube_scene.mtl"))
<< QSSGAssetImportManager::ImportState::Unsupported;
// A remote URL cannot be loaded from here, so IoError is the expected
// outcome. What matters is that it is not Unsupported: the extension has to
// be read from the path, because the suffix of the whole serialized URL
// would be "abc" and no importer would ever be found.
<< QSSGAssetImportManager::ImportState::IoError;
}
void tst_assetimport::importUrl()
{
QFETCH(QUrl, url);
QFETCH(QSSGAssetImportManager::ImportState, result);
QSSGAssetImportManager importManager;
QSSGSceneDesc::Scene scene;
QString error;
const auto importState = importManager.importFile(url, scene, &error);
if (importState != result)
qDebug() << "Error message:" << error;
QCOMPARE(importState, result);
scene.cleanup();
}
// The gltf and glb extensions route to the native glTF importer by default,
// and back to the Assimp importer when QT_QUICK3D_DISABLE_NATIVE_GLTF is
// set. Both paths must import successfully.
void tst_assetimport::nativeGltfRouting()
{
const QString file = QFINDTESTDATA(QStringLiteral("resources/cube_scene.gltf"));
QVERIFY(!file.isEmpty());
{
QSSGAssetImportManager manager;
// The native importer plugin must be present and claim gltf/glb
bool nativePresent = false;
const auto importers = manager.getImporterPluginInfos();
for (const auto &importer : importers) {
if (importer.name == QStringLiteral("gltf")) {
nativePresent = true;
QCOMPARE(importer.inputExtensions,
QStringList({ QStringLiteral("gltf"), QStringLiteral("glb") }));
}
}
if (!nativePresent)
QSKIP("Native glTF importer plugin not available");
QString error;
const auto state = manager.importFile(file, QDir(QStringLiteral("./")), &error);
QVERIFY2(state == QSSGAssetImportManager::ImportState::Success, qPrintable(error));
}
{
// The opt-out makes the native importer dormant, and the Assimp
// importer handles glTF like before
qputenv("QT_QUICK3D_DISABLE_NATIVE_GLTF", "1");
const auto cleanup = qScopeGuard([] { qunsetenv("QT_QUICK3D_DISABLE_NATIVE_GLTF"); });
QSSGAssetImportManager manager;
const auto importers = manager.getImporterPluginInfos();
for (const auto &importer : importers) {
if (importer.name == QStringLiteral("gltf"))
QVERIFY(importer.inputExtensions.isEmpty());
}
QString error;
const auto state = manager.importFile(file, QDir(QStringLiteral("./")), &error);
QVERIFY2(state == QSSGAssetImportManager::ImportState::Success, qPrintable(error));
}
}
static QSSGSceneDesc::Node *findNode(QSSGSceneDesc::Node *node, const QByteArray &name)
{
if (node->name == name)
return node;
for (QSSGSceneDesc::Node *child : std::as_const(node->children)) {
if (auto *found = findNode(child, name))
return found;
}
return nullptr;
}
static const QSSGSceneDesc::Property *findProperty(const QSSGSceneDesc::Node *node, const QByteArray &name)
{
for (const QSSGSceneDesc::Property *property : node->properties) {
if (property->name == name)
return property;
}
return nullptr;
}
// KHR_node_visibility hides a node, and its children through it, and
// EXT_texture_webp takes the WebP image whenever it can be decoded, keeping
// the regular image as the fallback otherwise.
void tst_assetimport::gltfNodeVisibilityAndWebp()
{
const QString file = QFINDTESTDATA(QStringLiteral("resources/visibility_webp.gltf"));
QVERIFY(!file.isEmpty());
QSSGAssetImportManager manager;
bool nativePresent = false;
for (const auto &importer : manager.getImporterPluginInfos())
nativePresent |= importer.name == QStringLiteral("gltf");
if (!nativePresent)
QSKIP("Native glTF importer plugin not available");
QSSGSceneDesc::Scene scene;
const auto cleanup = qScopeGuard([&scene] { scene.cleanup(); });
QString error;
QCOMPARE(manager.importFile(QUrl::fromLocalFile(file), scene, &error),
QSSGAssetImportManager::ImportState::Success);
QSSGSceneDesc::Node *hidden = findNode(scene.root, "hidden");
QVERIFY(hidden);
const QSSGSceneDesc::Property *visible = findProperty(hidden, "visible");
QVERIFY(visible);
QCOMPARE(visible->value.toBool(), false);
// Visibility cascades through Node.visible, so it is only set where the
// extension says so
for (const QByteArray &name : { QByteArrayLiteral("shown"), QByteArrayLiteral("hiddenChild") }) {
QSSGSceneDesc::Node *node = findNode(scene.root, name);
QVERIFY2(node, name.constData());
QVERIFY2(!findProperty(node, "visible"), name.constData());
}
// Textures are resources rather than part of the node tree
const auto it = std::find_if(scene.resources.cbegin(), scene.resources.cend(), [](const QSSGSceneDesc::Node *node) {
return node->runtimeType == QSSGSceneDesc::Node::RuntimeType::Image2D;
});
QVERIFY(it != scene.resources.cend());
const QSSGSceneDesc::Node *texture = *it;
const QSSGSceneDesc::Property *source = findProperty(texture, "source");
QVERIFY(source);
const bool webpSupported = QImageReader::supportedImageFormats().contains("webp");
QCOMPARE(source->value.toUrl().fileName(),
webpSupported ? QStringLiteral("preferred.webp") : QStringLiteral("fallback.png"));
}
void tst_assetimport::gltfMaterialVariant_data()
{
QTest::addColumn<QString>("variant");
QTest::addColumn<QByteArrayList>("materials");
QTest::addColumn<bool>("tangents");
// Without the option every variant stays switchable, so all materials
// are kept and tangents follow the one variant that needs them
QTest::newRow("all variants") << QString()
<< QByteArrayList { "defaultMaterial", "redMaterial", "blueMaterial" } << true;
QTest::newRow("Red") << QStringLiteral("Red") << QByteArrayList { "redMaterial" } << false;
QTest::newRow("Blue") << QStringLiteral("Blue") << QByteArrayList { "blueMaterial" } << true;
QTest::newRow("unknown") << QStringLiteral("Green") << QByteArrayList { "defaultMaterial" } << false;
}
// The baked KHR_materials_variants variant decides both the materials and,
// as only blueMaterial has a normal map, whether tangents are generated
void tst_assetimport::gltfMaterialVariant()
{
QFETCH(QString, variant);
QFETCH(QByteArrayList, materials);
QFETCH(bool, tangents);
const QString file = QFINDTESTDATA(QStringLiteral("resources/material_variants.gltf"));
QVERIFY(!file.isEmpty());
QSSGAssetImportManager manager;
bool nativePresent = false;
for (const auto &importer : manager.getImporterPluginInfos())
nativePresent |= importer.name == QStringLiteral("gltf");
if (!nativePresent)
QSKIP("Native glTF importer plugin not available");
QJsonObject options;
if (!variant.isEmpty())
options.insert(QStringLiteral("materialVariant"), QJsonObject { { QStringLiteral("value"), variant } });
if (QTest::currentDataTag() == QByteArrayLiteral("unknown"))
QTest::ignoreMessage(QtWarningMsg, QRegularExpression(QStringLiteral("no material variant \"Green\"")));
QSSGSceneDesc::Scene scene;
const auto cleanup = qScopeGuard([&scene] { scene.cleanup(); });
QString error;
QCOMPARE(manager.importFile(QUrl::fromLocalFile(file), scene, options, &error),
QSSGAssetImportManager::ImportState::Success);
QByteArrayList imported;
for (const QSSGSceneDesc::Node *node : scene.resources) {
if (node->nodeType == QSSGSceneDesc::Node::Type::Material)
imported.append(node->name);
}
QCOMPARE(imported, materials);
QCOMPARE(scene.meshStorage.size(), 1);
bool hasTangents = false;
for (const auto &entry : scene.meshStorage.first().vertexBuffer().entries)
hasTangents |= entry.name == QSSGMesh::MeshInternal::getTexTanAttrName();
QCOMPARE(hasTangents, tangents);
}
// The generated component binds each varying model's material list to the
// root's materialVariant property. balsam converts several assets in one
// process and the id allocator is process-wide, so the second root is not
// called "root" - the binding has to use whatever id the root actually got.
void tst_assetimport::generatedVariantBinding()
{
const QString file = QFINDTESTDATA(QStringLiteral("resources/material_variants_binding.gltf"));
QVERIFY(!file.isEmpty());
const auto convertAndRead = [&file](const QDir &outdir) {
QSSGAssetImportManager manager;
QString error;
const auto state = manager.importFile(file, outdir, &error);
if (state != QSSGAssetImportManager::ImportState::Success)
return QString();
const QStringList generated = outdir.entryList({ QStringLiteral("*.qml") }, QDir::Files);
if (generated.size() != 1)
return QString();
QFile qml(outdir.filePath(generated.first()));
if (!qml.open(QIODevice::ReadOnly))
return QString();
return QString::fromUtf8(qml.readAll());
};
QTemporaryDir first;
QVERIFY(first.isValid());
QTemporaryDir second;
QVERIFY(second.isValid());
// Both conversions happen in this one process, as they do in balsam
const QString firstQml = convertAndRead(QDir(first.path()));
if (firstQml.isEmpty())
QSKIP("glTF asset could not be converted");
const QString secondQml = convertAndRead(QDir(second.path()));
QVERIFY(!secondQml.isEmpty());
const QRegularExpression rootIdRe(QStringLiteral("\\bid: (root[0-9]*)\\b"));
for (const QString &qml : { firstQml, secondQml }) {
// The variant list is exposed as a typed list, matching RuntimeLoader
QVERIFY2(qml.contains(QStringLiteral("readonly property list<string> materialVariants: [\"Red\", \"Blue\"]")),
qPrintable(qml));
QVERIFY(qml.contains(QStringLiteral("property string materialVariant:")));
// Whatever id the root got, the binding must reference that id
const auto match = rootIdRe.match(qml);
QVERIFY2(match.hasMatch(), qPrintable(qml));
const QString rootId = match.captured(1);
QVERIFY(!rootId.isEmpty());
QVERIFY2(qml.contains(rootId + QStringLiteral(".materialVariant === \"Red\"")), qPrintable(qml));
// Only the model with variant mappings gets a ternary chain; the plain
// one keeps a static material list
QCOMPARE(qml.count(QStringLiteral(".materialVariant === ")), 2);
}
}
void tst_assetimport::materialExtensionMapping()
{
const QString file = QFINDTESTDATA(QStringLiteral("resources/brdf_extensions.gltf"));
QVERIFY(!file.isEmpty());
QSSGAssetImportManager manager;
bool nativePresent = false;
const auto importers = manager.getImporterPluginInfos();
for (const auto &importer : importers) {
if (importer.name == QStringLiteral("gltf"))
nativePresent = true;
}
if (!nativePresent)
QSKIP("Native glTF importer plugin not available");
QTemporaryDir outDir;
QVERIFY(outDir.isValid());
QString error;
const auto state = manager.importFile(file, QDir(outDir.path()), &error);
QVERIFY2(state == QSSGAssetImportManager::ImportState::Success, qPrintable(error));
const QStringList generated = QDir(outDir.path()).entryList(QStringList { QStringLiteral("*.qml") });
QCOMPARE(generated.size(), 1);
QFile qml(outDir.filePath(generated.first()));
QVERIFY(qml.open(QIODevice::ReadOnly | QIODevice::Text));
const QString contents = QString::fromUtf8(qml.readAll());
// The values are exactly representable as floats, so they are matched whole
// Sheen. The color factor is linear in the asset and has to come out as an
// sRGB color: linear 0.25, 0.5, 1.0 is #89bbff.
QVERIFY2(contents.contains(QStringLiteral("sheenColor: \"#ff89bbff\"\n")), qPrintable(contents));
QVERIFY(contents.contains(QStringLiteral("sheenRoughness: 0.25\n")));
QVERIFY(contents.contains(QStringLiteral("sheenColorMap:")));
QVERIFY(contents.contains(QStringLiteral("sheenRoughnessMap:")));
// Anisotropy. The asset states the rotation in radians, the property is in
// degrees, so pi/4 has to arrive as 45.
QVERIFY(contents.contains(QStringLiteral("anisotropyStrength: 0.625\n")));
QVERIFY2(contents.contains(QStringLiteral("anisotropyRotation: 45\n")), qPrintable(contents));
QVERIFY(contents.contains(QStringLiteral("anisotropyMap:")));
// Iridescence, including the property renamed from the extension's
// iridescenceIor, and the thickness range in nanometers
QVERIFY(contents.contains(QStringLiteral("iridescenceFactor: 0.875\n")));
QVERIFY(contents.contains(QStringLiteral("iridescenceIndexOfRefraction: 1.75\n")));
QVERIFY(contents.contains(QStringLiteral("iridescenceThicknessMinimum: 220\n")));
QVERIFY(contents.contains(QStringLiteral("iridescenceThicknessMaximum: 810\n")));
QVERIFY(contents.contains(QStringLiteral("iridescenceMap:")));
QVERIFY(contents.contains(QStringLiteral("iridescenceThicknessMap:")));
// Dispersion, which needs the transmission and volume the asset also sets
QVERIFY(contents.contains(QStringLiteral("dispersion: 0.5\n")));
QVERIFY(contents.contains(QStringLiteral("transmissionFactor: 0.75\n")));
QVERIFY(contents.contains(QStringLiteral("thicknessFactor: 2.5\n")));
}
QTEST_APPLESS_MAIN(tst_assetimport)
#include "tst_assetimport.moc"