Route bonsai through wgpu; delete the GL backend

Bonsai now drives the wgpu viewport for both sidecar and direct-IFC
loads. The GL viewer and its supporting state classes are gone.

SceneLoader rewire:
- Takes WgpuViewportWindow* instead of ViewportWindow*.
- Sidecar path reads metadata only (readSidecarMetadataOnly) and hands
  the StreamingSidecar off to the new applyCachedModel. Field accesses
  inside applySidecarData go through .meta.
- Direct-IFC path uses the wgpu A-path (upload{Mesh,Instance}Chunk +
  finalizeModel). The applyLodExtension call is dropped — wgpu has no
  live LOD1 splice; LOD1 still lands in the on-disk sidecar for the
  next open.

Bonsai migration:
- ViewportWindow → WgpuViewportWindow across MainWindow, Measurement,
  SessionState, and every modules/*/{Commands,Panel,View}.{h,cpp} —
  116 sites total. Same s/OverlayRenderer::/WgpuOverlayRenderer::/
  rename, 12 sites.
- Includes flipped from ../ifcviewer/ViewportWindow.h to
  ../ifcviewer-wgpu/WgpuViewportWindow.h. OverlayRenderer.h include
  dropped (transitively reached via the viewport header).
- BonsaiViewer links IfcViewerWgpu in addition to IfcViewer for the
  duration of the migration; the GL-side IfcViewer also publicly links
  IfcViewerWgpu so SceneLoader can resolve WgpuViewportWindow.

GL backend deletion:
- src/ifcviewer/ViewportWindow.{cpp,h}, BvhAccel.*, OverlayRenderer.*,
  Selection.*, Visibility.* all gone.
- src/ifcviewer-minimal/ removed entirely (MinimalWindow drove the GL
  viewport).
- src/ifcviewer/tests: test_bvh_accel, test_selection, test_visibility
  removed. The first has no replacement (wgpu doesn't use a per-instance
  BVH); the latter two are ported separately. test_lod_builder,
  test_sidecar_cache, test_instanced_geometry, test_federation remain
  (backend-agnostic).
- IfcViewer's CMakeLists drops OpenGL, Qt::OpenGL, Qt::Widgets — none
  of the surviving translation units reach for them.

Build flag plumbing:
- BUILD_BONSAIVIEWER now auto-enables BUILD_BONSAIVIEWER_WGPU since
  SceneLoader requires the wgpu lib for its WgpuViewportWindow* arg.
- The wgpu subprojects add_subdirectory ahead of the GL one so
  IfcViewerWgpu exists when IfcViewer's link evaluates.
- src/ifcviewer-minimal subdir reference removed from cmake/CMakeLists.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-06-01 17:38:33 +10:00
parent 9c067d1d0e
commit 2981500b3b
40 changed files with 208 additions and 8008 deletions
+3 -42
View File
@@ -32,10 +32,6 @@ function(add_ifcviewer_unit_test name)
catch_discover_tests(${name})
endfunction()
add_ifcviewer_unit_test(test_bvh_accel
SOURCES ${IFCVIEWER_SRC}/BvhAccel.cpp
)
if(WITH_MESH_OPTIMIZER)
add_ifcviewer_unit_test(test_lod_builder
SOURCES
@@ -50,11 +46,9 @@ add_ifcviewer_unit_test(test_sidecar_cache
add_ifcviewer_unit_test(test_instanced_geometry)
# Federation, Visibility and Selection are Qt-derived (QObject + signals).
# Unlike the other Tier-1 tests they have to pull Qt6 in directly and enable
# AUTOMOC for the Q_OBJECT moc-generation. OpenGL is needed only by the
# Selection test (Selection.cpp references QOpenGLFunctions_4_5_Core).
find_package(Qt${QT_VERSION} COMPONENTS Core Gui Test OpenGL REQUIRED PATHS ${QT_DIR})
# 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})
find_package(Eigen3 REQUIRED)
@@ -82,36 +76,3 @@ target_link_libraries(test_federation PRIVATE
IfcParse # Unit.cpp uses express::Base / file APIs
)
catch_discover_tests(test_federation)
# VisibilityState / SelectionState are QObjects (for their changed() signal)
# but their construction + mutation API touch no GL, so the tests exercise
# the pure CPU state machine without ever creating a context. Selection.cpp
# still references QOpenGLFunctions_4_5_Core, so test_selection has to link
# Qt6::OpenGL even though no GL call is reached at runtime.
add_executable(test_visibility
test_visibility.cpp
${IFCVIEWER_SRC}/Visibility.cpp
)
set_target_properties(test_visibility PROPERTIES AUTOMOC ON)
target_include_directories(test_visibility PRIVATE ${IFCVIEWER_SRC})
target_link_libraries(test_visibility PRIVATE
Catch2::Catch2WithMain
Qt${QT_VERSION}::Core
Qt${QT_VERSION}::Test # QSignalSpy
)
catch_discover_tests(test_visibility)
add_executable(test_selection
test_selection.cpp
${IFCVIEWER_SRC}/Selection.cpp
)
set_target_properties(test_selection PROPERTIES AUTOMOC ON)
target_include_directories(test_selection PRIVATE ${IFCVIEWER_SRC})
target_link_libraries(test_selection PRIVATE
Catch2::Catch2WithMain
Qt${QT_VERSION}::Core
Qt${QT_VERSION}::Gui # QtOpenGL depends on QtGui
Qt${QT_VERSION}::OpenGL # Selection.cpp: QOpenGLFunctions_4_5_Core
Qt${QT_VERSION}::Test # QSignalSpy
)
catch_discover_tests(test_selection)
-184
View File
@@ -1,184 +0,0 @@
/********************************************************************************
* *
* 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 "BvhAccel.h"
#include <catch2/catch_test_macros.hpp>
#include <random>
#include <vector>
namespace {
BvhItem makeItem(float x, float y, float z, float r, uint32_t model_id = 1) {
BvhItem it{};
it.aabb_min[0] = x - r;
it.aabb_min[1] = y - r;
it.aabb_min[2] = z - r;
it.aabb_max[0] = x + r;
it.aabb_max[1] = y + r;
it.aabb_max[2] = z + r;
it.model_id = model_id;
return it;
}
bool aabbContains(const float outer_min[3], const float outer_max[3],
const float inner_min[3], const float inner_max[3]) {
for (int a = 0; a < 3; ++a) {
if (inner_min[a] < outer_min[a]) return false;
if (inner_max[a] > outer_max[a]) return false;
}
return true;
}
void verifyNode(const ModelBvh& mbvh,
const std::vector<BvhItem>& items,
uint32_t node_idx) {
REQUIRE(node_idx < mbvh.nodes.size());
const BvhNode& node = mbvh.nodes[node_idx];
if (node.count > 0) {
// Leaf: every item's AABB must be inside the node AABB.
REQUIRE(node.count <= BVH_MAX_LEAF_SIZE);
for (uint32_t k = 0; k < node.count; ++k) {
uint32_t idx = mbvh.item_indices[node.right_or_first + k];
REQUIRE(idx < items.size());
REQUIRE(aabbContains(node.aabb_min, node.aabb_max,
items[idx].aabb_min, items[idx].aabb_max));
}
return;
}
// Interior: left child is at node_idx + 1, right at node.right_or_first.
uint32_t left_idx = node_idx + 1;
uint32_t right_idx = node.right_or_first;
REQUIRE(left_idx < mbvh.nodes.size());
REQUIRE(right_idx < mbvh.nodes.size());
REQUIRE(left_idx != right_idx);
const BvhNode& l = mbvh.nodes[left_idx];
const BvhNode& r = mbvh.nodes[right_idx];
REQUIRE(aabbContains(node.aabb_min, node.aabb_max, l.aabb_min, l.aabb_max));
REQUIRE(aabbContains(node.aabb_min, node.aabb_max, r.aabb_min, r.aabb_max));
REQUIRE(node.axis < 3);
verifyNode(mbvh, items, left_idx);
verifyNode(mbvh, items, right_idx);
}
} // namespace
TEST_CASE("BvhNode is 32 bytes (sidecar/cache layout invariant)", "[bvh]") {
REQUIRE(sizeof(BvhNode) == 32);
}
TEST_CASE("buildModelBvhOne on empty input produces no nodes", "[bvh]") {
std::vector<BvhItem> items;
ModelBvh mbvh = buildModelBvhOne(items, /*model_id=*/42);
REQUIRE(mbvh.model_id == 42);
REQUIRE(mbvh.nodes.empty());
REQUIRE(mbvh.item_indices.empty());
}
TEST_CASE("buildModelBvhOne with <= BVH_MAX_LEAF_SIZE items yields a single leaf", "[bvh]") {
std::vector<BvhItem> items;
for (int i = 0; i < 5; ++i) {
items.push_back(makeItem(float(i), 0.0f, 0.0f, 0.5f));
}
ModelBvh mbvh = buildModelBvhOne(items, 1);
REQUIRE(mbvh.nodes.size() == 1);
REQUIRE(mbvh.nodes[0].count == 5);
REQUIRE(mbvh.item_indices.size() == 5);
verifyNode(mbvh, items, 0);
}
TEST_CASE("buildModelBvhOne with many items splits and respects invariants", "[bvh]") {
std::mt19937 rng(0xC0FFEE);
std::uniform_real_distribution<float> coord(-100.0f, 100.0f);
std::uniform_real_distribution<float> radius(0.1f, 1.0f);
constexpr int N = 256;
std::vector<BvhItem> items;
items.reserve(N);
for (int i = 0; i < N; ++i) {
items.push_back(makeItem(coord(rng), coord(rng), coord(rng), radius(rng)));
}
ModelBvh mbvh = buildModelBvhOne(items, /*model_id=*/7);
REQUIRE(mbvh.model_id == 7);
REQUIRE(!mbvh.nodes.empty());
REQUIRE(mbvh.item_indices.size() == N);
// Permutation invariant: each item must appear exactly once.
std::vector<int> seen(N, 0);
for (uint32_t idx : mbvh.item_indices) {
REQUIRE(idx < uint32_t(N));
seen[idx]++;
}
for (int s : seen) REQUIRE(s == 1);
// Recursive structural invariants.
verifyNode(mbvh, items, 0);
// Sum of leaf counts must equal item count.
uint32_t leaf_total = 0;
for (const auto& n : mbvh.nodes) {
if (n.count > 0) leaf_total += n.count;
}
REQUIRE(leaf_total == N);
}
TEST_CASE("buildBvhSet partitions by model_id and gates on BVH_MIN_OBJECTS", "[bvh]") {
// Model 1: well above BVH_MIN_OBJECTS — should get a BVH.
// Model 2: a single item — below the gate, must be skipped.
std::vector<BvhItem> items;
for (uint32_t i = 0; i < BVH_MIN_OBJECTS + 4; ++i) {
items.push_back(makeItem(float(i), 0.0f, 0.0f, 0.5f, /*model_id=*/1));
}
items.push_back(makeItem(0.0f, 0.0f, 0.0f, 0.5f, /*model_id=*/2));
auto set = buildBvhSet(items);
REQUIRE(set);
REQUIRE(set->bvh_model_ids.count(1) == 1);
REQUIRE(set->bvh_model_ids.count(2) == 0);
REQUIRE(set->models.count(1) == 1);
REQUIRE(set->models.count(2) == 0);
const auto& mbvh = set->models.at(1);
REQUIRE(mbvh.model_id == 1);
REQUIRE(mbvh.item_indices.size() == BVH_MIN_OBJECTS + 4);
// item_indices reference positions in the *full* items array — the model-1
// items are at indices [0, BVH_MIN_OBJECTS + 4), so every entry must be
// less than that.
for (uint32_t idx : mbvh.item_indices) {
REQUIRE(idx < BVH_MIN_OBJECTS + 4);
}
verifyNode(mbvh, items, 0);
}
TEST_CASE("buildBvhSet returns empty set when nothing meets the gate", "[bvh]") {
std::vector<BvhItem> items;
for (uint32_t i = 0; i < BVH_MIN_OBJECTS - 1; ++i) {
items.push_back(makeItem(float(i), 0.0f, 0.0f, 0.5f));
}
auto set = buildBvhSet(items);
REQUIRE(set);
REQUIRE(set->bvh_model_ids.empty());
REQUIRE(set->models.empty());
}
-230
View File
@@ -1,230 +0,0 @@
/********************************************************************************
* *
* 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 SelectionState — the viewport's multi-selection state
// machine. The class also owns a GL flags SSBO, but every GL path guards on
// a context that initializeGl() has wired up; the test never calls
// initializeGl(), so the CPU-side selection set / active-id logic runs in
// full isolation (gl_ stays null and bindForRender is simply not exercised).
#include "Selection.h"
#include <catch2/catch_test_macros.hpp>
#include <QCoreApplication>
#include <QSignalSpy>
namespace {
// Catch2 owns main(), so QCoreApplication can't live in a TU constructor.
// Lazily construct it (intentionally leaked) the first time any test asks.
void ensureQApp() {
if (QCoreApplication::instance()) return;
static int argc = 1;
static char arg0[] = "test_selection";
static char* argv[] = {arg0, nullptr};
new QCoreApplication(argc, argv);
}
// First arg of the most recent changed(active_id) signal.
uint32_t lastActive(QSignalSpy& spy) {
REQUIRE(spy.count() > 0);
return spy.takeLast().at(0).toUInt();
}
} // namespace
TEST_CASE("SelectionState starts empty with no active id", "[selection]") {
ensureQApp();
SelectionState sel;
REQUIRE(sel.empty());
REQUIRE(sel.size() == 0);
REQUIRE(sel.activeObjectId() == 0);
REQUIRE_FALSE(sel.isSelected(1));
}
TEST_CASE("setSelectedObjectId selects a single id and makes it active",
"[selection]") {
ensureQApp();
SelectionState sel;
QSignalSpy spy(&sel, &SelectionState::changed);
sel.setSelectedObjectId(5);
REQUIRE(spy.count() == 1);
REQUIRE(sel.size() == 1);
REQUIRE(sel.isSelected(5));
REQUIRE(sel.activeObjectId() == 5);
REQUIRE(lastActive(spy) == 5);
}
TEST_CASE("setSelectedObjectId(0) clears the selection", "[selection]") {
ensureQApp();
SelectionState sel;
sel.setSelectedObjectId(5);
QSignalSpy spy(&sel, &SelectionState::changed);
sel.setSelectedObjectId(0);
REQUIRE(spy.count() == 1);
REQUIRE(sel.empty());
REQUIRE(sel.activeObjectId() == 0);
REQUIRE(lastActive(spy) == 0);
}
TEST_CASE("setSelection coerces active to 0 when it is not in the set",
"[selection]") {
ensureQApp();
SelectionState sel;
sel.setSelection({1, 2, 3}, /*active=*/9); // 9 not in the set
REQUIRE(sel.size() == 3);
REQUIRE(sel.activeObjectId() == 0);
sel.setSelection({1, 2, 3}, /*active=*/2); // 2 is in the set
REQUIRE(sel.activeObjectId() == 2);
}
TEST_CASE("setSelection drops object_id 0 and no-ops on identical state",
"[selection]") {
ensureQApp();
SelectionState sel;
QSignalSpy spy(&sel, &SelectionState::changed);
sel.setSelection({0, 1, 2}, /*active=*/1);
REQUIRE(spy.count() == 1);
REQUIRE(sel.size() == 2); // id 0 stripped
REQUIRE_FALSE(sel.isSelected(0));
REQUIRE(sel.activeObjectId() == 1);
// Same set + same active — no churn.
sel.setSelection({1, 2}, /*active=*/1);
REQUIRE(spy.count() == 1);
}
TEST_CASE("addToSelection adds ids, keeps active, ignores 0 and no-ops",
"[selection]") {
ensureQApp();
SelectionState sel;
sel.setSelectedObjectId(1);
QSignalSpy spy(&sel, &SelectionState::changed);
sel.addToSelection({2, 3});
REQUIRE(spy.count() == 1);
REQUIRE(sel.size() == 3);
REQUIRE(sel.activeObjectId() == 1); // active unchanged by add
REQUIRE(lastActive(spy) == 1);
// Nothing new -> no signal.
sel.addToSelection({2});
REQUIRE(spy.count() == 0);
// id 0 is never added.
sel.addToSelection({0});
REQUIRE(spy.count() == 0);
REQUIRE_FALSE(sel.isSelected(0));
REQUIRE(sel.size() == 3);
}
TEST_CASE("removeFromSelection clears active when the active id is removed",
"[selection]") {
ensureQApp();
SelectionState sel;
sel.setSelection({1, 2, 3}, /*active=*/2);
QSignalSpy spy(&sel, &SelectionState::changed);
// Removing a non-active id keeps the active id.
sel.removeFromSelection({1});
REQUIRE(spy.count() == 1);
REQUIRE(sel.size() == 2);
REQUIRE(sel.activeObjectId() == 2);
// Removing the active id drops the active.
sel.removeFromSelection({2});
REQUIRE(spy.count() == 2);
REQUIRE(sel.activeObjectId() == 0);
REQUIRE(sel.isSelected(3));
// Removing something absent -> no signal.
sel.removeFromSelection({99});
REQUIRE(spy.count() == 2);
}
TEST_CASE("toggleInSelection adds-as-active then removes-and-clears-active",
"[selection]") {
ensureQApp();
SelectionState sel;
QSignalSpy spy(&sel, &SelectionState::changed);
sel.toggleInSelection(5); // add
REQUIRE(sel.isSelected(5));
REQUIRE(sel.activeObjectId() == 5); // last-toggled becomes active
sel.toggleInSelection(6); // add — active follows the click
REQUIRE(sel.isSelected(6));
REQUIRE(sel.activeObjectId() == 6);
sel.toggleInSelection(5); // remove non-active — active unchanged
REQUIRE_FALSE(sel.isSelected(5));
REQUIRE(sel.activeObjectId() == 6);
sel.toggleInSelection(6); // remove the active — active cleared
REQUIRE(sel.empty());
REQUIRE(sel.activeObjectId() == 0);
REQUIRE(spy.count() == 4);
// id 0 is never toggled.
spy.clear();
sel.toggleInSelection(0);
REQUIRE(spy.count() == 0);
REQUIRE(sel.empty());
}
TEST_CASE("clearSelection empties the set; no-op when already empty",
"[selection]") {
ensureQApp();
SelectionState sel;
QSignalSpy spy(&sel, &SelectionState::changed);
sel.clearSelection(); // already empty
REQUIRE(spy.count() == 0);
sel.setSelectedObjectId(1);
REQUIRE(spy.count() == 1);
sel.clearSelection();
REQUIRE(spy.count() == 2);
REQUIRE(sel.empty());
REQUIRE(sel.activeObjectId() == 0);
}
TEST_CASE("reset clears state and emits only when state existed", "[selection]") {
ensureQApp();
SelectionState sel;
QSignalSpy spy(&sel, &SelectionState::changed);
sel.reset(); // nothing to clear
REQUIRE(spy.count() == 0);
sel.setSelectedObjectId(7);
REQUIRE(spy.count() == 1);
sel.reset();
REQUIRE(spy.count() == 2);
REQUIRE(sel.empty());
REQUIRE(sel.activeObjectId() == 0);
}
-180
View File
@@ -1,180 +0,0 @@
/********************************************************************************
* *
* 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 VisibilityState — the per-element hidden-set tracker.
// It is a QObject (for the changed() signal) but touches no GL, so the test
// exercises the full state machine directly and asserts the hot-path
// isHidden() flag mirror stays consistent with the canonical hidden set.
#include "Visibility.h"
#include <catch2/catch_test_macros.hpp>
#include <QCoreApplication>
#include <QSignalSpy>
namespace {
// Catch2 owns main(), so QCoreApplication can't live in a TU constructor.
// Lazily construct it (intentionally leaked) the first time any test asks.
void ensureQApp() {
if (QCoreApplication::instance()) return;
static int argc = 1;
static char arg0[] = "test_visibility";
static char* argv[] = {arg0, nullptr};
new QCoreApplication(argc, argv);
}
} // namespace
TEST_CASE("VisibilityState starts empty", "[visibility]") {
ensureQApp();
VisibilityState vis;
REQUIRE(vis.empty());
REQUIRE(vis.size() == 0);
REQUIRE_FALSE(vis.isHidden(0)); // 0 is the "no object" sentinel
REQUIRE_FALSE(vis.isHidden(1));
REQUIRE_FALSE(vis.isHidden(1u << 20));
}
TEST_CASE("hideObjects unions ids, emits changed, and isHidden reflects it",
"[visibility]") {
ensureQApp();
VisibilityState vis;
QSignalSpy spy(&vis, &VisibilityState::changed);
vis.hideObjects({1, 2, 3});
REQUIRE(spy.count() == 1);
REQUIRE(vis.size() == 3);
REQUIRE(vis.isHidden(1));
REQUIRE(vis.isHidden(2));
REQUIRE(vis.isHidden(3));
REQUIRE_FALSE(vis.isHidden(4));
// Unioning in a fresh id signals once more and grows the set.
vis.hideObjects({2, 4}); // 2 already hidden, 4 is new
REQUIRE(spy.count() == 2);
REQUIRE(vis.size() == 4);
REQUIRE(vis.isHidden(4));
}
TEST_CASE("hideObjects ignores object_id 0 and is idempotent", "[visibility]") {
ensureQApp();
VisibilityState vis;
QSignalSpy spy(&vis, &VisibilityState::changed);
vis.hideObjects({0});
REQUIRE(vis.empty());
REQUIRE(spy.count() == 0); // nothing changed
REQUIRE_FALSE(vis.isHidden(0));
vis.hideObjects({7});
REQUIRE(spy.count() == 1);
vis.hideObjects({7}); // already hidden — no-op
REQUIRE(spy.count() == 1);
}
TEST_CASE("showObjects subtracts and emits only when something changes",
"[visibility]") {
ensureQApp();
VisibilityState vis;
vis.hideObjects({1, 2, 3});
QSignalSpy spy(&vis, &VisibilityState::changed);
vis.showObjects({2});
REQUIRE(spy.count() == 1);
REQUIRE_FALSE(vis.isHidden(2));
REQUIRE(vis.isHidden(1));
REQUIRE(vis.isHidden(3));
REQUIRE(vis.size() == 2);
// Showing an id that was never hidden changes nothing.
vis.showObjects({99});
REQUIRE(spy.count() == 1);
}
TEST_CASE("setHidden replaces the set wholesale and drops id 0", "[visibility]") {
ensureQApp();
VisibilityState vis;
vis.hideObjects({1, 2});
QSignalSpy spy(&vis, &VisibilityState::changed);
vis.setHidden({3, 4, 0});
REQUIRE(spy.count() == 1);
REQUIRE(vis.size() == 2); // id 0 was stripped
REQUIRE_FALSE(vis.isHidden(0));
REQUIRE_FALSE(vis.isHidden(1)); // old members cleared
REQUIRE_FALSE(vis.isHidden(2));
REQUIRE(vis.isHidden(3));
REQUIRE(vis.isHidden(4));
// Replacing with an identical set is a no-op.
vis.setHidden({3, 4});
REQUIRE(spy.count() == 1);
}
TEST_CASE("showAll clears the set; no-op when already empty", "[visibility]") {
ensureQApp();
VisibilityState vis;
vis.hideObjects({1, 2});
QSignalSpy spy(&vis, &VisibilityState::changed);
vis.showAll();
REQUIRE(spy.count() == 1);
REQUIRE(vis.empty());
REQUIRE_FALSE(vis.isHidden(1));
REQUIRE_FALSE(vis.isHidden(2));
vis.showAll(); // already empty
REQUIRE(spy.count() == 1);
}
TEST_CASE("reset clears state and emits only when state existed", "[visibility]") {
ensureQApp();
VisibilityState vis;
QSignalSpy spy(&vis, &VisibilityState::changed);
vis.reset(); // nothing to clear
REQUIRE(spy.count() == 0);
vis.hideObjects({5});
REQUIRE(spy.count() == 1);
vis.reset();
REQUIRE(spy.count() == 2);
REQUIRE(vis.empty());
REQUIRE_FALSE(vis.isHidden(5));
}
TEST_CASE("hideObjects grows the flag mirror for large object ids", "[visibility]") {
ensureQApp();
VisibilityState vis;
// A high id forces the cpu_flags_ vector (used by the hot-path isHidden)
// to resize. isHidden must report it correctly without going out of
// bounds, and neighbouring ids must stay visible.
const uint32_t big = 1'000'000;
vis.hideObjects({big});
REQUIRE(vis.isHidden(big));
REQUIRE_FALSE(vis.isHidden(big - 1));
REQUIRE_FALSE(vis.isHidden(big + 1));
REQUIRE(vis.hiddenIds().count(big) == 1);
vis.noteObjectId(big * 2); // pre-grow only — id stays visible
REQUIRE_FALSE(vis.isHidden(big * 2));
}