mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-09 09:21:46 +00:00
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:
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user