Merge branch 'ifcviewer-wgpu' of https://github.com/IfcOpenShell/IfcOpenShell into ifcviewer-wgpu

This commit is contained in:
Thomas Krijnen
2026-07-03 13:35:06 +02:00
137 changed files with 4451 additions and 2902 deletions
@@ -283,7 +283,7 @@ namespace {
ModelGpuData make_model_with_one_instance(uint32_t object_id, uint32_t mesh_id,
double placement_tx) {
ModelGpuData m;
InstanceCpu inst{};
InstanceInfo inst{};
inst.mesh_id = mesh_id;
inst.object_id = object_id;
// Column-major identity with a tx for verification.
@@ -21,7 +21,7 @@
// vertex quantization used to fill it.
//
// quantizeVertex / octEncodeNormal (VertexQuantization.h) are the shared
// production helpers: ViewportWindow::uploadMeshChunk and SidecarBuilder both
// production helpers: ViewportWindow::uploadStreamedMesh and SidecarBuilder both
// route through them so the rendered VBO and the on-disk .ifcview record are
// byte-identical. The tests exercise that real implementation directly:
// - runtime size/alignment assertions (defense in depth for the static_asserts)
@@ -245,14 +245,14 @@ TEST_CASE("quantizeVertex passes the packed color through unchanged", "[instgeom
REQUIRE(std::memcmp(dst + INSTANCED_VERTEX_COLOR_OFFSET, rgba, 4) == 0);
}
TEST_CASE("MeshChunk and InstanceChunk default-init to zeroed metadata", "[instgeom]") {
MeshChunk mc;
TEST_CASE("StreamedMesh and StreamedInstance default-init to zeroed metadata", "[instgeom]") {
StreamedMesh mc;
REQUIRE(mc.model_id == 0);
REQUIRE(mc.local_mesh_id == 0);
REQUIRE(mc.vertices.empty());
REQUIRE(mc.indices.empty());
InstanceChunk ic;
StreamedInstance ic;
REQUIRE(ic.model_id == 0);
REQUIRE(ic.local_mesh_id == 0);
REQUIRE(ic.object_id == 0);
+8 -7
View File
@@ -83,7 +83,7 @@ SidecarData buildFixture() {
sd.instances.resize(5);
for (size_t i = 0; i < sd.instances.size(); ++i) {
InstanceCpu& inst = sd.instances[i];
InstanceInfo& inst = sd.instances[i];
inst.mesh_id = (i < 3) ? 0u : 1u;
inst.object_id = uint32_t(100 + i);
inst.color_override_rgba8 = uint32_t(0xAA000000u | (i * 0x010203u));
@@ -109,11 +109,10 @@ SidecarData buildFixture() {
sd.string_table = std::string("\0Wall\0Slab\0", 11); // includes embedded NULs
sd.elements.resize(3);
for (size_t i = 0; i < sd.elements.size(); ++i) {
PackedElementInfo& e = sd.elements[i];
ElementTableRecord& e = sd.elements[i];
e.object_id = uint32_t(100 + i);
e.model_id = 1;
e.ifc_id = int32_t(1000 + i);
e.parent_id = (i == 0) ? -1 : int32_t(100);
e.guid_offset = 0; e.guid_length = 0;
e.name_offset = 1; e.name_length = 4; // "Wall"
e.type_offset = 6; e.type_length = 4; // "Slab"
@@ -136,10 +135,10 @@ bool sidecarDataEqual(const SidecarData& a, const SidecarData& b) {
if (std::memcmp(&a.meshes[i], &b.meshes[i], sizeof(MeshInfo)) != 0) return false;
}
for (size_t i = 0; i < a.instances.size(); ++i) {
if (std::memcmp(&a.instances[i], &b.instances[i], sizeof(InstanceCpu)) != 0) return false;
if (std::memcmp(&a.instances[i], &b.instances[i], sizeof(InstanceInfo)) != 0) return false;
}
for (size_t i = 0; i < a.elements.size(); ++i) {
if (std::memcmp(&a.elements[i], &b.elements[i], sizeof(PackedElementInfo)) != 0) return false;
if (std::memcmp(&a.elements[i], &b.elements[i], sizeof(ElementTableRecord)) != 0) return false;
}
// v11 georef block.
@@ -155,10 +154,12 @@ bool sidecarDataEqual(const SidecarData& a, const SidecarData& b) {
} // namespace
TEST_CASE("MeshInfo and InstanceCpu have stable layouts (sidecar wire format)", "[sidecar]") {
TEST_CASE("MeshInfo and InstanceInfo have stable layouts (sidecar wire format)", "[sidecar]") {
REQUIRE(sizeof(MeshInfo) == 56);
REQUIRE(sizeof(InstanceInfo) == 232);
REQUIRE(sizeof(InstanceGpu) == 80);
REQUIRE(SIDECAR_VERSION == 16);
REQUIRE(sizeof(ElementTableRecord) == 36);
REQUIRE(SIDECAR_VERSION == 17);
REQUIRE(sizeof(SidecarChunk) == 56);
REQUIRE(SIDECAR_MAGIC == 0x49465657u);
}
+2 -2
View File
@@ -81,7 +81,7 @@ SidecarData buildFixture() {
for (uint32_t k = 0; k < 3; ++k) { // outer loop = interleave
for (int i = 0; i < N; ++i) {
if (k >= ninst(i)) continue;
InstanceCpu ic;
InstanceInfo ic;
ic.mesh_id = uint32_t(i); // authoritative
ic.object_id = obj++;
ic.model_id = 1;
@@ -114,7 +114,7 @@ struct InstSig {
}
};
InstSig sigFor(const SidecarData& sd, const InstanceCpu& inst) {
InstSig sigFor(const SidecarData& sd, const InstanceInfo& inst) {
const MeshInfo& m = sd.meshes.at(inst.mesh_id);
InstSig s{};
s.verts.assign(sd.vertices.begin() + m.vbo_byte_offset,
+13 -14
View File
@@ -84,7 +84,6 @@ SidecarData buildFixture() {
sd.elements[i].object_id = uint32_t(100 + i);
sd.elements[i].model_id = 1;
sd.elements[i].ifc_id = int32_t(1000 + i);
sd.elements[i].parent_id = (i == 0) ? -1 : int32_t(100);
}
// v16 stores geometry per-chunk (compressed); a fixture with geometry needs
// a chunk TOC covering its meshes (one chunk per mesh here).
@@ -113,8 +112,8 @@ TEST_CASE("readSidecarMetadataOnly returns metadata, skips bulk geometry",
// Chunk TOC carries compressed blob locators for each chunk.
REQUIRE(meta->meta.chunks.size() == sd.chunks.size());
REQUIRE(meta->meta.chunks[0].v_comp_size > 0);
// The deferred (property) block locator is recorded for on-demand fetch.
REQUIRE(meta->deferred_comp_size > 0);
// The element metadata block locator is recorded for on-demand fetch.
REQUIRE(meta->element_metadata_comp_size > 0);
// Metadata round-trips.
REQUIRE(meta->meta.meshes.size() == sd.meshes.size());
@@ -197,37 +196,37 @@ TEST_CASE("parseSidecarHead validates magic / version, reads geom length", "[str
REQUIRE_FALSE(parseSidecarHead(bad, sizeof(bad), got));
}
TEST_CASE("v16 deferred block: fetch via locator, decompress, parse", "[streaming]") {
fs::path dir = makeScratchDir("v16def");
TEST_CASE("v16 element metadata block: fetch via locator, decompress, parse", "[streaming]") {
fs::path dir = makeScratchDir("v16element");
fs::path ifc = dir / "model.ifc";
SidecarData sd = buildFixture();
REQUIRE(writeSidecar(ifc.string(), sd));
auto meta = readSidecarMetadataOnly(ifc.string());
REQUIRE(meta.has_value());
REQUIRE(meta->meta.meshes.size() == sd.meshes.size()); // critical
REQUIRE(meta->meta.meshes.size() == sd.meshes.size()); // geometry metadata
REQUIRE(meta->meta.chunks.size() == sd.chunks.size());
REQUIRE(meta->meta.elements.size() == sd.elements.size()); // desktop reads deferred too
REQUIRE(meta->deferred_comp_size > 0);
REQUIRE(meta->meta.elements.size() == sd.elements.size()); // desktop reads element metadata too
REQUIRE(meta->element_metadata_comp_size > 0);
// The on-demand path (web) fetches the compressed deferred frame via the
// The on-demand path (web) fetches the compressed element metadata frame via the
// recorded locator and decompresses it — verify that round-trips.
FILE* f = std::fopen((dir / "model.ifcview").string().c_str(), "rb");
REQUIRE(f);
std::vector<uint8_t> cz(size_t(meta->deferred_comp_size));
std::fseek(f, long(meta->deferred_comp_offset), SEEK_SET);
std::vector<uint8_t> cz(size_t(meta->element_metadata_comp_size));
std::fseek(f, long(meta->element_metadata_comp_offset), SEEK_SET);
REQUIRE(std::fread(cz.data(), 1, cz.size(), f) == cz.size());
std::fclose(f);
std::vector<uint8_t> raw(size_t(meta->deferred_raw_size));
std::vector<uint8_t> raw(size_t(meta->element_metadata_raw_size));
REQUIRE(SidecarCompress::decompress(cz.data(), cz.size(), raw.data(), raw.size()));
SidecarData d;
REQUIRE(parseSidecarDeferred(raw.data(), raw.size(), d));
REQUIRE(parseSidecarElementMetadata(raw.data(), raw.size(), d));
REQUIRE(d.elements.size() == sd.elements.size());
REQUIRE(d.string_table == sd.string_table);
SidecarData chopped;
REQUIRE_FALSE(parseSidecarDeferred(raw.data(), raw.size() - 1, chopped));
REQUIRE_FALSE(parseSidecarElementMetadata(raw.data(), raw.size() - 1, chopped));
}
TEST_CASE("planSidecarReadRanges coalesces adjacent ranges, keeps far ones split",
@@ -143,6 +143,63 @@ TEST_CASE("toggleXray flips the active state", "[camera][xray]") {
REQUIRE_FALSE(core.xrayActive());
}
TEST_CASE("section planes: add appends, clear drops all, capped at the max",
"[camera][section]") {
MockHost host; ViewportCore core(&host);
REQUIRE(core.sectionPlaneCount() == 0);
const Eigen::Vector3f pt(1, 2, 3), n(0, 0, 1);
REQUIRE(core.addSectionPlaneAtSurface(pt, n, 1.0f));
REQUIRE(core.addSectionPlaneAtSurface(pt, n, 1.0f));
REQUIRE(core.sectionPlaneCount() == 2);
// Fill to the cap (kMaxSectionPlanes == 6); further adds are rejected.
while (core.sectionPlaneCount() < 6) REQUIRE(core.addSectionPlaneAtSurface(pt, n, 1.0f));
REQUIRE_FALSE(core.addSectionPlaneAtSurface(pt, n, 1.0f));
REQUIRE(core.sectionPlaneCount() == 6);
core.clearSectionPlanes();
REQUIRE(core.sectionPlaneCount() == 0);
}
TEST_CASE("setNavPreset maps names to the shared button bindings", "[camera][nav]") {
MockHost host; ViewportCore core(&host);
using B = ViewportCore::MouseBtn; using M = ViewportCore::NavMod;
// Default is blender: orbit MMB, pan Shift+MMB, select LMB.
{
const auto& b = core.navBindings();
REQUIRE(b.orbit == B::Middle); REQUIRE(b.orbit_mod == M::Plain);
REQUIRE(b.pan == B::Middle); REQUIRE(b.pan_mod == M::Shift);
REQUIRE(b.select == B::Left); REQUIRE(b.select_mod == M::Plain);
}
SECTION("web: orbit LMB, pan MMB, select RMB") {
core.setNavPreset("web");
const auto& b = core.navBindings();
REQUIRE(b.orbit == B::Left); REQUIRE(b.orbit_mod == M::Plain);
REQUIRE(b.pan == B::Middle); REQUIRE(b.pan_mod == M::Plain);
REQUIRE(b.select == B::Right); REQUIRE(b.select_mod == M::Plain);
}
SECTION("rhino: orbit RMB, pan Shift+RMB") {
core.setNavPreset("rhino");
const auto& b = core.navBindings();
REQUIRE(b.orbit == B::Right); REQUIRE(b.pan == B::Right);
REQUIRE(b.pan_mod == M::Shift); REQUIRE(b.select == B::Left);
}
SECTION("revit: orbit Shift+MMB, pan MMB") {
core.setNavPreset("revit");
const auto& b = core.navBindings();
REQUIRE(b.orbit == B::Middle); REQUIRE(b.orbit_mod == M::Shift);
REQUIRE(b.pan == B::Middle); REQUIRE(b.pan_mod == M::Plain);
}
SECTION("unknown name falls back to blender") {
core.setNavPreset("web");
core.setNavPreset("nonsense");
const auto& b = core.navBindings();
REQUIRE(b.orbit == B::Middle); REQUIRE(b.select == B::Left);
}
}
TEST_CASE("hideSelected hides the selection; showAll restores", "[camera][visibility]") {
MockHost host; ViewportCore core(&host);
REQUIRE(core.hiddenCount() == 0);
@@ -158,3 +215,25 @@ TEST_CASE("hideSelected hides the selection; showAll restores", "[camera][visibi
core.showAll();
REQUIRE(core.hiddenCount() == 0);
}
TEST_CASE("applyMarqueeToSelection: replace / add / remove", "[camera][selection]") {
MockHost host; ViewportCore core(&host);
// No public selection accessor, so verify via hideSelected → hiddenCount.
SECTION("plain marquee replaces the selection") {
core.applyMarqueeToSelection({1, 2, 3}, /*add*/false, /*remove*/false);
core.hideSelected();
REQUIRE(core.hiddenCount() == 3);
}
SECTION("add unions, remove subtracts") {
core.applyMarqueeToSelection({5}, false, false); // replace → {5}
core.applyMarqueeToSelection({6, 7}, true, false); // add → {5,6,7}
core.applyMarqueeToSelection({6}, false, true); // remove → {5,7}
core.hideSelected();
REQUIRE(core.hiddenCount() == 2);
}
SECTION("id 0 is ignored") {
core.applyMarqueeToSelection({0, 9, 0}, false, false);
core.hideSelected();
REQUIRE(core.hiddenCount() == 1);
}
}