diff --git a/cmake/CMakeLists.txt b/cmake/CMakeLists.txt
index 660d55cf3d..133c10f818 100644
--- a/cmake/CMakeLists.txt
+++ b/cmake/CMakeLists.txt
@@ -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)
diff --git a/src/ifcviewer-full/CMakeLists.txt b/src/ifcviewer-full/CMakeLists.txt
index f578d96f70..ad5a5083ad 100644
--- a/src/ifcviewer-full/CMakeLists.txt
+++ b/src/ifcviewer-full/CMakeLists.txt
@@ -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()
diff --git a/src/ifcviewer-full/tests/CMakeLists.txt b/src/ifcviewer-full/tests/CMakeLists.txt
new file mode 100644
index 0000000000..705fec1ad0
--- /dev/null
+++ b/src/ifcviewer-full/tests/CMakeLists.txt
@@ -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 . #
+# #
+################################################################################
+
+# 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)
diff --git a/src/ifcviewer-full/tests/test_federation.cpp b/src/ifcviewer-full/tests/test_federation.cpp
new file mode 100644
index 0000000000..6226464220
--- /dev/null
+++ b/src/ifcviewer-full/tests/test_federation.cpp
@@ -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 . *
+ * *
+ ********************************************************************************/
+
+#include "Federation.h"
+
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+
+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:
+ // /fed_root/project.ifcfed
+ // /fed_root/sub/inside.ifc (under fed_dir)
+ // /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());
+}
diff --git a/src/ifcviewer/CMakeLists.txt b/src/ifcviewer/CMakeLists.txt
index b488cd5050..9e8385a2d1 100644
--- a/src/ifcviewer/CMakeLists.txt
+++ b/src/ifcviewer/CMakeLists.txt
@@ -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()
diff --git a/src/ifcviewer/tests/CMakeLists.txt b/src/ifcviewer/tests/CMakeLists.txt
new file mode 100644
index 0000000000..d6739bb22b
--- /dev/null
+++ b/src/ifcviewer/tests/CMakeLists.txt
@@ -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 . #
+# #
+################################################################################
+
+# 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)
diff --git a/src/ifcviewer/tests/test_bvh_accel.cpp b/src/ifcviewer/tests/test_bvh_accel.cpp
new file mode 100644
index 0000000000..3d39f922c4
--- /dev/null
+++ b/src/ifcviewer/tests/test_bvh_accel.cpp
@@ -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 . *
+ * *
+ ********************************************************************************/
+
+#include "BvhAccel.h"
+
+#include
+
+#include
+#include
+
+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& 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 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 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 coord(-100.0f, 100.0f);
+ std::uniform_real_distribution radius(0.1f, 1.0f);
+
+ constexpr int N = 256;
+ std::vector 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 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 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 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());
+}
diff --git a/src/ifcviewer/tests/test_instanced_geometry.cpp b/src/ifcviewer/tests/test_instanced_geometry.cpp
new file mode 100644
index 0000000000..2903811706
--- /dev/null
+++ b/src/ifcviewer/tests/test_instanced_geometry.cpp
@@ -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 . *
+ * *
+ ********************************************************************************/
+
+// 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
+
+#include
+#include
+
+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);
+}
diff --git a/src/ifcviewer/tests/test_lod_builder.cpp b/src/ifcviewer/tests/test_lod_builder.cpp
new file mode 100644
index 0000000000..9d82523ce5
--- /dev/null
+++ b/src/ifcviewer/tests/test_lod_builder.cpp
@@ -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 . *
+ * *
+ ********************************************************************************/
+
+#include "InstancedGeometry.h"
+#include "LodBuilder.h"
+#include "SidecarCache.h"
+
+#include
+
+#include
+#include
+#include
+#include
+
+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& 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(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);
+}
diff --git a/src/ifcviewer/tests/test_sidecar_cache.cpp b/src/ifcviewer/tests/test_sidecar_cache.cpp
new file mode 100644
index 0000000000..978305db53
--- /dev/null
+++ b/src/ifcviewer/tests/test_sidecar_cache.cpp
@@ -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 . *
+ * *
+ ********************************************************************************/
+
+#include "InstancedGeometry.h"
+#include "SidecarCache.h"
+
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+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 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));
+}