mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-13 10:57:49 +00:00
ifcviewer-web: async object pick → click-to-select on web
Desktop pick reads the pick staging buffer back with a blocking
`while(!done) waitTickInstance()` spin. On web that spin is a no-op
(Asyncify is off) and hangs the JS event loop, so click-to-select was
dead on web. This adds an async sibling that uses the spontaneous
map-callback pattern (AllowSpontaneous + the browser microtask loop)
already proven by the HiZ readback — no blocking.
- encodePickReadbackToStaging(x,y,want_normal): the pick-pass render +
copy-texel-to-staging, extracted from pickObjectAt verbatim and shared
by both readbacks (desktop sync path unchanged).
- pickObjectAtAsync(x,y,cb) [web]: encode, then map the staging buffer
with a spontaneous callback that delivers object_id to cb. One pick in
flight at a time (a pick issued mid-map is dropped → cb(0)).
- applyPickToSelection(id, add, remove): routes a pick result through the
selection state machine (replace / Shift-add / Ctrl-remove / empty-click
clear), mirroring the desktop ViewportWindow click semantics. selection_
marks dirty so the next render's uploadSelectionFlagsIfDirty flushes the
highlight.
main_web wires it: a left release under a 4px drag threshold (no orbit) is
a pick at down-position * devicePixelRatio, with Shift/Ctrl modifiers;
the result callback applies selection and requests a frame. Web + desktop
build clean; 107/107 unit tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -35,6 +35,9 @@
|
||||
#include <emscripten/emscripten.h>
|
||||
#include <emscripten/html5.h>
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
|
||||
namespace {
|
||||
|
||||
// CSS selector for the host <canvas>; 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<AppState*>(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<AppState*>(user)->nav_active = false;
|
||||
EM_BOOL onMouseUp(int, const EmscriptenMouseEvent* e, void* user) {
|
||||
auto* app = static_cast<AppState*>(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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void(std::uint32_t)> 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<ViewportCore*>(ud1);
|
||||
std::uint32_t object_id = 0;
|
||||
if (status == WGPUMapAsyncStatus_Success) {
|
||||
const std::uint32_t* mapped = static_cast<const std::uint32_t*>(
|
||||
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<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;
|
||||
|
||||
@@ -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<void(std::uint32_t)> 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<void(std::uint32_t)> pick_async_cb_;
|
||||
#endif
|
||||
|
||||
// ---- Frame uniforms + selection bind ----------------------------------
|
||||
//
|
||||
|
||||
Reference in New Issue
Block a user