mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-09 21:53:40 +00:00
ifcviewer: first-person / fly view — share the desktop fly camera with web
DRY the fly camera into ViewportCore so desktop and web share identical math: - flyMove(fwd,back,right,left,up,down,boost,dt): WASD in the view plane, QE along world +Z, forward = eye→target (no snap after orbiting), Shift = 5x, dt clamped to 0.1s. flyLook(dx,dy): turn in place (yaw/pitch, eye pinned, 0.2 deg/px, pitch ±89.9). flyAdjustSpeed(notches): wheel scales speed x1.25. fly_move_speed_ + orbitEye now live in the core (orbitEye's duplicate removed from ViewportWindow). - Desktop ViewportWindow: fpsIntegrate / mouse-look / wheel-speed call the core methods; behaviour unchanged, BonsaiViewer builds clean. - Web main_web: Shift+F (or the Fly toolbar button) enters and pointer-locks the canvas; W/A/S/D/Q/E + Shift are held-tracked (keydown/keyup) and integrated each RAF frame with wall-clock dt; pointer-lock mouse deltas drive flyLook; wheel tunes speed. Exit on Esc OR a canvas click (matches desktop) — a pointerlockchange handler catches the browser eating the first Esc to release the lock (so a single Esc exits), guarded by fly_locked so a denied lock on entry doesn't insta-exit. Fly button reflects/ syncs active state. Verified: web enter→WASD moves→click/Esc exits, 0 GPU errors; 113/113 desktop core + 6/6 web smoke; desktop app builds. 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','_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.
|
||||
|
||||
@@ -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<AppState*>(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<AppState*>(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<AppState*>(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<AppState*>(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<AppState*>(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;
|
||||
|
||||
@@ -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 @@
|
||||
<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>
|
||||
<button data-act="fly" id="fly-btn" title="Fly / first-person — WASD+mouse (⇧F)">Fly</button>
|
||||
<span class="sep"></span>
|
||||
<button data-view="0" title="Front (X)">Front</button>
|
||||
<button data-view="1" title="Back (Shift+X)">Back</button>
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user