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:
Dion Moult
2026-04-28 21:49:44 +10:00
parent 633c613da2
commit e21bd1ac96
10 changed files with 1145 additions and 0 deletions
+18
View File
@@ -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_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_TESTS "Build unit tests for IfcViewer (fetches Catch2 v3)" OFF)
option(BUILD_PACKAGE "" OFF)
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)
endif(BUILD_IFCGEOM)
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-minimal ifcviewer-minimal)
add_subdirectory(../src/ifcviewer-full ifcviewer-full)
+4
View File
@@ -34,3 +34,7 @@ set_target_properties(IfcViewerFull PROPERTIES
target_link_libraries(IfcViewerFull PRIVATE IfcViewer)
install(TARGETS IfcViewerFull EXPORT ${IFCOPENSHELL_EXPORT_TARGETS})
if(BUILD_IFCVIEWER_TESTS)
add_subdirectory(tests)
endif()
+41
View File
@@ -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());
}
+4
View File
@@ -70,3 +70,7 @@ install(TARGETS IfcViewer EXPORT ${IFCOPENSHELL_EXPORT_TARGETS})
install(FILES ${IFCVIEWER_H_FILES}
DESTINATION ${INCLUDEDIR}/ifcviewer
)
if(BUILD_IFCVIEWER_TESTS)
add_subdirectory(tests)
endif()
+49
View File
@@ -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)
+184
View File
@@ -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);
}
+191
View File
@@ -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);
}
+238
View File
@@ -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));
}