ifcviewer: tests for the new viewing features (fly, x-ray, visibility)

Coverage had lagged the recent feature work. Add both layers:

- test_viewport_camera (Catch2, headless): constructs ViewportCore with a mock
  ViewportHost — construction/teardown touch no GPU (the wgpu teardown lives in
  releaseWgpuModelGpuData, only reached with models loaded), and the camera ops
  are pure — so it unit-tests the SHARED fly math fast and deterministically:
  flyMove (forward step, 5x boost, opposing-key cancel, dt clamp, QE along +Z,
  degenerate-pitch stays finite), flyLook (turn-in-place pins the eye, pitch
  clamp), flyAdjustSpeed (x1.25/notch, [0.05,1000] clamp), toggleXray, and
  hideSelected/showAll. Links the built IfcViewerCore (ViewportCore.cpp is the
  monster TU that can't compile standalone). +9 cases → 122 desktop.

- smoke.spec.mjs (Playwright): fly (enter → W moves the camera → Esc exits),
  x-ray (toggle translucency on/off), and hide-after-pick, driving the exported
  C hooks end-to-end. +3 cases → 9 web.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-07-02 16:23:30 +10:00
parent 68d6280c93
commit 4faae025b4
3 changed files with 246 additions and 0 deletions
+72
View File
@@ -271,3 +271,75 @@ test('click selects an object and the highlight renders (async pick)', async ({
expect(gpuErrors, gpuErrors.join('\n')).toEqual([]);
});
// --- Nav / viewing features added on top of the streaming core --------------
// These drive the exported C hooks that the desktop reaches via hotkeys, so a
// break in the shared ViewportCore math (fly, visibility, x-ray) or the web
// wiring shows up here. All run against the embedded sample.
async function ready(page) {
await page.goto('/IfcViewerWeb.html');
await page.waitForFunction(
() => !!(window.Module && window.Module._app_ptr), null, { timeout: 30_000 });
await page.waitForTimeout(1000);
}
test('fly mode: enter, WASD moves the camera, exit', async ({ page }) => {
const gpuErrors = [];
page.on('console', (m) => { if (/Uncaptured WebGPU error|is invalid/i.test(m.text())) gpuErrors.push(m.text()); });
await ready(page);
await page.evaluate(() => window.Module._toggle_fly_c());
expect(await page.evaluate(() => window.Module._fly_is_active_c())).toBe(1);
const before = await shot(page);
await page.keyboard.down('w'); // fly forward
await page.waitForTimeout(500);
await page.keyboard.up('w');
await page.waitForTimeout(200);
const after = await shot(page);
expect(Buffer.compare(before, after), 'holding W did not move the fly camera').not.toBe(0);
await page.keyboard.press('Escape'); // single Esc exits
expect(await page.evaluate(() => window.Module._fly_is_active_c())).toBe(0);
expect(gpuErrors, gpuErrors.join('\n')).toEqual([]);
});
test('x-ray toggles translucency on and off', async ({ page }) => {
const gpuErrors = [];
page.on('console', (m) => { if (/Uncaptured WebGPU error|is invalid/i.test(m.text())) gpuErrors.push(m.text()); });
await ready(page);
const base = await shot(page);
await page.evaluate(() => window.Module._toggle_xray_c());
await page.waitForTimeout(300);
expect(await page.evaluate(() => window.Module._xray_is_active_c())).toBe(1);
const xray = await shot(page);
expect(Buffer.compare(base, xray), 'x-ray on did not change the render').not.toBe(0);
await page.evaluate(() => window.Module._toggle_xray_c());
await page.waitForTimeout(300);
expect(await page.evaluate(() => window.Module._xray_is_active_c())).toBe(0);
expect(gpuErrors, gpuErrors.join('\n')).toEqual([]);
});
test('hide selected removes geometry after a pick', async ({ page }) => {
const gpuErrors = [];
page.on('console', (m) => { if (/Uncaptured WebGPU error|is invalid/i.test(m.text())) gpuErrors.push(m.text()); });
await ready(page);
const box = await page.locator('#viewer-canvas').boundingBox();
const before = await shot(page);
// select whatever is under the centre, then hide it
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
await page.waitForTimeout(400);
await page.evaluate(() => window.Module._hide_selected_c());
await page.waitForTimeout(400);
const after = await shot(page);
expect(Buffer.compare(before, after), 'hide did not change the canvas — nothing picked or hide is a no-op').not.toBe(0);
// show all brings it back
await page.evaluate(() => window.Module._show_all_c());
await page.waitForTimeout(400);
expect(gpuErrors, gpuErrors.join('\n')).toEqual([]);
});
+14
View File
@@ -126,6 +126,20 @@ if(UNIX AND NOT APPLE AND WGPU_NATIVE_LIB_DIR)
)
endif()
# ViewportCore camera + visibility ops (fly math, x-ray, hide/show-all), the
# math shared verbatim by desktop + web. ViewportCore.cpp is the monster TU that
# can't compile standalone, so link the built IfcViewerCore lib; a mock
# ViewportHost stands in for the window, and no GPU is created (construction /
# teardown / the tested ops touch no wgpu).
add_ifcviewer_unit_test(test_viewport_camera
LIBS IfcViewerCore Eigen3::Eigen
)
if(UNIX AND NOT APPLE AND WGPU_NATIVE_LIB_DIR)
set_target_properties(test_viewport_camera 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})
@@ -0,0 +1,160 @@
// Headless unit tests for ViewportCore's pure camera + visibility ops — the
// fly camera math (flyMove / flyLook / flyAdjustSpeed) and hide/x-ray, all of
// which are shared verbatim by the desktop and web hosts. ViewportCore is
// constructed with a mock ViewportHost; no GPU is created, and construction /
// teardown touch no wgpu (the GPU teardown lives in releaseWgpuModelGpuData,
// only reached with models loaded). So these run fast and deterministically.
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "ViewportCore.h"
#include "ViewportHost.h"
#include <cmath>
using Catch::Matchers::WithinAbs;
namespace {
// Minimal host: the camera ops only ever call requestFrame().
struct MockHost : ViewportHost {
int frames_requested = 0;
WGPUSurface createSurface(WGPUInstance) override { return nullptr; }
void framebufferSize(int& w, int& h) const override { w = 800; h = 600; }
float dpr() const override { return 1.0f; }
void requestFrame() override { ++frames_requested; }
void quit() override {}
};
// eye = orbitEye convention: target + dist * (cos p cos y, cos p sin y, sin p).
Eigen::Vector3f eyeOf(const ViewportCore& c) {
const auto s = c.cameraState();
const float d2r = 3.14159265358979323846f / 180.0f;
const float y = s.yaw * d2r, p = s.pitch * d2r;
const float cp = std::cos(p), sp = std::sin(p), cy = std::cos(y), sy = std::sin(y);
return s.target + s.distance * Eigen::Vector3f(cp * cy, cp * sy, sp);
}
} // namespace
TEST_CASE("flyMove: W drives the eye toward the look direction", "[camera][fly]") {
MockHost host; ViewportCore core(&host);
// Look down -X: target at origin, eye at (+dist,0,0) → forward = (-1,0,0).
core.setCamera(0, 0, 0, /*dist*/10, /*yaw*/0, /*pitch*/0);
// dt is clamped to 0.1s inside flyMove, so use 0.1 for a predictable step:
// move = forward*speed*dt = (-1,0,0)*5*0.1.
core.flyMove(/*fwd*/true, false, false, false, false, false, /*boost*/false, /*dt*/0.1f);
const auto s = core.cameraState();
REQUIRE_THAT(s.target.x(), WithinAbs(-0.5f, 1e-4f));
REQUIRE_THAT(s.target.y(), WithinAbs(0.0f, 1e-4f));
REQUIRE_THAT(s.target.z(), WithinAbs(0.0f, 1e-4f));
}
TEST_CASE("flyMove: Shift boosts 5x, opposing keys cancel", "[camera][fly]") {
MockHost host; ViewportCore core(&host);
SECTION("boost") {
core.setCamera(0, 0, 0, 10, 0, 0);
core.flyMove(true, false, false, false, false, false, /*boost*/true, 0.1f);
REQUIRE_THAT(core.cameraState().target.x(), WithinAbs(-2.5f, 1e-3f)); // 5 * 5 * 0.1
}
SECTION("W+S cancel → no move") {
core.setCamera(1, 2, 3, 10, 0, 0);
core.flyMove(/*fwd*/true, /*back*/true, false, false, false, false, false, 0.1f);
const auto s = core.cameraState();
REQUIRE_THAT(s.target.x(), WithinAbs(1.0f, 1e-5f));
REQUIRE_THAT(s.target.y(), WithinAbs(2.0f, 1e-5f));
REQUIRE_THAT(s.target.z(), WithinAbs(3.0f, 1e-5f));
}
}
TEST_CASE("flyMove: dt is clamped so a stall can't warp the camera", "[camera][fly]") {
MockHost host; ViewportCore core(&host);
core.setCamera(0, 0, 0, 10, 0, 0);
core.flyMove(true, false, false, false, false, false, false, /*dt*/10.0f); // huge stall
// dt clamps to 0.1 → move = 5 * 0.1 = 0.5, not 50.
REQUIRE_THAT(core.cameraState().target.x(), WithinAbs(-0.5f, 1e-4f));
}
TEST_CASE("flyMove: QE move along world +Z; looking straight down stays finite",
"[camera][fly]") {
MockHost host; ViewportCore core(&host);
SECTION("E rises along +Z") {
core.setCamera(0, 0, 0, 10, 0, 0);
core.flyMove(false, false, false, false, /*up*/true, false, false, 0.1f);
const auto s = core.cameraState();
REQUIRE_THAT(s.target.z(), WithinAbs(0.5f, 1e-4f)); // 5 * 0.1
}
SECTION("degenerate pitch (top view) doesn't NaN the right vector") {
core.setStandardView(ViewportCore::StandardView::Top); // pitch ~ +90
core.flyMove(false, false, /*right*/true, false, false, false, false, 0.1f);
const auto s = core.cameraState();
REQUIRE(std::isfinite(s.target.x()));
REQUIRE(std::isfinite(s.target.y()));
REQUIRE(std::isfinite(s.target.z()));
}
}
TEST_CASE("flyLook: turns in place — the eye stays pinned", "[camera][fly]") {
MockHost host; ViewportCore core(&host);
core.setCamera(5, -3, 2, 12, 30, 10);
const Eigen::Vector3f eye_before = eyeOf(core);
const float yaw_before = core.cameraState().yaw;
const float pitch_before = core.cameraState().pitch;
core.flyLook(/*dx*/100.0f, /*dy*/40.0f);
const Eigen::Vector3f eye_after = eyeOf(core);
// Eye pinned (turn-in-place) to well within a millimetre.
REQUIRE_THAT((eye_after - eye_before).norm(), WithinAbs(0.0f, 1e-3f));
// Orientation actually changed: yaw -= dx*0.2, pitch += dy*0.2.
REQUIRE_THAT(core.cameraState().yaw, WithinAbs(yaw_before - 20.0f, 1e-3f));
REQUIRE_THAT(core.cameraState().pitch, WithinAbs(pitch_before + 8.0f, 1e-3f));
}
TEST_CASE("flyLook: pitch is clamped to +/-89.9", "[camera][fly]") {
MockHost host; ViewportCore core(&host);
core.setCamera(0, 0, 0, 10, 0, 0);
core.flyLook(0.0f, /*dy*/100000.0f);
REQUIRE(core.cameraState().pitch <= 89.9f);
core.flyLook(0.0f, -100000.0f);
REQUIRE(core.cameraState().pitch >= -89.9f);
}
TEST_CASE("flyAdjustSpeed: scales x1.25/notch, clamped to [0.05, 1000]", "[camera][fly]") {
MockHost host; ViewportCore core(&host);
REQUIRE_THAT(core.flySpeed(), WithinAbs(5.0f, 1e-5f)); // default
core.flyAdjustSpeed(1.0f);
REQUIRE_THAT(core.flySpeed(), WithinAbs(6.25f, 1e-4f)); // 5 * 1.25
core.flyAdjustSpeed(1000.0f); // way up
REQUIRE_THAT(core.flySpeed(), WithinAbs(1000.0f, 1e-2f)); // clamped high
core.flyAdjustSpeed(-1000.0f); // way down
REQUIRE_THAT(core.flySpeed(), WithinAbs(0.05f, 1e-4f)); // clamped low
}
TEST_CASE("toggleXray flips the active state", "[camera][xray]") {
MockHost host; ViewportCore core(&host);
REQUIRE_FALSE(core.xrayActive());
core.toggleXray();
REQUIRE(core.xrayActive());
core.toggleXray();
REQUIRE_FALSE(core.xrayActive());
}
TEST_CASE("hideSelected hides the selection; showAll restores", "[camera][visibility]") {
MockHost host; ViewportCore core(&host);
REQUIRE(core.hiddenCount() == 0);
core.applyPickToSelection(42, /*add*/false, /*remove*/false); // select object 42
core.hideSelected();
REQUIRE(core.hiddenCount() == 1);
// Hiding deselects, so a second hide with nothing selected is a no-op.
core.hideSelected();
REQUIRE(core.hiddenCount() == 1);
core.showAll();
REQUIRE(core.hiddenCount() == 0);
}