diff --git a/src/ifcviewer-web/tests/smoke.spec.mjs b/src/ifcviewer-web/tests/smoke.spec.mjs index d575ddafad..1664237336 100644 --- a/src/ifcviewer-web/tests/smoke.spec.mjs +++ b/src/ifcviewer-web/tests/smoke.spec.mjs @@ -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([]); +}); diff --git a/src/ifcviewer/tests/CMakeLists.txt b/src/ifcviewer/tests/CMakeLists.txt index cf839e2c10..e2b7c74e3c 100644 --- a/src/ifcviewer/tests/CMakeLists.txt +++ b/src/ifcviewer/tests/CMakeLists.txt @@ -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}) diff --git a/src/ifcviewer/tests/test_viewport_camera.cpp b/src/ifcviewer/tests/test_viewport_camera.cpp new file mode 100644 index 0000000000..47f28a6035 --- /dev/null +++ b/src/ifcviewer/tests/test_viewport_camera.cpp @@ -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 +#include + +#include "ViewportCore.h" +#include "ViewportHost.h" + +#include + +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); +}