mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-20 15:08:51 +00:00
ifcviewer: add tier-1 unit tests (Catch2 + CTest)
Covers the pure-logic modules with no Qt event loop or GL context: BVH build, LOD decimation, sidecar round-trip, instanced-geometry layout constants, and Federation save/load + relative-path policy. Each test binary compiles only the production source(s) under test, so the unit tier doesn't pull Qt/OpenCASCADE/IfcGeom into the test build. Gated behind BUILD_IFCVIEWER_TESTS=OFF; default builds remain offline. Catch2 v3.5.4 is fetched on demand via FetchContent.
This commit is contained in:
@@ -72,6 +72,7 @@ option(BUILD_GEOMSERVER "Build IfcGeomServer executable (Open CASCADE is require
|
|||||||
option(BUILD_IFCMAX "Build IfcMax, a 3ds Max plug-in, Windows-only." OFF)
|
option(BUILD_IFCMAX "Build IfcMax, a 3ds Max plug-in, Windows-only." OFF)
|
||||||
option(BUILD_QTVIEWER "Build IfcOpenShell Qt GUI Viewer" OFF) # QtViewer requires Qt6
|
option(BUILD_QTVIEWER "Build IfcOpenShell Qt GUI Viewer" OFF) # QtViewer requires Qt6
|
||||||
option(BUILD_IFCVIEWER "Build IfcViewer, a high-performance IFC viewer" OFF) # Requires Qt6 + OpenGL 4.5
|
option(BUILD_IFCVIEWER "Build IfcViewer, a high-performance IFC viewer" OFF) # Requires Qt6 + OpenGL 4.5
|
||||||
|
option(BUILD_IFCVIEWER_TESTS "Build unit tests for IfcViewer (fetches Catch2 v3)" OFF)
|
||||||
option(BUILD_PACKAGE "" OFF)
|
option(BUILD_PACKAGE "" OFF)
|
||||||
|
|
||||||
option(WITH_OPENCASCADE "Enable geometry interpretation using Open CASCADE" ON)
|
option(WITH_OPENCASCADE "Enable geometry interpretation using Open CASCADE" ON)
|
||||||
@@ -673,6 +674,23 @@ if(BUILD_IFCGEOM)
|
|||||||
install(TARGETS ${IFCGEOM_SCHEMA_LIBRARIES} ${kernel_libraries} IfcGeom)
|
install(TARGETS ${IFCGEOM_SCHEMA_LIBRARIES} ${kernel_libraries} IfcGeom)
|
||||||
endif(BUILD_IFCGEOM)
|
endif(BUILD_IFCGEOM)
|
||||||
if(BUILD_IFCVIEWER)
|
if(BUILD_IFCVIEWER)
|
||||||
|
if(BUILD_IFCVIEWER_TESTS)
|
||||||
|
# Catch2 v3 — fetched on demand. Test option is OFF by default so the
|
||||||
|
# default build remains offline-capable.
|
||||||
|
include(FetchContent)
|
||||||
|
FetchContent_Declare(
|
||||||
|
Catch2
|
||||||
|
GIT_REPOSITORY https://github.com/catchorg/Catch2.git
|
||||||
|
GIT_TAG v3.5.4
|
||||||
|
GIT_SHALLOW TRUE
|
||||||
|
)
|
||||||
|
FetchContent_MakeAvailable(Catch2)
|
||||||
|
list(APPEND CMAKE_MODULE_PATH ${catch2_SOURCE_DIR}/extras)
|
||||||
|
include(CTest)
|
||||||
|
include(Catch)
|
||||||
|
enable_testing()
|
||||||
|
endif()
|
||||||
|
|
||||||
add_subdirectory(../src/ifcviewer ifcviewer)
|
add_subdirectory(../src/ifcviewer ifcviewer)
|
||||||
add_subdirectory(../src/ifcviewer-minimal ifcviewer-minimal)
|
add_subdirectory(../src/ifcviewer-minimal ifcviewer-minimal)
|
||||||
add_subdirectory(../src/ifcviewer-full ifcviewer-full)
|
add_subdirectory(../src/ifcviewer-full ifcviewer-full)
|
||||||
|
|||||||
@@ -34,3 +34,7 @@ set_target_properties(IfcViewerFull PROPERTIES
|
|||||||
target_link_libraries(IfcViewerFull PRIVATE IfcViewer)
|
target_link_libraries(IfcViewerFull PRIVATE IfcViewer)
|
||||||
|
|
||||||
install(TARGETS IfcViewerFull EXPORT ${IFCOPENSHELL_EXPORT_TARGETS})
|
install(TARGETS IfcViewerFull EXPORT ${IFCOPENSHELL_EXPORT_TARGETS})
|
||||||
|
|
||||||
|
if(BUILD_IFCVIEWER_TESTS)
|
||||||
|
add_subdirectory(tests)
|
||||||
|
endif()
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
################################################################################
|
||||||
|
# #
|
||||||
|
# This file is part of IfcOpenShell. #
|
||||||
|
# #
|
||||||
|
# IfcOpenShell is free software: you can redistribute it and/or modify #
|
||||||
|
# it under the terms of the Lesser GNU General Public License as published by #
|
||||||
|
# the Free Software Foundation, either version 3.0 of the License, or #
|
||||||
|
# (at your option) any later version. #
|
||||||
|
# #
|
||||||
|
# IfcOpenShell is distributed in the hope that it will be useful, #
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
|
||||||
|
# Lesser GNU General Public License for more details. #
|
||||||
|
# #
|
||||||
|
# You should have received a copy of the Lesser GNU General Public License #
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>. #
|
||||||
|
# #
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
# Tier-1 tests for ifcviewer-full. Federation is QObject-derived but only
|
||||||
|
# uses Qt6::Core (no event loop, no GL), so tests can construct it directly.
|
||||||
|
|
||||||
|
set(IFCVIEWER_FULL_SRC ${CMAKE_CURRENT_SOURCE_DIR}/..)
|
||||||
|
|
||||||
|
# Federation::HomeView holds a QVector3D (defined in QtGui), and QSignalSpy
|
||||||
|
# / QTest live in Qt6::Test.
|
||||||
|
find_package(Qt${QT_VERSION} COMPONENTS Core Gui Test REQUIRED PATHS ${QT_DIR})
|
||||||
|
|
||||||
|
add_executable(test_federation
|
||||||
|
test_federation.cpp
|
||||||
|
${IFCVIEWER_FULL_SRC}/Federation.cpp
|
||||||
|
)
|
||||||
|
set_target_properties(test_federation PROPERTIES AUTOMOC ON)
|
||||||
|
target_include_directories(test_federation PRIVATE ${IFCVIEWER_FULL_SRC})
|
||||||
|
target_link_libraries(test_federation PRIVATE
|
||||||
|
Catch2::Catch2WithMain
|
||||||
|
Qt${QT_VERSION}::Core
|
||||||
|
Qt${QT_VERSION}::Gui # Federation::HomeView uses QVector3D from QtGui
|
||||||
|
Qt${QT_VERSION}::Test # QSignalSpy
|
||||||
|
)
|
||||||
|
catch_discover_tests(test_federation)
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
/********************************************************************************
|
||||||
|
* *
|
||||||
|
* This file is part of IfcOpenShell. *
|
||||||
|
* *
|
||||||
|
* IfcOpenShell is free software: you can redistribute it and/or modify *
|
||||||
|
* it under the terms of the Lesser GNU General Public License as published by *
|
||||||
|
* the Free Software Foundation, either version 3.0 of the License, or *
|
||||||
|
* (at your option) any later version. *
|
||||||
|
* *
|
||||||
|
* IfcOpenShell is distributed in the hope that it will be useful, *
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
||||||
|
* Lesser GNU General Public License for more details. *
|
||||||
|
* *
|
||||||
|
* You should have received a copy of the Lesser GNU General Public License *
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||||
|
* *
|
||||||
|
********************************************************************************/
|
||||||
|
|
||||||
|
#include "Federation.h"
|
||||||
|
|
||||||
|
#include <catch2/catch_test_macros.hpp>
|
||||||
|
|
||||||
|
#include <QCoreApplication>
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QSignalSpy>
|
||||||
|
#include <QTemporaryDir>
|
||||||
|
#include <QVector3D>
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// Catch2 owns main(), so QCoreApplication can't live in a TU constructor.
|
||||||
|
// Lazily construct it (intentionally leaked) the first time any test asks.
|
||||||
|
void ensureQApp() {
|
||||||
|
if (QCoreApplication::instance()) return;
|
||||||
|
static int argc = 1;
|
||||||
|
static char arg0[] = "test_federation";
|
||||||
|
static char* argv[] = { arg0, nullptr };
|
||||||
|
new QCoreApplication(argc, argv);
|
||||||
|
}
|
||||||
|
|
||||||
|
QString writeStubFile(const QString& path) {
|
||||||
|
// Federation::addModel cleanPath()s + absolutePath()s; the file doesn't
|
||||||
|
// need to exist to be added, but for some tests we want a real path under
|
||||||
|
// a temp dir so QFileInfo gives a stable answer.
|
||||||
|
QFileInfo fi(path);
|
||||||
|
QDir().mkpath(fi.absolutePath());
|
||||||
|
QFile f(path);
|
||||||
|
REQUIRE(f.open(QIODevice::WriteOnly));
|
||||||
|
f.write("stub");
|
||||||
|
f.close();
|
||||||
|
return QDir::cleanPath(fi.absoluteFilePath());
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonObject readJsonFile(const QString& path) {
|
||||||
|
QFile f(path);
|
||||||
|
REQUIRE(f.open(QIODevice::ReadOnly));
|
||||||
|
QJsonDocument doc = QJsonDocument::fromJson(f.readAll());
|
||||||
|
REQUIRE(doc.isObject());
|
||||||
|
return doc.object();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
TEST_CASE("Federation starts empty and not dirty", "[federation]") {
|
||||||
|
ensureQApp();
|
||||||
|
Federation fed;
|
||||||
|
REQUIRE(fed.models().empty());
|
||||||
|
REQUIRE_FALSE(fed.isDirty());
|
||||||
|
REQUIRE_FALSE(fed.hasHomeView());
|
||||||
|
REQUIRE(fed.filePath().isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("addModel emits dirty=true; markClean clears it; remove re-dirties", "[federation]") {
|
||||||
|
ensureQApp();
|
||||||
|
QTemporaryDir tmp;
|
||||||
|
REQUIRE(tmp.isValid());
|
||||||
|
|
||||||
|
Federation fed;
|
||||||
|
QSignalSpy spy(&fed, &Federation::dirtyChanged);
|
||||||
|
|
||||||
|
QString abs = writeStubFile(tmp.filePath("a.ifc"));
|
||||||
|
QString id = fed.addModel(abs);
|
||||||
|
REQUIRE_FALSE(id.isEmpty());
|
||||||
|
REQUIRE(fed.isDirty());
|
||||||
|
REQUIRE(spy.count() == 1);
|
||||||
|
REQUIRE(spy.takeFirst().at(0).toBool() == true);
|
||||||
|
|
||||||
|
fed.markClean();
|
||||||
|
REQUIRE_FALSE(fed.isDirty());
|
||||||
|
REQUIRE(spy.count() == 1);
|
||||||
|
REQUIRE(spy.takeFirst().at(0).toBool() == false);
|
||||||
|
|
||||||
|
fed.removeModel(id);
|
||||||
|
REQUIRE(fed.isDirty());
|
||||||
|
REQUIRE(spy.count() == 1);
|
||||||
|
REQUIRE(spy.takeFirst().at(0).toBool() == true);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("addModel rejects empty paths and nested .ifcfed sources", "[federation]") {
|
||||||
|
ensureQApp();
|
||||||
|
Federation fed;
|
||||||
|
REQUIRE(fed.addModel("").isEmpty());
|
||||||
|
REQUIRE(fed.addModel("nested.ifcfed").isEmpty());
|
||||||
|
REQUIRE(fed.addModel("nested.IfcFed").isEmpty()); // case-insensitive
|
||||||
|
REQUIRE(fed.models().empty());
|
||||||
|
REQUIRE_FALSE(fed.isDirty());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("setHomeView / clearHomeView toggle dirty + has_home_view", "[federation]") {
|
||||||
|
ensureQApp();
|
||||||
|
Federation fed;
|
||||||
|
QSignalSpy spy(&fed, &Federation::dirtyChanged);
|
||||||
|
|
||||||
|
Federation::HomeView hv;
|
||||||
|
hv.target = QVector3D(1, 2, 3);
|
||||||
|
hv.distance = 12.5f;
|
||||||
|
hv.yaw = 33.0f;
|
||||||
|
hv.pitch = 22.0f;
|
||||||
|
fed.setHomeView(hv);
|
||||||
|
REQUIRE(fed.hasHomeView());
|
||||||
|
REQUIRE(fed.isDirty());
|
||||||
|
REQUIRE(spy.count() == 1);
|
||||||
|
|
||||||
|
fed.markClean();
|
||||||
|
spy.clear();
|
||||||
|
|
||||||
|
fed.clearHomeView();
|
||||||
|
REQUIRE_FALSE(fed.hasHomeView());
|
||||||
|
REQUIRE(fed.isDirty());
|
||||||
|
REQUIRE(spy.count() == 1);
|
||||||
|
|
||||||
|
// Idempotent when already cleared.
|
||||||
|
fed.markClean();
|
||||||
|
spy.clear();
|
||||||
|
fed.clearHomeView();
|
||||||
|
REQUIRE_FALSE(fed.isDirty());
|
||||||
|
REQUIRE(spy.count() == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("save then load round-trips models, transform, visibility, home view", "[federation]") {
|
||||||
|
ensureQApp();
|
||||||
|
QTemporaryDir tmp;
|
||||||
|
REQUIRE(tmp.isValid());
|
||||||
|
|
||||||
|
QString src1 = writeStubFile(tmp.filePath("models/wall.ifc"));
|
||||||
|
QString src2 = writeStubFile(tmp.filePath("models/slab.ifc"));
|
||||||
|
QString fed_path = tmp.filePath("project.ifcfed");
|
||||||
|
|
||||||
|
Federation src;
|
||||||
|
QString id1 = src.addModel(src1, "Wall");
|
||||||
|
QString id2 = src.addModel(src2); // default display_name from filename
|
||||||
|
REQUIRE_FALSE(id1.isEmpty());
|
||||||
|
REQUIRE_FALSE(id2.isEmpty());
|
||||||
|
|
||||||
|
Federation::HomeView hv;
|
||||||
|
hv.target = QVector3D(10, 20, 30);
|
||||||
|
hv.distance = 77.0f;
|
||||||
|
hv.yaw = 11.0f;
|
||||||
|
hv.pitch = 7.0f;
|
||||||
|
src.setHomeView(hv);
|
||||||
|
|
||||||
|
QString err;
|
||||||
|
REQUIRE(src.save(fed_path, &err));
|
||||||
|
REQUIRE(err.isEmpty());
|
||||||
|
REQUIRE_FALSE(src.isDirty());
|
||||||
|
REQUIRE(QFileInfo::exists(fed_path));
|
||||||
|
|
||||||
|
Federation dst;
|
||||||
|
QStringList warnings;
|
||||||
|
REQUIRE(dst.load(fed_path, &warnings, &err));
|
||||||
|
REQUIRE(err.isEmpty());
|
||||||
|
REQUIRE(warnings.isEmpty());
|
||||||
|
|
||||||
|
REQUIRE(dst.models().size() == 2);
|
||||||
|
REQUIRE(dst.models()[0].id == id1);
|
||||||
|
REQUIRE(dst.models()[0].display_name == "Wall");
|
||||||
|
REQUIRE(dst.models()[0].source_path == src1);
|
||||||
|
REQUIRE(dst.models()[1].id == id2);
|
||||||
|
REQUIRE(dst.models()[1].display_name == "slab.ifc");
|
||||||
|
REQUIRE(dst.models()[1].source_path == src2);
|
||||||
|
|
||||||
|
REQUIRE(dst.hasHomeView());
|
||||||
|
REQUIRE(dst.homeView().target == QVector3D(10, 20, 30));
|
||||||
|
REQUIRE(dst.homeView().distance == 77.0f);
|
||||||
|
REQUIRE(dst.homeView().yaw == 11.0f);
|
||||||
|
REQUIRE(dst.homeView().pitch == 7.0f);
|
||||||
|
|
||||||
|
REQUIRE_FALSE(dst.isDirty());
|
||||||
|
REQUIRE(QFileInfo(dst.filePath()) == QFileInfo(fed_path));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("save stores paths relative when under fed_dir, absolute otherwise", "[federation]") {
|
||||||
|
ensureQApp();
|
||||||
|
QTemporaryDir root;
|
||||||
|
REQUIRE(root.isValid());
|
||||||
|
|
||||||
|
// Layout:
|
||||||
|
// <root>/fed_root/project.ifcfed
|
||||||
|
// <root>/fed_root/sub/inside.ifc (under fed_dir)
|
||||||
|
// <root>/elsewhere/outside.ifc (not under fed_dir)
|
||||||
|
QString fed_dir = root.filePath("fed_root");
|
||||||
|
QDir().mkpath(fed_dir);
|
||||||
|
QString fed_path = fed_dir + "/project.ifcfed";
|
||||||
|
QString inside = writeStubFile(fed_dir + "/sub/inside.ifc");
|
||||||
|
QString outside = writeStubFile(root.filePath("elsewhere/outside.ifc"));
|
||||||
|
|
||||||
|
Federation fed;
|
||||||
|
fed.addModel(inside);
|
||||||
|
fed.addModel(outside);
|
||||||
|
|
||||||
|
QString err;
|
||||||
|
REQUIRE(fed.save(fed_path, &err));
|
||||||
|
|
||||||
|
QJsonObject root_obj = readJsonFile(fed_path);
|
||||||
|
QJsonArray models = root_obj.value("models").toArray();
|
||||||
|
REQUIRE(models.size() == 2);
|
||||||
|
|
||||||
|
QString stored_inside = models[0].toObject().value("source").toObject()
|
||||||
|
.value("path").toString();
|
||||||
|
QString stored_outside = models[1].toObject().value("source").toObject()
|
||||||
|
.value("path").toString();
|
||||||
|
|
||||||
|
REQUIRE_FALSE(QFileInfo(stored_inside).isAbsolute());
|
||||||
|
REQUIRE(stored_inside == "sub/inside.ifc");
|
||||||
|
REQUIRE(QFileInfo(stored_outside).isAbsolute());
|
||||||
|
REQUIRE(QDir::cleanPath(stored_outside) == outside);
|
||||||
|
|
||||||
|
// Reload: source_path is resolved back to absolute either way.
|
||||||
|
Federation reload;
|
||||||
|
QStringList warnings;
|
||||||
|
REQUIRE(reload.load(fed_path, &warnings, &err));
|
||||||
|
REQUIRE(reload.models()[0].source_path == inside);
|
||||||
|
REQUIRE(reload.models()[1].source_path == outside);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Save-As to a different directory recomputes path relativity", "[federation]") {
|
||||||
|
ensureQApp();
|
||||||
|
QTemporaryDir root;
|
||||||
|
REQUIRE(root.isValid());
|
||||||
|
|
||||||
|
// Original layout: source lives under fed_root, fed file under fed_root.
|
||||||
|
QString fed_dir_a = root.filePath("fed_a");
|
||||||
|
QString fed_dir_b = root.filePath("fed_b");
|
||||||
|
QDir().mkpath(fed_dir_a);
|
||||||
|
QDir().mkpath(fed_dir_b);
|
||||||
|
QString src = writeStubFile(fed_dir_a + "/sub/m.ifc");
|
||||||
|
QString fed_a = fed_dir_a + "/proj.ifcfed";
|
||||||
|
QString fed_b = fed_dir_b + "/proj.ifcfed";
|
||||||
|
|
||||||
|
Federation fed;
|
||||||
|
fed.addModel(src);
|
||||||
|
|
||||||
|
QString err;
|
||||||
|
REQUIRE(fed.save(fed_a, &err));
|
||||||
|
QString stored_a = readJsonFile(fed_a).value("models").toArray()[0]
|
||||||
|
.toObject().value("source").toObject()
|
||||||
|
.value("path").toString();
|
||||||
|
REQUIRE_FALSE(QFileInfo(stored_a).isAbsolute());
|
||||||
|
|
||||||
|
// Save-As under a sibling directory: source is no longer under fed_dir,
|
||||||
|
// so it must be stored as absolute.
|
||||||
|
REQUIRE(fed.save(fed_b, &err));
|
||||||
|
QString stored_b = readJsonFile(fed_b).value("models").toArray()[0]
|
||||||
|
.toObject().value("source").toObject()
|
||||||
|
.value("path").toString();
|
||||||
|
REQUIRE(QFileInfo(stored_b).isAbsolute());
|
||||||
|
REQUIRE(QDir::cleanPath(stored_b) == src);
|
||||||
|
|
||||||
|
// After Save-As, filePath() reflects the new location.
|
||||||
|
REQUIRE(QFileInfo(fed.filePath()) == QFileInfo(fed_b));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("load on a missing file fails with an error and does not crash", "[federation]") {
|
||||||
|
ensureQApp();
|
||||||
|
Federation fed;
|
||||||
|
QStringList warnings;
|
||||||
|
QString err;
|
||||||
|
REQUIRE_FALSE(fed.load("/this/path/does/not/exist.ifcfed", &warnings, &err));
|
||||||
|
REQUIRE_FALSE(err.isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("load on malformed JSON fails with an error", "[federation]") {
|
||||||
|
ensureQApp();
|
||||||
|
QTemporaryDir tmp;
|
||||||
|
REQUIRE(tmp.isValid());
|
||||||
|
QString bad = tmp.filePath("bad.ifcfed");
|
||||||
|
{
|
||||||
|
QFile f(bad);
|
||||||
|
REQUIRE(f.open(QIODevice::WriteOnly));
|
||||||
|
f.write("{ this is not json");
|
||||||
|
f.close();
|
||||||
|
}
|
||||||
|
Federation fed;
|
||||||
|
QStringList warnings;
|
||||||
|
QString err;
|
||||||
|
REQUIRE_FALSE(fed.load(bad, &warnings, &err));
|
||||||
|
REQUIRE_FALSE(err.isEmpty());
|
||||||
|
}
|
||||||
@@ -70,3 +70,7 @@ install(TARGETS IfcViewer EXPORT ${IFCOPENSHELL_EXPORT_TARGETS})
|
|||||||
install(FILES ${IFCVIEWER_H_FILES}
|
install(FILES ${IFCVIEWER_H_FILES}
|
||||||
DESTINATION ${INCLUDEDIR}/ifcviewer
|
DESTINATION ${INCLUDEDIR}/ifcviewer
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if(BUILD_IFCVIEWER_TESTS)
|
||||||
|
add_subdirectory(tests)
|
||||||
|
endif()
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
################################################################################
|
||||||
|
# #
|
||||||
|
# This file is part of IfcOpenShell. #
|
||||||
|
# #
|
||||||
|
# IfcOpenShell is free software: you can redistribute it and/or modify #
|
||||||
|
# it under the terms of the Lesser GNU General Public License as published by #
|
||||||
|
# the Free Software Foundation, either version 3.0 of the License, or #
|
||||||
|
# (at your option) any later version. #
|
||||||
|
# #
|
||||||
|
# IfcOpenShell is distributed in the hope that it will be useful, #
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of #
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
|
||||||
|
# Lesser GNU General Public License for more details. #
|
||||||
|
# #
|
||||||
|
# You should have received a copy of the Lesser GNU General Public License #
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>. #
|
||||||
|
# #
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
# Tier-1 unit tests: pure-logic modules that need neither Qt nor an OpenGL
|
||||||
|
# context. Each test binary compiles the production source(s) under test
|
||||||
|
# directly (rather than linking the IfcViewer library) so the binaries stay
|
||||||
|
# small and don't pull Qt6, OpenCASCADE, IfcGeom, etc. into the test build.
|
||||||
|
|
||||||
|
set(IFCVIEWER_SRC ${CMAKE_CURRENT_SOURCE_DIR}/..)
|
||||||
|
|
||||||
|
function(add_ifcviewer_unit_test name)
|
||||||
|
cmake_parse_arguments(T "" "" "SOURCES;LIBS" ${ARGN})
|
||||||
|
add_executable(${name} ${name}.cpp ${T_SOURCES})
|
||||||
|
target_include_directories(${name} PRIVATE ${IFCVIEWER_SRC})
|
||||||
|
target_link_libraries(${name} PRIVATE Catch2::Catch2WithMain ${T_LIBS})
|
||||||
|
catch_discover_tests(${name})
|
||||||
|
endfunction()
|
||||||
|
|
||||||
|
add_ifcviewer_unit_test(test_bvh_accel
|
||||||
|
SOURCES ${IFCVIEWER_SRC}/BvhAccel.cpp
|
||||||
|
)
|
||||||
|
|
||||||
|
add_ifcviewer_unit_test(test_lod_builder
|
||||||
|
SOURCES
|
||||||
|
${IFCVIEWER_SRC}/LodBuilder.cpp
|
||||||
|
LIBS meshoptimizer::meshoptimizer
|
||||||
|
)
|
||||||
|
|
||||||
|
add_ifcviewer_unit_test(test_sidecar_cache
|
||||||
|
SOURCES ${IFCVIEWER_SRC}/SidecarCache.cpp
|
||||||
|
)
|
||||||
|
|
||||||
|
add_ifcviewer_unit_test(test_instanced_geometry)
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
/********************************************************************************
|
||||||
|
* *
|
||||||
|
* This file is part of IfcOpenShell. *
|
||||||
|
* *
|
||||||
|
* IfcOpenShell is free software: you can redistribute it and/or modify *
|
||||||
|
* it under the terms of the Lesser GNU General Public License as published by *
|
||||||
|
* the Free Software Foundation, either version 3.0 of the License, or *
|
||||||
|
* (at your option) any later version. *
|
||||||
|
* *
|
||||||
|
* IfcOpenShell is distributed in the hope that it will be useful, *
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
||||||
|
* Lesser GNU General Public License for more details. *
|
||||||
|
* *
|
||||||
|
* You should have received a copy of the Lesser GNU General Public License *
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||||
|
* *
|
||||||
|
********************************************************************************/
|
||||||
|
|
||||||
|
#include "BvhAccel.h"
|
||||||
|
|
||||||
|
#include <catch2/catch_test_macros.hpp>
|
||||||
|
|
||||||
|
#include <random>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
BvhItem makeItem(float x, float y, float z, float r, uint32_t model_id = 1) {
|
||||||
|
BvhItem it{};
|
||||||
|
it.aabb_min[0] = x - r;
|
||||||
|
it.aabb_min[1] = y - r;
|
||||||
|
it.aabb_min[2] = z - r;
|
||||||
|
it.aabb_max[0] = x + r;
|
||||||
|
it.aabb_max[1] = y + r;
|
||||||
|
it.aabb_max[2] = z + r;
|
||||||
|
it.model_id = model_id;
|
||||||
|
return it;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool aabbContains(const float outer_min[3], const float outer_max[3],
|
||||||
|
const float inner_min[3], const float inner_max[3]) {
|
||||||
|
for (int a = 0; a < 3; ++a) {
|
||||||
|
if (inner_min[a] < outer_min[a]) return false;
|
||||||
|
if (inner_max[a] > outer_max[a]) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void verifyNode(const ModelBvh& mbvh,
|
||||||
|
const std::vector<BvhItem>& items,
|
||||||
|
uint32_t node_idx) {
|
||||||
|
REQUIRE(node_idx < mbvh.nodes.size());
|
||||||
|
const BvhNode& node = mbvh.nodes[node_idx];
|
||||||
|
|
||||||
|
if (node.count > 0) {
|
||||||
|
// Leaf: every item's AABB must be inside the node AABB.
|
||||||
|
REQUIRE(node.count <= BVH_MAX_LEAF_SIZE);
|
||||||
|
for (uint32_t k = 0; k < node.count; ++k) {
|
||||||
|
uint32_t idx = mbvh.item_indices[node.right_or_first + k];
|
||||||
|
REQUIRE(idx < items.size());
|
||||||
|
REQUIRE(aabbContains(node.aabb_min, node.aabb_max,
|
||||||
|
items[idx].aabb_min, items[idx].aabb_max));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interior: left child is at node_idx + 1, right at node.right_or_first.
|
||||||
|
uint32_t left_idx = node_idx + 1;
|
||||||
|
uint32_t right_idx = node.right_or_first;
|
||||||
|
REQUIRE(left_idx < mbvh.nodes.size());
|
||||||
|
REQUIRE(right_idx < mbvh.nodes.size());
|
||||||
|
REQUIRE(left_idx != right_idx);
|
||||||
|
|
||||||
|
const BvhNode& l = mbvh.nodes[left_idx];
|
||||||
|
const BvhNode& r = mbvh.nodes[right_idx];
|
||||||
|
REQUIRE(aabbContains(node.aabb_min, node.aabb_max, l.aabb_min, l.aabb_max));
|
||||||
|
REQUIRE(aabbContains(node.aabb_min, node.aabb_max, r.aabb_min, r.aabb_max));
|
||||||
|
REQUIRE(node.axis < 3);
|
||||||
|
|
||||||
|
verifyNode(mbvh, items, left_idx);
|
||||||
|
verifyNode(mbvh, items, right_idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
TEST_CASE("BvhNode is 32 bytes (sidecar/cache layout invariant)", "[bvh]") {
|
||||||
|
REQUIRE(sizeof(BvhNode) == 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("buildModelBvhOne on empty input produces no nodes", "[bvh]") {
|
||||||
|
std::vector<BvhItem> items;
|
||||||
|
ModelBvh mbvh = buildModelBvhOne(items, /*model_id=*/42);
|
||||||
|
REQUIRE(mbvh.model_id == 42);
|
||||||
|
REQUIRE(mbvh.nodes.empty());
|
||||||
|
REQUIRE(mbvh.item_indices.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("buildModelBvhOne with <= BVH_MAX_LEAF_SIZE items yields a single leaf", "[bvh]") {
|
||||||
|
std::vector<BvhItem> items;
|
||||||
|
for (int i = 0; i < 5; ++i) {
|
||||||
|
items.push_back(makeItem(float(i), 0.0f, 0.0f, 0.5f));
|
||||||
|
}
|
||||||
|
ModelBvh mbvh = buildModelBvhOne(items, 1);
|
||||||
|
REQUIRE(mbvh.nodes.size() == 1);
|
||||||
|
REQUIRE(mbvh.nodes[0].count == 5);
|
||||||
|
REQUIRE(mbvh.item_indices.size() == 5);
|
||||||
|
verifyNode(mbvh, items, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("buildModelBvhOne with many items splits and respects invariants", "[bvh]") {
|
||||||
|
std::mt19937 rng(0xC0FFEE);
|
||||||
|
std::uniform_real_distribution<float> coord(-100.0f, 100.0f);
|
||||||
|
std::uniform_real_distribution<float> radius(0.1f, 1.0f);
|
||||||
|
|
||||||
|
constexpr int N = 256;
|
||||||
|
std::vector<BvhItem> items;
|
||||||
|
items.reserve(N);
|
||||||
|
for (int i = 0; i < N; ++i) {
|
||||||
|
items.push_back(makeItem(coord(rng), coord(rng), coord(rng), radius(rng)));
|
||||||
|
}
|
||||||
|
|
||||||
|
ModelBvh mbvh = buildModelBvhOne(items, /*model_id=*/7);
|
||||||
|
REQUIRE(mbvh.model_id == 7);
|
||||||
|
REQUIRE(!mbvh.nodes.empty());
|
||||||
|
REQUIRE(mbvh.item_indices.size() == N);
|
||||||
|
|
||||||
|
// Permutation invariant: each item must appear exactly once.
|
||||||
|
std::vector<int> seen(N, 0);
|
||||||
|
for (uint32_t idx : mbvh.item_indices) {
|
||||||
|
REQUIRE(idx < uint32_t(N));
|
||||||
|
seen[idx]++;
|
||||||
|
}
|
||||||
|
for (int s : seen) REQUIRE(s == 1);
|
||||||
|
|
||||||
|
// Recursive structural invariants.
|
||||||
|
verifyNode(mbvh, items, 0);
|
||||||
|
|
||||||
|
// Sum of leaf counts must equal item count.
|
||||||
|
uint32_t leaf_total = 0;
|
||||||
|
for (const auto& n : mbvh.nodes) {
|
||||||
|
if (n.count > 0) leaf_total += n.count;
|
||||||
|
}
|
||||||
|
REQUIRE(leaf_total == N);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("buildBvhSet partitions by model_id and gates on BVH_MIN_OBJECTS", "[bvh]") {
|
||||||
|
// Model 1: well above BVH_MIN_OBJECTS — should get a BVH.
|
||||||
|
// Model 2: a single item — below the gate, must be skipped.
|
||||||
|
std::vector<BvhItem> items;
|
||||||
|
for (uint32_t i = 0; i < BVH_MIN_OBJECTS + 4; ++i) {
|
||||||
|
items.push_back(makeItem(float(i), 0.0f, 0.0f, 0.5f, /*model_id=*/1));
|
||||||
|
}
|
||||||
|
items.push_back(makeItem(0.0f, 0.0f, 0.0f, 0.5f, /*model_id=*/2));
|
||||||
|
|
||||||
|
auto set = buildBvhSet(items);
|
||||||
|
REQUIRE(set);
|
||||||
|
REQUIRE(set->bvh_model_ids.count(1) == 1);
|
||||||
|
REQUIRE(set->bvh_model_ids.count(2) == 0);
|
||||||
|
REQUIRE(set->models.count(1) == 1);
|
||||||
|
REQUIRE(set->models.count(2) == 0);
|
||||||
|
|
||||||
|
const auto& mbvh = set->models.at(1);
|
||||||
|
REQUIRE(mbvh.model_id == 1);
|
||||||
|
REQUIRE(mbvh.item_indices.size() == BVH_MIN_OBJECTS + 4);
|
||||||
|
// item_indices reference positions in the *full* items array — the model-1
|
||||||
|
// items are at indices [0, BVH_MIN_OBJECTS + 4), so every entry must be
|
||||||
|
// less than that.
|
||||||
|
for (uint32_t idx : mbvh.item_indices) {
|
||||||
|
REQUIRE(idx < BVH_MIN_OBJECTS + 4);
|
||||||
|
}
|
||||||
|
verifyNode(mbvh, items, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("buildBvhSet returns empty set when nothing meets the gate", "[bvh]") {
|
||||||
|
std::vector<BvhItem> items;
|
||||||
|
for (uint32_t i = 0; i < BVH_MIN_OBJECTS - 1; ++i) {
|
||||||
|
items.push_back(makeItem(float(i), 0.0f, 0.0f, 0.5f));
|
||||||
|
}
|
||||||
|
auto set = buildBvhSet(items);
|
||||||
|
REQUIRE(set);
|
||||||
|
REQUIRE(set->bvh_model_ids.empty());
|
||||||
|
REQUIRE(set->models.empty());
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
/********************************************************************************
|
||||||
|
* *
|
||||||
|
* This file is part of IfcOpenShell. *
|
||||||
|
* *
|
||||||
|
* IfcOpenShell is free software: you can redistribute it and/or modify *
|
||||||
|
* it under the terms of the Lesser GNU General Public License as published by *
|
||||||
|
* the Free Software Foundation, either version 3.0 of the License, or *
|
||||||
|
* (at your option) any later version. *
|
||||||
|
* *
|
||||||
|
* IfcOpenShell is distributed in the hope that it will be useful, *
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
||||||
|
* Lesser GNU General Public License for more details. *
|
||||||
|
* *
|
||||||
|
* You should have received a copy of the Lesser GNU General Public License *
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||||
|
* *
|
||||||
|
********************************************************************************/
|
||||||
|
|
||||||
|
// Tier-1 coverage of the instanced-geometry GPU/sidecar layout.
|
||||||
|
//
|
||||||
|
// The production quantization helpers currently live inside
|
||||||
|
// ViewportWindow.cpp (see uploadMeshChunk). Once they're factored out into a
|
||||||
|
// reusable header (planned tier-3 prerequisite) this test will exercise the
|
||||||
|
// real implementation directly. For now we cover:
|
||||||
|
// - runtime size/alignment assertions (defense in depth for the static_asserts)
|
||||||
|
// - documented constants form a self-consistent layout
|
||||||
|
// - a reference position-quantization round-trip that pins the error bound
|
||||||
|
// declared in InstancedGeometry.h ("dequant to mix(aabb_min, aabb_max, t)")
|
||||||
|
|
||||||
|
#include "InstancedGeometry.h"
|
||||||
|
|
||||||
|
#include <catch2/catch_test_macros.hpp>
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
TEST_CASE("Instanced GPU/CPU struct sizes match the wire format", "[instgeom]") {
|
||||||
|
REQUIRE(sizeof(MeshGpu) == 32);
|
||||||
|
REQUIRE(sizeof(MeshInfo) == 56);
|
||||||
|
REQUIRE(sizeof(InstanceGpu) == 80);
|
||||||
|
REQUIRE(alignof(MeshGpu) == 16);
|
||||||
|
REQUIRE(alignof(InstanceGpu) == 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("INSTANCED_VERTEX_* constants are self-consistent", "[instgeom]") {
|
||||||
|
// Position (u16 x 3 = 6 B) + normal (i8 x 2 = 2 B) + color (u8 x 4 = 4 B)
|
||||||
|
// packed contiguously with no implicit padding.
|
||||||
|
REQUIRE(INSTANCED_VERTEX_POS_OFFSET == 0);
|
||||||
|
REQUIRE(INSTANCED_VERTEX_NORMAL_OFFSET == 6);
|
||||||
|
REQUIRE(INSTANCED_VERTEX_COLOR_OFFSET == 8);
|
||||||
|
REQUIRE(INSTANCED_VERTEX_STRIDE_BYTES == 12);
|
||||||
|
REQUIRE(INSTANCED_VERTEX_STRIDE_FLOATS == 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Position quantization round-trips within the documented error bound", "[instgeom]") {
|
||||||
|
// The quantization basis is per-mesh: t = (p - min) / (max - min) packed
|
||||||
|
// into u16, and dequantized as p' = min + t * (max - min). The round-trip
|
||||||
|
// error per axis is at most (max - min) / 65535 (one ulp of the u16 grid).
|
||||||
|
const float aabb_min[3] = {-3.5f, 100.25f, -1000.0f};
|
||||||
|
const float aabb_max[3] = { 7.5f, 200.25f, 1000.0f};
|
||||||
|
const float extent[3] = {
|
||||||
|
aabb_max[0] - aabb_min[0],
|
||||||
|
aabb_max[1] - aabb_min[1],
|
||||||
|
aabb_max[2] - aabb_min[2],
|
||||||
|
};
|
||||||
|
|
||||||
|
constexpr int kSamples = 65;
|
||||||
|
float worst_err = 0.0f;
|
||||||
|
for (int s = 0; s <= kSamples; ++s) {
|
||||||
|
float t = float(s) / float(kSamples);
|
||||||
|
for (int a = 0; a < 3; ++a) {
|
||||||
|
float p = aabb_min[a] + t * extent[a];
|
||||||
|
|
||||||
|
// Pack (the same formula buildLods/the streamer use against an AABB).
|
||||||
|
float tt = (p - aabb_min[a]) / extent[a];
|
||||||
|
if (tt < 0.0f) tt = 0.0f;
|
||||||
|
if (tt > 1.0f) tt = 1.0f;
|
||||||
|
uint16_t q = uint16_t(tt * 65535.0f + 0.5f);
|
||||||
|
|
||||||
|
// Unpack (matches the dequant in LodBuilder.cpp).
|
||||||
|
float pp = aabb_min[a] + (q / 65535.0f) * extent[a];
|
||||||
|
|
||||||
|
float err = std::fabs(pp - p);
|
||||||
|
if (err > worst_err) worst_err = err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The round-trip error must stay within one u16 ulp of the largest extent,
|
||||||
|
// with a small float-rounding margin.
|
||||||
|
float ulp = 0.0f;
|
||||||
|
for (int a = 0; a < 3; ++a) {
|
||||||
|
ulp = std::max(ulp, extent[a] / 65535.0f);
|
||||||
|
}
|
||||||
|
REQUIRE(worst_err <= ulp * 1.01f);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("MeshChunk and InstanceChunk default-init to zeroed metadata", "[instgeom]") {
|
||||||
|
MeshChunk mc;
|
||||||
|
REQUIRE(mc.model_id == 0);
|
||||||
|
REQUIRE(mc.local_mesh_id == 0);
|
||||||
|
REQUIRE(mc.vertices.empty());
|
||||||
|
REQUIRE(mc.indices.empty());
|
||||||
|
|
||||||
|
InstanceChunk ic;
|
||||||
|
REQUIRE(ic.model_id == 0);
|
||||||
|
REQUIRE(ic.local_mesh_id == 0);
|
||||||
|
REQUIRE(ic.object_id == 0);
|
||||||
|
REQUIRE(ic.color_override_rgba8 == 0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
/********************************************************************************
|
||||||
|
* *
|
||||||
|
* This file is part of IfcOpenShell. *
|
||||||
|
* *
|
||||||
|
* IfcOpenShell is free software: you can redistribute it and/or modify *
|
||||||
|
* it under the terms of the Lesser GNU General Public License as published by *
|
||||||
|
* the Free Software Foundation, either version 3.0 of the License, or *
|
||||||
|
* (at your option) any later version. *
|
||||||
|
* *
|
||||||
|
* IfcOpenShell is distributed in the hope that it will be useful, *
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
||||||
|
* Lesser GNU General Public License for more details. *
|
||||||
|
* *
|
||||||
|
* You should have received a copy of the Lesser GNU General Public License *
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||||
|
* *
|
||||||
|
********************************************************************************/
|
||||||
|
|
||||||
|
#include "InstancedGeometry.h"
|
||||||
|
#include "LodBuilder.h"
|
||||||
|
#include "SidecarCache.h"
|
||||||
|
|
||||||
|
#include <catch2/catch_test_macros.hpp>
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <cstring>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// Wipes LOD env-var knobs so tests run against the documented defaults
|
||||||
|
// regardless of the host shell.
|
||||||
|
struct ScopedEnvIsolate {
|
||||||
|
ScopedEnvIsolate() {
|
||||||
|
#ifdef _WIN32
|
||||||
|
_putenv_s("IFC_LOD_ERROR", "");
|
||||||
|
_putenv_s("IFC_LOD_RATIO", "");
|
||||||
|
_putenv_s("IFC_LOD_MIN_SAVINGS", "");
|
||||||
|
_putenv_s("IFC_LOD_DEBUG", "");
|
||||||
|
#else
|
||||||
|
unsetenv("IFC_LOD_ERROR");
|
||||||
|
unsetenv("IFC_LOD_RATIO");
|
||||||
|
unsetenv("IFC_LOD_MIN_SAVINGS");
|
||||||
|
unsetenv("IFC_LOD_DEBUG");
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Append one quantized vertex (positions only — normal/color zeroed) to the
|
||||||
|
// vertex byte buffer. Quantization basis is the mesh's local AABB.
|
||||||
|
void appendQuantizedVertex(std::vector<uint8_t>& bytes,
|
||||||
|
const float pos[3],
|
||||||
|
const float aabb_min[3],
|
||||||
|
const float aabb_max[3]) {
|
||||||
|
uint16_t qpos[3];
|
||||||
|
for (int a = 0; a < 3; ++a) {
|
||||||
|
float extent = aabb_max[a] - aabb_min[a];
|
||||||
|
float t = extent > 0.0f ? (pos[a] - aabb_min[a]) / extent : 0.0f;
|
||||||
|
if (t < 0.0f) t = 0.0f;
|
||||||
|
if (t > 1.0f) t = 1.0f;
|
||||||
|
qpos[a] = static_cast<uint16_t>(t * 65535.0f + 0.5f);
|
||||||
|
}
|
||||||
|
size_t before = bytes.size();
|
||||||
|
bytes.resize(before + INSTANCED_VERTEX_STRIDE_BYTES, 0);
|
||||||
|
std::memcpy(bytes.data() + before + INSTANCED_VERTEX_POS_OFFSET, qpos, sizeof(qpos));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a planar NxN grid mesh: (N-1)^2 quads = 2*(N-1)^2 triangles. Returns
|
||||||
|
// a single-mesh SidecarData with quantized vertex bytes and uint32 indices.
|
||||||
|
SidecarData makeGridMesh(int N) {
|
||||||
|
SidecarData sd;
|
||||||
|
MeshInfo mesh{};
|
||||||
|
mesh.local_aabb_min[0] = 0.0f; mesh.local_aabb_min[1] = 0.0f; mesh.local_aabb_min[2] = 0.0f;
|
||||||
|
mesh.local_aabb_max[0] = 1.0f; mesh.local_aabb_max[1] = 1.0f; mesh.local_aabb_max[2] = 0.0f;
|
||||||
|
mesh.vbo_byte_offset = 0;
|
||||||
|
mesh.ebo_byte_offset = 0;
|
||||||
|
mesh.vertex_count = uint32_t(N * N);
|
||||||
|
|
||||||
|
for (int j = 0; j < N; ++j) {
|
||||||
|
for (int i = 0; i < N; ++i) {
|
||||||
|
float pos[3] = {
|
||||||
|
float(i) / float(N - 1),
|
||||||
|
float(j) / float(N - 1),
|
||||||
|
0.0f
|
||||||
|
};
|
||||||
|
appendQuantizedVertex(sd.vertices, pos,
|
||||||
|
mesh.local_aabb_min, mesh.local_aabb_max);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int j = 0; j < N - 1; ++j) {
|
||||||
|
for (int i = 0; i < N - 1; ++i) {
|
||||||
|
uint32_t v00 = uint32_t(j * N + i);
|
||||||
|
uint32_t v10 = v00 + 1;
|
||||||
|
uint32_t v01 = v00 + uint32_t(N);
|
||||||
|
uint32_t v11 = v01 + 1;
|
||||||
|
sd.indices.push_back(v00); sd.indices.push_back(v10); sd.indices.push_back(v11);
|
||||||
|
sd.indices.push_back(v00); sd.indices.push_back(v11); sd.indices.push_back(v01);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mesh.index_count = uint32_t(sd.indices.size());
|
||||||
|
sd.meshes.push_back(mesh);
|
||||||
|
return sd;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
TEST_CASE("buildLods skips meshes below min_triangles", "[lod]") {
|
||||||
|
ScopedEnvIsolate guard;
|
||||||
|
// 9x9 grid -> 128 triangles. Default min_triangles is 500.
|
||||||
|
SidecarData sd = makeGridMesh(9);
|
||||||
|
REQUIRE(sd.meshes[0].index_count / 3 == 128u);
|
||||||
|
|
||||||
|
size_t indices_before = sd.indices.size();
|
||||||
|
buildLods(sd);
|
||||||
|
|
||||||
|
REQUIRE(sd.meshes[0].lod1_index_count == 0);
|
||||||
|
REQUIRE(sd.meshes[0].lod1_ebo_byte_offset == 0);
|
||||||
|
REQUIRE(sd.indices.size() == indices_before); // nothing appended
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("buildLods produces a valid LOD1 slice for a high-tri mesh", "[lod]") {
|
||||||
|
ScopedEnvIsolate guard;
|
||||||
|
// 30x30 grid -> 1682 triangles. Comfortably above min_triangles.
|
||||||
|
SidecarData sd = makeGridMesh(30);
|
||||||
|
const uint32_t lod0_indices = sd.meshes[0].index_count;
|
||||||
|
const size_t indices_before = sd.indices.size();
|
||||||
|
REQUIRE(lod0_indices / 3 >= 500u);
|
||||||
|
|
||||||
|
buildLods(sd);
|
||||||
|
|
||||||
|
const auto& m = sd.meshes[0];
|
||||||
|
REQUIRE(m.lod1_index_count > 0);
|
||||||
|
REQUIRE(m.lod1_index_count % 3 == 0);
|
||||||
|
REQUIRE(m.lod1_index_count < lod0_indices); // actually decimated
|
||||||
|
REQUIRE(m.lod1_ebo_byte_offset == indices_before * sizeof(uint32_t));
|
||||||
|
REQUIRE(sd.indices.size() == indices_before + m.lod1_index_count);
|
||||||
|
|
||||||
|
// LOD1 indices live in the appended slice and must reference real vertices
|
||||||
|
// within this mesh.
|
||||||
|
const uint32_t first = m.lod1_ebo_byte_offset / uint32_t(sizeof(uint32_t));
|
||||||
|
for (uint32_t k = 0; k < m.lod1_index_count; ++k) {
|
||||||
|
REQUIRE(sd.indices[first + k] < m.vertex_count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("buildLods is deterministic for the same input", "[lod]") {
|
||||||
|
ScopedEnvIsolate guard;
|
||||||
|
SidecarData a = makeGridMesh(30);
|
||||||
|
SidecarData b = makeGridMesh(30);
|
||||||
|
|
||||||
|
buildLods(a);
|
||||||
|
buildLods(b);
|
||||||
|
|
||||||
|
REQUIRE(a.meshes[0].lod1_index_count == b.meshes[0].lod1_index_count);
|
||||||
|
REQUIRE(a.meshes[0].lod1_ebo_byte_offset == b.meshes[0].lod1_ebo_byte_offset);
|
||||||
|
REQUIRE(a.indices == b.indices);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("buildLods is a no-op when sd is empty", "[lod]") {
|
||||||
|
ScopedEnvIsolate guard;
|
||||||
|
SidecarData sd;
|
||||||
|
buildLods(sd);
|
||||||
|
REQUIRE(sd.meshes.empty());
|
||||||
|
REQUIRE(sd.vertices.empty());
|
||||||
|
REQUIRE(sd.indices.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("summariseLods is consistent before and after buildLods", "[lod]") {
|
||||||
|
ScopedEnvIsolate guard;
|
||||||
|
SidecarData sd = makeGridMesh(30);
|
||||||
|
|
||||||
|
LodStats before = summariseLods(sd);
|
||||||
|
REQUIRE(before.meshes_total == 1);
|
||||||
|
REQUIRE(before.meshes_with_lod1 == 0);
|
||||||
|
REQUIRE(before.tris_lod0 == sd.meshes[0].index_count / 3);
|
||||||
|
REQUIRE(before.tris_lod1 == 0);
|
||||||
|
REQUIRE(before.tris_lod0_for_lod1 == 0);
|
||||||
|
|
||||||
|
buildLods(sd);
|
||||||
|
LodStats after = summariseLods(sd);
|
||||||
|
|
||||||
|
REQUIRE(after.meshes_total == before.meshes_total);
|
||||||
|
REQUIRE(after.tris_lod0 == before.tris_lod0); // LOD0 untouched
|
||||||
|
REQUIRE(after.meshes_with_lod1 == 1);
|
||||||
|
REQUIRE(after.tris_lod0_for_lod1 == before.tris_lod0);
|
||||||
|
REQUIRE(after.tris_lod1 == sd.meshes[0].lod1_index_count / 3);
|
||||||
|
REQUIRE(after.tris_lod1 < after.tris_lod0_for_lod1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
/********************************************************************************
|
||||||
|
* *
|
||||||
|
* This file is part of IfcOpenShell. *
|
||||||
|
* *
|
||||||
|
* IfcOpenShell is free software: you can redistribute it and/or modify *
|
||||||
|
* it under the terms of the Lesser GNU General Public License as published by *
|
||||||
|
* the Free Software Foundation, either version 3.0 of the License, or *
|
||||||
|
* (at your option) any later version. *
|
||||||
|
* *
|
||||||
|
* IfcOpenShell is distributed in the hope that it will be useful, *
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
||||||
|
* Lesser GNU General Public License for more details. *
|
||||||
|
* *
|
||||||
|
* You should have received a copy of the Lesser GNU General Public License *
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
|
||||||
|
* *
|
||||||
|
********************************************************************************/
|
||||||
|
|
||||||
|
#include "InstancedGeometry.h"
|
||||||
|
#include "SidecarCache.h"
|
||||||
|
|
||||||
|
#include <catch2/catch_test_macros.hpp>
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <random>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace fs = std::filesystem;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// Each test creates its own scratch directory under the OS tmp root so they
|
||||||
|
// can run in parallel without colliding on file paths.
|
||||||
|
fs::path makeScratchDir(const char* tag) {
|
||||||
|
fs::path base = fs::temp_directory_path() / "ifcviewer_test_sidecar";
|
||||||
|
fs::create_directories(base);
|
||||||
|
static std::atomic<uint64_t> counter{0};
|
||||||
|
auto unique = std::to_string(counter.fetch_add(1)) + "_" + tag;
|
||||||
|
fs::path dir = base / unique;
|
||||||
|
fs::create_directories(dir);
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
SidecarData buildFixture() {
|
||||||
|
SidecarData sd;
|
||||||
|
|
||||||
|
// 4 vertices worth of arbitrary bytes (12 B/vertex).
|
||||||
|
sd.vertices.resize(4 * INSTANCED_VERTEX_STRIDE_BYTES);
|
||||||
|
for (size_t i = 0; i < sd.vertices.size(); ++i) sd.vertices[i] = uint8_t(i * 7);
|
||||||
|
|
||||||
|
// Two meshes share the VBO — second mesh starts at vertex 2.
|
||||||
|
sd.indices = {0, 1, 2, 1, 2, 3};
|
||||||
|
|
||||||
|
MeshInfo m1{};
|
||||||
|
m1.vbo_byte_offset = 0;
|
||||||
|
m1.vertex_count = 2;
|
||||||
|
m1.ebo_byte_offset = 0;
|
||||||
|
m1.index_count = 3;
|
||||||
|
m1.local_aabb_min[0] = -1; m1.local_aabb_min[1] = -2; m1.local_aabb_min[2] = -3;
|
||||||
|
m1.local_aabb_max[0] = 4; m1.local_aabb_max[1] = 5; m1.local_aabb_max[2] = 6;
|
||||||
|
m1.first_instance = 0;
|
||||||
|
m1.instance_count = 3;
|
||||||
|
m1.lod1_ebo_byte_offset = 0;
|
||||||
|
m1.lod1_index_count = 0;
|
||||||
|
|
||||||
|
MeshInfo m2{};
|
||||||
|
m2.vbo_byte_offset = 2 * INSTANCED_VERTEX_STRIDE_BYTES;
|
||||||
|
m2.vertex_count = 2;
|
||||||
|
m2.ebo_byte_offset = 3 * sizeof(uint32_t);
|
||||||
|
m2.index_count = 3;
|
||||||
|
m2.local_aabb_min[0] = 10; m2.local_aabb_min[1] = 11; m2.local_aabb_min[2] = 12;
|
||||||
|
m2.local_aabb_max[0] = 13; m2.local_aabb_max[1] = 14; m2.local_aabb_max[2] = 15;
|
||||||
|
m2.first_instance = 3;
|
||||||
|
m2.instance_count = 2;
|
||||||
|
m2.lod1_ebo_byte_offset = 0;
|
||||||
|
m2.lod1_index_count = 0;
|
||||||
|
|
||||||
|
sd.meshes = {m1, m2};
|
||||||
|
|
||||||
|
sd.instances.resize(5);
|
||||||
|
for (size_t i = 0; i < sd.instances.size(); ++i) {
|
||||||
|
InstanceCpu& inst = sd.instances[i];
|
||||||
|
inst.mesh_id = (i < 3) ? 0u : 1u;
|
||||||
|
inst.object_id = uint32_t(100 + i);
|
||||||
|
inst.color_override_rgba8 = uint32_t(0xAA000000u | (i * 0x010203u));
|
||||||
|
inst.model_id = 1;
|
||||||
|
for (int k = 0; k < 16; ++k) inst.transform[k] = float(i) * 0.5f + float(k);
|
||||||
|
inst.world_aabb_min[0] = float(i);
|
||||||
|
inst.world_aabb_min[1] = float(i + 1);
|
||||||
|
inst.world_aabb_min[2] = float(i + 2);
|
||||||
|
inst.world_aabb_max[0] = float(i) + 10.0f;
|
||||||
|
inst.world_aabb_max[1] = float(i + 1) + 10.0f;
|
||||||
|
inst.world_aabb_max[2] = float(i + 2) + 10.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
sd.string_table = std::string("\0Wall\0Slab\0", 11); // includes embedded NULs
|
||||||
|
sd.elements.resize(3);
|
||||||
|
for (size_t i = 0; i < sd.elements.size(); ++i) {
|
||||||
|
PackedElementInfo& e = sd.elements[i];
|
||||||
|
e.object_id = uint32_t(100 + i);
|
||||||
|
e.model_id = 1;
|
||||||
|
e.ifc_id = int32_t(1000 + i);
|
||||||
|
e.parent_id = (i == 0) ? -1 : int32_t(100);
|
||||||
|
e.guid_offset = 0; e.guid_length = 0;
|
||||||
|
e.name_offset = 1; e.name_length = 4; // "Wall"
|
||||||
|
e.type_offset = 6; e.type_length = 4; // "Slab"
|
||||||
|
}
|
||||||
|
return sd;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool sidecarDataEqual(const SidecarData& a, const SidecarData& b) {
|
||||||
|
if (a.vertices != b.vertices) return false;
|
||||||
|
if (a.indices != b.indices) return false;
|
||||||
|
if (a.meshes.size() != b.meshes.size()) return false;
|
||||||
|
if (a.instances.size() != b.instances.size()) return false;
|
||||||
|
if (a.elements.size() != b.elements.size()) return false;
|
||||||
|
if (a.string_table != b.string_table) return false;
|
||||||
|
|
||||||
|
for (size_t i = 0; i < a.meshes.size(); ++i) {
|
||||||
|
if (std::memcmp(&a.meshes[i], &b.meshes[i], sizeof(MeshInfo)) != 0) return false;
|
||||||
|
}
|
||||||
|
for (size_t i = 0; i < a.instances.size(); ++i) {
|
||||||
|
if (std::memcmp(&a.instances[i], &b.instances[i], sizeof(InstanceCpu)) != 0) return false;
|
||||||
|
}
|
||||||
|
for (size_t i = 0; i < a.elements.size(); ++i) {
|
||||||
|
if (std::memcmp(&a.elements[i], &b.elements[i], sizeof(PackedElementInfo)) != 0) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
TEST_CASE("MeshInfo and InstanceCpu have stable layouts (sidecar wire format)", "[sidecar]") {
|
||||||
|
REQUIRE(sizeof(MeshInfo) == 56);
|
||||||
|
REQUIRE(sizeof(InstanceGpu) == 80);
|
||||||
|
REQUIRE(SIDECAR_VERSION == 9);
|
||||||
|
REQUIRE(SIDECAR_MAGIC == 0x49465657u);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("writeSidecar then readSidecar round-trips the full fixture", "[sidecar]") {
|
||||||
|
fs::path dir = makeScratchDir("roundtrip");
|
||||||
|
fs::path ifc = dir / "model.ifc";
|
||||||
|
fs::path expected = dir / "model.ifcview";
|
||||||
|
|
||||||
|
SidecarData original = buildFixture();
|
||||||
|
REQUIRE(writeSidecar(ifc.string(), original));
|
||||||
|
REQUIRE(fs::exists(expected));
|
||||||
|
|
||||||
|
auto loaded = readSidecar(ifc.string());
|
||||||
|
REQUIRE(loaded.has_value());
|
||||||
|
REQUIRE(sidecarDataEqual(original, *loaded));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("readSidecar returns nullopt when the sidecar is missing", "[sidecar]") {
|
||||||
|
fs::path dir = makeScratchDir("missing");
|
||||||
|
fs::path ifc = dir / "absent.ifc";
|
||||||
|
auto loaded = readSidecar(ifc.string());
|
||||||
|
REQUIRE_FALSE(loaded.has_value());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("readSidecar rejects a truncated header", "[sidecar]") {
|
||||||
|
fs::path dir = makeScratchDir("truncated");
|
||||||
|
fs::path ifc = dir / "bad.ifc";
|
||||||
|
fs::path bad = dir / "bad.ifcview";
|
||||||
|
{
|
||||||
|
FILE* f = std::fopen(bad.string().c_str(), "wb");
|
||||||
|
REQUIRE(f);
|
||||||
|
const char junk[] = "X";
|
||||||
|
std::fwrite(junk, 1, sizeof(junk), f);
|
||||||
|
std::fclose(f);
|
||||||
|
}
|
||||||
|
auto loaded = readSidecar(ifc.string());
|
||||||
|
REQUIRE_FALSE(loaded.has_value());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("readSidecar rejects a wrong magic / version", "[sidecar]") {
|
||||||
|
fs::path dir = makeScratchDir("wrongver");
|
||||||
|
fs::path ifc = dir / "old.ifc";
|
||||||
|
fs::path old = dir / "old.ifcview";
|
||||||
|
struct Hdr { uint32_t magic, version, endian; } h{
|
||||||
|
SIDECAR_MAGIC, SIDECAR_VERSION - 1, SIDECAR_ENDIAN
|
||||||
|
};
|
||||||
|
{
|
||||||
|
FILE* f = std::fopen(old.string().c_str(), "wb");
|
||||||
|
REQUIRE(f);
|
||||||
|
std::fwrite(&h, sizeof(h), 1, f);
|
||||||
|
// Write zeroed payload so the failure must come from the header check.
|
||||||
|
uint32_t zero = 0;
|
||||||
|
for (int i = 0; i < 6; ++i) std::fwrite(&zero, 4, 1, f);
|
||||||
|
std::fclose(f);
|
||||||
|
}
|
||||||
|
auto loaded = readSidecar(ifc.string());
|
||||||
|
REQUIRE_FALSE(loaded.has_value());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Empty SidecarData round-trips cleanly", "[sidecar]") {
|
||||||
|
fs::path dir = makeScratchDir("empty");
|
||||||
|
fs::path ifc = dir / "empty.ifc";
|
||||||
|
SidecarData empty;
|
||||||
|
REQUIRE(writeSidecar(ifc.string(), empty));
|
||||||
|
auto loaded = readSidecar(ifc.string());
|
||||||
|
REQUIRE(loaded.has_value());
|
||||||
|
REQUIRE(loaded->vertices.empty());
|
||||||
|
REQUIRE(loaded->indices.empty());
|
||||||
|
REQUIRE(loaded->meshes.empty());
|
||||||
|
REQUIRE(loaded->instances.empty());
|
||||||
|
REQUIRE(loaded->elements.empty());
|
||||||
|
REQUIRE(loaded->string_table.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Sidecar path stem maps .ifc / .ifcdb / extensionless to .ifcview", "[sidecar]") {
|
||||||
|
// The mapping is internal but observable: writing under one source name
|
||||||
|
// must be readable under any other name that maps to the same stem.
|
||||||
|
fs::path dir = makeScratchDir("stems");
|
||||||
|
SidecarData sd = buildFixture();
|
||||||
|
|
||||||
|
fs::path ifc_path = dir / "shared.ifc";
|
||||||
|
fs::path ifcdb_path = dir / "shared.ifcdb";
|
||||||
|
fs::path ifcdb_slash = dir / "shared.ifcdb/";
|
||||||
|
fs::path noext_path = dir / "shared";
|
||||||
|
|
||||||
|
REQUIRE(writeSidecar(ifc_path.string(), sd));
|
||||||
|
REQUIRE(fs::exists(dir / "shared.ifcview"));
|
||||||
|
|
||||||
|
auto a = readSidecar(ifcdb_path.string());
|
||||||
|
auto b = readSidecar(ifcdb_slash.string());
|
||||||
|
auto c = readSidecar(noext_path.string());
|
||||||
|
REQUIRE(a.has_value());
|
||||||
|
REQUIRE(b.has_value());
|
||||||
|
REQUIRE(c.has_value());
|
||||||
|
REQUIRE(sidecarDataEqual(sd, *a));
|
||||||
|
REQUIRE(sidecarDataEqual(sd, *b));
|
||||||
|
REQUIRE(sidecarDataEqual(sd, *c));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user