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