From a4f2075c3d3289b2195d13e451d4675032d61d6d Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Tue, 28 Jul 2026 15:48:16 +1000 Subject: [PATCH] ifcviewer: x-ray marquee selects through occluders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Box select resolved hits by reading the depth-tested object_id MRT, so only the front-most surface in each pixel could ever come back. In x-ray that is wrong twice over: you can see the geometry behind, and you still cannot select it. Add a second box-pick path used only while x-ray is active. It runs the same vs_pick geometry through fs_boxpick with depth compare Always, no depth write and no colour targets, scissored to the marquee — so nothing culls a fragment behind another and the pass's only output is an atomicOr of one bit per object into a hit bitmask. Reading that back gives every object with geometry inside the box, occluded or not. The bitmask rides alongside sel_flags at group(0) binding 2, allocated and bound by ensureSelectionFlagsBuffer so the two can never disagree about how many object ids exist. The layout entry is FRAGMENT-visible only: WebGPU forbids a read_write storage buffer in the vertex stage, and every pipeline shares this layout. Back-face culling is off for the pass — a box landing inside a closed solid would otherwise see none of its faces and miss it. Outside x-ray the depth-tested read stands, so a plain marquee still takes only what is visible. A failure to build the pipeline falls back to that path rather than breaking box select. Tests cover the three properties worth having: x-ray selects strictly more, its result is a superset of the plain one (a bare count would wave through a wrong scissor or an off-by-one in the bit decode), and turning x-ray off restores front-most-only. They need a model with real self-occlusion, which sidecar_bake cannot currently produce — it segfaults on any input, including the pristine sample.ifc — so they skip with an explanation until a fixture is supplied. See the note at the top of the spec. Co-Authored-By: Claude Opus 5 (1M context) --- src/ifcviewer-web/tests/xray-marquee.spec.mjs | 151 ++++++++++ src/ifcviewer/ViewportCore.cpp | 261 +++++++++++++++++- src/ifcviewer/ViewportCore.h | 36 +++ 3 files changed, 444 insertions(+), 4 deletions(-) create mode 100644 src/ifcviewer-web/tests/xray-marquee.spec.mjs diff --git a/src/ifcviewer-web/tests/xray-marquee.spec.mjs b/src/ifcviewer-web/tests/xray-marquee.spec.mjs new file mode 100644 index 0000000000..7fb9cd0a95 --- /dev/null +++ b/src/ifcviewer-web/tests/xray-marquee.spec.mjs @@ -0,0 +1,151 @@ +import { test, expect } from '@playwright/test'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// X-ray marquee must select THROUGH occluders (#xray-boxpick). +// +// Outside x-ray a box select reads the depth-tested object_id MRT, so only the +// front-most surface in each pixel can be returned. In x-ray the viewer runs a +// depth-less pass scissored to the marquee instead, ORing one bit per object +// into a bitmask — so anything with geometry inside the box is selected whether +// or not something sits in front of it. +// +// The embedded sample is three well-separated elements with barely any mutual +// occlusion, which cannot tell the two paths apart. These need a model whose +// elements genuinely stack behind each other, served as occluders.ifcview. +// +// That fixture is NOT committed. Authoring one needs sidecar_bake, which +// currently segfaults on any input including the pristine sample.ifc — so the +// suite skips rather than failing for a reason that has nothing to do with the +// feature. To run it, drop any multi-storey .ifcview into the serve directory +// as occluders.ifcview; once sidecar_bake works again this should become a +// purpose-built fixture (three stacked plates is enough) generated by +// make_sample.py and committed alongside sample.ifcview. + +const MODEL = '/IfcViewerWeb.html?model=/occluders.ifcview'; + +const SERVE_DIR = process.env.WEB_BUILD_DIR + ? path.resolve(process.env.WEB_BUILD_DIR) + : path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../build-web'); +const FIXTURE = path.join(SERVE_DIR, 'occluders.ifcview'); + +test.beforeAll(() => { + test.skip(!fs.existsSync(FIXTURE), + `no ${FIXTURE} — see the note at the top of this file for how to supply one`); +}); + +async function loadModel(page) { + const loaded = page.waitForEvent('console', { + predicate: (m) => /loaded sidecar \(source/.test(m.text()), + timeout: 30_000, + }); + await page.goto(MODEL); + await page.waitForFunction( + () => !!(window.Module && window.Module._app_ptr), null, { timeout: 30_000 }); + await loaded; + await page.waitForTimeout(1200); // stream chunks + settle +} + +// Drag the select button (Web preset: RMB) across the given canvas rect. +async function marquee(page, box, fx0, fy0, fx1, fy1) { + const px = (fx, fy) => [box.x + box.width * fx, box.y + box.height * fy]; + const [x0, y0] = px(fx0, fy0); + const [x1, y1] = px(fx1, fy1); + await page.mouse.move(x0, y0); + await page.mouse.down({ button: 'right' }); + await page.mouse.move((x0 + x1) / 2, (y0 + y1) / 2, { steps: 4 }); + await page.mouse.move(x1, y1, { steps: 6 }); + await page.mouse.up({ button: 'right' }); + await page.waitForTimeout(700); // async box-pick map + apply + // ifcv_get_selection_c returns the TOTAL, so (0, 0) is a pure count. + return page.evaluate(() => window.Module._ifcv_get_selection_c(0, 0)); +} + +const setXray = (page, on) => page.evaluate((want) => { + if ((window.Module._xray_is_active_c() !== 0) !== want) window.Module._toggle_xray_c(); + return window.Module._xray_is_active_c(); +}, on); + +test('x-ray marquee selects through occluders; plain marquee does not', async ({ page }) => { + const gpuErrors = []; + page.on('console', (m) => { + if (/Uncaptured WebGPU error|is invalid|Validation error/i.test(m.text())) + gpuErrors.push(m.text()); + }); + page.on('pageerror', (e) => gpuErrors.push('pageerror: ' + e.message)); + + await loadModel(page); + const box = await page.locator('#viewer-canvas').boundingBox(); + + // Same rect both times — the ONLY difference is x-ray. + const R = [0.3, 0.3, 0.7, 0.7]; + + expect(await setXray(page, false)).toBe(0); + const visibleOnly = await marquee(page, box, ...R); + + await page.evaluate(() => window.Module._ifcv_apply_selection_c(0, 0, 0)); + expect(await setXray(page, true)).toBe(1); + const throughAll = await marquee(page, box, ...R); + + expect(visibleOnly, 'plain marquee selected nothing — the rect missed the model') + .toBeGreaterThan(0); + expect(throughAll, + `x-ray marquee (${throughAll}) did not select more than the depth-tested one ` + + `(${visibleOnly}) — it is still taking only front-most surfaces`) + .toBeGreaterThan(visibleOnly); + + expect(gpuErrors, gpuErrors.join('\n')).toEqual([]); +}); + +test('x-ray marquee result is a superset of the plain one', async ({ page }) => { + // Every element a depth-tested marquee finds is visible, so selecting through + // must still include it. Catches a box-pick that returns a *different* set + // rather than a bigger one (wrong scissor, stale bits, off-by-one in the id + // decode) — all of which a bare count comparison would wave through. + await loadModel(page); + const box = await page.locator('#viewer-canvas').boundingBox(); + const R = [0.35, 0.35, 0.65, 0.65]; + + const ids = () => page.evaluate(() => { + const n = window.Module._ifcv_get_selection_c(0, 0); + if (!n) return []; + const ptr = window.Module._malloc(n * 4); + try { + window.Module._ifcv_get_selection_c(ptr, n); + return Array.from(window.Module.HEAPU32.subarray(ptr >>> 2, (ptr >>> 2) + n)); + } finally { window.Module._free(ptr); } + }); + + await setXray(page, false); + await marquee(page, box, ...R); + const plain = await ids(); + + await page.evaluate(() => window.Module._ifcv_apply_selection_c(0, 0, 0)); + await setXray(page, true); + await marquee(page, box, ...R); + const xray = new Set(await ids()); + + expect(plain.length).toBeGreaterThan(0); + const missing = plain.filter((id) => !xray.has(id)); + expect(missing, `x-ray marquee dropped ids the plain one found: ${missing.slice(0, 8)}`) + .toEqual([]); +}); + +test('leaving x-ray restores front-most-only box select', async ({ page }) => { + // The depth-tested path must not be left behind by the x-ray branch. + await loadModel(page); + const box = await page.locator('#viewer-canvas').boundingBox(); + const R = [0.3, 0.3, 0.7, 0.7]; + + await setXray(page, true); + const through = await marquee(page, box, ...R); + + await page.evaluate(() => window.Module._ifcv_apply_selection_c(0, 0, 0)); + expect(await setXray(page, false)).toBe(0); + const back = await marquee(page, box, ...R); + + expect(back).toBeGreaterThan(0); + expect(back, 'x-ray off still selected through — the branch is sticky') + .toBeLessThan(through); +}); diff --git a/src/ifcviewer/ViewportCore.cpp b/src/ifcviewer/ViewportCore.cpp index 4b94fe3f9f..6e54a414a3 100644 --- a/src/ifcviewer/ViewportCore.cpp +++ b/src/ifcviewer/ViewportCore.cpp @@ -820,6 +820,14 @@ struct PerModel { // because we cap the index by arrayLength before fetching. @group(0) @binding(1) var sel_flags: array; +// X-ray marquee select: one bit per object_id, set by fs_boxpick. That pass +// runs with depth testing off and the scissor clamped to the marquee rect, so +// every object with a fragment anywhere inside the box sets its bit whether it +// is occluded or not — which is the whole point of selecting through in x-ray. +// Only the box-pick pipeline writes it; the layout entry is FRAGMENT-visible +// only, because WebGPU forbids a read_write storage buffer in the vertex stage. +@group(0) @binding(2) var hit_flags: array>; + @group(1) @binding(0) var vertices: array; @group(1) @binding(1) var meshes: array; @group(1) @binding(2) var instances: array; @@ -1068,12 +1076,23 @@ fn fs_pick(in: VsOutPick) -> FsOutPick { out.world_pos = vec4(in.world_pos, 1.0); return out; } + +// X-ray marquee. No colour outputs and no depth write — the only result is the +// bit this sets, so every layer under the cursor is recorded rather than just +// the front-most fragment the depth test would leave standing. +@fragment +fn fs_boxpick(in: VsOutPick) { + if (is_section_clipped(in.world_pos)) { discard; } + let word = in.object_id >> 5u; + if (word >= arrayLength(&hit_flags)) { return; } + atomicOr(&hit_flags[word], 1u << (in.object_id & 31u)); +} )"; } // namespace bool ViewportCore::buildPipelines() { // ---- Bind group layouts ---------------------------------------------- - WGPUBindGroupLayoutEntry frame_entries[2] = {}; + WGPUBindGroupLayoutEntry frame_entries[3] = {}; frame_entries[0].binding = 0; frame_entries[0].visibility = WGPUShaderStage_Vertex | WGPUShaderStage_Fragment; frame_entries[0].buffer.type = WGPUBufferBindingType_Uniform; @@ -1081,9 +1100,14 @@ bool ViewportCore::buildPipelines() { frame_entries[1].binding = 1; frame_entries[1].visibility = WGPUShaderStage_Fragment; frame_entries[1].buffer.type = WGPUBufferBindingType_ReadOnlyStorage; + // X-ray marquee hit bits. Fragment-only: a read_write storage buffer is + // illegal in the vertex stage, and every pipeline shares this layout. + frame_entries[2].binding = 2; + frame_entries[2].visibility = WGPUShaderStage_Fragment; + frame_entries[2].buffer.type = WGPUBufferBindingType_Storage; WGPUBindGroupLayoutDescriptor frame_bgl_desc = {}; - frame_bgl_desc.entryCount = 2; + frame_bgl_desc.entryCount = 3; frame_bgl_desc.entries = frame_entries; frame_bgl_desc.label = svFromCStr("ifcviewer-wgpu.frame_bgl"); frame_bgl_ = wgpuDeviceCreateBindGroupLayout(device_, &frame_bgl_desc); @@ -1279,6 +1303,21 @@ void ViewportCore::ensureSelectionFlagsBuffer() { // Initialise to zero so any unused range reads as "not selected". // wgpuQueueWriteBuffer with a small zero block is enough; the rest // is created as zero-initialised by wgpu per the spec. + + // The x-ray marquee's hit bits ride the same capacity — one BIT per + // object where the flags take a word, so a thirty-second of the size. + // CopySrc because the box pick reads it back through a staging buffer. + if (hit_flags_buffer_) { + wgpuBufferRelease(hit_flags_buffer_); + hit_flags_buffer_ = nullptr; + } + hit_flags_words_ = (new_cap + 31u) / 32u; + WGPUBufferDescriptor hb = {}; + hb.size = uint64_t(hit_flags_words_) * sizeof(uint32_t); + hb.usage = WGPUBufferUsage_Storage | WGPUBufferUsage_CopyDst + | WGPUBufferUsage_CopySrc; + hb.label = svFromCStr("ifcviewer-wgpu.xray_hit_flags"); + hit_flags_buffer_ = wgpuDeviceCreateBuffer(device_, &hb); } // Rebuild the frame bind group against the (possibly new) buffer. @@ -1286,16 +1325,19 @@ void ViewportCore::ensureSelectionFlagsBuffer() { wgpuBindGroupRelease(frame_bind_group_); frame_bind_group_ = nullptr; } - WGPUBindGroupEntry fbg_entries[2] = {}; + WGPUBindGroupEntry fbg_entries[3] = {}; fbg_entries[0].binding = 0; fbg_entries[0].buffer = frame_uniform_buffer_; fbg_entries[0].size = sizeof(FrameUniforms); fbg_entries[1].binding = 1; fbg_entries[1].buffer = selection_flags_buffer_; fbg_entries[1].size = WGPU_WHOLE_SIZE; + fbg_entries[2].binding = 2; + fbg_entries[2].buffer = hit_flags_buffer_; + fbg_entries[2].size = WGPU_WHOLE_SIZE; WGPUBindGroupDescriptor fbg_desc = {}; fbg_desc.layout = frame_bgl_; - fbg_desc.entryCount = 2; + fbg_desc.entryCount = 3; fbg_desc.entries = fbg_entries; fbg_desc.label = svFromCStr("ifcviewer-wgpu.frame_bind_group"); frame_bind_group_ = wgpuDeviceCreateBindGroup(device_, &fbg_desc); @@ -1826,6 +1868,8 @@ void ViewportCore::shutdown() { if (frame_uniform_buffer_) { wgpuBufferRelease(frame_uniform_buffer_); frame_uniform_buffer_ = nullptr; } if (selection_flags_buffer_) { wgpuBufferRelease(selection_flags_buffer_); selection_flags_buffer_ = nullptr; } selection_flags_capacity_ = 0; + if (hit_flags_buffer_) { wgpuBufferRelease(hit_flags_buffer_); hit_flags_buffer_ = nullptr; } + hit_flags_words_ = 0; if (main_pipeline_) { wgpuRenderPipelineRelease(main_pipeline_); main_pipeline_ = nullptr; } if (main_pipeline_no_cull_) { wgpuRenderPipelineRelease(main_pipeline_no_cull_); main_pipeline_no_cull_ = nullptr; } if (main_pipeline_transparent_) { wgpuRenderPipelineRelease(main_pipeline_transparent_); main_pipeline_transparent_ = nullptr; } @@ -4921,6 +4965,53 @@ bool ViewportCore::buildPickPipeline() { Log::warn() << "wgpu pick pipeline creation failed"; return false; } + // The x-ray marquee variant rides along so no caller has to know about it. + // A failure here is not fatal: picksInRect falls back to the depth-tested + // read, which is the pre-x-ray behaviour rather than a broken viewport. + buildBoxPickPipeline(); + return true; +} + +bool ViewportCore::buildBoxPickPipeline() { + // No colour targets: fs_boxpick's only output is the atomic bit it sets, so + // the pass writes no image at all. A render pass still needs one attachment + // — the pick depth view serves, bound read-only. + WGPUFragmentState frag = {}; + frag.module = main_shader_module_; + frag.entryPoint = svFromCStr("fs_boxpick"); + frag.targetCount = 0; + frag.targets = nullptr; + + // Always/no-write is the whole trick: every fragment survives, so an + // occluded object records its bit just as a front-most one does. + WGPUDepthStencilState depth = {}; + depth.format = WGPUTextureFormat_Depth32Float; + depth.depthWriteEnabled = WGPUOptionalBool_False; + depth.depthCompare = WGPUCompareFunction_Always; + depth.stencilFront.compare = WGPUCompareFunction_Always; + depth.stencilBack.compare = WGPUCompareFunction_Always; + + WGPURenderPipelineDescriptor rp_desc = {}; + rp_desc.layout = pipeline_layout_; + rp_desc.label = svFromCStr("ifcviewer-wgpu.box_pick_pipeline"); + rp_desc.vertex.module = main_shader_module_; + rp_desc.vertex.entryPoint = svFromCStr("vs_pick"); + rp_desc.vertex.bufferCount = 0; + rp_desc.fragment = &frag; + rp_desc.depthStencil = &depth; + rp_desc.primitive.topology = WGPUPrimitiveTopology_TriangleList; + // No back-face cull. A box that lands inside a closed solid would otherwise + // see none of its faces and miss the object entirely. + rp_desc.primitive.cullMode = WGPUCullMode_None; + rp_desc.primitive.frontFace = WGPUFrontFace_CCW; + rp_desc.multisample.count = 1; + rp_desc.multisample.mask = 0xFFFFFFFFu; + + box_pick_pipeline_ = wgpuDeviceCreateRenderPipeline(device_, &rp_desc); + if (!box_pick_pipeline_) { + Log::warn() << "wgpu box-pick pipeline creation failed"; + return false; + } return true; } @@ -5026,11 +5117,17 @@ void ViewportCore::releasePickResources() { pick_position_staging_buffer_ = nullptr; } if (pick_pipeline_) { wgpuRenderPipelineRelease(pick_pipeline_); pick_pipeline_ = nullptr; } + if (box_pick_pipeline_) { wgpuRenderPipelineRelease(box_pick_pipeline_); box_pick_pipeline_ = nullptr; } if (box_pick_staging_buffer_) { wgpuBufferRelease(box_pick_staging_buffer_); box_pick_staging_buffer_ = nullptr; } box_pick_staging_capacity_ = 0; + if (hit_flags_staging_buffer_) { + wgpuBufferRelease(hit_flags_staging_buffer_); + hit_flags_staging_buffer_ = nullptr; + } + hit_flags_staging_capacity_ = 0; pick_w_ = pick_h_ = 0; } @@ -5544,7 +5641,131 @@ std::vector ViewportCore::collectMappedBoxPickIds( return out; } +bool ViewportCore::encodeXrayBoxPickToStaging(int& x, int& y, int& w, int& h, + std::uint64_t& needed_bytes_out) { + if (w <= 0 || h <= 0) return false; + if (!box_pick_pipeline_ || !device_ || !queue_ || models_gpu_.empty()) return false; + if (!hit_flags_buffer_ || hit_flags_words_ == 0) 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 false; + + ensurePickAttachments(configured_w_, configured_h_); + if (!pick_depth_view_) return false; + + const std::uint64_t needed_bytes = std::uint64_t(hit_flags_words_) * sizeof(std::uint32_t); + if (needed_bytes > hit_flags_staging_capacity_) { + if (hit_flags_staging_buffer_) { + wgpuBufferRelease(hit_flags_staging_buffer_); + hit_flags_staging_buffer_ = nullptr; + } + const std::uint64_t cap = std::max(needed_bytes * 2, 4 * 1024); + WGPUBufferDescriptor sb = {}; + sb.size = cap; + sb.usage = WGPUBufferUsage_CopyDst | WGPUBufferUsage_MapRead; + sb.label = svFromCStr("ifcviewer-wgpu.hit_flags_staging"); + hit_flags_staging_buffer_ = wgpuDeviceCreateBuffer(device_, &sb); + hit_flags_staging_capacity_ = cap; + } + if (!hit_flags_staging_buffer_) return false; + + WGPUCommandEncoder enc = wgpuDeviceCreateCommandEncoder(device_, nullptr); + + // Every pick starts from no hits; the bits are pure output. + wgpuCommandEncoderClearBuffer(enc, hit_flags_buffer_, 0, needed_bytes); + + // Depth is bound read-only and never compared (the pipeline is Always), so + // whatever the last pass left in it is irrelevant. + WGPURenderPassDepthStencilAttachment depth = {}; + depth.view = pick_depth_view_; + depth.depthLoadOp = WGPULoadOp_Undefined; + depth.depthStoreOp = WGPUStoreOp_Undefined; + depth.depthReadOnly = true; + depth.stencilLoadOp = WGPULoadOp_Undefined; + depth.stencilStoreOp = WGPUStoreOp_Undefined; + depth.stencilReadOnly = true; + + WGPURenderPassDescriptor pass_desc = {}; + pass_desc.colorAttachmentCount = 0; + pass_desc.colorAttachments = nullptr; + pass_desc.depthStencilAttachment = &depth; + pass_desc.label = svFromCStr("ifcviewer-wgpu.xray_box_pick_pass"); + + WGPURenderPassEncoder pass = wgpuCommandEncoderBeginRenderPass(enc, &pass_desc); + // The scissor is what makes this a BOX pick: geometry is drawn full-screen + // as usual, and only fragments landing in the marquee survive to set a bit. + wgpuRenderPassEncoderSetScissorRect(pass, std::uint32_t(x), std::uint32_t(y), + std::uint32_t(w), std::uint32_t(h)); + wgpuRenderPassEncoderSetPipeline(pass, box_pick_pipeline_); + wgpuRenderPassEncoderSetBindGroup(pass, 0, frame_bind_group_, 0, nullptr); + for (const auto& [session_model_id, m] : models_gpu_) { + if (m.hidden) continue; + for (const auto& c : m.chunks) { + if (!c.bind_group || c.total_visible_vertices == 0) continue; + wgpuRenderPassEncoderSetBindGroup(pass, 1, c.bind_group, 0, nullptr); + wgpuRenderPassEncoderDraw(pass, c.total_visible_vertices, 1, 0, 0); + } + } + wgpuRenderPassEncoderEnd(pass); + wgpuRenderPassEncoderRelease(pass); + + wgpuCommandEncoderCopyBufferToBuffer(enc, hit_flags_buffer_, 0, + hit_flags_staging_buffer_, 0, needed_bytes); + + WGPUCommandBuffer cmd = wgpuCommandEncoderFinish(enc, nullptr); + wgpuQueueSubmit(queue_, 1, &cmd); + wgpuCommandBufferRelease(cmd); + wgpuCommandEncoderRelease(enc); + + needed_bytes_out = needed_bytes; + return true; +} + +std::vector ViewportCore::collectMappedXrayHitIds(std::uint64_t needed_bytes) { + std::vector out; + const std::uint32_t* words = static_cast( + wgpuBufferGetConstMappedRange(hit_flags_staging_buffer_, 0, needed_bytes)); + if (words) { + const std::size_t n = std::size_t(needed_bytes / sizeof(std::uint32_t)); + for (std::size_t i = 0; i < n; ++i) { + const std::uint32_t bits = words[i]; + if (!bits) continue; // the overwhelmingly common case + for (std::uint32_t b = 0; b < 32u; ++b) { + if (!(bits & (1u << b))) continue; + const std::uint32_t id = std::uint32_t(i) * 32u + b; + if (id != 0) out.push_back(id); // 0 is the "no object" sentinel + } + } + } + wgpuBufferUnmap(hit_flags_staging_buffer_); + return out; +} + std::vector ViewportCore::picksInRect(int x, int y, int w, int h) { + // X-ray: select through, via the depth-less bitmask pass. + if (xrayActive() && box_pick_pipeline_) { + std::uint64_t hit_bytes = 0; + if (!encodeXrayBoxPickToStaging(x, y, w, h, hit_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(hit_flags_staging_buffer_, WGPUMapMode_Read, 0, hit_bytes, mcb); + while (!req.done) waitTickInstance(instance_); + if (!req.ok) return {}; + return collectMappedXrayHitIds(hit_bytes); + } + std::uint64_t padded_bpr = 0, needed_bytes = 0; if (!encodeBoxPickToStaging(x, y, w, h, padded_bpr, needed_bytes)) return {}; @@ -5570,6 +5791,38 @@ 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; } + + // X-ray: select through. Same spontaneous-map dance, but the mapped buffer + // is a per-object bitmask rather than a rect of the object_id image, so the + // rect dims the other callback walks are not needed here. + if (xrayActive() && box_pick_pipeline_) { + std::uint64_t hit_bytes = 0; + if (!encodeXrayBoxPickToStaging(x, y, w, h, hit_bytes)) { miss(); return; } + box_pick_async_bytes_ = hit_bytes; + box_pick_async_xray_ = true; + box_pick_async_in_flight_ = true; + box_pick_async_cb_ = std::move(cb); + + WGPUBufferMapCallbackInfo xcb = {}; + xcb.mode = kAsyncCbMode; + xcb.callback = [](WGPUMapAsyncStatus status, WGPUStringView /*msg*/, + void* ud1, void* /*ud2*/) { + auto* self = static_cast(ud1); + std::vector ids; + if (status == WGPUMapAsyncStatus_Success) { + ids = self->collectMappedXrayHitIds(self->box_pick_async_bytes_); + } + auto done = std::move(self->box_pick_async_cb_); + self->box_pick_async_cb_ = nullptr; + self->box_pick_async_in_flight_ = false; + self->box_pick_async_xray_ = false; + if (done) done(std::move(ids)); + }; + xcb.userdata1 = this; + wgpuBufferMapAsync(hit_flags_staging_buffer_, WGPUMapMode_Read, 0, hit_bytes, xcb); + return; + } + std::uint64_t padded_bpr = 0, needed_bytes = 0; if (!encodeBoxPickToStaging(x, y, w, h, padded_bpr, needed_bytes)) { miss(); return; } diff --git a/src/ifcviewer/ViewportCore.h b/src/ifcviewer/ViewportCore.h index 5f6ffda6f6..72e39c4209 100644 --- a/src/ifcviewer/ViewportCore.h +++ b/src/ifcviewer/ViewportCore.h @@ -734,6 +734,21 @@ public: int w, int h, std::uint64_t needed_bytes); + // Build the x-ray box-pick pipeline: vs_pick + fs_boxpick, depth compare + // Always with no depth write and no colour targets, so nothing culls a + // fragment behind another and the pass's only output is the hit bitmask. + bool buildBoxPickPipeline(); + + // Encode the depth-less box-pick pass scissored to (x,y,w,h): zero the hit + // bits, draw every visible chunk, copy the bitmask into + // hit_flags_staging_buffer_ and submit. Clamps the rect in place and reports + // the byte count to map. False if nothing is pickable or the rect is empty. + bool encodeXrayBoxPickToStaging(int& x, int& y, int& w, int& h, + std::uint64_t& needed_bytes_out); + // Read the (already-mapped) hit bitmask → the object ids whose bit is set. + // Unmaps before returning. + std::vector collectMappedXrayHitIds(std::uint64_t needed_bytes); + // CPU half of pickSurfaceAt: cast the pixel's world ray against every // instance carrying `object_id`, returning the closest hit's world pos, // normal (mrt_normal if non-degenerate, else the AABB-face normal), and the @@ -827,6 +842,11 @@ 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) — desktop only path. + // + // In x-ray this instead runs the depth-less box-pick pass (see + // encodeXrayBoxPickToStaging), so the marquee selects THROUGH occluders — + // matching what x-ray already lets you see. Outside x-ray the depth-tested + // MRT read stands, so a marquee still takes only what is actually visible. std::vector picksInRect(int x, int y, int w, int h); #if defined(__EMSCRIPTEN__) @@ -1106,6 +1126,11 @@ private: int pick_h_ = 0; WGPUBuffer box_pick_staging_buffer_ = nullptr; std::uint64_t box_pick_staging_capacity_ = 0; + // Readback target for the x-ray marquee's hit bits. Separate from the + // rect staging buffer above: that one holds an image, this one a bitmask. + WGPUBuffer hit_flags_staging_buffer_ = nullptr; + std::uint64_t hit_flags_staging_capacity_ = 0; + WGPURenderPipeline box_pick_pipeline_ = nullptr; #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. @@ -1119,6 +1144,10 @@ private: int box_pick_async_h_ = 0; std::uint64_t box_pick_async_padded_bpr_ = 0; std::uint64_t box_pick_async_bytes_ = 0; + // Which staging buffer the in-flight map belongs to: the x-ray bitmask or + // the object_id rect. Both share box_pick_async_in_flight_ so only one box + // pick can be outstanding either way. + bool box_pick_async_xray_ = false; // Async surface pick (section tool): chained id→normal staging maps. Reuses // pick_async_in_flight_ (same staging buffers as the single object pick). std::function surface_async_cb_; @@ -1143,6 +1172,13 @@ private: uint32_t selection_flags_capacity_ = 0; // u32 entries std::vector selection_flags_scratch_; + // X-ray marquee hit bits: one bit per object_id, written by fs_boxpick and + // read back to decide the selection. Allocated and bound alongside the + // selection flags (ensureSelectionFlagsBuffer) so the two never disagree + // about how many object ids exist. + WGPUBuffer hit_flags_buffer_ = nullptr; + uint32_t hit_flags_words_ = 0; // u32 words = ceil(capacity / 32) + // Active world-space section planes (up to kMaxSectionPlanes); packed // into the per-frame uniform every render and consumed by the WGSL // is_section_clipped fragment gate. The section tool in