ifcviewer: marquee box-select on web + suppress the canvas context menu

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 <div> 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 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-07-03 09:08:28 +10:00
parent d08c53d756
commit cd3d70172b
6 changed files with 247 additions and 43 deletions
+87 -24
View File
@@ -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<std::uint32_t>& 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<std::uint32_t> ViewportCore::picksInRect(int x, int y, int w, int h) {
std::vector<std::uint32_t> 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<std::uint32_t> 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<std::uint32_t> 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<MapReq*>(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<std::uint32_t> ViewportCore::collectMappedBoxPickIds(
std::uint64_t padded_bpr, int w, int h, std::uint64_t needed_bytes) {
std::vector<std::uint32_t> out;
const std::uint8_t* mapped = static_cast<const std::uint8_t*>(
wgpuBufferGetConstMappedRange(box_pick_staging_buffer_, 0, needed_bytes));
std::unordered_set<std::uint32_t> seen;
@@ -5242,12 +5246,71 @@ std::vector<std::uint32_t> 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<std::uint32_t> 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<MapReq*>(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<void(std::vector<std::uint32_t>)> 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<ViewportCore*>(ud1);
std::vector<std::uint32_t> 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,
+37 -1
View File
@@ -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<std::uint32_t> 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<std::uint32_t>& 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<std::uint32_t> 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<void(std::vector<std::uint32_t>)> 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<void(std::uint32_t)> 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<void(std::vector<std::uint32_t>)> 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 ----------------------------------
@@ -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);
}
}