mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-05 23:41:44 +00:00
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:
@@ -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([]);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user