mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-11 10:06:47 +00:00
Add VisibilityState/SelectionState tests; test real quantization helpers
test_instanced_geometry previously re-implemented vertex quantization inline, with a stale comment claiming the helpers still lived in ViewportWindow.cpp. They now live in VertexQuantization.h, so route the test through the real quantizeVertex/octEncodeNormal and add coverage for the degenerate-axis path, octahedral normal round-trip, the i8 normal error bound (~0.78 deg worst observed), and color passthrough. Add test_visibility and test_selection: Tier-1 coverage of the two per-object viewport state machines. Both are QObjects for their changed() signal but touch no GL on the construction/mutation path, so the tests exercise the pure CPU logic without a context. Suite goes from 39 to 61 cases. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -50,10 +50,11 @@ add_ifcviewer_unit_test(test_sidecar_cache
|
||||
|
||||
add_ifcviewer_unit_test(test_instanced_geometry)
|
||||
|
||||
# Federation is Qt-derived (QObject + signals + QVector3D + QJson*). Unlike
|
||||
# the other Tier-1 tests it has to pull Qt6::Core/Gui/Test in directly and
|
||||
# enable AUTOMOC for the Q_OBJECT moc-generation.
|
||||
find_package(Qt${QT_VERSION} COMPONENTS Core Gui Test REQUIRED PATHS ${QT_DIR})
|
||||
# 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})
|
||||
|
||||
find_package(Eigen3 REQUIRED)
|
||||
|
||||
@@ -81,3 +82,36 @@ 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)
|
||||
|
||||
@@ -17,23 +17,58 @@
|
||||
* *
|
||||
********************************************************************************/
|
||||
|
||||
// Tier-1 coverage of the instanced-geometry GPU/sidecar layout.
|
||||
// Tier-1 coverage of the instanced-geometry GPU/sidecar layout and the
|
||||
// vertex quantization used to fill it.
|
||||
//
|
||||
// The production quantization helpers currently live inside
|
||||
// ViewportWindow.cpp (see uploadMeshChunk). Once they're factored out into a
|
||||
// reusable header (planned tier-3 prerequisite) this test will exercise the
|
||||
// real implementation directly. For now we cover:
|
||||
// quantizeVertex / octEncodeNormal (VertexQuantization.h) are the shared
|
||||
// production helpers: ViewportWindow::uploadMeshChunk 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)
|
||||
// - documented constants form a self-consistent layout
|
||||
// - a reference position-quantization round-trip that pins the error bound
|
||||
// declared in InstancedGeometry.h ("dequant to mix(aabb_min, aabb_max, t)")
|
||||
// - documented INSTANCED_VERTEX_* constants form a self-consistent layout
|
||||
// - position quantization round-trips within the u16-grid error bound
|
||||
// - octahedral normal encode/decode round-trips, and the i8-packed normal
|
||||
// written by quantizeVertex stays within its documented angular error
|
||||
|
||||
#include "InstancedGeometry.h"
|
||||
#include "VertexQuantization.h"
|
||||
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
|
||||
namespace {
|
||||
|
||||
// Inverse of octEncodeNormal: square [-1,1]^2 -> unit sphere. The test owns
|
||||
// the decode (the production header only ships the encoder, since the GPU
|
||||
// shader does the decode); it is the standard Meyer et al. octahedral unfold.
|
||||
void octDecodeNormal(const float e[2], float out[3]) {
|
||||
float x = e[0];
|
||||
float y = e[1];
|
||||
float z = 1.0f - std::fabs(x) - std::fabs(y);
|
||||
if (z < 0.0f) {
|
||||
float ox = (1.0f - std::fabs(y)) * (x >= 0.0f ? 1.0f : -1.0f);
|
||||
float oy = (1.0f - std::fabs(x)) * (y >= 0.0f ? 1.0f : -1.0f);
|
||||
x = ox;
|
||||
y = oy;
|
||||
}
|
||||
float len = std::sqrt(x * x + y * y + z * z);
|
||||
out[0] = x / len;
|
||||
out[1] = y / len;
|
||||
out[2] = z / len;
|
||||
}
|
||||
|
||||
// Angle (degrees) between two unit-ish vectors.
|
||||
float angleDeg(const float a[3], const float b[3]) {
|
||||
float dot = a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
||||
if (dot > 1.0f) dot = 1.0f;
|
||||
if (dot < -1.0f) dot = -1.0f;
|
||||
return std::acos(dot) * (180.0f / 3.14159265358979323846f);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("Instanced GPU/CPU struct sizes match the wire format", "[instgeom]") {
|
||||
REQUIRE(sizeof(MeshGpu) == 32);
|
||||
@@ -53,41 +88,47 @@ TEST_CASE("INSTANCED_VERTEX_* constants are self-consistent", "[instgeom]") {
|
||||
REQUIRE(INSTANCED_VERTEX_STRIDE_FLOATS == 7);
|
||||
}
|
||||
|
||||
TEST_CASE("Position quantization round-trips within the documented error bound", "[instgeom]") {
|
||||
TEST_CASE("quantizeVertex round-trips position within the documented error bound",
|
||||
"[instgeom]") {
|
||||
// The quantization basis is per-mesh: t = (p - min) / (max - min) packed
|
||||
// into u16, and dequantized as p' = min + t * (max - min). The round-trip
|
||||
// error per axis is at most (max - min) / 65535 (one ulp of the u16 grid).
|
||||
const float aabb_min[3] = {-3.5f, 100.25f, -1000.0f};
|
||||
const float aabb_max[3] = { 7.5f, 200.25f, 1000.0f};
|
||||
const float extent[3] = {
|
||||
// into u16, dequantized as p' = min + (q / 65535) * (max - min). The
|
||||
// round-trip error per axis is at most (max - min) / 65535 (one ulp of the
|
||||
// u16 grid).
|
||||
const float aabb_min[3] = {-3.5f, 100.25f, -1000.0f};
|
||||
const float aabb_max[3] = { 7.5f, 200.25f, 1000.0f};
|
||||
const float extent[3] = {
|
||||
aabb_max[0] - aabb_min[0],
|
||||
aabb_max[1] - aabb_min[1],
|
||||
aabb_max[2] - aabb_min[2],
|
||||
};
|
||||
const float extent_recip[3] = {
|
||||
1.0f / extent[0], 1.0f / extent[1], 1.0f / extent[2],
|
||||
};
|
||||
|
||||
constexpr int kSamples = 65;
|
||||
float worst_err = 0.0f;
|
||||
for (int s = 0; s <= kSamples; ++s) {
|
||||
float t = float(s) / float(kSamples);
|
||||
|
||||
// A streamer-format vertex: pos3 + normal3 + color-as-float.
|
||||
float src[INSTANCED_VERTEX_STRIDE_FLOATS] = {0};
|
||||
for (int a = 0; a < 3; ++a) src[a] = aabb_min[a] + t * extent[a];
|
||||
src[5] = 1.0f; // arbitrary valid normal (0,0,1)
|
||||
|
||||
uint8_t dst[INSTANCED_VERTEX_STRIDE_BYTES];
|
||||
quantizeVertex(src, aabb_min, extent_recip, dst);
|
||||
|
||||
const uint16_t* q =
|
||||
reinterpret_cast<const uint16_t*>(dst + INSTANCED_VERTEX_POS_OFFSET);
|
||||
for (int a = 0; a < 3; ++a) {
|
||||
float p = aabb_min[a] + t * extent[a];
|
||||
|
||||
// Pack (the same formula buildLods/the streamer use against an AABB).
|
||||
float tt = (p - aabb_min[a]) / extent[a];
|
||||
if (tt < 0.0f) tt = 0.0f;
|
||||
if (tt > 1.0f) tt = 1.0f;
|
||||
uint16_t q = uint16_t(tt * 65535.0f + 0.5f);
|
||||
|
||||
// Unpack (matches the dequant in LodBuilder.cpp).
|
||||
float pp = aabb_min[a] + (q / 65535.0f) * extent[a];
|
||||
|
||||
float err = std::fabs(pp - p);
|
||||
float pp = aabb_min[a] + (q[a] / 65535.0f) * extent[a];
|
||||
float err = std::fabs(pp - src[a]);
|
||||
if (err > worst_err) worst_err = err;
|
||||
}
|
||||
}
|
||||
|
||||
// The round-trip error must stay within one u16 ulp of the largest extent,
|
||||
// with a small float-rounding margin.
|
||||
// Worst error must stay within one u16 ulp of the largest extent, with a
|
||||
// small float-rounding margin.
|
||||
float ulp = 0.0f;
|
||||
for (int a = 0; a < 3; ++a) {
|
||||
ulp = std::max(ulp, extent[a] / 65535.0f);
|
||||
@@ -95,6 +136,115 @@ TEST_CASE("Position quantization round-trips within the documented error bound",
|
||||
REQUIRE(worst_err <= ulp * 1.01f);
|
||||
}
|
||||
|
||||
TEST_CASE("quantizeVertex handles a degenerate (zero-extent) axis", "[instgeom]") {
|
||||
// A planar mesh has a flat axis: extent_recip is 0 there (see the header
|
||||
// contract). Every vertex on that axis must quantize to 0, not NaN.
|
||||
const float aabb_min[3] = {0.0f, 0.0f, 5.0f};
|
||||
const float extent_recip[3] = {1.0f, 1.0f, 0.0f}; // Z is degenerate
|
||||
|
||||
float src[INSTANCED_VERTEX_STRIDE_FLOATS] = {0};
|
||||
src[0] = 0.5f; src[1] = 0.25f; src[2] = 5.0f;
|
||||
src[5] = 1.0f;
|
||||
|
||||
uint8_t dst[INSTANCED_VERTEX_STRIDE_BYTES];
|
||||
quantizeVertex(src, aabb_min, extent_recip, dst);
|
||||
|
||||
const uint16_t* q =
|
||||
reinterpret_cast<const uint16_t*>(dst + INSTANCED_VERTEX_POS_OFFSET);
|
||||
REQUIRE(q[2] == 0); // degenerate axis collapses to the grid origin
|
||||
}
|
||||
|
||||
TEST_CASE("octEncodeNormal / octDecodeNormal round-trip unit normals", "[instgeom]") {
|
||||
// The float-precision oct map is a bijection on the sphere — encode then
|
||||
// decode must recover the original direction tightly (the i8 packing,
|
||||
// which adds the real error, is covered separately below).
|
||||
const float normals[][3] = {
|
||||
{ 1, 0, 0}, {-1, 0, 0}, {0, 1, 0}, {0, -1, 0},
|
||||
{ 0, 0, 1}, { 0, 0,-1}, // axis-aligned
|
||||
{ 0.5773503f, 0.5773503f, 0.5773503f}, // +++ diagonal
|
||||
{-0.5773503f, -0.5773503f, -0.5773503f}, // --- diagonal (z < 0 fold)
|
||||
{ 0.7071068f, 0.0f, -0.7071068f}, // z < 0 fold
|
||||
{ 0.2672612f, 0.5345225f, 0.8017837f}, // arbitrary
|
||||
};
|
||||
|
||||
for (const auto& n : normals) {
|
||||
float e[2];
|
||||
octEncodeNormal(n, e);
|
||||
REQUIRE(e[0] >= -1.0f);
|
||||
REQUIRE(e[0] <= 1.0f);
|
||||
REQUIRE(e[1] >= -1.0f);
|
||||
REQUIRE(e[1] <= 1.0f);
|
||||
|
||||
float decoded[3];
|
||||
octDecodeNormal(e, decoded);
|
||||
INFO("normal (" << n[0] << ", " << n[1] << ", " << n[2] << ")");
|
||||
REQUIRE(angleDeg(n, decoded) < 0.01f);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("quantizeVertex packs the normal within its documented i8 error bound",
|
||||
"[instgeom]") {
|
||||
// quantizeVertex stores the octahedral normal as i8 x 2. The header
|
||||
// documents "~1.4 deg worst-case error" for that packing; sweep a dense
|
||||
// set of directions and pin the worst observed error well below a 3 deg
|
||||
// regression ceiling (a broken encoder is off by tens of degrees).
|
||||
const float aabb_min[3] = {0, 0, 0};
|
||||
const float extent_recip[3] = {1, 1, 1};
|
||||
|
||||
float worst_err = 0.0f;
|
||||
constexpr int kSteps = 40;
|
||||
for (int i = 0; i <= kSteps; ++i) {
|
||||
for (int j = 0; j <= kSteps; ++j) {
|
||||
// Spherical sweep over the full sphere.
|
||||
float theta = 3.14159265f * float(i) / float(kSteps); // polar
|
||||
float phi = 2.0f * 3.14159265f * float(j) / float(kSteps); // azimuth
|
||||
float n[3] = {
|
||||
std::sin(theta) * std::cos(phi),
|
||||
std::sin(theta) * std::sin(phi),
|
||||
std::cos(theta),
|
||||
};
|
||||
|
||||
float src[INSTANCED_VERTEX_STRIDE_FLOATS] = {0};
|
||||
src[3] = n[0]; src[4] = n[1]; src[5] = n[2];
|
||||
|
||||
uint8_t dst[INSTANCED_VERTEX_STRIDE_BYTES];
|
||||
quantizeVertex(src, aabb_min, extent_recip, dst);
|
||||
|
||||
// Decode the stored i8 oct pair back to a direction.
|
||||
const int8_t* packed =
|
||||
reinterpret_cast<const int8_t*>(dst + INSTANCED_VERTEX_NORMAL_OFFSET);
|
||||
float e[2] = {packed[0] / 127.0f, packed[1] / 127.0f};
|
||||
float decoded[3];
|
||||
octDecodeNormal(e, decoded);
|
||||
|
||||
worst_err = std::max(worst_err, angleDeg(n, decoded));
|
||||
}
|
||||
}
|
||||
|
||||
INFO("worst i8 octahedral normal error: " << worst_err << " deg");
|
||||
REQUIRE(worst_err > 0.0f); // sanity: quantization is actually lossy
|
||||
REQUIRE(worst_err < 3.0f); // regression ceiling around the documented ~1.4 deg
|
||||
}
|
||||
|
||||
TEST_CASE("quantizeVertex passes the packed color through unchanged", "[instgeom]") {
|
||||
// The streamer packs an rgba8 into the 7th float slot; quantizeVertex
|
||||
// memcpy's those 4 bytes straight into the VBO color field.
|
||||
const uint8_t rgba[4] = {0x11, 0x22, 0x33, 0x44};
|
||||
float color_as_float;
|
||||
std::memcpy(&color_as_float, rgba, 4);
|
||||
|
||||
const float aabb_min[3] = {0, 0, 0};
|
||||
const float extent_recip[3] = {1, 1, 1};
|
||||
float src[INSTANCED_VERTEX_STRIDE_FLOATS] = {0};
|
||||
src[5] = 1.0f; // valid normal
|
||||
src[6] = color_as_float; // color slot
|
||||
|
||||
uint8_t dst[INSTANCED_VERTEX_STRIDE_BYTES];
|
||||
quantizeVertex(src, aabb_min, extent_recip, dst);
|
||||
|
||||
REQUIRE(std::memcmp(dst + INSTANCED_VERTEX_COLOR_OFFSET, rgba, 4) == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("MeshChunk and InstanceChunk default-init to zeroed metadata", "[instgeom]") {
|
||||
MeshChunk mc;
|
||||
REQUIRE(mc.model_id == 0);
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
/********************************************************************************
|
||||
* *
|
||||
* 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);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/********************************************************************************
|
||||
* *
|
||||
* 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));
|
||||
}
|
||||
Reference in New Issue
Block a user