mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-13 19:07:57 +00:00
ifcviewer-web: navigation parity — view all, XYZ views, ortho, zoom-to-selected
The camera math already lived in the shared ViewportCore; the desktop just bound keys to it. The web build had no keyboard handler and no camera UI, so none of it was reachable. Wire it up (Tier 1 + 2 of nav parity; fly mode is a separate follow-up). Shared core: - setStandardView(StandardView) — named Front/Back/Left/Right/Top/Bottom wrapper over setStandardView(yaw,pitch), so the axis→angle mapping lives in one place. - frameSelection() — lifts the desktop's "union selected AABBs → frameAabb(1.30)" focus logic out of ViewportWindow into the core. Desktop's focusOnSelectedObject and the X/Y/Z hotkeys now call these (DRY, behaviour unchanged). Web: - main_web gains a keydown handler matching the desktop bindings — Home=view all, F=zoom to selected, P=ortho toggle, X/Y/Z (+Shift=negative)=standard views — plus exported entry points (view_all_c / frame_selection_c / toggle_projection_c / projection_is_ortho_c / standard_view_c) for the toolbar. - shell.html adds a bottom nav toolbar (Fit / Focus / Persp-Ortho / the six views) with the hotkeys in tooltips; the ortho button reflects state. Verified: Z key and Front button both move the camera, ortho toggles render + label, zero GPU errors; 111/111 unit + 6/6 web smoke pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
|
||||
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<AppState*>(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.
|
||||
|
||||
@@ -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 @@
|
||||
<button id="add-btn" title="Add file(s) to the current scene (federation)">Add</button>
|
||||
<button id="open-btn">Open .ifcview…</button>
|
||||
<input id="file-input" type="file" accept=".ifcview" multiple>
|
||||
<div id="nav-toolbar">
|
||||
<button data-act="fit" title="Fit all (Home)">Fit</button>
|
||||
<button data-act="focus" title="Zoom to selected (F)">Focus</button>
|
||||
<button data-act="ortho" id="ortho-btn" title="Toggle orthographic / perspective (P)">Persp</button>
|
||||
<span class="sep"></span>
|
||||
<button data-view="0" title="Front (X)">Front</button>
|
||||
<button data-view="1" title="Back (Shift+X)">Back</button>
|
||||
<button data-view="2" title="Left (Shift+Y)">Left</button>
|
||||
<button data-view="3" title="Right (Y)">Right</button>
|
||||
<button data-view="4" title="Top (Z)">Top</button>
|
||||
<button data-view="5" title="Bottom (Shift+Z)">Bottom</button>
|
||||
</div>
|
||||
<div id="status">Starting…</div>
|
||||
<script>
|
||||
// Emscripten Module hook: route stderr to the status overlay so any
|
||||
@@ -273,6 +293,24 @@
|
||||
}
|
||||
fileInput.value = ''; // let the same file be re-picked
|
||||
});
|
||||
|
||||
// Navigation toolbar → C camera calls (same core methods as the hotkeys).
|
||||
function refreshOrthoLabel() {
|
||||
var b = document.getElementById('ortho-btn');
|
||||
if (b && Module._projection_is_ortho_c)
|
||||
b.textContent = Module._projection_is_ortho_c() ? 'Ortho' : 'Persp';
|
||||
}
|
||||
document.querySelectorAll('#nav-toolbar button').forEach(function(b) {
|
||||
b.addEventListener('click', function() {
|
||||
if (!Module._view_all_c) return; // viewer not ready yet
|
||||
var act = b.getAttribute('data-act');
|
||||
var view = b.getAttribute('data-view');
|
||||
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 (view !== null) Module._standard_view_c(parseInt(view, 10));
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<!-- IfcViewerWeb.js is emitted alongside this shell by the emcc build;
|
||||
`--shell-file` injects this HTML around it. -->
|
||||
|
||||
@@ -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<float>::infinity(),
|
||||
std::numeric_limits<float>::infinity(),
|
||||
std::numeric_limits<float>::infinity() };
|
||||
float hi[3] = { -std::numeric_limits<float>::infinity(),
|
||||
-std::numeric_limits<float>::infinity(),
|
||||
-std::numeric_limits<float>::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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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<float>::infinity(),
|
||||
std::numeric_limits<float>::infinity(),
|
||||
std::numeric_limits<float>::infinity() };
|
||||
float hi[3] = { -std::numeric_limits<float>::infinity(),
|
||||
-std::numeric_limits<float>::infinity(),
|
||||
-std::numeric_limits<float>::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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user