ifcviewer: extract ChunkPlanner + InstanceCompose; add Tier-1 test trio

The chunk planner (Morton sort + greedy pack) and instance composition
(federation × placement matrix chain + world-AABB derive) were inline
helpers in ViewportWindow.cpp. Pulled both out as free-function modules
so the math + lookup logic can be exercised without a Qt window or a
wgpu device. ViewportWindow now delegates; InstanceLookup is a using-
alias to InstanceCompose::InstanceLookup.

Also added an addSubBufferForTesting / clearSubPoolsForTesting seam to
BufferPool so the sub-allocator invariants can be pinned with fake
WGPUBuffer handles. The fakes are never dereferenced; the guard drops
the sub-pools before destructor would call wgpuBufferRelease.

Three new test binaries under src/ifcviewer/tests/, 33 cases / 173
assertions: BufferPool first-fit + alignment + coalescing + multi-
sub-pool isolation; ChunkPlanner Morton split / interleave / stable
sort / greedy-pack monotonicity and single-mesh-oversize; InstanceCompose
identity / translation / order-of-multiplication / large-placement
cancellation against federation false origin / column-major writeback /
findInstance lookup paths.
This commit is contained in:
Dion Moult
2026-06-04 17:19:24 +10:00
parent 749476d1a7
commit 1fe4570860
12 changed files with 1315 additions and 165 deletions
+39
View File
@@ -52,11 +52,50 @@ add_ifcviewer_unit_test(test_sidecar_cache
add_ifcviewer_unit_test(test_instanced_geometry)
# ChunkPlanner: Morton sort + greedy-pack — pure CPU, no Qt / no wgpu.
add_ifcviewer_unit_test(test_chunk_planner
SOURCES ${IFCVIEWER_SRC}/ChunkPlanner.cpp
)
# InstanceCompose: matrix composition + cross-model object_id lookup.
# Pulls in wgpu_native for the WGPUBuffer typedef via ModelGpuData.h
# (never touched at runtime). Eigen for Matrix4d.
find_package(Eigen3 REQUIRED)
add_ifcviewer_unit_test(test_instance_compose
SOURCES ${IFCVIEWER_SRC}/InstanceCompose.cpp
LIBS wgpu_native Eigen3::Eigen
)
if(UNIX AND NOT APPLE AND WGPU_NATIVE_LIB_DIR)
set_target_properties(test_instance_compose PROPERTIES
BUILD_RPATH "${WGPU_NATIVE_LIB_DIR}"
)
endif()
# Header-only state-machine tests for the renderer subsystems (selection,
# visibility). Subjects are inline in their .h files, so no SOURCES needed.
add_ifcviewer_unit_test(test_selection)
add_ifcviewer_unit_test(test_visibility)
# BufferPool sub-allocator invariants. The pool's wgpu calls live inside
# addSubBuffer() (the growth path); tests use the addSubBufferForTesting
# seam to preseed sub-pools with fake handles, so the only wgpu touchpoint
# the linker needs is the production destructor's wgpuBufferRelease — which
# is never reached for fake handles because we don't call destroy() / let
# the pool go out of scope holding any. Linking wgpu_native satisfies the
# symbol regardless. Qt6::Core comes along for qInfo() inside
# addSubBuffer's diagnostic log — also never reached at test runtime, but
# the unresolved symbol would fail link.
find_package(Qt${QT_VERSION} COMPONENTS Core REQUIRED PATHS ${QT_DIR})
add_ifcviewer_unit_test(test_buffer_pool
SOURCES ${IFCVIEWER_SRC}/BufferPool.cpp
LIBS wgpu_native Qt${QT_VERSION}::Core
)
if(UNIX AND NOT APPLE AND WGPU_NATIVE_LIB_DIR)
set_target_properties(test_buffer_pool PROPERTIES
BUILD_RPATH "${WGPU_NATIVE_LIB_DIR}"
)
endif()
# Federation is Qt-derived (QObject + signals). It has to pull Qt6 in
# directly and enable AUTOMOC for the Q_OBJECT moc-generation.
find_package(Qt${QT_VERSION} COMPONENTS Core Gui Test REQUIRED PATHS ${QT_DIR})
+254
View File
@@ -0,0 +1,254 @@
/********************************************************************************
* *
* 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 BufferPool's sub-allocator. The pool's free-list +
// coalescing logic is pure CPU bookkeeping; wgpu calls only happen inside
// addSubBuffer() during growth. We pre-seed sub-pools via the test-only
// addSubBufferForTesting() seam so the tests don't need a real device,
// then exercise alloc / free / alignment / coalescing / multi-sub-pool
// behaviour against the public API.
//
// The fake handles below are never dereferenced — BufferPool treats
// WGPUBuffer as an opaque token it just hands back inside Slice. Using
// `reinterpret_cast<WGPUBuffer>(0x100)` etc. gives us stable identities
// for cross-pool sub_idx assertions.
#include "BufferPool.h"
#include <catch2/catch_all.hpp>
#include <cstdint>
namespace {
WGPUBuffer fake_handle(uintptr_t id) {
// Any non-null pointer works; the value is only used for == comparisons
// and never dereferenced. Adding an offset by id keeps multiple fakes
// visibly distinct in failure messages.
return reinterpret_cast<WGPUBuffer>(static_cast<uintptr_t>(0x1000) + id);
}
// RAII guard so the pool's destructor doesn't try to wgpuBufferRelease()
// our fake handles. Drops the sub-pools via the test seam first.
struct FakePoolGuard {
BufferPool& p;
~FakePoolGuard() { p.clearSubPoolsForTesting(); }
};
} // namespace
TEST_CASE("empty pool reports zero capacity and refuses allocs", "[buffer_pool]") {
BufferPool pool;
FakePoolGuard guard{pool};
REQUIRE(pool.sub_buffer_count() == 0);
REQUIRE(pool.total_capacity_bytes() == 0);
REQUIRE(pool.total_used_bytes() == 0);
REQUIRE(pool.total_free_bytes() == 0);
REQUIRE(pool.largest_free_run_bytes() == 0);
// No sub-pool exists yet; without a configured device addSubBuffer
// can't grow, so alloc returns an invalid Slice rather than UB.
auto s = pool.alloc(64, 16);
REQUIRE_FALSE(s.valid());
REQUIRE(s.size == 0);
}
TEST_CASE("single alloc returns a valid aligned slice", "[buffer_pool]") {
BufferPool pool;
FakePoolGuard guard{pool};
pool.addSubBufferForTesting(fake_handle(1), 1024);
REQUIRE(pool.sub_buffer_count() == 1);
REQUIRE(pool.total_capacity_bytes() == 1024);
REQUIRE(pool.total_used_bytes() == 0);
REQUIRE(pool.largest_free_run_bytes() == 1024);
auto s = pool.alloc(/*size=*/100, /*align=*/256);
REQUIRE(s.valid());
REQUIRE(s.buffer == fake_handle(1));
REQUIRE(s.size == 100);
REQUIRE((s.offset % 256) == 0);
REQUIRE(s.sub_idx == 0);
// `used` tracks alloc sizes (excludes pad). Free space drops by both.
REQUIRE(pool.total_used_bytes() == 100);
REQUIRE(pool.total_free_bytes() == 1024 - 100);
}
TEST_CASE("alloc-then-free round-trip returns the slot to the pool", "[buffer_pool]") {
BufferPool pool;
FakePoolGuard guard{pool};
pool.addSubBufferForTesting(fake_handle(1), 1024);
auto s = pool.alloc(256, 1);
REQUIRE(s.valid());
REQUIRE(pool.total_used_bytes() == 256);
pool.free(s);
REQUIRE(pool.total_used_bytes() == 0);
REQUIRE(pool.largest_free_run_bytes() == 1024);
// After the free-with-coalesce the pool is byte-identical to its
// initial state, so an alloc of the original size can reuse the
// same offset.
auto s2 = pool.alloc(256, 1);
REQUIRE(s2.valid());
REQUIRE(s2.offset == s.offset);
}
TEST_CASE("alignment padding is reclaimable by smaller allocs", "[buffer_pool]") {
BufferPool pool;
FakePoolGuard guard{pool};
pool.addSubBufferForTesting(fake_handle(1), 1024);
// First alloc requests 256-byte alignment; offset 0 already satisfies
// it, so no pad. Second alloc of size 100 follows at offset 256.
auto a = pool.alloc(100, 256);
auto b = pool.alloc(100, 256);
REQUIRE(a.offset == 0);
REQUIRE(b.offset == 256);
REQUIRE(b.offset >= a.offset + a.size);
// Used = sum of allocation sizes only. The 156 bytes of pad inside the
// first 256-byte slot remain in free_ranges and are reclaimable by an
// alloc small enough to fit them.
REQUIRE(pool.total_used_bytes() == 200);
auto c = pool.alloc(50, 1);
REQUIRE(c.valid());
REQUIRE(c.offset >= 100); // lands in the leading pad of slot 0
REQUIRE(c.offset < 256);
}
TEST_CASE("free coalesces adjacent ranges in the same sub-pool", "[buffer_pool]") {
BufferPool pool;
FakePoolGuard guard{pool};
pool.addSubBufferForTesting(fake_handle(1), 1024);
auto a = pool.alloc(256, 1);
auto b = pool.alloc(256, 1);
auto c = pool.alloc(256, 1);
REQUIRE(a.offset + a.size == b.offset);
REQUIRE(b.offset + b.size == c.offset);
// Free in non-adjacent order: a, then c, leaves a hole around b.
pool.free(a);
pool.free(c);
// largest_free_run can be a (256), b (still alloc'd, no), c+tail
// (256 + remaining = at least 256). It's not 768 because b is in
// the middle.
REQUIRE(pool.largest_free_run_bytes() < 768);
pool.free(b);
// Now all three runs collapse into one contiguous free block, plus
// the tail. largest_free_run is the entire sub-pool again.
REQUIRE(pool.largest_free_run_bytes() == 1024);
REQUIRE(pool.total_used_bytes() == 0);
}
TEST_CASE("alloc fails gracefully when no sub-pool can fit", "[buffer_pool]") {
BufferPool pool;
FakePoolGuard guard{pool};
pool.addSubBufferForTesting(fake_handle(1), 512);
auto big = pool.alloc(512, 1);
REQUIRE(big.valid());
REQUIRE(pool.total_used_bytes() == 512);
// Pool is now full and can_grow() is false (we never configure'd
// a device, so per_sub_buffer_capacity_ is 0). alloc returns
// an invalid Slice rather than asserting or growing into garbage.
REQUIRE_FALSE(pool.can_grow());
auto fail = pool.alloc(1, 1);
REQUIRE_FALSE(fail.valid());
}
TEST_CASE("multi sub-pool alloc spans pools and reports correct sub_idx", "[buffer_pool]") {
BufferPool pool;
FakePoolGuard guard{pool};
pool.addSubBufferForTesting(fake_handle(1), 256);
pool.addSubBufferForTesting(fake_handle(2), 256);
REQUIRE(pool.sub_buffer_count() == 2);
REQUIRE(pool.total_capacity_bytes() == 512);
// First alloc fits in sub-pool 0.
auto a = pool.alloc(256, 1);
REQUIRE(a.valid());
REQUIRE(a.buffer == fake_handle(1));
REQUIRE(a.sub_idx == 0);
// Second alloc can't fit in sub-pool 0 (full); first-fit moves to
// sub-pool 1.
auto b = pool.alloc(256, 1);
REQUIRE(b.valid());
REQUIRE(b.buffer == fake_handle(2));
REQUIRE(b.sub_idx == 1);
REQUIRE(pool.total_used_bytes() == 512);
REQUIRE(pool.total_free_bytes() == 0);
}
TEST_CASE("free routes by sub_idx — no cross-pool coalescing", "[buffer_pool]") {
BufferPool pool;
FakePoolGuard guard{pool};
pool.addSubBufferForTesting(fake_handle(1), 256);
pool.addSubBufferForTesting(fake_handle(2), 256);
auto a = pool.alloc(256, 1); // sub 0
auto b = pool.alloc(256, 1); // sub 1
REQUIRE(a.sub_idx == 0);
REQUIRE(b.sub_idx == 1);
pool.free(a);
pool.free(b);
// Both sub-pools are individually empty, but they are distinct
// buffers — largest_free_run is per-sub-buffer, not summed across.
REQUIRE(pool.total_used_bytes() == 0);
REQUIRE(pool.largest_free_run_bytes() == 256);
}
TEST_CASE("free with invalid slice is a no-op", "[buffer_pool]") {
BufferPool pool;
FakePoolGuard guard{pool};
pool.addSubBufferForTesting(fake_handle(1), 512);
auto a = pool.alloc(128, 1);
REQUIRE(a.valid());
const uint64_t used_before = pool.total_used_bytes();
// Default-constructed Slice has size=0 + null buffer + sub_idx=-1.
// free() should silently ignore it (this is the path real callers
// hit when an alloc failed earlier and they unconditionally free).
BufferPool::Slice junk;
REQUIRE_FALSE(junk.valid());
pool.free(junk);
REQUIRE(pool.total_used_bytes() == used_before);
// Sub-index out of range is also ignored.
BufferPool::Slice bad_idx = a;
bad_idx.sub_idx = 99;
pool.free(bad_idx);
REQUIRE(pool.total_used_bytes() == used_before);
// Real free still works after these no-ops.
pool.free(a);
REQUIRE(pool.total_used_bytes() == 0);
}
+224
View File
@@ -0,0 +1,224 @@
/********************************************************************************
* *
* 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 ChunkPlanner. Both passes are pure CPU bookkeeping:
// the Morton sort orders mesh ids by Z-order code over centroids, and the
// greedy pack stamps the sorted sequence into chunks ≤ a bytes-budget.
// Tests use small, hand-checkable inputs so each assertion pins one
// invariant (locality, permutation closure, monotonic codes, packing
// monotonicity, single-mesh overflow, empty edge cases).
#include "ChunkPlanner.h"
#include <catch2/catch_all.hpp>
#include <algorithm>
#include <cstdint>
#include <set>
#include <vector>
// -----------------------------------------------------------------------------
// mortonSplit21 / mortonCode3D — primitive invariants
// -----------------------------------------------------------------------------
TEST_CASE("mortonSplit21 leaves zero bits between original bits", "[chunk_planner][morton]") {
REQUIRE(ChunkPlanner::mortonSplit21(0u) == 0ull);
// Every original 1-bit lands at position 3*k; the bits in between
// stay zero. v=0b111 → 0b1001001 = 0x49.
REQUIRE(ChunkPlanner::mortonSplit21(0b111u) == 0x49ull);
// The high 11 bits above bit 20 must be discarded (mask 0x1FFFFF).
REQUIRE(ChunkPlanner::mortonSplit21(1u << 21) == 0ull);
REQUIRE(ChunkPlanner::mortonSplit21(0xFFFFFFFFu) ==
ChunkPlanner::mortonSplit21(0x1FFFFFu));
}
TEST_CASE("mortonCode3D interleaves x and y and z onto bits 0/1/2 mod 3", "[chunk_planner][morton]") {
REQUIRE(ChunkPlanner::mortonCode3D(0, 0, 0) == 0ull);
// x = 1, y = 0, z = 0 → bit 0 set only.
REQUIRE(ChunkPlanner::mortonCode3D(1, 0, 0) == 1ull);
REQUIRE(ChunkPlanner::mortonCode3D(0, 1, 0) == 2ull);
REQUIRE(ChunkPlanner::mortonCode3D(0, 0, 1) == 4ull);
// All three set → 0b111.
REQUIRE(ChunkPlanner::mortonCode3D(1, 1, 1) == 7ull);
}
TEST_CASE("mortonCode3D is monotone along each axis with others fixed", "[chunk_planner][morton]") {
// Walking one axis up monotonically should make the code monotone in
// that axis when the other two are zero (no carry from interleaving).
for (uint32_t a = 0; a < 8; ++a) {
REQUIRE(ChunkPlanner::mortonCode3D(a, 0, 0)
< ChunkPlanner::mortonCode3D(a + 1, 0, 0));
REQUIRE(ChunkPlanner::mortonCode3D(0, a, 0)
< ChunkPlanner::mortonCode3D(0, a + 1, 0));
REQUIRE(ChunkPlanner::mortonCode3D(0, 0, a)
< ChunkPlanner::mortonCode3D(0, 0, a + 1));
}
}
// -----------------------------------------------------------------------------
// sortMeshIdsByMorton — permutation + locality
// -----------------------------------------------------------------------------
TEST_CASE("Morton sort returns a permutation of all input ids", "[chunk_planner][sort]") {
const std::size_t N = 32;
std::vector<float> cx(N), cy(N), cz(N);
std::vector<uint32_t> inst(N, 1);
// Sprinkle centroids over a 1m grid.
for (std::size_t i = 0; i < N; ++i) {
cx[i] = float((i ) & 0x3);
cy[i] = float((i >> 2 ) & 0x3);
cz[i] = float((i >> 4 ) & 0x1);
}
auto sorted = ChunkPlanner::sortMeshIdsByMorton(N, cx, cy, cz, inst);
REQUIRE(sorted.size() == N);
// Every input id appears exactly once.
std::set<uint32_t> seen(sorted.begin(), sorted.end());
REQUIRE(seen.size() == N);
REQUIRE(*seen.begin() == 0u);
REQUIRE(*seen.rbegin() == uint32_t(N - 1));
}
TEST_CASE("Morton sort puts spatial neighbours close in the sequence", "[chunk_planner][sort]") {
// Four corners of a unit square in the XY plane. We expect (0,0)
// and (1,0) to appear adjacent in the sorted order, ditto (0,1)
// and (1,1) — never (0,0) then (1,1) with a corner in between,
// which is what the previous (z, y, x) lex sort would produce.
//
// mortonCode3D({0,1,0}, {0,0,1}, 0) over the quad:
// (0,0,0) → 0
// (1,0,0) → 1
// (0,1,0) → 2
// (1,1,0) → 3
// So expected order: 0, 1, 2, 3 → ids that map to those codes.
std::vector<float> cx = {0.f, 1.f, 0.f, 1.f};
std::vector<float> cy = {0.f, 0.f, 1.f, 1.f};
std::vector<float> cz = {0.f, 0.f, 0.f, 0.f};
std::vector<uint32_t> inst = {1, 1, 1, 1};
auto sorted = ChunkPlanner::sortMeshIdsByMorton(4, cx, cy, cz, inst);
REQUIRE(sorted == std::vector<uint32_t>{0, 1, 2, 3});
}
TEST_CASE("Morton sort is stable on tied codes", "[chunk_planner][sort]") {
// All four meshes share the same centroid → identical Morton
// codes. stable_sort preserves their original id order.
std::vector<float> cx = {0.f, 0.f, 0.f, 0.f};
std::vector<float> cy = {0.f, 0.f, 0.f, 0.f};
std::vector<float> cz = {0.f, 0.f, 0.f, 0.f};
std::vector<uint32_t> inst = {1, 1, 1, 1};
auto sorted = ChunkPlanner::sortMeshIdsByMorton(4, cx, cy, cz, inst);
REQUIRE(sorted == std::vector<uint32_t>{0, 1, 2, 3});
}
// -----------------------------------------------------------------------------
// greedyPackChunks — packing rules
// -----------------------------------------------------------------------------
TEST_CASE("Empty input produces an empty chunk plan", "[chunk_planner][pack]") {
auto chunks = ChunkPlanner::greedyPackChunks({}, {}, 12, 1024);
REQUIRE(chunks.empty());
}
TEST_CASE("All meshes fit in one chunk under the bytes ceiling", "[chunk_planner][pack]") {
// 4 meshes × 100 vertices × 12 B/v = 4800 B << 1 MB ceiling.
std::vector<uint32_t> sorted = {0, 1, 2, 3};
std::vector<uint32_t> v_counts = {100, 100, 100, 100};
auto chunks = ChunkPlanner::greedyPackChunks(sorted, v_counts, 12, 1024 * 1024);
REQUIRE(chunks.size() == 1);
REQUIRE(chunks[0] == std::vector<uint32_t>{0, 1, 2, 3});
}
TEST_CASE("Greedy pack splits at the bytes ceiling while preserving order", "[chunk_planner][pack]") {
// ceiling = 500 B; stride = 10 B. Each mesh: 200 B. Two fit (400 B),
// a third doesn't (would exceed), so it starts a new chunk.
std::vector<uint32_t> sorted = {10, 20, 30, 40, 50};
std::vector<uint32_t> v_counts(60, 0);
for (uint32_t mi : sorted) v_counts[mi] = 20; // 20 v × 10 B = 200 B
auto chunks = ChunkPlanner::greedyPackChunks(sorted, v_counts, 10, 500);
REQUIRE(chunks.size() == 3);
REQUIRE(chunks[0] == std::vector<uint32_t>{10, 20});
REQUIRE(chunks[1] == std::vector<uint32_t>{30, 40});
REQUIRE(chunks[2] == std::vector<uint32_t>{50});
}
TEST_CASE("A mesh larger than the chunk ceiling gets its own oversized chunk", "[chunk_planner][pack]") {
// First chunk holds the small mesh (200 B). The huge mesh (10_000 B)
// alone exceeds the 500 B ceiling but still goes in one chunk —
// the planner never splits a mesh across chunks.
std::vector<uint32_t> sorted = {0, 1, 2};
std::vector<uint32_t> v_counts = {20, 1000, 20}; // 200 B, 10_000 B, 200 B
auto chunks = ChunkPlanner::greedyPackChunks(sorted, v_counts, 10, 500);
REQUIRE(chunks.size() == 3);
REQUIRE(chunks[0] == std::vector<uint32_t>{0});
REQUIRE(chunks[1] == std::vector<uint32_t>{1}); // oversized; alone
REQUIRE(chunks[2] == std::vector<uint32_t>{2});
}
TEST_CASE("Every input mesh lands in exactly one chunk", "[chunk_planner][pack]") {
// Heterogeneous sizes — confirm the planner is a partition: every
// mesh id from `sorted` appears in some chunk exactly once, and
// the per-chunk byte sums obey the ceiling (subject to the
// single-mesh-oversize exception).
std::vector<uint32_t> sorted = {3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5};
std::vector<uint32_t> v_counts(16, 0);
v_counts[1] = 10; v_counts[2] = 20; v_counts[3] = 30; v_counts[4] = 50;
v_counts[5] = 5; v_counts[6] = 80; v_counts[9] = 15;
const uint64_t stride = 12;
const uint64_t ceil = 500;
auto chunks = ChunkPlanner::greedyPackChunks(sorted, v_counts, stride, ceil);
std::vector<uint32_t> flat;
for (const auto& c : chunks) {
REQUIRE_FALSE(c.empty());
for (auto mi : c) flat.push_back(mi);
// Per-chunk byte tally check: either ≤ ceiling, or chunk has
// exactly one mesh that singly exceeds it.
uint64_t bytes = 0;
for (auto mi : c) bytes += uint64_t(v_counts[mi]) * stride;
if (bytes > ceil) {
REQUIRE(c.size() == 1);
}
}
// Multiset equality: same mesh ids, same multiplicity, same order.
REQUIRE(flat == sorted);
}
TEST_CASE("Zero-size meshes don't create new chunks unnecessarily", "[chunk_planner][pack]") {
// A mesh with vertex_count = 0 contributes 0 bytes; it should pack
// into whatever the current chunk is without triggering a flush.
std::vector<uint32_t> sorted = {0, 1, 2, 3};
std::vector<uint32_t> v_counts = {10, 0, 10, 0};
auto chunks = ChunkPlanner::greedyPackChunks(sorted, v_counts, 10, 1000);
REQUIRE(chunks.size() == 1);
REQUIRE(chunks[0] == std::vector<uint32_t>{0, 1, 2, 3});
}
@@ -0,0 +1,344 @@
/********************************************************************************
* *
* 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 InstanceCompose. Both helpers are pure CPU:
// - composeInstance does the federation × placement matrix chain plus
// a corner-AABB transform.
// - findInstanceInModels walks a model map and resolves an object id.
// Tests stay in-memory: ModelGpuData carries WGPUBuffer pointers that
// default to nullptr and are never released by anything in this test
// (releaseWgpuModelGpuData is only called from ViewportWindow).
#include "InstanceCompose.h"
#include "ModelGpuData.h"
#include <catch2/catch_all.hpp>
#include <array>
#include <cmath>
#include <cstring>
namespace {
// Build a 4x4 identity in column-major double[16] order.
std::array<double, 16> identity_dcm() {
std::array<double, 16> M{};
M[0] = M[5] = M[10] = M[15] = 1.0;
return M;
}
// Build a 4x4 column-major double[16] translation matrix.
std::array<double, 16> translation_dcm(double tx, double ty, double tz) {
auto M = identity_dcm();
M[12] = tx; M[13] = ty; M[14] = tz;
return M;
}
// Build a 4x4 column-major double[16] scale matrix.
std::array<double, 16> scale_dcm(double s) {
std::array<double, 16> M{};
M[0] = M[5] = M[10] = s;
M[15] = 1.0;
return M;
}
bool nearly_equal(float a, float b, float eps = 1e-5f) {
return std::fabs(a - b) <= eps;
}
} // namespace
// -----------------------------------------------------------------------------
// worldAabbFromLocal — corner walk
// -----------------------------------------------------------------------------
TEST_CASE("worldAabbFromLocal identity-matrix copies the local AABB", "[instance_compose][aabb]") {
const float lmin[3] = {-1.0f, -2.0f, -3.0f};
const float lmax[3] = { 4.0f, 5.0f, 6.0f};
const float Mid[16] = {
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1
};
float omin[3], omax[3];
InstanceCompose::worldAabbFromLocal(lmin, lmax, Mid, omin, omax);
REQUIRE(nearly_equal(omin[0], lmin[0]));
REQUIRE(nearly_equal(omin[1], lmin[1]));
REQUIRE(nearly_equal(omin[2], lmin[2]));
REQUIRE(nearly_equal(omax[0], lmax[0]));
REQUIRE(nearly_equal(omax[1], lmax[1]));
REQUIRE(nearly_equal(omax[2], lmax[2]));
}
TEST_CASE("worldAabbFromLocal translation shifts every axis", "[instance_compose][aabb]") {
const float lmin[3] = {0.0f, 0.0f, 0.0f};
const float lmax[3] = {1.0f, 1.0f, 1.0f};
// Column-major translation by (10, 20, 30).
const float Mt[16] = {
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, 0,
10, 20, 30, 1
};
float omin[3], omax[3];
InstanceCompose::worldAabbFromLocal(lmin, lmax, Mt, omin, omax);
REQUIRE(nearly_equal(omin[0], 10.0f));
REQUIRE(nearly_equal(omin[1], 20.0f));
REQUIRE(nearly_equal(omin[2], 30.0f));
REQUIRE(nearly_equal(omax[0], 11.0f));
REQUIRE(nearly_equal(omax[1], 21.0f));
REQUIRE(nearly_equal(omax[2], 31.0f));
}
TEST_CASE("worldAabbFromLocal 90° z-rotation swaps x/y and keeps extent", "[instance_compose][aabb]") {
// Column-major rotation by 90° around z. Pre-rotation AABB is
// [(0..2) × (0..1) × (0..1)]; post-rotation the x-extent becomes
// -1..0 and y becomes 0..2.
const float lmin[3] = {0.0f, 0.0f, 0.0f};
const float lmax[3] = {2.0f, 1.0f, 1.0f};
const float Mr[16] = {
0, 1, 0, 0,
-1, 0, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1
};
float omin[3], omax[3];
InstanceCompose::worldAabbFromLocal(lmin, lmax, Mr, omin, omax);
REQUIRE(nearly_equal(omin[0], -1.0f));
REQUIRE(nearly_equal(omax[0], 0.0f));
REQUIRE(nearly_equal(omin[1], 0.0f));
REQUIRE(nearly_equal(omax[1], 2.0f));
REQUIRE(nearly_equal(omin[2], 0.0f));
REQUIRE(nearly_equal(omax[2], 1.0f));
}
// -----------------------------------------------------------------------------
// composeInstance — matrix product correctness
// -----------------------------------------------------------------------------
TEST_CASE("composeInstance with all-identity matrices returns placement", "[instance_compose][matrix]") {
auto P = translation_dcm(7.0, 8.0, 9.0);
const float lmin[3] = {-1, -1, -1};
const float lmax[3] = { 1, 1, 1};
float T[16];
float omin[3], omax[3];
InstanceCompose::composeInstance(
P.data(),
Eigen::Matrix4d::Identity(),
Eigen::Matrix4d::Identity(),
Eigen::Matrix4d::Identity(),
lmin, lmax,
T, omin, omax);
REQUIRE(nearly_equal(T[12], 7.0f));
REQUIRE(nearly_equal(T[13], 8.0f));
REQUIRE(nearly_equal(T[14], 9.0f));
// Local AABB shifted by P's translation.
REQUIRE(nearly_equal(omin[0], 6.0f));
REQUIRE(nearly_equal(omax[0], 8.0f));
REQUIRE(nearly_equal(omin[1], 7.0f));
REQUIRE(nearly_equal(omax[1], 9.0f));
}
TEST_CASE("composeInstance applies federation × model × coordop × placement in order", "[instance_compose][matrix]") {
// Each matrix translates by a distinct vector along one axis so
// their composition is unambiguous and the order is detectable.
//
// placement : t = (1, 0, 0)
// coordop : t = (0, 2, 0)
// model_transform : t = (0, 0, 4)
// federation : t = (8, 0, 0)
//
// composed = fed * model * coordop * placement
// Translation composition for pure translations is just sum:
// (8+0+0+1, 0+0+2+0, 0+4+0+0) = (9, 2, 4).
auto P = translation_dcm(1.0, 0.0, 0.0);
Eigen::Matrix4d coordop = Eigen::Matrix4d::Identity();
coordop(1, 3) = 2.0;
Eigen::Matrix4d mt = Eigen::Matrix4d::Identity();
mt(2, 3) = 4.0;
Eigen::Matrix4d fed = Eigen::Matrix4d::Identity();
fed(0, 3) = 8.0;
const float lmin[3] = {0, 0, 0};
const float lmax[3] = {0, 0, 0};
float T[16];
float omin[3], omax[3];
InstanceCompose::composeInstance(
P.data(), fed, mt, coordop, lmin, lmax,
T, omin, omax);
REQUIRE(nearly_equal(T[12], 9.0f));
REQUIRE(nearly_equal(T[13], 2.0f));
REQUIRE(nearly_equal(T[14], 4.0f));
}
TEST_CASE("composeInstance cancels large placement against federation false origin", "[instance_compose][matrix]") {
// Real-world federated case: IFC placement at 10 km off origin
// gets cancelled by an opposite federation false origin. After
// composition the rendered geometry should sit near the origin
// with float32-clean precision.
auto P = translation_dcm(1e7, 1e7, 1e7);
Eigen::Matrix4d fed = Eigen::Matrix4d::Identity();
fed(0, 3) = -1e7;
fed(1, 3) = -1e7;
fed(2, 3) = -1e7;
const float lmin[3] = {0, 0, 0};
const float lmax[3] = {0, 0, 0};
float T[16];
float omin[3], omax[3];
InstanceCompose::composeInstance(
P.data(), fed,
Eigen::Matrix4d::Identity(),
Eigen::Matrix4d::Identity(),
lmin, lmax, T, omin, omax);
// After the double-precision cancellation we get exact 0. A naive
// float-precision implementation would lose meters of accuracy at
// 10 km from origin (float32 ULP at 1e7 is ~1.0).
REQUIRE(nearly_equal(T[12], 0.0f, 1e-2f));
REQUIRE(nearly_equal(T[13], 0.0f, 1e-2f));
REQUIRE(nearly_equal(T[14], 0.0f, 1e-2f));
}
TEST_CASE("composeInstance writes column-major float[16]", "[instance_compose][matrix]") {
// Compose with an identity rotation/translation chain and check
// that the matrix lands in column-major order (T[12,13,14] = tx,ty,tz
// — not row-major T[3,7,11]).
auto P = translation_dcm(5.0, 6.0, 7.0);
const float lmin[3] = {0, 0, 0};
const float lmax[3] = {0, 0, 0};
float T[16];
float omin[3], omax[3];
InstanceCompose::composeInstance(
P.data(),
Eigen::Matrix4d::Identity(),
Eigen::Matrix4d::Identity(),
Eigen::Matrix4d::Identity(),
lmin, lmax, T, omin, omax);
REQUIRE(nearly_equal(T[12], 5.0f));
REQUIRE(nearly_equal(T[13], 6.0f));
REQUIRE(nearly_equal(T[14], 7.0f));
REQUIRE(nearly_equal(T[15], 1.0f));
// Row-major positions stay zero.
REQUIRE(nearly_equal(T[3], 0.0f));
REQUIRE(nearly_equal(T[7], 0.0f));
REQUIRE(nearly_equal(T[11], 0.0f));
}
TEST_CASE("composeInstance scales the world AABB by the placement scale", "[instance_compose][aabb]") {
auto P = scale_dcm(2.0);
const float lmin[3] = {-1, -1, -1};
const float lmax[3] = { 1, 1, 1};
float T[16];
float omin[3], omax[3];
InstanceCompose::composeInstance(
P.data(),
Eigen::Matrix4d::Identity(),
Eigen::Matrix4d::Identity(),
Eigen::Matrix4d::Identity(),
lmin, lmax, T, omin, omax);
REQUIRE(nearly_equal(omin[0], -2.0f));
REQUIRE(nearly_equal(omax[0], 2.0f));
REQUIRE(nearly_equal(omin[1], -2.0f));
REQUIRE(nearly_equal(omax[1], 2.0f));
REQUIRE(nearly_equal(omin[2], -2.0f));
REQUIRE(nearly_equal(omax[2], 2.0f));
}
// -----------------------------------------------------------------------------
// findInstanceInModels — lookup correctness
// -----------------------------------------------------------------------------
namespace {
// Build a ModelGpuData with one instance carrying object_id and mesh_id.
// All wgpu pointers stay nullptr; nothing in the test exercises them.
ModelGpuData make_model_with_one_instance(uint32_t object_id, uint32_t mesh_id,
double placement_tx) {
ModelGpuData m;
InstanceCpu inst{};
inst.mesh_id = mesh_id;
inst.object_id = object_id;
// Column-major identity with a tx for verification.
inst.placement_transformation[0] = 1.0;
inst.placement_transformation[5] = 1.0;
inst.placement_transformation[10] = 1.0;
inst.placement_transformation[15] = 1.0;
inst.placement_transformation[12] = placement_tx;
m.instances.push_back(inst);
m.object_id_to_instance[object_id] = 0;
return m;
}
} // namespace
TEST_CASE("findInstanceInModels returns false for object_id == 0", "[instance_compose][lookup]") {
std::unordered_map<uint32_t, ModelGpuData> models;
models.emplace(1u, make_model_with_one_instance(7u, 3u, 0.0));
InstanceCompose::InstanceLookup out;
REQUIRE_FALSE(InstanceCompose::findInstanceInModels(0, models, out));
}
TEST_CASE("findInstanceInModels returns false when no model owns the id", "[instance_compose][lookup]") {
std::unordered_map<uint32_t, ModelGpuData> models;
models.emplace(1u, make_model_with_one_instance(7u, 3u, 0.0));
models.emplace(2u, make_model_with_one_instance(8u, 4u, 0.0));
InstanceCompose::InstanceLookup out;
REQUIRE_FALSE(InstanceCompose::findInstanceInModels(999, models, out));
}
TEST_CASE("findInstanceInModels fills the correct lookup for an owned id", "[instance_compose][lookup]") {
std::unordered_map<uint32_t, ModelGpuData> models;
models.emplace(1u, make_model_with_one_instance(7u, 3u, 11.0));
models.emplace(2u, make_model_with_one_instance(8u, 4u, 22.0));
InstanceCompose::InstanceLookup out;
REQUIRE(InstanceCompose::findInstanceInModels(8u, models, out));
REQUIRE(out.model_id == 2u);
REQUIRE(out.mesh_id == 4u);
REQUIRE(out.placement_transformation[12] == 22.0);
REQUIRE(out.placement_transformation[0] == 1.0);
REQUIRE(out.placement_transformation[15] == 1.0);
}
TEST_CASE("findInstanceInModels skips a corrupt instance-index entry", "[instance_compose][lookup]") {
// Map points at an index that doesn't exist in the instances
// vector — the lookup should treat that as "not found here"
// rather than reading past the array.
ModelGpuData m;
m.object_id_to_instance[42u] = 99u; // empty instances vector
std::unordered_map<uint32_t, ModelGpuData> models;
models.emplace(1u, std::move(m));
InstanceCompose::InstanceLookup out;
REQUIRE_FALSE(InstanceCompose::findInstanceInModels(42u, models, out));
}