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