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
+59 -18
View File
@@ -35,9 +35,12 @@
#include <emscripten/emscripten.h>
#include <emscripten/html5.h>
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <cstdlib>
#include <cstring>
#include <vector>
namespace {
@@ -110,6 +113,24 @@ int canvasCssHeight() {
return (h > 1.0) ? int(h) : 1;
}
// Marquee rectangle overlay. The rubber-band is a plain DOM <div> (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<long>(app->down_x, e->targetX);
const long y0 = std::min<long>(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<long>(app->down_x, e->targetX);
const long y0 = std::min<long>(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<std::uint32_t> 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;
}
+13
View File
@@ -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 @@
</head>
<body>
<canvas id="viewer-canvas" width="1280" height="800"></canvas>
<div id="marquee"></div>
<div id="progress"><div id="progress-fill"></div></div>
<div id="progress-panel">
<div id="progress-summary"></div>
@@ -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');
+29
View File
@@ -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([]);
});