From cd3d70172bf2fa86c3aa8b173af05ef010cf5b6c Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Fri, 3 Jul 2026 09:08:28 +1000 Subject: [PATCH] ifcviewer: marquee box-select on web + suppress the canvas context menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring rubber-band box-select to the web on the Web preset's select button (RMB). - Core: factor the pick-pass encode + rect copy out of picksInRect into encodeBoxPickToStaging (mirroring how single-pick shares encodePickReadbackToStaging), shared by the sync picksInRect (desktop) and a new async picksInRectAsync (web) — the latter maps the staging buffer via a spontaneous callback because the sync spin-map hangs the JS loop. New applyMarqueeToSelection (plain replace / Shift add / Ctrl remove). - Web main_web: a select-button drag past the click threshold draws a marquee rubber-band (a plain DOM
positioned in CSS px — no GPU overlay pass, which the web lib lacks) and on release box-picks the rect (device px) and applies it to the selection. A click (no drag) still single-picks. - Web shell.html: the #marquee div + styling, and — the reported bug — a contextmenu preventDefault on the canvas so RMB (now the select button) doesn't pop the browser menu. (Firefox still forces its native menu on Shift+RightClick; that's a browser escape hatch pages can't override.) Tests: applyMarqueeToSelection replace/add/remove + id-0 (Catch2, 124 total); web smoke marquee drag → rubber-band shown → selection changes → hidden (10/10). Desktop picksInRect unchanged in behaviour; BonsaiViewer builds. Co-Authored-By: Claude Opus 4.8 --- src/ifcviewer-web/main_web.cpp | 77 ++++++++++--- src/ifcviewer-web/shell.html | 13 +++ src/ifcviewer-web/tests/smoke.spec.mjs | 29 +++++ src/ifcviewer/ViewportCore.cpp | 111 +++++++++++++++---- src/ifcviewer/ViewportCore.h | 38 ++++++- src/ifcviewer/tests/test_viewport_camera.cpp | 22 ++++ 6 files changed, 247 insertions(+), 43 deletions(-) diff --git a/src/ifcviewer-web/main_web.cpp b/src/ifcviewer-web/main_web.cpp index 8208a9250a..95dfac91e9 100644 --- a/src/ifcviewer-web/main_web.cpp +++ b/src/ifcviewer-web/main_web.cpp @@ -35,9 +35,12 @@ #include #include +#include #include #include +#include #include +#include namespace { @@ -110,6 +113,24 @@ int canvasCssHeight() { return (h > 1.0) ? int(h) : 1; } +// Marquee rectangle overlay. The rubber-band is a plain DOM
(shell.html) +// positioned in CSS px — the canvas fills the viewport, so canvas-relative +// coords are viewport coords. Cheaper + pixel-perfect vs a GPU overlay pass +// (which the web lib doesn't have anyway). +void showMarquee(int x, int y, int w, int h) { + EM_ASM({ + var m = document.getElementById('marquee'); + if (m) { + m.style.display = 'block'; + m.style.left = $0 + 'px'; m.style.top = $1 + 'px'; + m.style.width = $2 + 'px'; m.style.height = $3 + 'px'; + } + }, x, y, w, h); +} +void hideMarquee() { + EM_ASM({ var m = document.getElementById('marquee'); if (m) m.style.display = 'none'; }); +} + NavKind classifyPress(const ViewportCore::NavBindings& b, int em_button, bool shift, bool ctrl, bool alt) { using MB = ViewportCore::MouseBtn; using M = ViewportCore::NavMod; @@ -152,7 +173,14 @@ EM_BOOL onMouseMove(int, const EmscriptenMouseEvent* e, void* user) { app->nav_drag_px += std::abs(dx) + std::abs(dy); 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). + else if (app->nav_kind == NavKind::Select && app->nav_drag_px > kClickDragThresholdPx) { + // Select-button drag → draw the marquee rubber-band (CSS px). + const long x0 = std::min(app->down_x, e->targetX); + const long y0 = std::min(app->down_y, e->targetY); + showMarquee(int(x0), int(y0), + int(std::labs(long(e->targetX) - app->down_x)), + int(std::labs(long(e->targetY) - app->down_y))); + } return EM_TRUE; } @@ -163,23 +191,36 @@ EM_BOOL onMouseUp(int, const EmscriptenMouseEvent* e, void* user) { app->nav_active = false; app->nav_kind = NavKind::None; - // 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 && 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); - 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); - // Demo the v15 on-demand deferred fetch: log the picked object's - // IFC GUID (first pick fetches the property block off the network). - if (id != 0) app->core.logSelectedObjectGuidWeb(id); - app->host.requestFrame(); - }); + if (was_active && kind == NavKind::Select && app->ready) { + const double dpr = emscripten_get_device_pixel_ratio(); + const bool add = e->shiftKey; + const bool remove = e->ctrlKey; + if (app->nav_drag_px > kClickDragThresholdPx) { + // Marquee drag → box-pick the rect (device px) and apply to selection. + hideMarquee(); + const long x0 = std::min(app->down_x, e->targetX); + const long y0 = std::min(app->down_y, e->targetY); + const int rx = int(x0 * dpr), ry = int(y0 * dpr); + const int rw = int(std::labs(long(e->targetX) - app->down_x) * dpr); + const int rh = int(std::labs(long(e->targetY) - app->down_y) * dpr); + app->core.picksInRectAsync(rx, ry, rw, rh, + [app, add, remove](std::vector ids) { + app->core.applyMarqueeToSelection(ids, add, remove); + app->host.requestFrame(); + }); + } else { + // No real drag → single pick under the cursor (Shift add, Ctrl remove, + // plain replace). Async readback: highlight lands a frame later. + const int px = int(app->down_x * dpr); + const int py = int(app->down_y * dpr); + app->core.pickObjectAtAsync(px, py, [app, add, remove](std::uint32_t id) { + app->core.applyPickToSelection(id, add, remove); + // v15 on-demand deferred fetch: log the picked object's IFC GUID + // (first pick fetches the property block off the network). + if (id != 0) app->core.logSelectedObjectGuidWeb(id); + app->host.requestFrame(); + }); + } } return EM_TRUE; } diff --git a/src/ifcviewer-web/shell.html b/src/ifcviewer-web/shell.html index 17ec6f082f..f3339d9c8d 100644 --- a/src/ifcviewer-web/shell.html +++ b/src/ifcviewer-web/shell.html @@ -8,6 +8,10 @@ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; } #viewer-canvas { display: block; width: 100vw; height: 100vh; outline: none; background: #1a1d24; } + /* Marquee (box-select) rubber-band. Positioned in CSS px by main_web; never + eats pointer events so the drag keeps reaching the canvas. */ + #marquee { position: fixed; display: none; z-index: 50; pointer-events: none; + border: 1px solid #4a9eff; background: rgba(74, 158, 255, 0.15); } /* Log overlay sits bottom-left and never eats pointer events (so it can't block orbit drags over the canvas). It auto-scrolls to the newest line. Capped small; collapses further once the app is live. */ @@ -63,6 +67,7 @@ +
@@ -302,6 +307,14 @@ // the current scene (federation). Multiple files can be picked at once. Each // File is registered as its own byte-source (kept alive in __ifcvSources for // lazy Blob.slice reads) and streamed independently. + // RMB is the select/marquee button in the Web nav preset, so suppress the + // browser context menu over the canvas. (Firefox forces its native menu on + // Shift+RightClick regardless — a browser escape hatch pages can't override.) + var viewerCanvas = document.getElementById('viewer-canvas'); + if (viewerCanvas) { + viewerCanvas.addEventListener('contextmenu', function(ev) { ev.preventDefault(); }); + } + var openBtn = document.getElementById('open-btn'); var addBtn = document.getElementById('add-btn'); var fileInput = document.getElementById('file-input'); diff --git a/src/ifcviewer-web/tests/smoke.spec.mjs b/src/ifcviewer-web/tests/smoke.spec.mjs index 6316c4e264..fc7acc0a82 100644 --- a/src/ifcviewer-web/tests/smoke.spec.mjs +++ b/src/ifcviewer-web/tests/smoke.spec.mjs @@ -343,3 +343,32 @@ test('hide selected removes geometry after a pick', async ({ page }) => { await page.waitForTimeout(400); expect(gpuErrors, gpuErrors.join('\n')).toEqual([]); }); + +test('RMB marquee drag box-selects (Web preset)', async ({ page }) => { + const gpuErrors = []; + page.on('console', (m) => { if (/Uncaptured WebGPU error|is invalid/i.test(m.text())) gpuErrors.push(m.text()); }); + await ready(page); + + const box = await page.locator('#viewer-canvas').boundingBox(); + const cx = box.x + box.width / 2, cy = box.y + box.height / 2; + const before = await shot(page); + await page.mouse.move(cx - 120, cy - 90); + await page.mouse.down({ button: 'right' }); + await page.mouse.move(cx - 40, cy - 30, { steps: 4 }); + const midDragVisible = await page.evaluate(() => { + const m = document.getElementById('marquee'); + return !!(m && getComputedStyle(m).display !== 'none'); + }); + await page.mouse.move(cx + 120, cy + 90, { steps: 6 }); + await page.mouse.up({ button: 'right' }); + await page.waitForTimeout(600); // async box-pick + apply + render + const after = await shot(page); + const hiddenAfter = await page.evaluate(() => { + const m = document.getElementById('marquee'); + return !!(m && getComputedStyle(m).display === 'none'); + }); + expect(midDragVisible, 'marquee rubber-band was not shown during the drag').toBe(true); + expect(Buffer.compare(before, after), 'box-select did not change the canvas').not.toBe(0); + expect(hiddenAfter, 'marquee was not hidden after release').toBe(true); + expect(gpuErrors, gpuErrors.join('\n')).toEqual([]); +}); diff --git a/src/ifcviewer/ViewportCore.cpp b/src/ifcviewer/ViewportCore.cpp index 240bf494eb..91a9e8eb47 100644 --- a/src/ifcviewer/ViewportCore.cpp +++ b/src/ifcviewer/ViewportCore.cpp @@ -5018,6 +5018,17 @@ void ViewportCore::applyPickToSelection(std::uint32_t object_id, bool add, bool else selection_.replace(object_id); } +void ViewportCore::applyMarqueeToSelection(const std::vector& ids, + bool add, bool remove) { + if (!add && !remove) selection_.clear(); // plain marquee replaces + for (std::uint32_t id : ids) { + if (id == 0) continue; + if (remove) selection_.remove(id); + else selection_.add(id); // replace (post-clear) or add + } + host_->requestFrame(); +} + void ViewportCore::hideSelected() { if (selection_.count() == 0) return; for (uint32_t id : selection_.selectionIds()) visibility_.hide(id); @@ -5110,19 +5121,20 @@ void ViewportCore::pickObjectAtAsync(int x_pixels, int y_pixels, } #endif // __EMSCRIPTEN__ -std::vector ViewportCore::picksInRect(int x, int y, int w, int h) { - std::vector out; - if (w <= 0 || h <= 0) return out; - if (!pick_pipeline_ || !device_ || !queue_ || models_gpu_.empty()) return out; - if (configured_w_ <= 0 || configured_h_ <= 0) return out; +bool ViewportCore::encodeBoxPickToStaging(int& x, int& y, int& w, int& h, + std::uint64_t& padded_bpr_out, + std::uint64_t& needed_bytes_out) { + if (w <= 0 || h <= 0) return false; + if (!pick_pipeline_ || !device_ || !queue_ || models_gpu_.empty()) return false; + if (configured_w_ <= 0 || configured_h_ <= 0) return false; if (x < 0) { w += x; x = 0; } if (y < 0) { h += y; y = 0; } if (x + w > configured_w_) w = configured_w_ - x; if (y + h > configured_h_) h = configured_h_ - y; - if (w <= 0 || h <= 0) return out; + if (w <= 0 || h <= 0) return false; ensurePickAttachments(configured_w_, configured_h_); - if (!pick_color_view_ || !pick_depth_view_) return out; + if (!pick_color_view_ || !pick_depth_view_) return false; // Padded bytes-per-row. R32UInt = 4 B/texel; align to 256 B. constexpr std::uint64_t kWgpuBytesPerRowAlign = 256; @@ -5144,7 +5156,7 @@ std::vector ViewportCore::picksInRect(int x, int y, int w, int h) box_pick_staging_buffer_ = wgpuDeviceCreateBuffer(device_, &sb); box_pick_staging_capacity_ = cap; } - if (!box_pick_staging_buffer_) return out; + if (!box_pick_staging_buffer_) return false; WGPUCommandEncoder enc = wgpuDeviceCreateCommandEncoder(device_, nullptr); @@ -5212,22 +5224,14 @@ std::vector ViewportCore::picksInRect(int x, int y, int w, int h) wgpuCommandBufferRelease(cmd); wgpuCommandEncoderRelease(enc); - struct MapReq { bool done = false; bool ok = false; }; - MapReq req; - WGPUBufferMapCallbackInfo mcb = {}; - mcb.mode = kAsyncCbMode; - mcb.callback = [](WGPUMapAsyncStatus status, WGPUStringView /*msg*/, - void* ud1, void* /*ud2*/) { - auto* r = static_cast(ud1); - r->done = true; - r->ok = (status == WGPUMapAsyncStatus_Success); - }; - mcb.userdata1 = &req; - wgpuBufferMapAsync(box_pick_staging_buffer_, WGPUMapMode_Read, - 0, needed_bytes, mcb); - while (!req.done) waitTickInstance(instance_); - if (!req.ok) return out; + padded_bpr_out = padded_bpr; + needed_bytes_out = needed_bytes; + return true; +} +std::vector ViewportCore::collectMappedBoxPickIds( + std::uint64_t padded_bpr, int w, int h, std::uint64_t needed_bytes) { + std::vector out; const std::uint8_t* mapped = static_cast( wgpuBufferGetConstMappedRange(box_pick_staging_buffer_, 0, needed_bytes)); std::unordered_set seen; @@ -5242,12 +5246,71 @@ std::vector ViewportCore::picksInRect(int x, int y, int w, int h) } } wgpuBufferUnmap(box_pick_staging_buffer_); - out.reserve(seen.size()); for (std::uint32_t id : seen) out.push_back(id); return out; } +std::vector ViewportCore::picksInRect(int x, int y, int w, int h) { + std::uint64_t padded_bpr = 0, needed_bytes = 0; + if (!encodeBoxPickToStaging(x, y, w, h, padded_bpr, needed_bytes)) return {}; + + struct MapReq { bool done = false; bool ok = false; }; + MapReq req; + WGPUBufferMapCallbackInfo mcb = {}; + mcb.mode = kAsyncCbMode; + mcb.callback = [](WGPUMapAsyncStatus status, WGPUStringView /*msg*/, + void* ud1, void* /*ud2*/) { + auto* r = static_cast(ud1); + r->done = true; + r->ok = (status == WGPUMapAsyncStatus_Success); + }; + mcb.userdata1 = &req; + wgpuBufferMapAsync(box_pick_staging_buffer_, WGPUMapMode_Read, 0, needed_bytes, mcb); + while (!req.done) waitTickInstance(instance_); + if (!req.ok) return {}; + return collectMappedBoxPickIds(padded_bpr, w, h, needed_bytes); +} + +#if defined(__EMSCRIPTEN__) +void ViewportCore::picksInRectAsync(int x, int y, int w, int h, + std::function)> cb) { + auto miss = [&cb]() { if (cb) cb({}); }; + if (box_pick_async_in_flight_) { miss(); return; } + std::uint64_t padded_bpr = 0, needed_bytes = 0; + if (!encodeBoxPickToStaging(x, y, w, h, padded_bpr, needed_bytes)) { miss(); return; } + + // Stash the (clamped) rect so the spontaneous map callback can walk the + // padded staging rows without recomputing. + box_pick_async_w_ = w; + box_pick_async_h_ = h; + box_pick_async_padded_bpr_ = padded_bpr; + box_pick_async_bytes_ = needed_bytes; + box_pick_async_in_flight_ = true; + box_pick_async_cb_ = std::move(cb); + + WGPUBufferMapCallbackInfo mcb = {}; + mcb.mode = kAsyncCbMode; // AllowSpontaneous on web + mcb.callback = [](WGPUMapAsyncStatus status, WGPUStringView /*msg*/, + void* ud1, void* /*ud2*/) { + auto* self = static_cast(ud1); + std::vector ids; + if (status == WGPUMapAsyncStatus_Success) { + ids = self->collectMappedBoxPickIds(self->box_pick_async_padded_bpr_, + self->box_pick_async_w_, + self->box_pick_async_h_, + self->box_pick_async_bytes_); + } + auto cb = std::move(self->box_pick_async_cb_); + self->box_pick_async_cb_ = nullptr; + self->box_pick_async_in_flight_ = false; + if (cb) cb(std::move(ids)); + }; + mcb.userdata1 = this; + wgpuBufferMapAsync(box_pick_staging_buffer_, WGPUMapMode_Read, 0, needed_bytes, mcb); +} +#endif + bool ViewportCore::pickSurfaceAt(int x_pixels, int y_pixels, std::uint32_t& object_id_out, Eigen::Vector3f& world_pos_out, diff --git a/src/ifcviewer/ViewportCore.h b/src/ifcviewer/ViewportCore.h index 4de8b188b3..e6ea2c261e 100644 --- a/src/ifcviewer/ViewportCore.h +++ b/src/ifcviewer/ViewportCore.h @@ -632,6 +632,20 @@ public: // async (pickObjectAtAsync) readbacks. Caller validates bounds/attachments. void encodePickReadbackToStaging(int x_pixels, int y_pixels, bool want_normal); + // Encode the pick pass + copy the (x,y,w,h) object_id sub-rect into + // box_pick_staging_buffer_ and submit. Clamps the rect (x/y/w/h in-out) and + // reports the padded bytes-per-row + total mapped size. Shared by the sync + // picksInRect and async picksInRectAsync — they differ only in the map. + // False if nothing is pickable or the rect is empty. + bool encodeBoxPickToStaging(int& x, int& y, int& w, int& h, + std::uint64_t& padded_bpr_out, + std::uint64_t& needed_bytes_out); + // Read the (already-mapped) box-pick staging buffer → unique non-zero ids in + // the w×h rect (rows padded to padded_bpr). Unmaps before returning. + std::vector collectMappedBoxPickIds(std::uint64_t padded_bpr, + int w, int h, + std::uint64_t needed_bytes); + // Tear down every pick-owned wgpu resource (pipeline + MRTs + // staging buffers). Called from shutdown() before device_ dies. void releasePickResources(); @@ -648,6 +662,11 @@ public: // Marks selection_ dirty for the next render's flush. void applyPickToSelection(std::uint32_t object_id, bool add, bool remove); + // Apply a marquee box-pick result to the selection: plain = replace with + // `ids`, add = union, remove = subtract. Schedules a frame. + void applyMarqueeToSelection(const std::vector& ids, + bool add, bool remove); + // Visibility + X-ray, shared by desktop (H / Shift+H / Alt+H / Alt+X) and // web. Hidden objects are skipped by the cull and xray_alpha_cap_ is read // by the frame uniform, both per frame — so each call just mutates state and @@ -673,9 +692,18 @@ public: // 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). + // non-zero ids. Synchronous (rare interaction) — desktop only path. std::vector picksInRect(int x, int y, int w, int h); +#if defined(__EMSCRIPTEN__) + // Async marquee box select for web (the sync spin-map would hang the JS + // loop). Same pick pass + rect copy as picksInRect, mapped via a spontaneous + // callback that delivers the unique non-zero ids to `cb`. One in flight at a + // time (a box-pick issued while another is mapping is dropped → cb({})). + void picksInRectAsync(int x, int y, int w, int h, + std::function)> cb); +#endif + // Run pickObjectAt + raycast against every instance carrying the // hit object_id, then return the closest hit's world position, // world normal, and (optionally) the bounding-sphere radius. The @@ -919,6 +947,14 @@ private: // pick_async_cb_ fires with object_id when the spontaneous map resolves. bool pick_async_in_flight_ = false; std::function pick_async_cb_; + // Async box-pick (marquee) state (web). Rect dims are stashed so the + // spontaneous map callback knows how to walk the padded staging rows. + bool box_pick_async_in_flight_ = false; + std::function)> box_pick_async_cb_; + int box_pick_async_w_ = 0; + int box_pick_async_h_ = 0; + std::uint64_t box_pick_async_padded_bpr_ = 0; + std::uint64_t box_pick_async_bytes_ = 0; #endif // ---- Frame uniforms + selection bind ---------------------------------- diff --git a/src/ifcviewer/tests/test_viewport_camera.cpp b/src/ifcviewer/tests/test_viewport_camera.cpp index cca86eed37..01f4a5f018 100644 --- a/src/ifcviewer/tests/test_viewport_camera.cpp +++ b/src/ifcviewer/tests/test_viewport_camera.cpp @@ -196,3 +196,25 @@ TEST_CASE("hideSelected hides the selection; showAll restores", "[camera][visibi core.showAll(); REQUIRE(core.hiddenCount() == 0); } + +TEST_CASE("applyMarqueeToSelection: replace / add / remove", "[camera][selection]") { + MockHost host; ViewportCore core(&host); + // No public selection accessor, so verify via hideSelected → hiddenCount. + SECTION("plain marquee replaces the selection") { + core.applyMarqueeToSelection({1, 2, 3}, /*add*/false, /*remove*/false); + core.hideSelected(); + REQUIRE(core.hiddenCount() == 3); + } + SECTION("add unions, remove subtracts") { + core.applyMarqueeToSelection({5}, false, false); // replace → {5} + core.applyMarqueeToSelection({6, 7}, true, false); // add → {5,6,7} + core.applyMarqueeToSelection({6}, false, true); // remove → {5,7} + core.hideSelected(); + REQUIRE(core.hiddenCount() == 2); + } + SECTION("id 0 is ignored") { + core.applyMarqueeToSelection({0, 9, 0}, false, false); + core.hideSelected(); + REQUIRE(core.hiddenCount() == 1); + } +}