diff --git a/src/ifcviewer-web/CMakeLists.txt b/src/ifcviewer-web/CMakeLists.txt index f4e36eac5d..6fce649499 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']" + "-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','_view_all_c','_frame_selection_c','_toggle_projection_c','_projection_is_ortho_c','_standard_view_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 f1cec88c7a..2ad8d7337c 100644 --- a/src/ifcviewer-web/main_web.cpp +++ b/src/ifcviewer-web/main_web.cpp @@ -37,6 +37,7 @@ #include #include +#include namespace { @@ -154,6 +155,27 @@ EM_BOOL onWheel(int, const EmscriptenWheelEvent* e, void* user) { 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. +EM_BOOL onKeyDown(int, const EmscriptenKeyboardEvent* e, void* user) { + auto* app = static_cast(user); + if (!app->ready || e->repeat) return EM_FALSE; + const char* code = e->code; + const bool shift = e->shiftKey; + using SV = ViewportCore::StandardView; + if (!std::strcmp(code, "Home")) app->core.viewAll(); + else if (!std::strcmp(code, "KeyF") && !shift) app->core.frameSelection(); + else if (!std::strcmp(code, "KeyP") && !shift) app->core.toggleProjection(); + else if (!std::strcmp(code, "KeyX")) app->core.setStandardView(shift ? SV::Back : SV::Front); + else if (!std::strcmp(code, "KeyY")) app->core.setStandardView(shift ? SV::Left : SV::Right); + else if (!std::strcmp(code, "KeyZ")) app->core.setStandardView(shift ? SV::Bottom : SV::Top); + else return EM_FALSE; // let every other key through to the browser + app->host.requestFrame(); + 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 @@ -163,6 +185,7 @@ void installInputHandlers(AppState* app) { emscripten_set_mousemove_callback(EMSCRIPTEN_EVENT_TARGET_WINDOW, app, EM_FALSE, onMouseMove); 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); EM_ASM({ var c = document.querySelector(UTF8ToString($0)); if (c) c.addEventListener('contextmenu', function(ev) { ev.preventDefault(); }); @@ -210,6 +233,32 @@ extern "C" EMSCRIPTEN_KEEPALIVE void clear_scene_c() { g_app->core.resetScene(); } +// Viewport-navigation entry points for the shell.html toolbar (buttons that +// mirror the keyboard hotkeys). Each schedules a frame. +extern "C" EMSCRIPTEN_KEEPALIVE void view_all_c() { + if (!g_app || !g_app->ready) return; + g_app->core.viewAll(); g_app->host.requestFrame(); +} +extern "C" EMSCRIPTEN_KEEPALIVE void frame_selection_c() { + if (!g_app || !g_app->ready) return; + g_app->core.frameSelection(); g_app->host.requestFrame(); +} +extern "C" EMSCRIPTEN_KEEPALIVE void toggle_projection_c() { + if (!g_app || !g_app->ready) return; + g_app->core.toggleProjection(); g_app->host.requestFrame(); +} +extern "C" EMSCRIPTEN_KEEPALIVE int projection_is_ortho_c() { + return (g_app && g_app->ready && g_app->core.projectionOrtho()) ? 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; + using SV = ViewportCore::StandardView; + static const SV map[6] = { SV::Front, SV::Back, SV::Left, SV::Right, SV::Top, SV::Bottom }; + g_app->core.setStandardView(map[id]); + g_app->host.requestFrame(); +} + // Streaming progress for the loading bar (shell.html polls these each frame). // total == 0 while still fetching metadata; resident climbs to total as // geometry chunks arrive. diff --git a/src/ifcviewer-web/shell.html b/src/ifcviewer-web/shell.html index 3412e13fb4..3347ec4035 100644 --- a/src/ifcviewer-web/shell.html +++ b/src/ifcviewer-web/shell.html @@ -29,6 +29,14 @@ #open-btn:hover { background: #3182ce; } #add-btn:hover { background: #3b465c; } #file-input { display: none; } + /* Navigation toolbar (bottom-centre): buttons mirror the desktop hotkeys. */ + #nav-toolbar { position: fixed; bottom: 10px; left: 50%; + transform: translateX(-50%); z-index: 10; display: flex; gap: 4px; + background: rgba(20,22,28,.82); padding: 5px 6px; border-radius: 6px; } + #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 .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. */ #progress { position: fixed; top: 0; left: 0; right: 0; height: 3px; @@ -60,6 +68,18 @@ +
Starting…
diff --git a/src/ifcviewer/ViewportCore.cpp b/src/ifcviewer/ViewportCore.cpp index 43d7a58824..51b0425a2e 100644 --- a/src/ifcviewer/ViewportCore.cpp +++ b/src/ifcviewer/ViewportCore.cpp @@ -445,6 +445,40 @@ void ViewportCore::setStandardView(float yaw_deg, float pitch_deg) { host_->requestFrame(); } +void ViewportCore::setStandardView(StandardView view) { + switch (view) { + case StandardView::Front: setStandardView(0.0f, 0.0f); break; + case StandardView::Back: setStandardView(180.0f, 0.0f); break; + case StandardView::Right: setStandardView(90.0f, 0.0f); break; + case StandardView::Left: setStandardView(270.0f, 0.0f); break; + case StandardView::Top: setStandardView(camera_yaw_deg_, 90.0f); break; + case StandardView::Bottom: setStandardView(camera_yaw_deg_, -90.0f); break; + } +} + +bool ViewportCore::frameSelection() { + if (selection_.count() == 0) return false; + float lo[3] = { std::numeric_limits::infinity(), + std::numeric_limits::infinity(), + std::numeric_limits::infinity() }; + float hi[3] = { -std::numeric_limits::infinity(), + -std::numeric_limits::infinity(), + -std::numeric_limits::infinity() }; + bool any = false; + for (uint32_t id : selection_.selectionIds()) { + float mn[3], mx[3]; + if (!computeObjectAabb(id, mn, mx)) continue; + for (int i = 0; i < 3; ++i) { + lo[i] = std::min(lo[i], mn[i]); + hi[i] = std::max(hi[i], mx[i]); + } + any = true; + } + if (!any) return false; + frameAabb(lo, hi, 1.30f); + return true; +} + void ViewportCore::orbitBy(float dx_px, float dy_px) { // 0.4 deg/px matches the GL viewport. pitch is clamped just shy of // the pole so orbitEye() stays well-conditioned. diff --git a/src/ifcviewer/ViewportCore.h b/src/ifcviewer/ViewportCore.h index 9b1af7b983..b7985887e1 100644 --- a/src/ifcviewer/ViewportCore.h +++ b/src/ifcviewer/ViewportCore.h @@ -185,6 +185,19 @@ public: float dist, float yaw_deg, float pitch_deg); void setStandardView(float yaw_deg, float pitch_deg); + // Named axis-aligned views. Front/Back/Left/Right pin yaw at 0/180/270/90 + // (pitch 0); Top/Bottom pin pitch at ±90° and keep the current yaw. Wraps + // setStandardView(yaw,pitch) so the mapping lives in one place (shared by + // the desktop hotkeys and the web toolbar/keys). + enum class StandardView { Front, Back, Left, Right, Top, Bottom }; + void setStandardView(StandardView view); + + // Frame the current selection: union the selected objects' world AABBs and + // fit the camera to them (same 1.30 padding as the desktop "F" hotkey). + // No-op with an empty selection or no resolvable AABBs; returns whether it + // framed anything. Uses the shared selection + computeObjectAabb/frameAabb. + bool frameSelection(); + // ---- Incremental orbit navigation --------------------------------------- // // Pixel-delta camera moves, shared by every host (Qt desktop + web). diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index f1fc845681..967e119bf0 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -1519,31 +1519,10 @@ bool ViewportWindow::computeObjectAabb(uint32_t id, void ViewportWindow::focusOnSelectedObject() { if (fps_mode_) return; - if (selection_.count() == 0) { - Log::info() << "[wgpu] focus: no object selected"; - return; + // Shared math lives in ViewportCore::frameSelection (also the web path). + if (!core_.frameSelection()) { + Log::info() << "[wgpu] focus: no object selected / no AABB available"; } - float lo[3] = { std::numeric_limits::infinity(), - std::numeric_limits::infinity(), - std::numeric_limits::infinity() }; - float hi[3] = { -std::numeric_limits::infinity(), - -std::numeric_limits::infinity(), - -std::numeric_limits::infinity() }; - bool any = false; - for (uint32_t id : selection_.selectionIds()) { - float mn[3], mx[3]; - if (!computeObjectAabb(id, mn, mx)) continue; - for (int i = 0; i < 3; ++i) { - lo[i] = std::min(lo[i], mn[i]); - hi[i] = std::max(hi[i], mx[i]); - } - any = true; - } - if (!any) { - Log::info() << "[wgpu] focus: no AABB available"; - return; - } - frameAabb(lo, hi, 1.30f); } // setStandardView / toggleProjection / cameraString moved to ViewportCore (#84-i). @@ -2226,10 +2205,11 @@ void ViewportWindow::keyPressEvent(QKeyEvent* event) { && (mods == Qt::NoModifier || mods == Qt::ShiftModifier) && !event->isAutoRepeat()) { const bool neg = (mods & Qt::ShiftModifier); + using SV = ViewportCore::StandardView; switch (key) { - case Qt::Key_X: setStandardView(neg ? 180.0f : 0.0f, 0.0f); break; - case Qt::Key_Y: setStandardView(neg ? 270.0f : 90.0f, 0.0f); break; - case Qt::Key_Z: setStandardView(camera_yaw_deg_, neg ? -90.0f : 90.0f); break; + case Qt::Key_X: core_.setStandardView(neg ? SV::Back : SV::Front); break; + case Qt::Key_Y: core_.setStandardView(neg ? SV::Left : SV::Right); break; + case Qt::Key_Z: core_.setStandardView(neg ? SV::Bottom : SV::Top); break; } return; }