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

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

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

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