mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-08-05 23:41:44 +00:00
ifcviewer: x-ray marquee selects through occluders
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
});
|
||||
@@ -820,6 +820,14 @@ struct PerModel {
|
||||
// because we cap the index by arrayLength before fetching.
|
||||
@group(0) @binding(1) var<storage, read> sel_flags: array<u32>;
|
||||
|
||||
// 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<storage, read_write> hit_flags: array<atomic<u32>>;
|
||||
|
||||
@group(1) @binding(0) var<storage, read> vertices: array<u32>;
|
||||
@group(1) @binding(1) var<storage, read> meshes: array<MeshQuant>;
|
||||
@group(1) @binding(2) var<storage, read> instances: array<InstanceRecord>;
|
||||
@@ -1068,12 +1076,23 @@ fn fs_pick(in: VsOutPick) -> FsOutPick {
|
||||
out.world_pos = vec4<f32>(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<std::uint32_t> 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<std::uint64_t>(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<std::uint32_t> ViewportCore::collectMappedXrayHitIds(std::uint64_t needed_bytes) {
|
||||
std::vector<std::uint32_t> out;
|
||||
const std::uint32_t* words = static_cast<const std::uint32_t*>(
|
||||
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<std::uint32_t> 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<MapReq*>(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<void(std::vector<std::uint32_t>)> 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<ViewportCore*>(ud1);
|
||||
std::vector<std::uint32_t> 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; }
|
||||
|
||||
|
||||
@@ -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<std::uint32_t> 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<std::uint32_t> 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<void(SurfaceHit)> surface_async_cb_;
|
||||
@@ -1143,6 +1172,13 @@ private:
|
||||
uint32_t selection_flags_capacity_ = 0; // u32 entries
|
||||
std::vector<uint32_t> 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
|
||||
|
||||
Reference in New Issue
Block a user