diff --git a/src/bonsaiviewer/MainWindow.cpp b/src/bonsaiviewer/MainWindow.cpp index 5cfb1d0d45..4aee23127c 100644 --- a/src/bonsaiviewer/MainWindow.cpp +++ b/src/bonsaiviewer/MainWindow.cpp @@ -100,10 +100,10 @@ void MainWindow::setupChrome() { QMainWindow::AllowTabbedDocks | QMainWindow::GroupedDragging); - auto bind_shortcut = [this](const QKeySequence& sequence, auto fn) { + auto bind_shortcut = [this](const QKeySequence& sequence, auto handler) { auto* shortcut = new QShortcut(sequence, this); shortcut->setContext(Qt::WindowShortcut); - connect(shortcut, &QShortcut::activated, this, fn); + connect(shortcut, &QShortcut::activated, this, handler); }; bind_shortcut(QKeySequence("Ctrl+Shift+L"), [this]() { modules::viewport::commands::toggleDistance(*viewport_widget_->viewport()); @@ -496,6 +496,20 @@ void MainWindow::setupStatus() { status_perf_label_->setVisible(show); if (!show) status_perf_label_->clear(); }); + + // Nav mouse preset. The WGPU_NAV_PRESET env var is a dev override applied at + // ViewportWindow construction; otherwise apply the persisted Settings choice + // here, and re-apply live whenever the user changes it in the dialog. + if (!std::getenv("WGPU_NAV_PRESET")) { + if (auto* vp = viewport_widget_->viewport()) + vp->applyNavPreset(AppSettings::navPresetName(AppSettings::instance().navPreset())); + } + connect(&AppSettings::instance(), &AppSettings::navPresetChanged, this, + [this](AppSettings::NavPreset preset) { + if (auto* vp = viewport_widget_->viewport()) + vp->applyNavPreset(AppSettings::navPresetName(preset)); + }); + connect(session_state_, &bonsaiviewer::SessionState::statusMessageChanged, this, [this](const QString& mode, const QString& detail) { status_mode_label_->setText(mode); @@ -530,17 +544,17 @@ void MainWindow::setupLoader() { }); connect(viewport_widget_->viewport(), &ViewportWindow::frameStatsUpdated, this, - [this](const ViewportWindow::FrameStats& s) { + [this](const ViewportWindow::FrameStats& stats) { if (!status_perf_label_->isVisible()) return; status_perf_label_->setText( QString("%1 fps | %2 ms | %3/%4 obj | %5/%6 tri | %7 draws") - .arg(s.fps, 0, 'f', 1) - .arg(s.frame_time_ms, 0, 'f', 1) - .arg(s.visible_objects) - .arg(s.total_objects) - .arg(s.visible_triangles) - .arg(s.total_triangles) - .arg(s.gl_draw_calls)); + .arg(stats.fps, 0, 'f', 1) + .arg(stats.frame_time_ms, 0, 'f', 1) + .arg(stats.visible_objects) + .arg(stats.total_objects) + .arg(stats.visible_triangles) + .arg(stats.total_triangles) + .arg(stats.gl_draw_calls)); }); connect(viewport_widget_->viewport(), &ViewportWindow::objectPicked, this, [this](uint32_t object_id) { diff --git a/src/bonsaiviewer/modules/settings/Dialog.cpp b/src/bonsaiviewer/modules/settings/Dialog.cpp index 2f029d03e5..f93e114531 100644 --- a/src/bonsaiviewer/modules/settings/Dialog.cpp +++ b/src/bonsaiviewer/modules/settings/Dialog.cpp @@ -188,10 +188,11 @@ void SettingsDialog::setupUi() { nav_preset_combo_->addItem("Blender (Orbit MMB, Pan Shift+MMB)"); nav_preset_combo_->addItem("Rhino (Orbit RMB, Pan Shift+RMB)"); nav_preset_combo_->addItem("Revit (Orbit Shift+MMB, Pan MMB)"); + nav_preset_combo_->addItem("Web (Orbit LMB, Pan MMB, Select RMB)"); nav_preset_combo_->setToolTip( - "Mouse-button mapping for orbit and pan. Selection stays on " - "left mouse button for every preset, so click + box-select " - "always work."); + "Mouse-button mapping for orbit, pan, and selection. Selection is " + "on the left mouse button for Blender/Rhino/Revit and on the right " + "for Web; click + box-select use whichever the preset assigns."); form->addRow("Preset", nav_preset_combo_); section->addBodyWidget(body); @@ -390,20 +391,20 @@ QWidget* SettingsDialog::buildConnectorsTab() { body_layout->addWidget(empty); } - for (const auto& m : manifests) { + for (const auto& manifest : manifests) { auto* row = new QWidget(body); auto* row_layout = new QHBoxLayout(row); row_layout->setContentsMargins(0, 0, 0, 0); row_layout->setSpacing(8); - auto* name = new QLabel(m.name, row); + auto* name = new QLabel(manifest.name, row); auto* version = new QLabel( - m.version.isEmpty() ? QString() : QString("v%1").arg(m.version), row); + manifest.version.isEmpty() ? QString() : QString("v%1").arg(manifest.version), row); version->setProperty("textRole", "secondary"); auto* settings_button = new QPushButton("Settings…", row); settings_button->setIcon(components::icons::makeSvgIcon(":/icons/settings.svg")); - const QString connector_id = m.id; + const QString connector_id = manifest.id; connect(settings_button, &QPushButton::clicked, this, [this, connector_id, settings_button]() { if (!session_state_) return; diff --git a/src/ifcviewer-web/main_web.cpp b/src/ifcviewer-web/main_web.cpp index 4a0d02bde8..8208a9250a 100644 --- a/src/ifcviewer-web/main_web.cpp +++ b/src/ifcviewer-web/main_web.cpp @@ -45,6 +45,9 @@ namespace { // WebViewportHost selector below. constexpr const char* kCanvasSelector = "#viewer-canvas"; +// What a mouse drag drives, decided against the active nav preset's bindings. +enum class NavKind { None, Orbit, Pan, Select }; + struct AppState { WebViewportHost host{ kCanvasSelector }; ViewportCore core{ &host }; @@ -56,15 +59,14 @@ struct AppState { bool ready = false; // ---- Mouse navigation state ---- - // A drag is armed on mousedown and released on mouseup. button is the - // DOM button code (0 left, 1 middle, 2 right). Left orbits; middle or - // right pans — matches common web 3D-viewer bindings and covers both - // three-button mice and trackpad (right-drag) users. - bool nav_active = false; - int nav_button = 0; - // Accumulated |movement| since mousedown, in CSS px. A left release under - // the click threshold (no real drag) is treated as a pick instead of an - // orbit. Captures the down position (canvas-relative CSS px) for the pick. + // A drag is armed on mousedown and released on mouseup. What the press + // drives (orbit / pan / select) is decided against the active nav preset's + // bindings (ViewportCore::navBindings), so any preset works on web too. + bool nav_active = false; + NavKind nav_kind = NavKind::None; + // Accumulated |movement| since mousedown, in CSS px. A select-button release + // under the click threshold (no real drag) is treated as a pick; a drag will + // become a marquee. Captures the down position (canvas-relative CSS px). float nav_drag_px = 0.0f; long down_x = 0; long down_y = 0; @@ -78,7 +80,13 @@ struct AppState { // 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; + bool key_w_pressed = false; + bool key_a_pressed = false; + bool key_s_pressed = false; + bool key_d_pressed = false; + bool key_q_pressed = false; + bool key_e_pressed = false; + bool key_shift_pressed = false; double fly_last_ms = 0.0; }; @@ -102,13 +110,26 @@ int canvasCssHeight() { return (h > 1.0) ? int(h) : 1; } +NavKind classifyPress(const ViewportCore::NavBindings& b, int em_button, + bool shift, bool ctrl, bool alt) { + using MB = ViewportCore::MouseBtn; using M = ViewportCore::NavMod; + const MB btn = (em_button == 0) ? MB::Left : (em_button == 1) ? MB::Middle : MB::Right; + const M mod = shift ? M::Shift : ctrl ? M::Ctrl : alt ? M::Alt : M::Plain; + if (btn == b.orbit && mod == b.orbit_mod) return NavKind::Orbit; + if (btn == b.pan && mod == b.pan_mod) return NavKind::Pan; + if (btn == b.select) return NavKind::Select; // Shift/Ctrl = add/remove + return NavKind::None; +} + 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) { + const NavKind kind = classifyPress(app->core.navBindings(), e->button, + e->shiftKey, e->ctrlKey, e->altKey); + if (kind != NavKind::None) { app->nav_active = true; - app->nav_button = e->button; + app->nav_kind = kind; app->nav_drag_px = 0.0f; app->down_x = e->targetX; // canvas-relative CSS px app->down_y = e->targetY; @@ -129,24 +150,23 @@ EM_BOOL onMouseMove(int, const EmscriptenMouseEvent* e, void* user) { const float dx = float(e->movementX); const float dy = float(e->movementY); app->nav_drag_px += std::abs(dx) + std::abs(dy); - if (app->nav_button == 0) { - app->core.orbitBy(dx, dy); - } else { - app->core.panBy(dx, dy, canvasCssHeight()); - } + if (app->nav_kind == NavKind::Orbit) app->core.orbitBy(dx, dy); + else if (app->nav_kind == NavKind::Pan) app->core.panBy(dx, dy, canvasCssHeight()); + // NavKind::Select drag → marquee box-select (next step). return EM_TRUE; } EM_BOOL onMouseUp(int, const EmscriptenMouseEvent* e, void* user) { auto* app = static_cast(user); - const bool was_active = app->nav_active; - const int button = app->nav_button; + const bool was_active = app->nav_active; + const NavKind kind = app->nav_kind; app->nav_active = false; + app->nav_kind = NavKind::None; - // Left release with no real drag → pick the object under the cursor and - // route it through selection (Shift add, Ctrl remove, plain replace). + // Select-button release with no real drag → pick the object under the cursor + // and route it through selection (Shift add, Ctrl remove, plain replace). // Async readback: the highlight appears a frame after the result lands. - if (was_active && button == 0 && app->ready && + if (was_active && kind == NavKind::Select && app->ready && app->nav_drag_px <= kClickDragThresholdPx) { const double dpr = emscripten_get_device_pixel_ratio(); const int px = int(app->down_x * dpr); @@ -183,14 +203,14 @@ EM_BOOL onWheel(int, const EmscriptenWheelEvent* e, void* user) { // 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; + if (!std::strcmp(code, "KeyW")) app->key_w_pressed = down; + else if (!std::strcmp(code, "KeyA")) app->key_a_pressed = down; + else if (!std::strcmp(code, "KeyS")) app->key_s_pressed = down; + else if (!std::strcmp(code, "KeyD")) app->key_d_pressed = down; + else if (!std::strcmp(code, "KeyQ")) app->key_q_pressed = down; + else if (!std::strcmp(code, "KeyE")) app->key_e_pressed = down; else if (!std::strcmp(code, "ShiftLeft") || !std::strcmp(code, "ShiftRight")) - app->k_shift = down; + app->key_shift_pressed = down; else return false; return true; } @@ -205,7 +225,9 @@ void setFlyMode(AppState* app, bool on) { 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->key_w_pressed = app->key_a_pressed = app->key_s_pressed = false; + app->key_d_pressed = app->key_q_pressed = app->key_e_pressed = false; + app->key_shift_pressed = false; app->fly_locked = false; emscripten_exit_pointerlock(); Log::info() << "[fly] off"; @@ -312,8 +334,10 @@ extern "C" EMSCRIPTEN_KEEPALIVE void raf_tick_c(void* user) { 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); + app->core.flyMove(app->key_w_pressed, app->key_s_pressed, + app->key_d_pressed, app->key_a_pressed, + app->key_e_pressed, app->key_q_pressed, + app->key_shift_pressed, dt); } if (app->host.consumeFrameRequest()) { @@ -387,11 +411,15 @@ extern "C" EMSCRIPTEN_KEEPALIVE void standard_view_c(int id) { // geometry chunks arrive. extern "C" EMSCRIPTEN_KEEPALIVE int ifcv_chunks_resident_c() { if (!g_app) return 0; - int r = 0, t = 0; g_app->core.streamingProgress(r, t); return r; + int resident_chunks = 0, total_chunks = 0; + g_app->core.streamingProgress(resident_chunks, total_chunks); + return resident_chunks; } extern "C" EMSCRIPTEN_KEEPALIVE int ifcv_chunks_total_c() { if (!g_app) return 0; - int r = 0, t = 0; g_app->core.streamingProgress(r, t); return t; + int resident_chunks = 0, total_chunks = 0; + g_app->core.streamingProgress(resident_chunks, total_chunks); + return total_chunks; } // Per-model progress for the federation loading panel: how many models are in @@ -401,11 +429,15 @@ extern "C" EMSCRIPTEN_KEEPALIVE int ifcv_model_count_c() { } extern "C" EMSCRIPTEN_KEEPALIVE int ifcv_model_resident_c(int idx) { if (!g_app) return 0; - int r = 0, t = 0; g_app->core.streamingModelProgress(idx, r, t); return r; + int resident_chunks = 0, total_chunks = 0; + g_app->core.streamingModelProgress(idx, resident_chunks, total_chunks); + return resident_chunks; } extern "C" EMSCRIPTEN_KEEPALIVE int ifcv_model_total_c(int idx) { if (!g_app) return 0; - int r = 0, t = 0; g_app->core.streamingModelProgress(idx, r, t); return t; + int resident_chunks = 0, total_chunks = 0; + g_app->core.streamingModelProgress(idx, resident_chunks, total_chunks); + return total_chunks; } // Combined byte progress for the loading bar: total geometry, bytes the current @@ -413,23 +445,28 @@ extern "C" EMSCRIPTEN_KEEPALIVE int ifcv_model_total_c(int idx) { // JS gets exact byte counts well past 2 GB. extern "C" EMSCRIPTEN_KEEPALIVE double ifcv_bytes_total_c() { if (!g_app) return 0.0; - std::uint64_t tot = 0, need = 0, load = 0; - g_app->core.streamingByteProgress(tot, need, load); return double(tot); + std::uint64_t total_bytes = 0, needed_bytes = 0, loaded_bytes = 0; + g_app->core.streamingByteProgress(total_bytes, needed_bytes, loaded_bytes); + return double(total_bytes); } extern "C" EMSCRIPTEN_KEEPALIVE double ifcv_bytes_needed_c() { if (!g_app) return 0.0; - std::uint64_t tot = 0, need = 0, load = 0; - g_app->core.streamingByteProgress(tot, need, load); return double(need); + std::uint64_t total_bytes = 0, needed_bytes = 0, loaded_bytes = 0; + g_app->core.streamingByteProgress(total_bytes, needed_bytes, loaded_bytes); + return double(needed_bytes); } extern "C" EMSCRIPTEN_KEEPALIVE double ifcv_bytes_loaded_c() { if (!g_app) return 0.0; - std::uint64_t tot = 0, need = 0, load = 0; - g_app->core.streamingByteProgress(tot, need, load); return double(load); + std::uint64_t total_bytes = 0, needed_bytes = 0, loaded_bytes = 0; + g_app->core.streamingByteProgress(total_bytes, needed_bytes, loaded_bytes); + return double(loaded_bytes); } int main(int /*argc*/, char** /*argv*/) { Log::info() << "ifcviewer-web: starting"; g_app = new AppState(); + // Default to the web mouse scheme: LMB orbit, MMB pan, RMB select/marquee. + g_app->core.setNavPreset("web"); g_app->core.initWgpuAsyncWeb([](bool ok) { if (!ok) { Log::warn() << "ifcviewer-web: wgpu init failed"; diff --git a/src/ifcviewer-web/tests/smoke.spec.mjs b/src/ifcviewer-web/tests/smoke.spec.mjs index 1664237336..6316c4e264 100644 --- a/src/ifcviewer-web/tests/smoke.spec.mjs +++ b/src/ifcviewer-web/tests/smoke.spec.mjs @@ -261,7 +261,7 @@ test('click selects an object and the highlight renders (async pick)', async ({ const cx = box.x + box.width / 2; const cy = box.y + box.height / 2; const before = await shot(page); - await page.mouse.click(cx, cy); + await page.mouse.click(cx, cy, { button: 'right' }); // Web preset: RMB selects await page.waitForTimeout(600); // async pick result + flush + render const after = await shot(page); expect( @@ -330,8 +330,8 @@ test('hide selected removes geometry after a pick', async ({ 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); + // select whatever is under the centre (Web preset: RMB selects), then hide it + await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2, { button: 'right' }); await page.waitForTimeout(400); await page.evaluate(() => window.Module._hide_selected_c()); await page.waitForTimeout(400); diff --git a/src/ifcviewer/AppSettings.cpp b/src/ifcviewer/AppSettings.cpp index 8b8212cb4a..86d23ea57b 100644 --- a/src/ifcviewer/AppSettings.cpp +++ b/src/ifcviewer/AppSettings.cpp @@ -186,6 +186,16 @@ AppSettings::NavPreset AppSettings::navPreset() const { return nav_preset_; } +const char* AppSettings::navPresetName(NavPreset preset) { + switch (preset) { + case NavPreset::Rhino: return "rhino"; + case NavPreset::Revit: return "revit"; + case NavPreset::Web: return "web"; + case NavPreset::Blender: break; + } + return "blender"; +} + void AppSettings::setNavPreset(NavPreset value) { if (nav_preset_ == value) return; nav_preset_ = value; @@ -220,7 +230,7 @@ void AppSettings::load() { static_cast(NavPreset::Blender)).toInt(); // Clamp to known values so a stale config doesn't drop us into // an undefined preset slot. - if (raw < 0 || raw > static_cast(NavPreset::Revit)) { + if (raw < 0 || raw > static_cast(NavPreset::Web)) { nav_preset_ = NavPreset::Blender; } else { nav_preset_ = static_cast(raw); diff --git a/src/ifcviewer/AppSettings.h b/src/ifcviewer/AppSettings.h index 9c38819a6a..fbe177059e 100644 --- a/src/ifcviewer/AppSettings.h +++ b/src/ifcviewer/AppSettings.h @@ -37,13 +37,18 @@ public: // Blender — Orbit MMB, Pan Shift+MMB (current default) // Rhino — Orbit RMB, Pan Shift+RMB // Revit — Orbit Shift+MMB, Pan MMB + // Web — Orbit LMB, Pan MMB, Select RMB enum class NavPreset { Blender = 0, Rhino = 1, Revit = 2, + Web = 3, }; Q_ENUM(NavPreset) + // Preset → the lowercase name ViewportCore::setNavPreset / applyNavPreset take. + static const char* navPresetName(NavPreset preset); + static AppSettings& instance(); QString geometryLibrary() const; diff --git a/src/ifcviewer/ViewportCore.cpp b/src/ifcviewer/ViewportCore.cpp index 636e206d84..240bf494eb 100644 --- a/src/ifcviewer/ViewportCore.cpp +++ b/src/ifcviewer/ViewportCore.cpp @@ -461,6 +461,18 @@ void ViewportCore::setStandardView(StandardView view) { } } +void ViewportCore::setNavPreset(const char* name) { + using B = MouseBtn; using M = NavMod; + if (name && std::strcmp(name, "rhino") == 0) + nav_bindings_ = { B::Right, M::Plain, B::Right, M::Shift, B::Left, M::Plain }; + else if (name && std::strcmp(name, "revit") == 0) + nav_bindings_ = { B::Middle, M::Shift, B::Middle, M::Plain, B::Left, M::Plain }; + else if (name && std::strcmp(name, "web") == 0) + nav_bindings_ = { B::Left, M::Plain, B::Middle, M::Plain, B::Right, M::Plain }; + else // blender (default) + nav_bindings_ = { B::Middle, M::Plain, B::Middle, M::Shift, B::Left, M::Plain }; +} + bool ViewportCore::frameSelection() { if (selection_.count() == 0) return false; float lo[3] = { std::numeric_limits::infinity(), @@ -2901,13 +2913,13 @@ void ViewportCore::applyCachedModel(std::uint32_t model_id, models_gpu_.erase(it); } - ModelGpuData m; - m.vertex_bytes = 0; // accumulated from chunks below (v16 has no section) - m.index_count = 0; - m.mesh_count = std::uint32_t(metadata.meta.meshes.size()); - m.instance_count = std::uint32_t(metadata.meta.instances.size()); - m.streaming_file_path = metadata.file_path; - m.geometry_section_offset = metadata.geometry_section_offset; + ModelGpuData model_gpu_data; + model_gpu_data.vertex_bytes = 0; // accumulated from chunks below (v16 has no section) + model_gpu_data.index_count = 0; + model_gpu_data.mesh_count = std::uint32_t(metadata.meta.meshes.size()); + model_gpu_data.instance_count = std::uint32_t(metadata.meta.instances.size()); + model_gpu_data.streaming_file_path = metadata.file_path; + model_gpu_data.geometry_section_offset = metadata.geometry_section_offset; // ---- Spatial chunk plan ---------------------------------------------- // A sidecar carries a baked chunk TOC (v14): each chunk is a contiguous @@ -2918,10 +2930,10 @@ void ViewportCore::applyCachedModel(std::uint32_t model_id, // would scatter the chunks. In-memory direct loads (finalizeModel) carry // no TOC, so they fall back to deriving the same Morton + greedy plan. const std::size_t n_meshes = metadata.meta.meshes.size(); - m.mesh_chunk_idx.assign(n_meshes, 0); - m.mesh_chunk_local_base_vertex.assign(n_meshes, 0); - m.mesh_chunk_local_ebo_first_u32.assign(n_meshes, 0); - m.mesh_chunk_local_lod1_first_u32.assign(n_meshes, 0); + model_gpu_data.mesh_chunk_idx.assign(n_meshes, 0); + model_gpu_data.mesh_chunk_local_base_vertex.assign(n_meshes, 0); + model_gpu_data.mesh_chunk_local_ebo_first_u32.assign(n_meshes, 0); + model_gpu_data.mesh_chunk_local_lod1_first_u32.assign(n_meshes, 0); std::vector> chunk_mesh_ids; std::vector instance_to_chunk; @@ -2930,14 +2942,14 @@ void ViewportCore::applyCachedModel(std::uint32_t model_id, if (!metadata.meta.chunks.empty()) { // Baked TOC: chunk ci is meshes [first_mesh, first_mesh + mesh_count). chunk_mesh_ids.reserve(metadata.meta.chunks.size()); - for (const auto& ch : metadata.meta.chunks) { - std::vector ids; - ids.reserve(ch.mesh_count); - for (std::uint32_t k = 0; k < ch.mesh_count; ++k) { - const std::uint32_t mi = ch.first_mesh + k; - if (mi < n_meshes) ids.push_back(mi); + for (const auto& sidecar_chunk : metadata.meta.chunks) { + std::vector mesh_ids; + mesh_ids.reserve(sidecar_chunk.mesh_count); + for (std::uint32_t k = 0; k < sidecar_chunk.mesh_count; ++k) { + const std::uint32_t mesh_index = sidecar_chunk.first_mesh + k; + if (mesh_index < n_meshes) mesh_ids.push_back(mesh_index); } - chunk_mesh_ids.push_back(std::move(ids)); + chunk_mesh_ids.push_back(std::move(mesh_ids)); } } else { // No TOC (direct load): derive the plan from mesh centroids. @@ -2972,25 +2984,27 @@ void ViewportCore::applyCachedModel(std::uint32_t model_id, { std::vector mesh_to_chunk(n_meshes, 0); - for (std::size_t ci = 0; ci < chunk_mesh_ids.size(); ++ci) { - for (std::uint32_t mi : chunk_mesh_ids[ci]) mesh_to_chunk[mi] = std::uint32_t(ci); + for (std::size_t chunk_index = 0; chunk_index < chunk_mesh_ids.size(); ++chunk_index) { + for (std::uint32_t mesh_index : chunk_mesh_ids[chunk_index]) { + mesh_to_chunk[mesh_index] = std::uint32_t(chunk_index); + } } for (std::size_t i = 0; i < metadata.meta.instances.size(); ++i) { - const std::uint32_t mi = metadata.meta.instances[i].mesh_id; - if (mi < n_meshes) instance_to_chunk[i] = mesh_to_chunk[mi]; + const std::uint32_t mesh_index = metadata.meta.instances[i].mesh_id; + if (mesh_index < n_meshes) instance_to_chunk[i] = mesh_to_chunk[mesh_index]; } } std::vector chunk_instance_count(chunk_mesh_ids.size(), 0); for (std::size_t i = 0; i < instance_to_chunk.size(); ++i) { - const std::uint32_t ci = instance_to_chunk[i]; - if (ci < chunk_instance_count.size()) ++chunk_instance_count[ci]; + const std::uint32_t chunk_index = instance_to_chunk[i]; + if (chunk_index < chunk_instance_count.size()) ++chunk_instance_count[chunk_index]; } // ---- Allocate per-chunk state. NO pool slices yet (chunks are // non-resident); the per-frame loader brings them in as cull marks // them visible. - m.chunks.resize(chunk_mesh_ids.size()); + model_gpu_data.chunks.resize(chunk_mesh_ids.size()); struct MeshLocal { std::uint32_t base_vertex; std::uint32_t ebo_first; @@ -2998,46 +3012,51 @@ void ViewportCore::applyCachedModel(std::uint32_t model_id, }; std::vector> chunk_mesh_offsets(chunk_mesh_ids.size()); - for (std::size_t ci = 0; ci < chunk_mesh_ids.size(); ++ci) { - ModelGpuData::Chunk& c = m.chunks[ci]; - c.mesh_ids = std::move(chunk_mesh_ids[ci]); - c.is_resident = false; + for (std::size_t chunk_index = 0; chunk_index < chunk_mesh_ids.size(); ++chunk_index) { + ModelGpuData::Chunk& chunk = model_gpu_data.chunks[chunk_index]; + chunk.mesh_ids = std::move(chunk_mesh_ids[chunk_index]); + chunk.is_resident = false; - std::uint32_t chunk_local_v = 0; - std::uint32_t chunk_local_i = 0; - for (std::uint32_t mi : c.mesh_ids) { - const MeshInfo& mesh = metadata.meta.meshes[mi]; - m.mesh_chunk_idx[mi] = std::uint32_t(ci); - m.mesh_chunk_local_base_vertex[mi] = chunk_local_v; - m.mesh_chunk_local_ebo_first_u32[mi] = chunk_local_i; - chunk_mesh_offsets[ci][mi] = MeshLocal{chunk_local_v, chunk_local_i, 0}; - chunk_local_v += mesh.vertex_count; - chunk_local_i += mesh.index_count; + std::uint32_t chunk_local_vertex_count = 0; + std::uint32_t chunk_local_index_count = 0; + for (std::uint32_t mesh_index : chunk.mesh_ids) { + const MeshInfo& mesh = metadata.meta.meshes[mesh_index]; + model_gpu_data.mesh_chunk_idx[mesh_index] = std::uint32_t(chunk_index); + model_gpu_data.mesh_chunk_local_base_vertex[mesh_index] = chunk_local_vertex_count; + model_gpu_data.mesh_chunk_local_ebo_first_u32[mesh_index] = chunk_local_index_count; + chunk_mesh_offsets[chunk_index][mesh_index] = + MeshLocal{chunk_local_vertex_count, chunk_local_index_count, 0}; + chunk_local_vertex_count += mesh.vertex_count; + chunk_local_index_count += mesh.index_count; } std::uint32_t chunk_local_lod1 = 0; - for (std::uint32_t mi : c.mesh_ids) { - const MeshInfo& mesh = metadata.meta.meshes[mi]; + for (std::uint32_t mesh_index : chunk.mesh_ids) { + const MeshInfo& mesh = metadata.meta.meshes[mesh_index]; if (mesh.lod1_index_count == 0) continue; - m.mesh_chunk_local_lod1_first_u32[mi] = chunk_local_i + chunk_local_lod1; - chunk_mesh_offsets[ci][mi].lod1_first = chunk_local_i + chunk_local_lod1; + model_gpu_data.mesh_chunk_local_lod1_first_u32[mesh_index] = + chunk_local_index_count + chunk_local_lod1; + chunk_mesh_offsets[chunk_index][mesh_index].lod1_first = + chunk_local_index_count + chunk_local_lod1; chunk_local_lod1 += mesh.lod1_index_count; } - c.vertex_count = chunk_local_v; - c.vertex_byte_size = std::uint64_t(chunk_local_v) * INSTANCED_VERTEX_STRIDE_BYTES; - c.index_count = chunk_local_i + chunk_local_lod1; - c.lod1_index_count = chunk_local_lod1; + chunk.vertex_count = chunk_local_vertex_count; + chunk.vertex_byte_size = std::uint64_t(chunk_local_vertex_count) * INSTANCED_VERTEX_STRIDE_BYTES; + chunk.index_count = chunk_local_index_count + chunk_local_lod1; + chunk.lod1_index_count = chunk_local_lod1; // v16: compressed-blob locators from the baked TOC (streaming path). - if (ci < metadata.meta.chunks.size()) { - const SidecarChunk& sc = metadata.meta.chunks[ci]; - c.v_comp_off = sc.v_comp_off; c.v_comp_size = sc.v_comp_size; - c.i_comp_off = sc.i_comp_off; c.i_comp_size = sc.i_comp_size; + if (chunk_index < metadata.meta.chunks.size()) { + const SidecarChunk& sidecar_chunk = metadata.meta.chunks[chunk_index]; + chunk.v_comp_off = sidecar_chunk.v_comp_off; + chunk.v_comp_size = sidecar_chunk.v_comp_size; + chunk.i_comp_off = sidecar_chunk.i_comp_off; + chunk.i_comp_size = sidecar_chunk.i_comp_size; } - m.vertex_bytes += c.vertex_byte_size; - m.index_count += std::uint32_t(c.index_count); + model_gpu_data.vertex_bytes += chunk.vertex_byte_size; + model_gpu_data.index_count += std::uint32_t(chunk.index_count); // Small per-chunk buffers, allocated upfront so cull can write into // them. visible_draws_buffer cap = chunk's instance count. - const std::size_t chunk_inst = std::max(chunk_instance_count[ci], 1); + const std::size_t chunk_inst = std::max(chunk_instance_count[chunk_index], 1); const std::size_t draws_bytes = chunk_inst * sizeof(ModelGpuData::VisibleDrawGpu); const std::size_t ps_bytes = (chunk_inst + 1) * sizeof(std::uint32_t); @@ -3045,27 +3064,27 @@ void ViewportCore::applyCachedModel(std::uint32_t model_id, vd_desc.size = std::max(draws_bytes, 16); vd_desc.usage = WGPUBufferUsage_Storage | WGPUBufferUsage_CopyDst; vd_desc.label = svFromCStr("model.chunk.visible_draws"); - c.visible_draws_buffer = wgpuDeviceCreateBuffer(device_, &vd_desc); - c.visible_draws_capacity = chunk_inst; - m.vram_bytes_ssbo += vd_desc.size; + chunk.visible_draws_buffer = wgpuDeviceCreateBuffer(device_, &vd_desc); + chunk.visible_draws_capacity = chunk_inst; + model_gpu_data.vram_bytes_ssbo += vd_desc.size; WGPUBufferDescriptor ps_desc = {}; ps_desc.size = std::max(ps_bytes, 16); ps_desc.usage = WGPUBufferUsage_Storage | WGPUBufferUsage_CopyDst; ps_desc.label = svFromCStr("model.chunk.prefix_sums"); - c.prefix_sums_buffer = wgpuDeviceCreateBuffer(device_, &ps_desc); - c.prefix_sums_capacity = chunk_inst + 1; - m.vram_bytes_ssbo += ps_desc.size; + chunk.prefix_sums_buffer = wgpuDeviceCreateBuffer(device_, &ps_desc); + chunk.prefix_sums_capacity = chunk_inst + 1; + model_gpu_data.vram_bytes_ssbo += ps_desc.size; WGPUBufferDescriptor mu_desc = {}; mu_desc.size = 16; mu_desc.usage = WGPUBufferUsage_Uniform | WGPUBufferUsage_CopyDst; mu_desc.label = svFromCStr("model.chunk.uniform"); - c.per_chunk_uniform = wgpuDeviceCreateBuffer(device_, &mu_desc); - m.vram_bytes_ssbo += 16; + chunk.per_chunk_uniform = wgpuDeviceCreateBuffer(device_, &mu_desc); + model_gpu_data.vram_bytes_ssbo += 16; - c.visible_draws_scratch.reserve(chunk_inst); - c.prefix_sums_scratch.reserve(chunk_inst + 1); + chunk.visible_draws_scratch.reserve(chunk_inst); + chunk.prefix_sums_scratch.reserve(chunk_inst + 1); } // Index section is NOT loaded upfront. Each chunk's index slice is @@ -3074,115 +3093,116 @@ void ViewportCore::applyCachedModel(std::uint32_t model_id, // MeshGpu storage (per-mesh quant basis). std::vector mesh_gpu; mesh_gpu.reserve(metadata.meta.meshes.size()); - for (const auto& mi : metadata.meta.meshes) { - MeshGpu mg = {}; - mg.aabb_min[0] = mi.local_aabb_min[0]; - mg.aabb_min[1] = mi.local_aabb_min[1]; - mg.aabb_min[2] = mi.local_aabb_min[2]; - mg.aabb_max[0] = mi.local_aabb_max[0]; - mg.aabb_max[1] = mi.local_aabb_max[1]; - mg.aabb_max[2] = mi.local_aabb_max[2]; - mesh_gpu.push_back(mg); + for (const auto& mesh_info : metadata.meta.meshes) { + MeshGpu mesh_gpu_record = {}; + mesh_gpu_record.aabb_min[0] = mesh_info.local_aabb_min[0]; + mesh_gpu_record.aabb_min[1] = mesh_info.local_aabb_min[1]; + mesh_gpu_record.aabb_min[2] = mesh_info.local_aabb_min[2]; + mesh_gpu_record.aabb_max[0] = mesh_info.local_aabb_max[0]; + mesh_gpu_record.aabb_max[1] = mesh_info.local_aabb_max[1]; + mesh_gpu_record.aabb_max[2] = mesh_info.local_aabb_max[2]; + mesh_gpu.push_back(mesh_gpu_record); } const std::size_t mesh_storage_bytes = mesh_gpu.size() * sizeof(MeshGpu); - m.mesh_storage = createBufferWithData( + model_gpu_data.mesh_storage = createBufferWithData( device_, queue_, mesh_gpu.data(), mesh_storage_bytes, WGPUBufferUsage_Storage, "model.mesh_storage"); - m.vram_bytes_ssbo += mesh_storage_bytes; + model_gpu_data.vram_bytes_ssbo += mesh_storage_bytes; // InstanceGpu storage. Rebase object_ids globally. const std::uint32_t object_id_base = next_object_id_; std::uint32_t max_local_id = 0; std::vector inst_gpu; inst_gpu.reserve(metadata.meta.instances.size()); - for (auto& ic : metadata.meta.instances) { - if (ic.object_id > max_local_id) max_local_id = ic.object_id; - ic.object_id = object_id_base + ic.object_id; - InstanceGpu ig = {}; - std::memcpy(ig.transform, ic.transform, sizeof(ig.transform)); - ig.object_id = ic.object_id; - ig.color_override_rgba8 = ic.color_override_rgba8; - ig.mesh_id = ic.mesh_id; - inst_gpu.push_back(ig); + for (auto& instance_cpu : metadata.meta.instances) { + if (instance_cpu.object_id > max_local_id) max_local_id = instance_cpu.object_id; + instance_cpu.object_id = object_id_base + instance_cpu.object_id; + InstanceGpu instance_gpu = {}; + std::memcpy(instance_gpu.transform, instance_cpu.transform, sizeof(instance_gpu.transform)); + instance_gpu.object_id = instance_cpu.object_id; + instance_gpu.color_override_rgba8 = instance_cpu.color_override_rgba8; + instance_gpu.mesh_id = instance_cpu.mesh_id; + inst_gpu.push_back(instance_gpu); } next_object_id_ = object_id_base + max_local_id + 1; - m.object_id_base = object_id_base; // deferred elements rebase to match + model_gpu_data.object_id_base = object_id_base; // deferred elements rebase to match const std::size_t inst_storage_bytes = inst_gpu.size() * sizeof(InstanceGpu); - m.instance_storage = createBufferWithData( + model_gpu_data.instance_storage = createBufferWithData( device_, queue_, inst_gpu.data(), inst_storage_bytes, WGPUBufferUsage_Storage, "model.instance_storage"); - m.vram_bytes_ssbo += inst_storage_bytes; + model_gpu_data.vram_bytes_ssbo += inst_storage_bytes; // Hand off CPU mirrors. - m.meshes = std::move(metadata.meta.meshes); - m.instances = std::move(metadata.meta.instances); + model_gpu_data.meshes = std::move(metadata.meta.meshes); + model_gpu_data.instances = std::move(metadata.meta.instances); // Streaming defers per-mesh vertex data until the owning chunk is // loaded. Both volumes + Area-tool CPU shadow fill in per-chunk // inside applyStreamedChunk as the bytes arrive. - m.mesh_local_volumes.assign(m.meshes.size(), 0.0); - m.mesh_triangles_cache.assign(m.meshes.size(), ModelGpuData::MeshTriangles{}); - m.mesh_has_alpha.assign(m.meshes.size(), std::uint8_t(0)); + model_gpu_data.mesh_local_volumes.assign(model_gpu_data.meshes.size(), 0.0); + model_gpu_data.mesh_triangles_cache.assign(model_gpu_data.meshes.size(), ModelGpuData::MeshTriangles{}); + model_gpu_data.mesh_has_alpha.assign(model_gpu_data.meshes.size(), std::uint8_t(0)); // object_id → instance index lookup. Volume tool reads it on every // selection mutation; per-pick latency stays O(K) instead of O(K*N). - m.object_id_to_instance.clear(); - m.object_id_to_instance.reserve(m.instances.size()); - for (std::uint32_t i = 0; i < std::uint32_t(m.instances.size()); ++i) { - m.object_id_to_instance.emplace(m.instances[i].object_id, i); + model_gpu_data.object_id_to_instance.clear(); + model_gpu_data.object_id_to_instance.reserve(model_gpu_data.instances.size()); + for (std::uint32_t i = 0; i < std::uint32_t(model_gpu_data.instances.size()); ++i) { + model_gpu_data.object_id_to_instance.emplace(model_gpu_data.instances[i].object_id, i); } // Per-chunk world AABBs + instance-id lists from instance_to_chunk. - for (std::size_t ci = 0; ci < m.chunks.size(); ++ci) { - m.chunks[ci].instance_ids.reserve(m.instances.size() / m.chunks.size() + 4); + for (std::size_t chunk_index = 0; chunk_index < model_gpu_data.chunks.size(); ++chunk_index) { + model_gpu_data.chunks[chunk_index].instance_ids.reserve( + model_gpu_data.instances.size() / model_gpu_data.chunks.size() + 4); } - for (std::uint32_t inst_idx = 0; inst_idx < std::uint32_t(m.instances.size()); ++inst_idx) { - const auto& inst = m.instances[inst_idx]; - const std::uint32_t ci = instance_to_chunk[inst_idx]; - if (ci >= m.chunks.size()) continue; - auto& c = m.chunks[ci]; + for (std::uint32_t inst_idx = 0; inst_idx < std::uint32_t(model_gpu_data.instances.size()); ++inst_idx) { + const auto& inst = model_gpu_data.instances[inst_idx]; + const std::uint32_t chunk_index = instance_to_chunk[inst_idx]; + if (chunk_index >= model_gpu_data.chunks.size()) continue; + auto& chunk = model_gpu_data.chunks[chunk_index]; for (int a = 0; a < 3; ++a) { - c.aabb_min[a] = std::min(c.aabb_min[a], inst.world_aabb_min[a]); - c.aabb_max[a] = std::max(c.aabb_max[a], inst.world_aabb_max[a]); + chunk.aabb_min[a] = std::min(chunk.aabb_min[a], inst.world_aabb_min[a]); + chunk.aabb_max[a] = std::max(chunk.aabb_max[a], inst.world_aabb_max[a]); } - c.instance_ids.push_back(inst_idx); + chunk.instance_ids.push_back(inst_idx); } // Populate per-instance arrays from the per-chunk per-mesh offsets // computed during chunk construction. { - const std::size_t n_inst = m.instances.size(); - m.instance_chunk_idx.assign(n_inst, 0); - m.instance_base_vertex.assign(n_inst, 0); - m.instance_ebo_first_u32.assign(n_inst, 0); - m.instance_lod1_first_u32.assign(n_inst, 0); + const std::size_t n_inst = model_gpu_data.instances.size(); + model_gpu_data.instance_chunk_idx.assign(n_inst, 0); + model_gpu_data.instance_base_vertex.assign(n_inst, 0); + model_gpu_data.instance_ebo_first_u32.assign(n_inst, 0); + model_gpu_data.instance_lod1_first_u32.assign(n_inst, 0); for (std::size_t i = 0; i < n_inst; ++i) { - const std::uint32_t ci = instance_to_chunk[i]; - const std::uint32_t mi = m.instances[i].mesh_id; - if (ci >= chunk_mesh_offsets.size()) continue; - auto it_off = chunk_mesh_offsets[ci].find(mi); - if (it_off == chunk_mesh_offsets[ci].end()) continue; - m.instance_chunk_idx[i] = ci; - m.instance_base_vertex[i] = it_off->second.base_vertex; - m.instance_ebo_first_u32[i] = it_off->second.ebo_first; - m.instance_lod1_first_u32[i] = it_off->second.lod1_first; + const std::uint32_t chunk_index = instance_to_chunk[i]; + const std::uint32_t mesh_index = model_gpu_data.instances[i].mesh_id; + if (chunk_index >= chunk_mesh_offsets.size()) continue; + auto it_off = chunk_mesh_offsets[chunk_index].find(mesh_index); + if (it_off == chunk_mesh_offsets[chunk_index].end()) continue; + model_gpu_data.instance_chunk_idx[i] = chunk_index; + model_gpu_data.instance_base_vertex[i] = it_off->second.base_vertex; + model_gpu_data.instance_ebo_first_u32[i] = it_off->second.ebo_first; + model_gpu_data.instance_lod1_first_u32[i] = it_off->second.lod1_first; } } - auto [inserted, _] = models_gpu_.emplace(model_id, std::move(m)); - ModelGpuData& mref = inserted->second; + auto [inserted, _] = models_gpu_.emplace(model_id, std::move(model_gpu_data)); + ModelGpuData& inserted_model = inserted->second; Log::info() << "[wgpu stream] applyCachedModel mid=" << model_id - << " verts=" << mref.vertex_bytes << "B (deferred)" - << " idx=" << mref.index_count - << " meshes=" << mref.mesh_count - << " instances=" << mref.instance_count - << " chunks=" << mref.chunks.size(); + << " verts=" << inserted_model.vertex_bytes << "B (deferred)" + << " idx=" << inserted_model.index_count + << " meshes=" << inserted_model.mesh_count + << " instances=" << inserted_model.instance_count + << " chunks=" << inserted_model.chunks.size(); if (!initial_view_applied_) { viewAll(); @@ -3208,10 +3228,10 @@ void ViewportCore::uploadMeshChunk(const MeshChunk& chunk) { -std::numeric_limits::infinity(), -std::numeric_limits::infinity() }; for (std::size_t i = 0; i < n_verts; ++i) { - const float* v = chunk.vertices.data() + i * INSTANCED_VERTEX_STRIDE_FLOATS; + const float* vertex = chunk.vertices.data() + i * INSTANCED_VERTEX_STRIDE_FLOATS; for (int a = 0; a < 3; ++a) { - if (v[a] < bmin[a]) bmin[a] = v[a]; - if (v[a] > bmax[a]) bmax[a] = v[a]; + if (vertex[a] < bmin[a]) bmin[a] = vertex[a]; + if (vertex[a] > bmax[a]) bmax[a] = vertex[a]; } } float extent_recip[3]; @@ -3256,20 +3276,20 @@ void ViewportCore::uploadMeshChunk(const MeshChunk& chunk) { void ViewportCore::uploadInstanceChunk(const InstanceChunk& chunk) { SidecarData& s = getOrCreateDirectStaging(pending_direct_loads_, chunk.model_id); - InstanceCpu inst{}; - inst.mesh_id = chunk.local_mesh_id; - inst.object_id = chunk.object_id; - inst.color_override_rgba8 = chunk.color_override_rgba8; - inst.model_id = chunk.model_id; - std::memcpy(inst.placement_transformation, chunk.transform, - sizeof(inst.placement_transformation)); + InstanceCpu instance{}; + instance.mesh_id = chunk.local_mesh_id; + instance.object_id = chunk.object_id; + instance.color_override_rgba8 = chunk.color_override_rgba8; + instance.model_id = chunk.model_id; + std::memcpy(instance.placement_transformation, chunk.transform, + sizeof(instance.placement_transformation)); for (int i = 0; i < 16; ++i) { - inst.transform[i] = float(chunk.transform[i]); + instance.transform[i] = float(chunk.transform[i]); } - std::memcpy(inst.world_aabb_min, chunk.world_aabb_min, sizeof(inst.world_aabb_min)); - std::memcpy(inst.world_aabb_max, chunk.world_aabb_max, sizeof(inst.world_aabb_max)); + std::memcpy(instance.world_aabb_min, chunk.world_aabb_min, sizeof(instance.world_aabb_min)); + std::memcpy(instance.world_aabb_max, chunk.world_aabb_max, sizeof(instance.world_aabb_max)); - s.instances.push_back(inst); + s.instances.push_back(instance); } std::uint32_t ViewportCore::loadSidecarFromPath(const std::string& path) { @@ -3746,16 +3766,16 @@ void ViewportCore::finalizeModel(std::uint32_t model_id) { } std::unique_ptr staging_ptr = std::move(it->second); pending_direct_loads_.erase(it); - SidecarData& s = *staging_ptr; + SidecarData& sidecar_data = *staging_ptr; if (!device_ || !queue_) { Log::warn() << "[wgpu direct] finalizeModel without an initialised device"; return; } - if (s.meshes.empty() || s.instances.empty()) { + if (sidecar_data.meshes.empty() || sidecar_data.instances.empty()) { Log::info() << "[wgpu direct] finalizeModel(" << model_id - << "): empty staging (meshes=" << s.meshes.size() - << " instances=" << s.instances.size() << ")"; + << "): empty staging (meshes=" << sidecar_data.meshes.size() + << " instances=" << sidecar_data.instances.size() << ")"; return; } @@ -3765,7 +3785,7 @@ void ViewportCore::finalizeModel(std::uint32_t model_id) { // that to skip these chunks (they're already resident after the // applyStreamedChunk loop below). StreamingSidecar metadata; - metadata.meta = std::move(s); + metadata.meta = std::move(sidecar_data); // Direct load: geometry is already in memory (uploaded below), streamed // from nothing — leave file_path empty so the streaming worker skips it. metadata.geometry_section_offset = 0; @@ -3783,38 +3803,40 @@ void ViewportCore::finalizeModel(std::uint32_t model_id) { << "): applyCachedModel produced no model entry"; return; } - ModelGpuData& m = model_it->second; + ModelGpuData& model_gpu_data = model_it->second; // Gather each chunk's vertex + index bytes from the staged buffers. std::size_t chunks_uploaded = 0; - for (std::size_t ci = 0; ci < m.chunks.size(); ++ci) { - auto& c = m.chunks[ci]; - if (c.mesh_ids.empty()) continue; + for (std::size_t chunk_index = 0; chunk_index < model_gpu_data.chunks.size(); ++chunk_index) { + auto& chunk = model_gpu_data.chunks[chunk_index]; + if (chunk.mesh_ids.empty()) continue; - std::vector vbytes(c.vertex_byte_size); - std::vector idx; - idx.reserve(c.index_count); + std::vector vbytes(chunk.vertex_byte_size); + std::vector indices; + indices.reserve(chunk.index_count); - for (std::uint32_t mi : c.mesh_ids) { - const MeshInfo& mesh = m.meshes[mi]; - const std::size_t vsz = std::size_t(mesh.vertex_count) * INSTANCED_VERTEX_STRIDE_BYTES; - if (vsz > 0) { - const std::size_t dst_off = std::size_t(m.mesh_chunk_local_base_vertex[mi]) + for (std::uint32_t mesh_index : chunk.mesh_ids) { + const MeshInfo& mesh = model_gpu_data.meshes[mesh_index]; + const std::size_t vertex_byte_count = + std::size_t(mesh.vertex_count) * INSTANCED_VERTEX_STRIDE_BYTES; + if (vertex_byte_count > 0) { + const std::size_t destination_vertex_offset = + std::size_t(model_gpu_data.mesh_chunk_local_base_vertex[mesh_index]) * INSTANCED_VERTEX_STRIDE_BYTES; - std::memcpy(vbytes.data() + dst_off, - raw_vertices.data() + mesh.vbo_byte_offset, vsz); + std::memcpy(vbytes.data() + destination_vertex_offset, + raw_vertices.data() + mesh.vbo_byte_offset, vertex_byte_count); } if (mesh.index_count > 0) { const std::uint32_t* src = raw_indices.data() + (mesh.ebo_byte_offset / sizeof(std::uint32_t)); - idx.insert(idx.end(), src, src + mesh.index_count); + indices.insert(indices.end(), src, src + mesh.index_count); } } - if (!applyStreamedChunk(m, ci, vbytes, idx)) { + if (!applyStreamedChunk(model_gpu_data, chunk_index, vbytes, indices)) { Log::warn() << "[wgpu direct] finalizeModel(" << model_id - << "): applyStreamedChunk failed on chunk " << ci + << "): applyStreamedChunk failed on chunk " << chunk_index << " (pool OOM?)"; continue; } @@ -3823,9 +3845,9 @@ void ViewportCore::finalizeModel(std::uint32_t model_id) { Log::info() << "[wgpu direct] finalizeModel mid=" << model_id - << " meshes=" << m.meshes.size() - << " instances=" << m.instances.size() - << " chunks=" << chunks_uploaded << "/" << m.chunks.size() + << " meshes=" << model_gpu_data.meshes.size() + << " instances=" << model_gpu_data.instances.size() + << " chunks=" << chunks_uploaded << "/" << model_gpu_data.chunks.size() << " verts=" << raw_vertices.size() << "B" << " idx=" << raw_indices.size(); } diff --git a/src/ifcviewer/ViewportCore.h b/src/ifcviewer/ViewportCore.h index 95304248f4..4de8b188b3 100644 --- a/src/ifcviewer/ViewportCore.h +++ b/src/ifcviewer/ViewportCore.h @@ -192,6 +192,30 @@ public: enum class StandardView { Front, Back, Left, Right, Top, Bottom }; void setStandardView(StandardView view); + // ---- Navigation mouse bindings (shared, preset-driven) ------------------ + // + // Which mouse button (+ modifier) orbits / pans / selects. Owned by the core + // as pure data so BOTH hosts and ALL presets share one source of truth — the + // desktop maps these to Qt::MouseButton, the web to DOM button codes. Select + // is preset-driven too (not hardcoded to LMB) so a "web" preset can move it + // to RMB. Marquee box-select uses the same button as select (drag vs click). + enum class MouseBtn { Left, Middle, Right }; + // Plain (not "None": X11 #defines None to 0L, which would corrupt the token). + enum class NavMod { Plain, Shift, Ctrl, Alt }; + struct NavBindings { + MouseBtn orbit; NavMod orbit_mod; + MouseBtn pan; NavMod pan_mod; + MouseBtn select; NavMod select_mod; + }; + // name: "blender" (default) | "rhino" | "revit" | "web". Unknown → blender. + // blender orbit MMB, pan Shift+MMB, select LMB + // rhino orbit RMB, pan Shift+RMB, select LMB + // revit orbit Shift+MMB, pan MMB, select LMB + // web orbit LMB, pan MMB, select RMB (LMB stays free to + // orbit-drag; RMB click-selects / drag-marquees, no ambiguity) + void setNavPreset(const char* name); + const NavBindings& navBindings() const { return nav_bindings_; } + // 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 @@ -1151,6 +1175,10 @@ private: // 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; + // Nav mouse bindings; default matches the historical "blender" preset. + NavBindings nav_bindings_ = { MouseBtn::Middle, NavMod::Plain, + MouseBtn::Middle, NavMod::Shift, + MouseBtn::Left, NavMod::Plain }; // 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 cc201fdfaa..6f799568bd 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -1533,21 +1533,32 @@ void ViewportWindow::fpsIntegrate() { // chunkScreenAreaPx moved to ViewportCore (#84-h). -void ViewportWindow::applyNavPreset(const char* name) { - // Matches GL AppSettings::NavPreset semantics exactly. - // blender — Orbit MMB, Pan Shift+MMB (default) - // rhino — Orbit RMB, Pan Shift+RMB - // revit — Orbit Shift+MMB, Pan MMB - if (name && std::strcmp(name, "rhino") == 0) { - orbit_button_ = Qt::RightButton; orbit_mods_ = Qt::NoModifier; - pan_button_ = Qt::RightButton; pan_mods_ = Qt::ShiftModifier; - } else if (name && std::strcmp(name, "revit") == 0) { - orbit_button_ = Qt::MiddleButton; orbit_mods_ = Qt::ShiftModifier; - pan_button_ = Qt::MiddleButton; pan_mods_ = Qt::NoModifier; - } else { - orbit_button_ = Qt::MiddleButton; orbit_mods_ = Qt::NoModifier; - pan_button_ = Qt::MiddleButton; pan_mods_ = Qt::ShiftModifier; +static Qt::MouseButton toQtBtn(ViewportCore::MouseBtn b) { + switch (b) { + case ViewportCore::MouseBtn::Left: return Qt::LeftButton; + case ViewportCore::MouseBtn::Middle: return Qt::MiddleButton; + case ViewportCore::MouseBtn::Right: return Qt::RightButton; } + return Qt::LeftButton; +} +static Qt::KeyboardModifiers toQtMod(ViewportCore::NavMod m) { + switch (m) { + case ViewportCore::NavMod::Plain: return Qt::NoModifier; + case ViewportCore::NavMod::Shift: return Qt::ShiftModifier; + case ViewportCore::NavMod::Ctrl: return Qt::ControlModifier; + case ViewportCore::NavMod::Alt: return Qt::AltModifier; + } + return Qt::NoModifier; +} + +void ViewportWindow::applyNavPreset(const char* name) { + // The preset table lives in ViewportCore (shared with web). Map its bindings + // to the Qt types the mouse handlers compare against. + core_.setNavPreset(name); + const auto& b = core_.navBindings(); + orbit_button_ = toQtBtn(b.orbit); orbit_mods_ = toQtMod(b.orbit_mod); + pan_button_ = toQtBtn(b.pan); pan_mods_ = toQtMod(b.pan_mod); + select_button_ = toQtBtn(b.select); select_mods_ = toQtMod(b.select_mod); } // ----------------------------------------------------------------------------- @@ -1633,15 +1644,15 @@ void ViewportWindow::mousePressEvent(QMouseEvent* event) { && (mods & Qt::KeyboardModifierMask) == pan_mods_) { nav_drag_kind_ = NavDrag::Pan; setPivotIndicatorVisible(true); - } else if (event->button() == Qt::LeftButton + } else if (event->button() == select_button_ && !section_tool_active_ && tool_mode_ != ToolMode::Area && tool_mode_ != ToolMode::Length && nav_drag_kind_ == NavDrag::Inactive) { - // Arm marquee box-select. Plain / Shift / Ctrl LMB without a tool - // intercepting the click; if the cursor never moves past the - // threshold this stays armed-only and the release falls through - // to single-pick. + // Arm marquee box-select. Plain / Shift / Ctrl on the select button + // (Shift/Ctrl = add/remove) without a tool intercepting the click; if + // the cursor never moves past the threshold this stays armed-only and + // the release falls through to single-pick. box_select_armed_ = true; box_select_active_ = false; box_select_start_pos_ = nav_press_pos_; @@ -1660,7 +1671,7 @@ void ViewportWindow::mouseReleaseEvent(QMouseEvent* event) { // Marquee finalisation: only commit when the drag actually became // active (cursor moved past threshold). Press-time mods decide the // set op so a mid-drag Shift release doesn't flip the behaviour. - if (box_select_armed_ && event->button() == Qt::LeftButton) { + if (box_select_armed_ && event->button() == select_button_) { const bool was_active = box_select_active_; box_select_armed_ = false; box_select_active_ = false; @@ -1702,7 +1713,7 @@ void ViewportWindow::mouseReleaseEvent(QMouseEvent* event) { // LMB-click without drag → pick the object under the cursor and // route through the selection state. Shift = add, Ctrl = remove, // no modifier = replace. Empty-space click clears. - if (event->button() == Qt::LeftButton && !nav_dragged_) { + if (event->button() == select_button_ && !nav_dragged_) { const Eigen::Vector2i pos = toV2i(event->position().toPoint()); const int px = int(pos.x() * devicePixelRatio()); const int py = int(pos.y() * devicePixelRatio()); diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h index ceefe60c35..497c2ed926 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -239,12 +239,15 @@ public: private: // Re-aim the orbit camera so the bounding sphere of [mn, mx] fits. void frameAabb(const float mn[3], const float mx[3], float padding); - // Resolve nav_preset_ env var to orbit/pan bindings. - void applyNavPreset(const char* name); // chunkScreenAreaPx moved to ViewportCore (#84-h). public: + // Apply a nav mouse preset by name ("blender"|"rhino"|"revit"|"web"). + // Sources the shared binding table from ViewportCore; called from init + // (env / persisted setting) and live from the Settings dialog. + void applyNavPreset(const char* name); + // Queue a one-shot framebuffer capture: the next rendered frame is // copied back to host memory and saved to `path` as PNG. If @@ -800,10 +803,15 @@ private: // so the click-vs-drag distinction at mouseReleaseEvent's pick path keeps // working. Set at init from WGPU_NAV_PRESET=blender|rhino|revit (default // blender, matching GL's AppSettings::NavPreset::Blender default). - Qt::MouseButton orbit_button_ = Qt::MiddleButton; - Qt::KeyboardModifiers orbit_mods_ = Qt::NoModifier; - Qt::MouseButton pan_button_ = Qt::MiddleButton; - Qt::KeyboardModifiers pan_mods_ = Qt::ShiftModifier; + // Mirror of ViewportCore's preset bindings, mapped to Qt types by + // applyNavPreset (the core owns the preset table; these are the Qt-side + // cache the mouse handlers compare against). + Qt::MouseButton orbit_button_ = Qt::MiddleButton; + Qt::KeyboardModifiers orbit_mods_ = Qt::NoModifier; + Qt::MouseButton pan_button_ = Qt::MiddleButton; + Qt::KeyboardModifiers pan_mods_ = Qt::ShiftModifier; + Qt::MouseButton select_button_ = Qt::LeftButton; + Qt::KeyboardModifiers select_mods_ = Qt::NoModifier; // Set by mousePressEvent based on which binding matched; consumed by // mouseMoveEvent so mid-drag modifier changes don't switch axes. enum class NavDrag : uint8_t { Inactive, Orbit, Pan }; diff --git a/src/ifcviewer/tests/test_viewport_camera.cpp b/src/ifcviewer/tests/test_viewport_camera.cpp index 47f28a6035..cca86eed37 100644 --- a/src/ifcviewer/tests/test_viewport_camera.cpp +++ b/src/ifcviewer/tests/test_viewport_camera.cpp @@ -143,6 +143,44 @@ TEST_CASE("toggleXray flips the active state", "[camera][xray]") { REQUIRE_FALSE(core.xrayActive()); } +TEST_CASE("setNavPreset maps names to the shared button bindings", "[camera][nav]") { + MockHost host; ViewportCore core(&host); + using B = ViewportCore::MouseBtn; using M = ViewportCore::NavMod; + + // Default is blender: orbit MMB, pan Shift+MMB, select LMB. + { + const auto& b = core.navBindings(); + REQUIRE(b.orbit == B::Middle); REQUIRE(b.orbit_mod == M::Plain); + REQUIRE(b.pan == B::Middle); REQUIRE(b.pan_mod == M::Shift); + REQUIRE(b.select == B::Left); REQUIRE(b.select_mod == M::Plain); + } + SECTION("web: orbit LMB, pan MMB, select RMB") { + core.setNavPreset("web"); + const auto& b = core.navBindings(); + REQUIRE(b.orbit == B::Left); REQUIRE(b.orbit_mod == M::Plain); + REQUIRE(b.pan == B::Middle); REQUIRE(b.pan_mod == M::Plain); + REQUIRE(b.select == B::Right); REQUIRE(b.select_mod == M::Plain); + } + SECTION("rhino: orbit RMB, pan Shift+RMB") { + core.setNavPreset("rhino"); + const auto& b = core.navBindings(); + REQUIRE(b.orbit == B::Right); REQUIRE(b.pan == B::Right); + REQUIRE(b.pan_mod == M::Shift); REQUIRE(b.select == B::Left); + } + SECTION("revit: orbit Shift+MMB, pan MMB") { + core.setNavPreset("revit"); + const auto& b = core.navBindings(); + REQUIRE(b.orbit == B::Middle); REQUIRE(b.orbit_mod == M::Shift); + REQUIRE(b.pan == B::Middle); REQUIRE(b.pan_mod == M::Plain); + } + SECTION("unknown name falls back to blender") { + core.setNavPreset("web"); + core.setNavPreset("nonsense"); + const auto& b = core.navBindings(); + REQUIRE(b.orbit == B::Middle); REQUIRE(b.select == B::Left); + } +} + TEST_CASE("hideSelected hides the selection; showAll restores", "[camera][visibility]") { MockHost host; ViewportCore core(&host); REQUIRE(core.hiddenCount() == 0);