From 1fe45708606a1d56283422cfd1cbeec12629a589 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 4 Jun 2026 17:19:24 +1000 Subject: [PATCH] ifcviewer: extract ChunkPlanner + InstanceCompose; add Tier-1 test trio MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/ifcviewer/BufferPool.cpp | 14 + src/ifcviewer/BufferPool.h | 14 + src/ifcviewer/ChunkPlanner.cpp | 116 ++++++ src/ifcviewer/ChunkPlanner.h | 85 +++++ src/ifcviewer/InstanceCompose.cpp | 98 +++++ src/ifcviewer/InstanceCompose.h | 90 +++++ src/ifcviewer/ViewportWindow.cpp | 192 ++-------- src/ifcviewer/ViewportWindow.h | 10 +- src/ifcviewer/tests/CMakeLists.txt | 39 ++ src/ifcviewer/tests/test_buffer_pool.cpp | 254 +++++++++++++ src/ifcviewer/tests/test_chunk_planner.cpp | 224 ++++++++++++ src/ifcviewer/tests/test_instance_compose.cpp | 344 ++++++++++++++++++ 12 files changed, 1315 insertions(+), 165 deletions(-) create mode 100644 src/ifcviewer/ChunkPlanner.cpp create mode 100644 src/ifcviewer/ChunkPlanner.h create mode 100644 src/ifcviewer/InstanceCompose.cpp create mode 100644 src/ifcviewer/InstanceCompose.h create mode 100644 src/ifcviewer/tests/test_buffer_pool.cpp create mode 100644 src/ifcviewer/tests/test_chunk_planner.cpp create mode 100644 src/ifcviewer/tests/test_instance_compose.cpp diff --git a/src/ifcviewer/BufferPool.cpp b/src/ifcviewer/BufferPool.cpp index 3bb686e7fb..4ba9ec7072 100644 --- a/src/ifcviewer/BufferPool.cpp +++ b/src/ifcviewer/BufferPool.cpp @@ -223,3 +223,17 @@ uint64_t BufferPool::largest_free_run_bytes() const { } return m; } + +void BufferPool::addSubBufferForTesting(WGPUBuffer fake_buffer, uint64_t capacity) { + SubPool sp; + sp.buffer = fake_buffer; + sp.capacity = capacity; + sp.used = 0; + sp.free_ranges.push_back({0, capacity}); + sub_pools_.push_back(std::move(sp)); +} + +void BufferPool::clearSubPoolsForTesting() { + // Skip wgpuBufferRelease — handles are fakes that would crash on deref. + sub_pools_.clear(); +} diff --git a/src/ifcviewer/BufferPool.h b/src/ifcviewer/BufferPool.h index d5036aa964..cbfeef0ea2 100644 --- a/src/ifcviewer/BufferPool.h +++ b/src/ifcviewer/BufferPool.h @@ -111,6 +111,20 @@ public: // could rescue them, or whether eviction is the only path. bool can_grow() const { return !growth_disabled_ && per_sub_buffer_capacity_ > 0; } + // Test-only seam. Production code populates sub-pools lazily through + // alloc() → addSubBuffer() → wgpuDeviceCreateBuffer; that path needs a + // real WGPUDevice and is impractical to exercise from a unit test. + // tests/test_buffer_pool.cpp uses this method to preseed a sub-pool + // with a known capacity and a fake (non-null) WGPUBuffer handle the + // allocator only treats as opaque — the free-list bookkeeping never + // dereferences it. Not for production use. + void addSubBufferForTesting(WGPUBuffer fake_buffer, uint64_t capacity); + // Drop fake-handle sub-pools without calling wgpuBufferRelease on + // them. Tests must call this before the pool destructs (or rely on + // the FakePoolGuard fixture in test_buffer_pool.cpp), otherwise + // ~BufferPool → destroy() would dereference the fake handles. + void clearSubPoolsForTesting(); + private: struct FreeRange { uint64_t offset; uint64_t size; }; struct SubPool { diff --git a/src/ifcviewer/ChunkPlanner.cpp b/src/ifcviewer/ChunkPlanner.cpp new file mode 100644 index 0000000000..1b7c6d575b --- /dev/null +++ b/src/ifcviewer/ChunkPlanner.cpp @@ -0,0 +1,116 @@ +/******************************************************************************** + * * + * 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 "ChunkPlanner.h" + +#include +#include +#include + +namespace ChunkPlanner { + +uint64_t mortonSplit21(uint32_t v) { + uint64_t r = v & 0x1FFFFFu; + r = (r | r << 32) & 0x001F00000000FFFFULL; + r = (r | r << 16) & 0x001F0000FF0000FFULL; + r = (r | r << 8) & 0x100F00F00F00F00FULL; + r = (r | r << 4) & 0x10C30C30C30C30C3ULL; + r = (r | r << 2) & 0x1249249249249249ULL; + return r; +} + +uint64_t mortonCode3D(uint32_t x, uint32_t y, uint32_t z) { + return mortonSplit21(x) | (mortonSplit21(y) << 1) | (mortonSplit21(z) << 2); +} + +std::vector sortMeshIdsByMorton( + std::size_t n_meshes, + const std::vector& mesh_cx, + const std::vector& mesh_cy, + const std::vector& mesh_cz, + const std::vector& mesh_inst_count) { + // Per-model bounds over centroids. Quantising relative to these + // gives the Morton code its full 21-bit-per-axis resolution + // (~2 M bins per axis = sub-millimetre on a kilometre-scale scene, + // way more than we need; the cost is the same regardless). + float bmin[3] = { std::numeric_limits::infinity(), + std::numeric_limits::infinity(), + std::numeric_limits::infinity() }; + float bmax[3] = { -std::numeric_limits::infinity(), + -std::numeric_limits::infinity(), + -std::numeric_limits::infinity() }; + for (std::size_t i = 0; i < n_meshes; ++i) { + if (mesh_inst_count[i] == 0) continue; + bmin[0] = std::min(bmin[0], mesh_cx[i]); bmax[0] = std::max(bmax[0], mesh_cx[i]); + bmin[1] = std::min(bmin[1], mesh_cy[i]); bmax[1] = std::max(bmax[1], mesh_cy[i]); + bmin[2] = std::min(bmin[2], mesh_cz[i]); bmax[2] = std::max(bmax[2], mesh_cz[i]); + } + const float ext[3] = { + std::max(bmax[0] - bmin[0], 1e-3f), + std::max(bmax[1] - bmin[1], 1e-3f), + std::max(bmax[2] - bmin[2], 1e-3f), + }; + constexpr uint32_t MORTON_BITS = 21; + constexpr uint32_t MORTON_MAX = (1u << MORTON_BITS) - 1u; + + std::vector codes(n_meshes, 0); + for (uint32_t i = 0; i < uint32_t(n_meshes); ++i) { + if (mesh_inst_count[i] == 0) continue; + const float nx = (mesh_cx[i] - bmin[0]) / ext[0]; + const float ny = (mesh_cy[i] - bmin[1]) / ext[1]; + const float nz = (mesh_cz[i] - bmin[2]) / ext[2]; + const uint32_t qx = std::min(uint32_t(nx * float(MORTON_MAX + 1u)), MORTON_MAX); + const uint32_t qy = std::min(uint32_t(ny * float(MORTON_MAX + 1u)), MORTON_MAX); + const uint32_t qz = std::min(uint32_t(nz * float(MORTON_MAX + 1u)), MORTON_MAX); + codes[i] = mortonCode3D(qx, qy, qz); + } + + std::vector sorted(n_meshes); + std::iota(sorted.begin(), sorted.end(), 0u); + std::stable_sort(sorted.begin(), sorted.end(), + [&](uint32_t a, uint32_t b) { return codes[a] < codes[b]; }); + return sorted; +} + +std::vector> greedyPackChunks( + const std::vector& sorted_mesh_ids, + const std::vector& mesh_vertex_count, + uint64_t vertex_stride_bytes, + uint64_t chunk_vertex_bytes_limit) { + std::vector> chunks; + if (sorted_mesh_ids.empty()) return chunks; + + chunks.push_back({}); + uint64_t current_chunk_bytes = 0; + for (uint32_t mi : sorted_mesh_ids) { + const uint64_t mesh_bytes = + uint64_t(mesh_vertex_count[mi]) * vertex_stride_bytes; + if (current_chunk_bytes > 0 + && current_chunk_bytes + mesh_bytes > chunk_vertex_bytes_limit) { + chunks.push_back({}); + current_chunk_bytes = 0; + } + chunks.back().push_back(mi); + current_chunk_bytes += mesh_bytes; + } + if (chunks.back().empty()) chunks.pop_back(); + return chunks; +} + +} // namespace ChunkPlanner diff --git a/src/ifcviewer/ChunkPlanner.h b/src/ifcviewer/ChunkPlanner.h new file mode 100644 index 0000000000..1cc9595263 --- /dev/null +++ b/src/ifcviewer/ChunkPlanner.h @@ -0,0 +1,85 @@ +/******************************************************************************** + * * + * 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 . * + * * + ********************************************************************************/ + +#ifndef CHUNKPLANNER_H +#define CHUNKPLANNER_H + +// Chunk-planning helpers for the wgpu viewport. Replaces a previous +// lexicographic (z, y, x) sort with a 3D Morton (Z-order) sort over mesh +// centroids, then greedy-packs the resulting order into chunks bounded by +// a vertex-bytes ceiling. The two passes are split so each is unit- +// testable in isolation (no Qt / no wgpu). + +#include +#include +#include + +namespace ChunkPlanner { + +// Interleave the low 21 bits of v with two zero bits between each, +// returning bits at positions 0, 3, 6, ..., 60 — one axis of a +// standard 21-bit-per-axis 3D Morton code. ORing three of these +// shifted by 0, 1, 2 gives a 63-bit (x, y, z)-interleaved code; the +// resulting integer ordering puts spatially-close points close in +// the sorted sequence (the classic Z-order curve). +uint64_t mortonSplit21(uint32_t v); + +uint64_t mortonCode3D(uint32_t x, uint32_t y, uint32_t z); + +// Return a mesh-id permutation sorted by 3D Morton (Z-order) code over +// the meshes' centroids. Replaces a lexicographic (z, y, x) sort, +// which was effectively a 1D Z-slab traversal — chunks ended up +// spanning the whole XY extent of the model, ~50m × 50m × 0.5m for a +// typical building. Morton clusters spatially in all 3 axes, so each +// chunk's AABB becomes a tight 3D voxel — small enough that +// per-chunk frustum / contribution / HiZ rejection becomes meaningful +// (a 1km-wide AABB never gets occluded; a 10m voxel often does). +// +// Meshes with no instances get a Morton code of 0 and sink to the +// front; they contribute no geometry / AABBs so where they land in +// the chunk plan doesn't matter. +std::vector sortMeshIdsByMorton( + std::size_t n_meshes, + const std::vector& mesh_cx, + const std::vector& mesh_cy, + const std::vector& mesh_cz, + const std::vector& mesh_inst_count); + +// Greedy-pack a pre-sorted mesh-id sequence into chunks bounded by +// `chunk_vertex_bytes_limit`. Each mesh is placed in the current +// chunk; if adding it would push the running byte count over the +// limit (and the chunk is non-empty), a new chunk is started. +// +// A mesh whose own vertex bytes already exceed the limit lands alone +// in its own (over-sized) chunk — the planner never splits a mesh +// across chunks, because the mega-draw bookkeeping is per-mesh +// chunk-local-offset. +// +// `sorted_mesh_ids` is the order produced by sortMeshIdsByMorton. +// `mesh_vertex_count[mesh_id]` gives the vertex count per mesh. +// `vertex_stride_bytes` is the per-vertex byte size on the GPU. +std::vector> greedyPackChunks( + const std::vector& sorted_mesh_ids, + const std::vector& mesh_vertex_count, + uint64_t vertex_stride_bytes, + uint64_t chunk_vertex_bytes_limit); + +} // namespace ChunkPlanner + +#endif // CHUNKPLANNER_H diff --git a/src/ifcviewer/InstanceCompose.cpp b/src/ifcviewer/InstanceCompose.cpp new file mode 100644 index 0000000000..13e6069007 --- /dev/null +++ b/src/ifcviewer/InstanceCompose.cpp @@ -0,0 +1,98 @@ +/******************************************************************************** + * * + * 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 "InstanceCompose.h" + +#include +#include + +namespace InstanceCompose { + +void worldAabbFromLocal(const float local_min[3], const float local_max[3], + const float M[16], + float world_min_out[3], float world_max_out[3]) { + world_min_out[0] = world_min_out[1] = world_min_out[2] = + std::numeric_limits::max(); + world_max_out[0] = world_max_out[1] = world_max_out[2] = + -std::numeric_limits::max(); + for (int c = 0; c < 8; ++c) { + const float x = (c & 1) ? local_max[0] : local_min[0]; + const float y = (c & 2) ? local_max[1] : local_min[1]; + const float z = (c & 4) ? local_max[2] : local_min[2]; + const float wx = M[0]*x + M[4]*y + M[8] *z + M[12]; + const float wy = M[1]*x + M[5]*y + M[9] *z + M[13]; + const float wz = M[2]*x + M[6]*y + M[10]*z + M[14]; + if (wx < world_min_out[0]) world_min_out[0] = wx; + if (wx > world_max_out[0]) world_max_out[0] = wx; + if (wy < world_min_out[1]) world_min_out[1] = wy; + if (wy > world_max_out[1]) world_max_out[1] = wy; + if (wz < world_min_out[2]) world_min_out[2] = wz; + if (wz > world_max_out[2]) world_max_out[2] = wz; + } +} + +void composeInstance( + const double placement_col_major[16], + const Eigen::Matrix4d& federated_false_origin, + const Eigen::Matrix4d& model_transformation, + const Eigen::Matrix4d& coordinate_operation, + const float local_aabb_min[3], const float local_aabb_max[3], + float transform_col_major_out[16], + float world_aabb_min_out[3], float world_aabb_max_out[3]) { + using Mat4dCol = Eigen::Matrix; + using Mat4fCol = Eigen::Matrix; + + const Eigen::Matrix4d P = + Eigen::Map(placement_col_major); + const Eigen::Matrix4d composed = + federated_false_origin * + model_transformation * + coordinate_operation * + P; + + Eigen::Map T_f(transform_col_major_out); + T_f = composed.cast(); + + worldAabbFromLocal(local_aabb_min, local_aabb_max, + transform_col_major_out, + world_aabb_min_out, world_aabb_max_out); +} + +bool findInstanceInModels( + uint32_t object_id, + const std::unordered_map& models, + InstanceLookup& out) { + if (object_id == 0) return false; + for (const auto& [mid, m] : models) { + auto it = m.object_id_to_instance.find(object_id); + if (it == m.object_id_to_instance.end()) continue; + const uint32_t inst_idx = it->second; + if (inst_idx >= m.instances.size()) continue; + const InstanceCpu& inst = m.instances[inst_idx]; + out.model_id = mid; + out.mesh_id = inst.mesh_id; + std::memcpy(out.placement_transformation, + inst.placement_transformation, + sizeof(out.placement_transformation)); + return true; + } + return false; +} + +} // namespace InstanceCompose diff --git a/src/ifcviewer/InstanceCompose.h b/src/ifcviewer/InstanceCompose.h new file mode 100644 index 0000000000..34d668ee98 --- /dev/null +++ b/src/ifcviewer/InstanceCompose.h @@ -0,0 +1,90 @@ +/******************************************************************************** + * * + * 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 . * + * * + ********************************************************************************/ + +#ifndef INSTANCECOMPOSE_H +#define INSTANCECOMPOSE_H + +// Instance-transform composition + per-instance world AABB derivation, +// plus the cross-model object-id → (model, mesh, placement) lookup. +// Pulled out of ViewportWindow as a free-function module so the matrix +// math + lookup logic can be unit-tested without spinning up a Qt window +// or a wgpu device. + +#include + +#include +#include + +#include "ModelGpuData.h" + +namespace InstanceCompose { + +// Transform the 8 corners of [local_min, local_max] through the +// column-major 4x4 matrix M (float[16]) and bound the result in +// world space. Called after every recompose so per-instance world +// AABBs (and the chunk AABBs derived from them) reflect the current +// federation matrices. +void worldAabbFromLocal(const float local_min[3], const float local_max[3], + const float M[16], + float world_min_out[3], float world_max_out[3]); + +// composed = federated_false_origin +// * model_transformation +// * coordinate_operation +// * placement +// +// Maths in double; narrow only at the end. Large IFC placements need +// to be cancelled by federated_false_origin before the float cast or +// precision is lost. The composed float matrix is written into +// transform_col_major_out (column-major, GPU-uploadable), then the +// local AABB is transformed by the same matrix to produce the +// world-space AABB. +void composeInstance( + const double placement_col_major[16], + const Eigen::Matrix4d& federated_false_origin, + const Eigen::Matrix4d& model_transformation, + const Eigen::Matrix4d& coordinate_operation, + const float local_aabb_min[3], const float local_aabb_max[3], + float transform_col_major_out[16], + float world_aabb_min_out[3], float world_aabb_max_out[3]); + +// Result of a successful findInstance lookup. The placement_transformation +// is double[16] column-major (pre-CoordinateOperation / FederatedFalseOrigin +// / ModelTransformation) — the same convention as InstanceCpu so the +// measurement / picking tools can re-compose at need. +struct InstanceLookup { + uint32_t model_id = 0; + uint32_t mesh_id = 0; + double placement_transformation[16]{}; +}; + +// Walk a map of models looking for the one that owns `object_id`, +// fill `out` with that instance's (model_id, mesh_id, placement) and +// return true. Returns false for object_id == 0 (the sentinel for +// "no object") or when no model owns the id. Defensive: skips +// instances whose stored index is out-of-range for the model's +// instance array. +bool findInstanceInModels( + uint32_t object_id, + const std::unordered_map& models, + InstanceLookup& out); + +} // namespace InstanceCompose + +#endif // INSTANCECOMPOSE_H diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index 7c159d311d..0b0e19f759 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -19,6 +19,8 @@ #include "ViewportWindow.h" #include "AreaMeasurement.h" +#include "ChunkPlanner.h" +#include "InstanceCompose.h" #include "LengthMeasurement.h" #include "StreamingLoader.h" #include "VertexQuantization.h" @@ -113,31 +115,6 @@ static double computeMeshLocalVolumeQuantised( // real triangle hit — see pickMeshLocalAt's refinement block. // Slab method ray-AABB. inv_d is precomputed 1/dir per axis. -// Transform the 8 corners of [local_min, local_max] through the -// column-major 4x4 `M` and bound the result in world space. Used after a -// federation-matrix change so per-instance world AABBs (and the chunk -// AABBs derived from them) reflect the recomposed transform. -static void worldAabbFromLocalVp(const float local_min[3], - const float local_max[3], - const float M[16], - float out_min[3], float out_max[3]) { - out_min[0] = out_min[1] = out_min[2] = std::numeric_limits::max(); - out_max[0] = out_max[1] = out_max[2] = -std::numeric_limits::max(); - for (int c = 0; c < 8; ++c) { - const float x = (c & 1) ? local_max[0] : local_min[0]; - const float y = (c & 2) ? local_max[1] : local_min[1]; - const float z = (c & 4) ? local_max[2] : local_min[2]; - const float wx = M[0]*x + M[4]*y + M[8] *z + M[12]; - const float wy = M[1]*x + M[5]*y + M[9] *z + M[13]; - const float wz = M[2]*x + M[6]*y + M[10]*z + M[14]; - if (wx < out_min[0]) out_min[0] = wx; - if (wx > out_max[0]) out_max[0] = wx; - if (wy < out_min[1]) out_min[1] = wy; - if (wy > out_max[1]) out_max[1] = wy; - if (wz < out_min[2]) out_min[2] = wz; - if (wz > out_max[2]) out_max[2] = wz; - } -} static bool rayAabbSlab(const float ro[3], const float inv_d[3], const float bmin[3], const float bmax[3]) { @@ -237,87 +214,6 @@ static WGPUBuffer createBufferWithData(WGPUDevice device, WGPUQueue queue, return buf; } -// Interleave the low 21 bits of v with two zero bits between each, -// returning bits at positions 0, 3, 6, ..., 60 — one axis of a -// standard 21-bit-per-axis 3D Morton code. ORing three of these -// shifted by 0, 1, 2 gives a 63-bit (x, y, z)-interleaved code; the -// resulting integer ordering puts spatially-close points close in -// the sorted sequence (the classic Z-order curve). -static uint64_t mortonSplit21(uint32_t v) { - uint64_t r = v & 0x1FFFFFu; - r = (r | r << 32) & 0x001F00000000FFFFULL; - r = (r | r << 16) & 0x001F0000FF0000FFULL; - r = (r | r << 8) & 0x100F00F00F00F00FULL; - r = (r | r << 4) & 0x10C30C30C30C30C3ULL; - r = (r | r << 2) & 0x1249249249249249ULL; - return r; -} - -static uint64_t mortonCode3D(uint32_t x, uint32_t y, uint32_t z) { - return mortonSplit21(x) | (mortonSplit21(y) << 1) | (mortonSplit21(z) << 2); -} - -// Return a mesh-id permutation sorted by 3D Morton (Z-order) code over -// the meshes' centroids. Replaces a lexicographic (z, y, x) sort, -// which was effectively a 1D Z-slab traversal — chunks ended up -// spanning the whole XY extent of the model, ~50m × 50m × 0.5m for a -// typical building. Morton clusters spatially in all 3 axes, so each -// chunk's AABB becomes a tight 3D voxel — small enough that -// per-chunk frustum / contribution / HiZ rejection becomes meaningful -// (a 1km-wide AABB never gets occluded; a 10m voxel often does). -// -// Meshes with no instances get a Morton code of 0 and sink to the -// front; they contribute no geometry / AABBs so where they land in -// the chunk plan doesn't matter. -static std::vector sortMeshIdsByMorton( - std::size_t n_meshes, - const std::vector& mesh_cx, - const std::vector& mesh_cy, - const std::vector& mesh_cz, - const std::vector& mesh_inst_count) { - // Per-model bounds over centroids. Quantising relative to these - // gives the Morton code its full 21-bit-per-axis resolution - // (~2 M bins per axis = sub-millimetre on a kilometre-scale scene, - // way more than we need; the cost is the same regardless). - float bmin[3] = { std::numeric_limits::infinity(), - std::numeric_limits::infinity(), - std::numeric_limits::infinity() }; - float bmax[3] = { -std::numeric_limits::infinity(), - -std::numeric_limits::infinity(), - -std::numeric_limits::infinity() }; - for (std::size_t i = 0; i < n_meshes; ++i) { - if (mesh_inst_count[i] == 0) continue; - bmin[0] = std::min(bmin[0], mesh_cx[i]); bmax[0] = std::max(bmax[0], mesh_cx[i]); - bmin[1] = std::min(bmin[1], mesh_cy[i]); bmax[1] = std::max(bmax[1], mesh_cy[i]); - bmin[2] = std::min(bmin[2], mesh_cz[i]); bmax[2] = std::max(bmax[2], mesh_cz[i]); - } - const float ext[3] = { - std::max(bmax[0] - bmin[0], 1e-3f), - std::max(bmax[1] - bmin[1], 1e-3f), - std::max(bmax[2] - bmin[2], 1e-3f), - }; - constexpr uint32_t MORTON_BITS = 21; - constexpr uint32_t MORTON_MAX = (1u << MORTON_BITS) - 1u; - - std::vector codes(n_meshes, 0); - for (uint32_t i = 0; i < uint32_t(n_meshes); ++i) { - if (mesh_inst_count[i] == 0) continue; - const float nx = (mesh_cx[i] - bmin[0]) / ext[0]; - const float ny = (mesh_cy[i] - bmin[1]) / ext[1]; - const float nz = (mesh_cz[i] - bmin[2]) / ext[2]; - const uint32_t qx = std::min(uint32_t(nx * float(MORTON_MAX + 1u)), MORTON_MAX); - const uint32_t qy = std::min(uint32_t(ny * float(MORTON_MAX + 1u)), MORTON_MAX); - const uint32_t qz = std::min(uint32_t(nz * float(MORTON_MAX + 1u)), MORTON_MAX); - codes[i] = mortonCode3D(qx, qy, qz); - } - - std::vector sorted(n_meshes); - std::iota(sorted.begin(), sorted.end(), 0u); - std::stable_sort(sorted.begin(), sorted.end(), - [&](uint32_t a, uint32_t b) { return codes[a] < codes[b]; }); - return sorted; -} - void releaseWgpuModelGpuData(ModelGpuData& m, BufferPool& pool) { for (auto& c : m.chunks) { if (c.bind_group) { wgpuBindGroupRelease(c.bind_group); c.bind_group = nullptr; } @@ -873,22 +769,17 @@ void ViewportWindow::applyCachedModel(uint32_t model_id, std::vector instance_to_chunk; instance_to_chunk.assign(metadata.meta.instances.size(), 0); { - std::vector sorted_mesh_ids = - sortMeshIdsByMorton(n_meshes, mesh_cx, mesh_cy, mesh_cz, mesh_inst_count); - chunk_mesh_ids.push_back({}); - uint64_t current_chunk_bytes = 0; - for (uint32_t mi : sorted_mesh_ids) { - const MeshInfo& mesh = metadata.meta.meshes[mi]; - const uint64_t mesh_bytes = uint64_t(mesh.vertex_count) * INSTANCED_VERTEX_STRIDE_BYTES; - if (current_chunk_bytes > 0 - && current_chunk_bytes + mesh_bytes > WGPU_CHUNK_VERTEX_BYTES_LIMIT) { - chunk_mesh_ids.push_back({}); - current_chunk_bytes = 0; - } - chunk_mesh_ids.back().push_back(mi); - current_chunk_bytes += mesh_bytes; + std::vector sorted_mesh_ids = ChunkPlanner::sortMeshIdsByMorton( + n_meshes, mesh_cx, mesh_cy, mesh_cz, mesh_inst_count); + std::vector mesh_vertex_count; + mesh_vertex_count.reserve(n_meshes); + for (size_t i = 0; i < n_meshes; ++i) { + mesh_vertex_count.push_back(metadata.meta.meshes[i].vertex_count); } - if (chunk_mesh_ids.back().empty()) chunk_mesh_ids.pop_back(); + chunk_mesh_ids = ChunkPlanner::greedyPackChunks( + sorted_mesh_ids, mesh_vertex_count, + INSTANCED_VERTEX_STRIDE_BYTES, + WGPU_CHUNK_VERTEX_BYTES_LIMIT); // Derive instance_to_chunk via mesh_id → chunk lookup table. std::vector mesh_to_chunk(n_meshes, 0); for (size_t ci = 0; ci < chunk_mesh_ids.size(); ++ci) { @@ -1391,29 +1282,29 @@ void ViewportWindow::setModelTransformation(uint32_t model_id, void ViewportWindow::composeInstanceFromPlacement(InstanceCpu& inst, const ModelGpuData& m) const { - // Maths in double; narrow at the end so a large IFC placement - // gets cancelled by federated_false_origin_meters_ before the - // float cast loses precision. - using Mat4dCol = Eigen::Matrix; - using Mat4fCol = Eigen::Matrix; - const Eigen::Matrix4d P = - Eigen::Map(inst.placement_transformation); - - const Eigen::Matrix4d composed = - federated_false_origin_meters_ * - m.model_transformation_meters * - m.coordinate_operation_meters * - P; - - Eigen::Map T_f(inst.transform); - T_f = composed.cast(); - if (inst.mesh_id < m.meshes.size()) { const MeshInfo& mi = m.meshes[inst.mesh_id]; - worldAabbFromLocalVp(mi.local_aabb_min, mi.local_aabb_max, - inst.transform, - inst.world_aabb_min, inst.world_aabb_max); + InstanceCompose::composeInstance( + inst.placement_transformation, + federated_false_origin_meters_, + m.model_transformation_meters, + m.coordinate_operation_meters, + mi.local_aabb_min, mi.local_aabb_max, + inst.transform, + inst.world_aabb_min, inst.world_aabb_max); } else { + // Unknown mesh id: still compose the transform (downstream may + // use it for picking / readback even without geometry), but + // emit a degenerate world AABB so cull doesn't pick this up. + const float zero[3] = {0.0f, 0.0f, 0.0f}; + InstanceCompose::composeInstance( + inst.placement_transformation, + federated_false_origin_meters_, + m.model_transformation_meters, + m.coordinate_operation_meters, + zero, zero, + inst.transform, + inst.world_aabb_min, inst.world_aabb_max); for (int a = 0; a < 3; ++a) { inst.world_aabb_min[a] = 0.0f; inst.world_aabb_max[a] = 0.0f; @@ -1468,24 +1359,7 @@ void ViewportWindow::recomposeAndUploadModel(uint32_t model_id) { } bool ViewportWindow::findInstance(uint32_t object_id, InstanceLookup& out) const { - if (object_id == 0) return false; - for (const auto& [mid, m] : models_gpu_) { - auto it = m.object_id_to_instance.find(object_id); - if (it == m.object_id_to_instance.end()) continue; - const uint32_t inst_idx = it->second; - if (inst_idx >= m.instances.size()) continue; - const InstanceCpu& inst = m.instances[inst_idx]; - out.model_id = mid; - out.mesh_id = inst.mesh_id; - // InstanceCpu::placement_transformation is already double[16] - // column-major (large IFC placements need double precision until - // FederatedFalseOrigin cancels them); copy straight through. - std::memcpy(out.placement_transformation, - inst.placement_transformation, - sizeof(out.placement_transformation)); - return true; - } - return false; + return InstanceCompose::findInstanceInModels(object_id, models_gpu_, out); } bool ViewportWindow::firstGeometryPointWorldM(uint32_t model_id, diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h index 22bdf280c6..85589e623c 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -42,6 +42,7 @@ #include "SidecarCache.h" #include "BufferPool.h" +#include "InstanceCompose.h" #include "ModelGpuData.h" #include "OverlayRenderer.h" #include "SelectionState.h" @@ -382,12 +383,9 @@ public: // Pure CPU lookup: object_id → owning model + mesh + raw placement // matrix (column-major, pre-CoordinateOperation / FederatedFalseOrigin // / ModelTransformation). Mirrors GL ViewportWindow::InstanceLookup - // so Measurement.cpp ports unchanged. - struct InstanceLookup { - uint32_t model_id = 0; - uint32_t mesh_id = 0; - double placement_transformation[16]{}; - }; + // so Measurement.cpp ports unchanged. The canonical struct lives in + // InstanceCompose so the lookup can be unit-tested without Qt. + using InstanceLookup = InstanceCompose::InstanceLookup; bool findInstance(uint32_t object_id, InstanceLookup& out) const; // A point that actually lies on the model's first instance — the diff --git a/src/ifcviewer/tests/CMakeLists.txt b/src/ifcviewer/tests/CMakeLists.txt index d349803b33..0755d0a2fc 100644 --- a/src/ifcviewer/tests/CMakeLists.txt +++ b/src/ifcviewer/tests/CMakeLists.txt @@ -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}) diff --git a/src/ifcviewer/tests/test_buffer_pool.cpp b/src/ifcviewer/tests/test_buffer_pool.cpp new file mode 100644 index 0000000000..8d1391d498 --- /dev/null +++ b/src/ifcviewer/tests/test_buffer_pool.cpp @@ -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 . * + * * + ********************************************************************************/ + +// 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(0x100)` etc. gives us stable identities +// for cross-pool sub_idx assertions. + +#include "BufferPool.h" + +#include + +#include + +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(static_cast(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); +} diff --git a/src/ifcviewer/tests/test_chunk_planner.cpp b/src/ifcviewer/tests/test_chunk_planner.cpp new file mode 100644 index 0000000000..087e29b3e3 --- /dev/null +++ b/src/ifcviewer/tests/test_chunk_planner.cpp @@ -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 . * + * * + ********************************************************************************/ + +// 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 + +#include +#include +#include +#include + +// ----------------------------------------------------------------------------- +// 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 cx(N), cy(N), cz(N); + std::vector 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 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 cx = {0.f, 1.f, 0.f, 1.f}; + std::vector cy = {0.f, 0.f, 1.f, 1.f}; + std::vector cz = {0.f, 0.f, 0.f, 0.f}; + std::vector inst = {1, 1, 1, 1}; + + auto sorted = ChunkPlanner::sortMeshIdsByMorton(4, cx, cy, cz, inst); + REQUIRE(sorted == std::vector{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 cx = {0.f, 0.f, 0.f, 0.f}; + std::vector cy = {0.f, 0.f, 0.f, 0.f}; + std::vector cz = {0.f, 0.f, 0.f, 0.f}; + std::vector inst = {1, 1, 1, 1}; + + auto sorted = ChunkPlanner::sortMeshIdsByMorton(4, cx, cy, cz, inst); + REQUIRE(sorted == std::vector{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 sorted = {0, 1, 2, 3}; + std::vector 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{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 sorted = {10, 20, 30, 40, 50}; + std::vector 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{10, 20}); + REQUIRE(chunks[1] == std::vector{30, 40}); + REQUIRE(chunks[2] == std::vector{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 sorted = {0, 1, 2}; + std::vector 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{0}); + REQUIRE(chunks[1] == std::vector{1}); // oversized; alone + REQUIRE(chunks[2] == std::vector{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 sorted = {3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5}; + std::vector 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 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 sorted = {0, 1, 2, 3}; + std::vector v_counts = {10, 0, 10, 0}; + + auto chunks = ChunkPlanner::greedyPackChunks(sorted, v_counts, 10, 1000); + REQUIRE(chunks.size() == 1); + REQUIRE(chunks[0] == std::vector{0, 1, 2, 3}); +} diff --git a/src/ifcviewer/tests/test_instance_compose.cpp b/src/ifcviewer/tests/test_instance_compose.cpp new file mode 100644 index 0000000000..324434454e --- /dev/null +++ b/src/ifcviewer/tests/test_instance_compose.cpp @@ -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 . * + * * + ********************************************************************************/ + +// 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 + +#include +#include +#include + +namespace { + +// Build a 4x4 identity in column-major double[16] order. +std::array identity_dcm() { + std::array M{}; + M[0] = M[5] = M[10] = M[15] = 1.0; + return M; +} + +// Build a 4x4 column-major double[16] translation matrix. +std::array 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 scale_dcm(double s) { + std::array 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 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 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 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 models; + models.emplace(1u, std::move(m)); + + InstanceCompose::InstanceLookup out; + REQUIRE_FALSE(InstanceCompose::findInstanceInModels(42u, models, out)); +}