diff --git a/src/ifcviewer-web/main_web.cpp b/src/ifcviewer-web/main_web.cpp index 144d6b196b..a2d524cd2e 100644 --- a/src/ifcviewer-web/main_web.cpp +++ b/src/ifcviewer-web/main_web.cpp @@ -35,6 +35,9 @@ #include #include +#include +#include + namespace { // CSS selector for the host ; must match shell.html + the @@ -58,8 +61,18 @@ struct AppState { // 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. + float nav_drag_px = 0.0f; + long down_x = 0; + long down_y = 0; }; +// Click vs drag threshold (CSS px). Below this total travel a left release is +// a pick, above it the gesture was an orbit. +constexpr float kClickDragThresholdPx = 4.0f; + // One global so the JS-side RAF loop can recover state through a // pointer round-trip (set into Module._app_ptr from on_complete). AppState* g_app = nullptr; @@ -76,8 +89,11 @@ int canvasCssHeight() { EM_BOOL onMouseDown(int, const EmscriptenMouseEvent* e, void* user) { auto* app = static_cast(user); if (e->button == 0 || e->button == 1 || e->button == 2) { - app->nav_active = true; - app->nav_button = e->button; + app->nav_active = true; + app->nav_button = e->button; + app->nav_drag_px = 0.0f; + app->down_x = e->targetX; // canvas-relative CSS px + app->down_y = e->targetY; } return EM_TRUE; } @@ -88,6 +104,7 @@ 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 { @@ -96,8 +113,27 @@ EM_BOOL onMouseMove(int, const EmscriptenMouseEvent* e, void* user) { return EM_TRUE; } -EM_BOOL onMouseUp(int, const EmscriptenMouseEvent* /*e*/, void* user) { - static_cast(user)->nav_active = false; +EM_BOOL onMouseUp(int, const EmscriptenMouseEvent* e, void* user) { + auto* app = static_cast(user); + const bool was_active = app->nav_active; + const int button = app->nav_button; + app->nav_active = false; + + // Left 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 && + app->nav_drag_px <= kClickDragThresholdPx) { + const double dpr = emscripten_get_device_pixel_ratio(); + const int px = int(app->down_x * dpr); + const int py = int(app->down_y * dpr); + const bool add = e->shiftKey; + const bool remove = e->ctrlKey; + app->core.pickObjectAtAsync(px, py, [app, add, remove](std::uint32_t id) { + app->core.applyPickToSelection(id, add, remove); + app->host.requestFrame(); + }); + } return EM_TRUE; } diff --git a/src/ifcviewer/ViewportCore.cpp b/src/ifcviewer/ViewportCore.cpp index b929fe88e8..c85db64a10 100644 --- a/src/ifcviewer/ViewportCore.cpp +++ b/src/ifcviewer/ViewportCore.cpp @@ -4373,18 +4373,13 @@ void ViewportCore::releasePickResources() { pick_w_ = pick_h_ = 0; } -std::uint32_t ViewportCore::pickObjectAt(int x_pixels, int y_pixels, - Eigen::Vector3f* normal_out) { - if (normal_out) *normal_out = Eigen::Vector3f(0, 0, 1); - if (!pick_pipeline_ || !device_ || !queue_ || models_gpu_.empty()) return 0; - if (configured_w_ <= 0 || configured_h_ <= 0) return 0; - if (x_pixels < 0 || y_pixels < 0 || - x_pixels >= configured_w_ || y_pixels >= configured_h_) return 0; - - ensurePickAttachments(configured_w_, configured_h_); - if (!pick_color_view_ || !pick_depth_view_ || !pick_staging_buffer_) return 0; - if (normal_out && !pick_normal_staging_buffer_) return 0; - +// Encode the one-shot pick pass (object_id + optional normal targets) and +// copy the texel at (x, y) into the pick staging buffer(s), then submit. +// Shared by the synchronous desktop pickObjectAt and the async web pick — +// only the readback after this differs (blocking map-spin vs spontaneous +// callback). Assumes the caller validated bounds + attachments. +void ViewportCore::encodePickReadbackToStaging(int x_pixels, int y_pixels, + bool want_normal) { // The current frame's visible_draws are already on the GPU (uploaded // by the last render's cullModelCpuUpload). Encode a one-shot pass. WGPUCommandEncoder enc = wgpuDeviceCreateCommandEncoder(device_, nullptr); @@ -4449,7 +4444,7 @@ std::uint32_t ViewportCore::pickObjectAt(int x_pixels, int y_pixels, wgpuCommandEncoderCopyTextureToBuffer(enc, &src, &dst, &extent); - if (normal_out) { + if (want_normal) { WGPUTexelCopyTextureInfo nsrc = {}; nsrc.texture = pick_normal_texture_; nsrc.aspect = WGPUTextureAspect_All; @@ -4468,6 +4463,21 @@ std::uint32_t ViewportCore::pickObjectAt(int x_pixels, int y_pixels, wgpuQueueSubmit(queue_, 1, &cmd); wgpuCommandBufferRelease(cmd); wgpuCommandEncoderRelease(enc); +} + +std::uint32_t ViewportCore::pickObjectAt(int x_pixels, int y_pixels, + Eigen::Vector3f* normal_out) { + if (normal_out) *normal_out = Eigen::Vector3f(0, 0, 1); + if (!pick_pipeline_ || !device_ || !queue_ || models_gpu_.empty()) return 0; + if (configured_w_ <= 0 || configured_h_ <= 0) return 0; + if (x_pixels < 0 || y_pixels < 0 || + x_pixels >= configured_w_ || y_pixels >= configured_h_) return 0; + + ensurePickAttachments(configured_w_, configured_h_); + if (!pick_color_view_ || !pick_depth_view_ || !pick_staging_buffer_) return 0; + if (normal_out && !pick_normal_staging_buffer_) return 0; + + encodePickReadbackToStaging(x_pixels, y_pixels, normal_out != nullptr); // Sync wait — pick is rare (click), so the GPU stall is fine. struct MapReq { bool done = false; bool ok = false; }; @@ -4540,6 +4550,70 @@ std::uint32_t ViewportCore::pickObjectAt(int x_pixels, int y_pixels, return object_id; } +// Route a pick result through the selection state machine. Mirrors the +// desktop ViewportWindow::mouseReleaseEvent semantics: no modifier replaces, +// add(=Shift) extends, remove(=Ctrl) subtracts, and an empty-space click +// (id == 0) with no modifier clears. Marks selection_ dirty so the next +// render's uploadSelectionFlagsIfDirty flushes the highlight. +void ViewportCore::applyPickToSelection(std::uint32_t object_id, bool add, bool remove) { + if (object_id == 0) { + if (!add && !remove) selection_.clear(); + return; + } + if (remove) selection_.remove(object_id); + else if (add) selection_.add(object_id); + else selection_.replace(object_id); +} + +#if defined(__EMSCRIPTEN__) +void ViewportCore::pickObjectAtAsync(int x_pixels, int y_pixels, + std::function cb) { + // Async sibling of pickObjectAt for the web build, where the blocking + // map-spin would hang the JS event loop. Encodes the same pick pass, then + // maps the staging buffer with a spontaneous callback (AllowSpontaneous + + // the browser microtask loop) that delivers object_id to `cb`. No normal + // readback — object pick only (surface pick lands in a follow-up). + auto miss = [&cb](std::uint32_t id) { if (cb) cb(id); }; + + if (!pick_pipeline_ || !device_ || !queue_ || models_gpu_.empty()) { miss(0); return; } + if (configured_w_ <= 0 || configured_h_ <= 0) { miss(0); return; } + if (x_pixels < 0 || y_pixels < 0 || + x_pixels >= configured_w_ || y_pixels >= configured_h_) { miss(0); return; } + + ensurePickAttachments(configured_w_, configured_h_); + if (!pick_color_view_ || !pick_depth_view_ || !pick_staging_buffer_) { miss(0); return; } + + // One pick in flight at a time. Clicks are far slower than a readback, so + // dropping a pick issued while another is mapping is acceptable (and + // avoids racing two maps on the same staging buffer). + if (pick_async_in_flight_) { miss(0); return; } + pick_async_in_flight_ = true; + pick_async_cb_ = std::move(cb); + + encodePickReadbackToStaging(x_pixels, y_pixels, /*want_normal=*/false); + + WGPUBufferMapCallbackInfo mcb = {}; + mcb.mode = kAsyncCbMode; // AllowSpontaneous on web + mcb.callback = [](WGPUMapAsyncStatus status, WGPUStringView /*msg*/, + void* ud1, void* /*ud2*/) { + auto* self = static_cast(ud1); + std::uint32_t object_id = 0; + if (status == WGPUMapAsyncStatus_Success) { + const std::uint32_t* mapped = static_cast( + wgpuBufferGetConstMappedRange(self->pick_staging_buffer_, 0, 256)); + object_id = mapped ? mapped[0] : 0u; + wgpuBufferUnmap(self->pick_staging_buffer_); + } + auto cb = std::move(self->pick_async_cb_); + self->pick_async_cb_ = nullptr; + self->pick_async_in_flight_ = false; + if (cb) cb(object_id); + }; + mcb.userdata1 = this; + wgpuBufferMapAsync(pick_staging_buffer_, WGPUMapMode_Read, 0, 256, mcb); +} +#endif // __EMSCRIPTEN__ + std::vector ViewportCore::picksInRect(int x, int y, int w, int h) { std::vector out; if (w <= 0 || h <= 0) return out; diff --git a/src/ifcviewer/ViewportCore.h b/src/ifcviewer/ViewportCore.h index 2a40202328..1eee525c6f 100644 --- a/src/ifcviewer/ViewportCore.h +++ b/src/ifcviewer/ViewportCore.h @@ -533,6 +533,11 @@ public: // to the supplied size. Idempotent when dimensions match. void ensurePickAttachments(int w, int h); + // Encode the one-shot pick pass + copy the (x, y) texel into the pick + // staging buffer(s) and submit. Shared by the sync (pickObjectAt) and + // async (pickObjectAtAsync) readbacks. Caller validates bounds/attachments. + void encodePickReadbackToStaging(int x_pixels, int y_pixels, bool want_normal); + // Tear down every pick-owned wgpu resource (pipeline + MRTs + // staging buffers). Called from shutdown() before device_ dies. void releasePickResources(); @@ -544,6 +549,21 @@ public: std::uint32_t pickObjectAt(int x_pixels, int y_pixels, Eigen::Vector3f* normal_out = nullptr); + // Route a pick result into the selection state machine (replace / add / + // remove / empty-click-clear), mirroring the desktop click semantics. + // Marks selection_ dirty for the next render's flush. + void applyPickToSelection(std::uint32_t object_id, bool add, bool remove); + +#if defined(__EMSCRIPTEN__) + // Async object pick for the web build: encodes the same pick pass as + // pickObjectAt but reads the staging buffer back via a spontaneous map + // callback (no blocking spin, which would hang the JS event loop) and + // delivers object_id to `cb`. Object-id only; one pick in flight at a + // time (a pick issued while another is mapping is dropped → cb(0)). + void pickObjectAtAsync(int x_pixels, int y_pixels, + std::function cb); +#endif + // Marquee box select: encode the pick pass, copy the (x, y, w, h) // sub-rect of the object_id MRT back, return the set of unique // non-zero ids. Synchronous (rare interaction). @@ -775,6 +795,12 @@ private: int pick_h_ = 0; WGPUBuffer box_pick_staging_buffer_ = nullptr; std::uint64_t box_pick_staging_capacity_ = 0; +#if defined(__EMSCRIPTEN__) + // Async object-pick state (web). Held while the staging map is in flight; + // pick_async_cb_ fires with object_id when the spontaneous map resolves. + bool pick_async_in_flight_ = false; + std::function pick_async_cb_; +#endif // ---- Frame uniforms + selection bind ---------------------------------- //