ifcviewer: preset-driven nav mouse bindings + a "Web" preset (desktop + web)

Make orbit/pan/select mouse bindings pure data owned by ViewportCore so both
hosts and every preset share one source of truth, and add a "Web" preset. This
rounds out the matrix: the desktop gains a web-style scheme and the web inherits
all presets, with no per-platform hardcoding.

- Core: NavBindings { orbit, pan, select button + modifier } + setNavPreset
  ("blender" default | "rhino" | "revit" | "web") + navBindings(). Select is
  preset-driven too (was hardcoded LMB) so "web" moves it to RMB. web = orbit
  LMB, pan MMB, select RMB (LMB drag orbits with no click/drag ambiguity; RMB
  click-selects / drag-marquees). NavMod uses "Plain" not "None" (X11 #defines
  None to 0L).
- Desktop ViewportWindow: applyNavPreset sources the core table (mapped to Qt);
  marquee-arm / single-pick dispatch keys off select_button_. Default stays
  blender → no behaviour change.
- Desktop config: AppSettings::NavPreset gains Web + navPresetName(); the
  Settings dialog lists it. This also FIXES a pre-existing gap — the preset combo
  was persisted but never applied (only WGPU_NAV_PRESET env worked). MainWindow
  now applies the persisted preset at startup (env override still wins) and live
  on navPresetChanged, so all four presets actually work from the dialog.
- Web main_web: classifyPress routes the pressed button through navBindings()
  (orbit/pan/select), defaulting to the "web" preset; context menu already
  suppressed so RMB is free.

Tests: setNavPreset table (Catch2, 123 total); web smoke select tests use RMB.
BonsaiViewer builds; 9/9 web smoke.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-07-03 08:37:27 +10:00
parent cf05bbd1bb
commit f1d97ac5aa
11 changed files with 430 additions and 256 deletions
+24 -10
View File
@@ -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) {
+8 -7
View File
@@ -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;
+79 -42
View File
@@ -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<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) {
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<AppState*>(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";
+3 -3
View File
@@ -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);
+11 -1
View File
@@ -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<int>(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<int>(NavPreset::Revit)) {
if (raw < 0 || raw > static_cast<int>(NavPreset::Web)) {
nav_preset_ = NavPreset::Blender;
} else {
nav_preset_ = static_cast<NavPreset>(raw);
+5
View File
@@ -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;
+188 -166
View File
@@ -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<float>::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<std::vector<std::uint32_t>> chunk_mesh_ids;
std::vector<std::uint32_t> 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<std::uint32_t> 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<std::uint32_t> 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<std::uint32_t> 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<std::uint32_t> 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<std::unordered_map<std::uint32_t, MeshLocal>>
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<std::size_t>(chunk_instance_count[ci], 1);
const std::size_t chunk_inst = std::max<std::size_t>(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<std::uint64_t>(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<std::uint64_t>(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<MeshGpu> 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<InstanceGpu> 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<float>::infinity(),
-std::numeric_limits<float>::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<SidecarData> 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<std::uint8_t> vbytes(c.vertex_byte_size);
std::vector<std::uint32_t> idx;
idx.reserve(c.index_count);
std::vector<std::uint8_t> vbytes(chunk.vertex_byte_size);
std::vector<std::uint32_t> 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();
}
+28
View File
@@ -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
+32 -21
View File
@@ -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());
+14 -6
View File
@@ -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 };
@@ -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);