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)); +}