diff --git a/src/ifcviewer-web/CMakeLists.txt b/src/ifcviewer-web/CMakeLists.txt index 4a342889d8..aa3301eb1c 100644 --- a/src/ifcviewer-web/CMakeLists.txt +++ b/src/ifcviewer-web/CMakeLists.txt @@ -104,7 +104,7 @@ target_link_options(IfcViewerWeb PRIVATE # EMSCRIPTEN_KEEPALIVE alone keeps the symbols in the binary but doesn't # add them to Module. ccall lets shell.html pass a JS string (the ?model # URL) to load_sidecar_from_url_c without manual heap marshalling. - "-sEXPORTED_FUNCTIONS=['_main','_raf_tick_c','_load_sidecar_from_source_c','_clear_scene_c','_ifcv_on_range_done','_ifcv_chunks_resident_c','_ifcv_chunks_total_c','_ifcv_model_count_c','_ifcv_model_resident_c','_ifcv_model_total_c','_ifcv_bytes_total_c','_ifcv_bytes_needed_c','_ifcv_bytes_loaded_c','_view_all_c','_frame_selection_c','_toggle_projection_c','_projection_is_ortho_c','_standard_view_c']" + "-sEXPORTED_FUNCTIONS=['_main','_raf_tick_c','_load_sidecar_from_source_c','_clear_scene_c','_ifcv_on_range_done','_ifcv_chunks_resident_c','_ifcv_chunks_total_c','_ifcv_model_count_c','_ifcv_model_resident_c','_ifcv_model_total_c','_ifcv_bytes_total_c','_ifcv_bytes_needed_c','_ifcv_bytes_loaded_c','_view_all_c','_frame_selection_c','_toggle_projection_c','_projection_is_ortho_c','_standard_view_c','_toggle_fly_c','_fly_is_active_c']" # ccall: shell.html passes the ?model URL string to load_sidecar_from_url_c. # HEAPU8: lets tooling/tests read the wasm heap size (e.g. to verify a large # sidecar streams by range instead of loading whole). Standard, zero-cost. diff --git a/src/ifcviewer-web/main_web.cpp b/src/ifcviewer-web/main_web.cpp index f48203e461..af6b64ef93 100644 --- a/src/ifcviewer-web/main_web.cpp +++ b/src/ifcviewer-web/main_web.cpp @@ -68,6 +68,18 @@ struct AppState { float nav_drag_px = 0.0f; long down_x = 0; long down_y = 0; + + // ---- Fly (first-person) mode ---- + // Shift+F enters (pointer-locks the canvas), Esc exits. While flying, held + // W/A/S/D/Q/E + Shift are integrated each frame via ViewportCore::flyMove, + // and pointer-lock mouse deltas drive flyLook. dt from fly_last_ms. + bool fly_mode = false; + // True once the browser actually granted pointer lock for this fly session. + // Distinguishes "lock lost" (Esc/click-out → exit fly) from "lock denied on + // entry" (headless / permission) where fly stays keyboard-drivable. + bool fly_locked = false; + bool k_w = false, k_a = false, k_s = false, k_d = false, k_q = false, k_e = false, k_shift = false; + double fly_last_ms = 0.0; }; // Click vs drag threshold (CSS px). Below this total travel a left release is @@ -78,6 +90,9 @@ constexpr float kClickDragThresholdPx = 4.0f; // pointer round-trip (set into Module._app_ptr from on_complete). AppState* g_app = nullptr; +// Defined below (with the fly helpers); onMouseDown needs it to exit fly on click. +void setFlyMode(AppState* app, bool on); + // Logical (CSS-pixel) height of the canvas. Mouse movementX/Y deltas are // in CSS pixels, so pan's world-units-per-pixel must use CSS-pixel height // too (not the DPR-scaled framebuffer height). @@ -89,6 +104,8 @@ int canvasCssHeight() { EM_BOOL onMouseDown(int, const EmscriptenMouseEvent* e, void* user) { auto* app = static_cast(user); + // In fly mode a click exits (matches the desktop app). + if (app->fly_mode) { setFlyMode(app, false); return EM_TRUE; } if (e->button == 0 || e->button == 1 || e->button == 2) { app->nav_active = true; app->nav_button = e->button; @@ -101,7 +118,13 @@ EM_BOOL onMouseDown(int, const EmscriptenMouseEvent* e, void* user) { EM_BOOL onMouseMove(int, const EmscriptenMouseEvent* e, void* user) { auto* app = static_cast(user); - if (!app->ready || !app->nav_active) return EM_FALSE; + if (!app->ready) return EM_FALSE; + // Fly mode: pointer-locked mouse deltas turn the camera in place. + if (app->fly_mode) { + app->core.flyLook(float(e->movementX), float(e->movementY)); + return EM_TRUE; + } + if (!app->nav_active) return EM_FALSE; const float dx = float(e->movementX); const float dy = float(e->movementY); @@ -151,19 +174,59 @@ EM_BOOL onWheel(int, const EmscriptenWheelEvent* e, void* user) { double dy = e->deltaY; if (e->deltaMode == DOM_DELTA_LINE) dy *= 16.0; else if (e->deltaMode == DOM_DELTA_PAGE) dy *= 800.0; + // In fly mode the wheel tunes move speed (Blender convention), not zoom. + if (app->fly_mode) { app->core.flyAdjustSpeed(-float(dy) / 100.0f); return EM_TRUE; } app->core.dollyBy(-float(dy) / 100.0f); return EM_TRUE; // consume so the page doesn't scroll } -// Viewport nav hotkeys, matching the desktop bindings (ViewportWindow): -// Home view all F zoom to selected P ortho/persp toggle -// X/Y/Z front/right/top Shift+X/Y/Z back/left/bottom -// `.code` is layout-independent (physical key), so this works on any keymap. +// Track a held fly movement key (W/A/S/D/Q/E/Shift). Returns true if `code` was +// one. Physical `.code` so it's keymap-independent. +bool setFlyKey(AppState* app, const char* code, bool down) { + if (!std::strcmp(code, "KeyW")) app->k_w = down; + else if (!std::strcmp(code, "KeyA")) app->k_a = down; + else if (!std::strcmp(code, "KeyS")) app->k_s = down; + else if (!std::strcmp(code, "KeyD")) app->k_d = down; + else if (!std::strcmp(code, "KeyQ")) app->k_q = down; + else if (!std::strcmp(code, "KeyE")) app->k_e = down; + else if (!std::strcmp(code, "ShiftLeft") || !std::strcmp(code, "ShiftRight")) + app->k_shift = down; + else return false; + return true; +} + +// Enter/leave fly mode: pointer-lock the canvas for mouse-look on enter, release +// on exit. Shared camera math is ViewportCore::flyMove/flyLook. +void setFlyMode(AppState* app, bool on) { + if (app->fly_mode == on) return; + app->fly_mode = on; + if (on) { + app->fly_last_ms = emscripten_get_now(); + emscripten_request_pointerlock(kCanvasSelector, EM_TRUE); + Log::info() << "[fly] on — WASD/QE move, mouse looks, Shift boosts, wheel = speed, Esc exits"; + } else { + app->k_w = app->k_a = app->k_s = app->k_d = app->k_q = app->k_e = app->k_shift = false; + app->fly_locked = false; + emscripten_exit_pointerlock(); + Log::info() << "[fly] off"; + } + app->host.requestFrame(); +} + +// Viewport hotkeys, matching the desktop bindings (ViewportWindow): +// Home view all · F zoom to selected · P ortho/persp · X/Y/Z (+Shift) views +// Shift+F enter fly · Esc exit fly · while flying: W/A/S/D/Q/E held + Shift. EM_BOOL onKeyDown(int, const EmscriptenKeyboardEvent* e, void* user) { auto* app = static_cast(user); - if (!app->ready || e->repeat) return EM_FALSE; + if (!app->ready) return EM_FALSE; const char* code = e->code; const bool shift = e->shiftKey; + // Shift+F toggles fly; Esc leaves it. + if (!std::strcmp(code, "KeyF") && shift && !e->repeat) { setFlyMode(app, !app->fly_mode); return EM_TRUE; } + if (!std::strcmp(code, "Escape") && app->fly_mode) { setFlyMode(app, false); return EM_TRUE; } + // While flying, WASDQE/Shift are held-movement keys, not hotkeys. + if (app->fly_mode) { if (setFlyKey(app, code, true)) return EM_TRUE; return EM_FALSE; } + if (e->repeat) return EM_FALSE; using SV = ViewportCore::StandardView; if (!std::strcmp(code, "Home")) app->core.viewAll(); else if (!std::strcmp(code, "KeyF") && !shift) app->core.frameSelection(); @@ -176,6 +239,26 @@ EM_BOOL onKeyDown(int, const EmscriptenKeyboardEvent* e, void* user) { return EM_TRUE; } +EM_BOOL onKeyUp(int, const EmscriptenKeyboardEvent* e, void* user) { + auto* app = static_cast(user); + return setFlyKey(app, e->code, false) ? EM_TRUE : EM_FALSE; +} + +// Pointer-lock is the fly-look mechanism. When it's LOST (the browser eats the +// first Esc to release it, or the user clicks out) leave fly mode — this is what +// makes a single Esc exit cleanly. `fly_locked` guards against a denied lock on +// entry (headless / permission) firing this and insta-exiting: we only treat a +// loss as an exit if we'd actually acquired the lock. +EM_BOOL onPointerLockChange(int, const EmscriptenPointerlockChangeEvent* e, void* user) { + auto* app = static_cast(user); + if (e->isActive) { + app->fly_locked = true; + } else if (app->fly_locked && app->fly_mode) { + setFlyMode(app, false); + } + return EM_TRUE; +} + // Register pointer + wheel handlers once the app is live. mousedown binds // to the canvas; mousemove/up bind to the window so a drag keeps tracking // when the pointer leaves the canvas. A JS-side contextmenu suppressor @@ -186,6 +269,9 @@ void installInputHandlers(AppState* app) { emscripten_set_mouseup_callback(EMSCRIPTEN_EVENT_TARGET_WINDOW, app, EM_FALSE, onMouseUp); emscripten_set_wheel_callback(kCanvasSelector, app, EM_FALSE, onWheel); emscripten_set_keydown_callback(EMSCRIPTEN_EVENT_TARGET_WINDOW, app, EM_FALSE, onKeyDown); + emscripten_set_keyup_callback(EMSCRIPTEN_EVENT_TARGET_WINDOW, app, EM_FALSE, onKeyUp); + emscripten_set_pointerlockchange_callback(EMSCRIPTEN_EVENT_TARGET_DOCUMENT, app, EM_FALSE, + onPointerLockChange); EM_ASM({ var c = document.querySelector(UTF8ToString($0)); if (c) c.addEventListener('contextmenu', function(ev) { ev.preventDefault(); }); @@ -209,6 +295,17 @@ extern "C" EMSCRIPTEN_KEEPALIVE void raf_tick_c(void* user) { app->last_h = h; } + // Fly mode: integrate held-key movement each frame (dt from wall clock). + // flyMove schedules a frame when it actually moves, so a still fly camera + // costs nothing. + if (app->fly_mode) { + const double now = emscripten_get_now(); + const float dt = float((now - app->fly_last_ms) / 1000.0); + app->fly_last_ms = now; + app->core.flyMove(app->k_w, app->k_s, app->k_d, app->k_a, + app->k_e, app->k_q, app->k_shift, dt); + } + if (app->host.consumeFrameRequest()) { app->core.render(); } @@ -250,6 +347,15 @@ extern "C" EMSCRIPTEN_KEEPALIVE void toggle_projection_c() { extern "C" EMSCRIPTEN_KEEPALIVE int projection_is_ortho_c() { return (g_app && g_app->ready && g_app->core.projectionOrtho()) ? 1 : 0; } +// Toggle fly (first-person) mode from the toolbar. The button click is a user +// gesture, so the pointer-lock request inside succeeds. +extern "C" EMSCRIPTEN_KEEPALIVE void toggle_fly_c() { + if (g_app && g_app->ready) setFlyMode(g_app, !g_app->fly_mode); +} +extern "C" EMSCRIPTEN_KEEPALIVE int fly_is_active_c() { + return (g_app && g_app->ready && g_app->fly_mode) ? 1 : 0; +} + // id: 0 Front, 1 Back, 2 Left, 3 Right, 4 Top, 5 Bottom. extern "C" EMSCRIPTEN_KEEPALIVE void standard_view_c(int id) { if (!g_app || !g_app->ready || id < 0 || id > 5) return; diff --git a/src/ifcviewer-web/shell.html b/src/ifcviewer-web/shell.html index 68ed076ee7..846179b3b7 100644 --- a/src/ifcviewer-web/shell.html +++ b/src/ifcviewer-web/shell.html @@ -36,6 +36,7 @@ #nav-toolbar button { background: #2d3748; color: #c8ccd6; border: none; padding: 5px 9px; border-radius: 4px; font-size: 12px; cursor: pointer; } #nav-toolbar button:hover { background: #3b465c; } + #nav-toolbar button.active { background: #2b6cb0; color: #fff; } #nav-toolbar .sep { width: 1px; background: #3b465c; margin: 2px 3px; } /* Streaming loading UI: a thin top progress strip (aggregate) + a centred panel with a per-model segmented bar. Shown only while models stream. */ @@ -77,6 +78,7 @@ + @@ -156,6 +158,10 @@ }); } window.updateLoadProgress(); + if (Module._fly_is_active_c) { + var fb = document.getElementById('fly-btn'); + if (fb) fb.classList.toggle('active', !!Module._fly_is_active_c()); + } Module._raf_tick_c(Module._app_ptr); } requestAnimationFrame(shellTick); @@ -331,6 +337,7 @@ if (act === 'fit') Module._view_all_c(); else if (act === 'focus') Module._frame_selection_c(); else if (act === 'ortho') { Module._toggle_projection_c(); refreshOrthoLabel(); } + else if (act === 'fly') Module._toggle_fly_c(); else if (view !== null) Module._standard_view_c(parseInt(view, 10)); }); }); diff --git a/src/ifcviewer/ViewportCore.cpp b/src/ifcviewer/ViewportCore.cpp index 85e46f339b..9a3c55f8d3 100644 --- a/src/ifcviewer/ViewportCore.cpp +++ b/src/ifcviewer/ViewportCore.cpp @@ -526,6 +526,66 @@ void ViewportCore::dollyBy(float notches) { host_->requestFrame(); } +void ViewportCore::flyMove(bool fwd, bool back, bool right, bool left, + bool up, bool down, bool boost, float dt_seconds) { + if (dt_seconds <= 0.0f) return; + if (dt_seconds > 0.1f) dt_seconds = 0.1f; // stall clamp + // Forward = orbit eye -> target, kept as the view direction in fly mode too + // so entering fly right after orbiting doesn't snap to a new heading. + const Eigen::Vector3f target(camera_target_[0], camera_target_[1], camera_target_[2]); + const Eigen::Vector3f eye = orbitEye(camera_target_, camera_distance_, + camera_yaw_deg_, camera_pitch_deg_); + Eigen::Vector3f forward = target - eye; + if (forward.norm() < 1e-6f) return; + forward.normalize(); + // Looking straight up/down degenerates cross(forward, worldZ); fall back to + // worldY so `right` doesn't go NaN. + const Eigen::Vector3f world_up(0.0f, 0.0f, 1.0f); + const Eigen::Vector3f right_basis = + (std::abs(camera_pitch_deg_) >= 89.0f) ? Eigen::Vector3f(0.0f, 1.0f, 0.0f) : world_up; + Eigen::Vector3f right_vec = forward.cross(right_basis); + right_vec.normalize(); + Eigen::Vector3f move(0.0f, 0.0f, 0.0f); + if (fwd) move += forward; + if (back) move -= forward; + if (right) move += right_vec; + if (left) move -= right_vec; + if (up) move += world_up; + if (down) move -= world_up; + if (move.isZero()) return; + move.normalize(); + const float speed = fly_move_speed_ * (boost ? 5.0f : 1.0f); // absolute m/s + const Eigen::Vector3f delta = move * (speed * dt_seconds); + camera_target_[0] += delta.x(); + camera_target_[1] += delta.y(); + camera_target_[2] += delta.z(); + host_->requestFrame(); +} + +void ViewportCore::flyLook(float dx_px, float dy_px) { + // Turn in place: pin the eye, rotate yaw/pitch, then re-derive the orbit + // target so orbitEye(target, dist, new_yaw, new_pitch) == the pinned eye. + const Eigen::Vector3f pinned_eye = orbitEye(camera_target_, camera_distance_, + camera_yaw_deg_, camera_pitch_deg_); + camera_yaw_deg_ -= dx_px * 0.2f; + camera_pitch_deg_ += dy_px * 0.2f; + camera_pitch_deg_ = std::clamp(camera_pitch_deg_, -89.9f, 89.9f); + constexpr float kDeg2Rad = 0.01745329251994329577f; + const float yaw = camera_yaw_deg_ * kDeg2Rad; + const float pit = camera_pitch_deg_ * kDeg2Rad; + const float cp = std::cos(pit), sp = std::sin(pit); + const float cy = std::cos(yaw), sy = std::sin(yaw); + camera_target_[0] = pinned_eye.x() - camera_distance_ * cp * cy; + camera_target_[1] = pinned_eye.y() - camera_distance_ * cp * sy; + camera_target_[2] = pinned_eye.z() - camera_distance_ * sp; + host_->requestFrame(); +} + +void ViewportCore::flyAdjustSpeed(float notches) { + const float factor = std::pow(1.25f, notches); // up = faster + fly_move_speed_ = std::clamp(fly_move_speed_ * factor, 0.05f, 1000.0f); +} + void ViewportCore::toggleProjection() { projection_ortho_ = !projection_ortho_; std::fprintf(stderr, "[info] [wgpu] projection: %s\n", diff --git a/src/ifcviewer/ViewportCore.h b/src/ifcviewer/ViewportCore.h index 3b8d40407c..b09a41378b 100644 --- a/src/ifcviewer/ViewportCore.h +++ b/src/ifcviewer/ViewportCore.h @@ -217,6 +217,25 @@ public: void panBy(float dx_px, float dy_px, int viewport_height_px); void dollyBy(float notches); + // ---- First-person / fly navigation -------------------------------------- + // + // Shared fly-camera math (desktop + web). The HOST owns the fly-mode flag, + // held-key tracking, cursor/pointer-lock, and per-frame timing; it calls + // these each frame while flying. Behaviour is identical across platforms. + // + // flyMove: WASD moves in the view plane, QE rises/falls along world +Z; + // forward = eye→target so it doesn't snap after orbiting. `boost` + // (Shift) is 5x. Speed is metres/second (flyAdjustSpeed tunes it); + // `dt` seconds is clamped to 0.1 so a stall can't warp the camera. + // flyLook: mouse-look — turn the camera in place (yaw/pitch) with the eye + // pinned; pixel deltas, 0.2 deg/px, pitch clamped to ±89.9. + // flyAdjustSpeed: wheel scales the move speed (x1.25 per notch, clamped). + void flyMove(bool fwd, bool back, bool right, bool left, + bool up, bool down, bool boost, float dt_seconds); + void flyLook(float dx_px, float dy_px); + void flyAdjustSpeed(float notches); + float flySpeed() const { return fly_move_speed_; } + void toggleProjection(); bool projectionOrtho() const { return projection_ortho_; } std::string cameraString() const; @@ -1116,6 +1135,9 @@ private: float camera_fov_y_deg_ = 45.0f; float camera_near_ = 0.1f; float camera_far_ = 10000.0f; + // Fly-camera move speed (m/s), wheel-adjustable via flyAdjustSpeed. Shared + // by desktop + web fly mode; the mode flag itself lives in each host. + float fly_move_speed_ = 5.0f; // Perspective by default; toggleProjection (P key) flips this. When // true, buildViewProj uses an orthographic matrix sized by // camera_distance_ × tan(fov/2) so toggling looks like a smooth diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index 967e119bf0..c43413b622 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -77,10 +77,6 @@ static inline float srgbToLinear(float s) { static constexpr uint64_t WGPU_BYTES_PER_ROW_ALIGN = 256; -// Forward declaration — defined below alongside updateFrameUniforms. Used -// by render() to extract camera/frustum state without duplicating the math. -static Eigen::Vector3f orbitEye(const float target[3], float dist, - float yaw_deg, float pitch_deg); // computeMeshLocalVolumeQuantised moved to ViewportCore.cpp anon // namespace (#84-n). @@ -1467,21 +1463,8 @@ void ViewportWindow::driveStreamingLoads() { core_.driveStreamingLoads(); } // rotation about Z (positive = anticlockwise looking down +Z); pitch is // elevation above the XY plane. -static Eigen::Vector3f orbitEye(const float target[3], float dist, - float yaw_deg, float pitch_deg) { - // Matches the GL ViewportWindow::updateCamera convention exactly so the - // orbit pivot, framing, and benchmark camera path align between backends. - // eye.x = target.x + dist * cos(pitch) * cos(yaw) - // eye.y = target.y + dist * cos(pitch) * sin(yaw) - // eye.z = target.z + dist * sin(pitch) - const float yaw = qDegreesToRadians(yaw_deg); - const float pit = qDegreesToRadians(pitch_deg); - const float cp = std::cos(pit), sp = std::sin(pit); - const float cy = std::cos(yaw), sy = std::sin(yaw); - return Eigen::Vector3f(target[0] + dist * cp * cy, - target[1] + dist * cp * sy, - target[2] + dist * sp); -} +// orbitEye moved to ViewportCore (its last ViewportWindow uses — fly-mode step +// + mouse-look — now go through ViewportCore::flyMove / flyLook). // Shared camera-math helper. Every site that needs (view, proj) for cull, // streaming projection, pick, or render uniforms calls this so the @@ -1565,58 +1548,12 @@ void ViewportWindow::fpsIntegrate() { float dt = float(double(elapsed_ns) / 1e9); if (dt > 0.1f) dt = 0.1f; - // Forward = orbit eye -> target, kept as the camera's view direction in - // fly mode too so a Shift+F right after orbiting doesn't snap to a new - // heading. WASD moves in the screen plane; QE rises/falls along world +Z. - const Eigen::Vector3f target(camera_target_[0], camera_target_[1], camera_target_[2]); - const Eigen::Vector3f eye = orbitEye(camera_target_, camera_distance_, - camera_yaw_deg_, camera_pitch_deg_); - Eigen::Vector3f forward = (target - eye); forward.normalize(); - // When looking straight up/down, cross(forward, worldZ) degenerates; - // fall back to worldY so right doesn't go NaN and WASD still works. - const Eigen::Vector3f world_up(0.0f, 0.0f, 1.0f); - const Eigen::Vector3f right_basis = (std::abs(camera_pitch_deg_) >= 89.0f) - ? Eigen::Vector3f(0.0f, 1.0f, 0.0f) - : world_up; - Eigen::Vector3f right = forward.cross(right_basis); - right.normalize(); - - Eigen::Vector3f move(0, 0, 0); - if (fps_keys_held_.count(Qt::Key_W)) move += forward; - if (fps_keys_held_.count(Qt::Key_S)) move -= forward; - if (fps_keys_held_.count(Qt::Key_D)) move += right; - if (fps_keys_held_.count(Qt::Key_A)) move -= right; - if (fps_keys_held_.count(Qt::Key_E)) move += world_up; - if (fps_keys_held_.count(Qt::Key_Q)) move -= world_up; - if (move.isZero()) return; - move.normalize(); - - // Absolute m/s, scrollwheel-adjustable (Blender / GL convention). - // Scaling with camera_distance_ produced "stuttery" speed on big scenes - // because distance varies frame-to-frame (and worse, wheel zoom kept - // changing it underneath fly mode). - const float speed = fps_move_speed_ - * (fps_keys_held_.count(Qt::Key_Shift) ? 5.0f : 1.0f); - const Eigen::Vector3f delta = move * (speed * dt); - - camera_target_[0] += delta.x(); - camera_target_[1] += delta.y(); - camera_target_[2] += delta.z(); - requestUpdate(); - - if (fly_debug_) { - // dt timeline: see if values jitter (under/over-integration symptoms). - // Show in ms with 2dp so small jumps are visible. - const qint64 since_render_ns = fly_render_clock_.isValid() - ? fly_render_clock_.nsecsElapsed() : 0; - fly_render_clock_.restart(); - Log::info().noquote().nospace() - << "[fly] dt=" << QString::number(dt * 1000.0f, 'f', 2) << "ms" - << " render_gap=" << QString::number(double(since_render_ns) / 1e6, 'f', 2) << "ms" - << " keys=" << fps_keys_held_.size() - << " speed=" << QString::number(speed, 'f', 2) << "m/s" - << " delta=" << QString::number(delta.norm(), 'f', 4) << "m"; - } + // Fly-camera math lives in ViewportCore (shared with the web path). + core_.flyMove( + fps_keys_held_.count(Qt::Key_W) != 0, fps_keys_held_.count(Qt::Key_S) != 0, + fps_keys_held_.count(Qt::Key_D) != 0, fps_keys_held_.count(Qt::Key_A) != 0, + fps_keys_held_.count(Qt::Key_E) != 0, fps_keys_held_.count(Qt::Key_Q) != 0, + fps_keys_held_.count(Qt::Key_Shift) != 0, dt); } // chunkScreenAreaPx moved to ViewportCore (#84-h). @@ -1980,32 +1917,9 @@ void ViewportWindow::mouseMoveEvent(QMouseEvent* event) { const int dx = pos.x() - fps_press_center_.x(); const int dy = pos.y() - fps_press_center_.y(); - // Save eye BEFORE rotating so we can pin it after. - const Eigen::Vector3f pinned_eye = orbitEye(camera_target_, camera_distance_, - camera_yaw_deg_, camera_pitch_deg_); - - // Convention: mouse-up looks up, mouse-down looks down (non-inverted). - // orbitEye stores pitch with sin(pitch) controlling eye.z relative to - // target → larger pitch = eye higher = looking down. To make mouse-up - // (dy<0) look up (i.e. raise pitch in our stored convention so the - // camera tilts down toward the target… wait, with eye pinned in FPS - // mode the relationship inverts: increasing pitch pulls *target* up, - // which means forward tilts down). Net: dy>0 (down) increases pitch - // → forward tilts down → looking down. `+=` is correct here even - // though orbit-mode also uses `+=` for the opposite visual reason. - camera_yaw_deg_ -= float(dx) * 0.2f; - camera_pitch_deg_ += float(dy) * 0.2f; - camera_pitch_deg_ = std::clamp(camera_pitch_deg_, -89.9f, 89.9f); - - // Re-derive target so orbitEye(target, dist, new_yaw, new_pitch) == - // pinned_eye. eye = target + dist*(cp*cy, cp*sy, sp) → invert. - const float yaw = qDegreesToRadians(camera_yaw_deg_); - const float pit = qDegreesToRadians(camera_pitch_deg_); - const float cp = std::cos(pit), sp = std::sin(pit); - const float cy = std::cos(yaw), sy = std::sin(yaw); - camera_target_[0] = pinned_eye.x() - camera_distance_ * cp * cy; - camera_target_[1] = pinned_eye.y() - camera_distance_ * cp * sy; - camera_target_[2] = pinned_eye.z() - camera_distance_ * sp; + // Mouse-look math (turn-in-place) lives in ViewportCore, shared with + // the web pointer-lock path. + core_.flyLook(float(dx), float(dy)); fps_ignore_next_mouse_move_ = true; QCursor::setPos(mapToGlobal(QPoint(fps_press_center_.x(), fps_press_center_.y()))); @@ -2231,10 +2145,9 @@ void ViewportWindow::wheelEvent(QWheelEvent* event) { // Zooming would re-aim the orbit pivot and yank speed (if it were // distance-scaled) — neither belongs in a free-fly camera. if (fps_mode_) { - const float factor = std::pow(1.25f, notches); - fps_move_speed_ = std::clamp(fps_move_speed_ * factor, 0.05f, 1000.0f); + core_.flyAdjustSpeed(notches); // shared: x1.25/notch, clamped Log::info().noquote().nospace() - << "[wgpu] fly speed: " << QString::number(fps_move_speed_, 'f', 2) << " m/s"; + << "[wgpu] fly speed: " << QString::number(core_.flySpeed(), 'f', 2) << " m/s"; return; } // Orbit mode: zoom in/out around the pivot (math in ViewportCore). diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h index 9a683f98b6..ceefe60c35 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -779,12 +779,8 @@ private: Stopwatch fps_last_tick_; Eigen::Vector2i fps_press_center_; bool fps_ignore_next_mouse_move_ = false; - // Fly base speed in m/s at no-modifier (Shift gives a 5× boost). Default - // 5.0 matches GL fps_move_speed_. Scrollwheel in fly mode adjusts this - // by ×1.25 / ×0.8 per notch, Blender-style — wheel does NOT zoom while - // in fly mode (which would change camera_distance_ underneath us and - // make speed jitter if speed were distance-scaled). - float fps_move_speed_ = 5.0f; + // Fly base speed (m/s) + wheel adjustment now live in ViewportCore + // (fly_move_speed_ / flyAdjustSpeed), shared with the web fly path. // Per-frame [fly] dt log when WGPU_FLY_DEBUG=1. Diagnoses stutter: // print dt of each fpsIntegrate call and the prior render's elapsed // ms. Off by default (env-gated) so the normal log stays clean.