wgpu: marquee box-select (drag rect + Shift/Ctrl set ops)

Drag LMB in empty space to select every visible object whose pick-pixel
falls inside the rect. Mirrors GL ViewportWindow's marquee.

UI flow

  - LMB press in non-tool-consuming context arms the marquee. The
    cursor must move past kBoxSelectThresholdPx (5 logical) for it to
    become active — until then a release falls through to single-pick,
    so an unintentional micro-drag still picks under the cursor.
  - Press-time modifiers decide the set op so a mid-drag Shift release
    doesn't flip behaviour:
      plain  → selection.clear() then add every picked id
      Shift  → add to current selection
      Ctrl   → remove from current selection
  - Section tool intercepts plain LMB first (already wired); the
    marquee is mutually exclusive with it.

Rectangle pick

picksInRect(x, y, w, h):
  - Render the existing pick pass (R32UInt object_id + RGBA16F normal
    MRT) into the persistent pick attachments.
  - copyTextureToBuffer the rect region of pick_color_texture_ into
    box_pick_staging_buffer_ (regrown 2× on demand to fit the
    largest rect we've seen). R32UInt is a color format so partial
    sub-rect copies are allowed (unlike Depth32Float).
  - Iterate the mapped staging buffer, accumulate unique non-zero ids
    into an unordered_set, return.

Visual rect

A new marquee overlay pipeline draws the drag rect on the resolved
surface after the corner gizmo. Two passes per active frame share one
uniform buffer / bind group:

  fill    — 6-vert unit quad, vs_fill maps (0,1)² to NDC via
            rect_min/rect_max, fs_fill outputs color × fill_alpha
  outline — 24-vert thick-line quad (4 segs × 6 verts), uses the
            shared thick_line_clip + fs_main from THICK_LINE_HELPERS_WGSL
            so the rect outline has analytical AA without MSAA

Colour: Bonsai decorator_color_special (0.157, 0.565, 1.000) for the
axis-blue parity the user requested; outline alpha 0.95, fill alpha
0.20 of that so geometry behind the rect still reads.

Closes #41 and #61.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Dion Moult
2026-05-30 16:46:30 +10:00
parent 0ed355154a
commit 07913e2832
2 changed files with 570 additions and 0 deletions
+532
View File
@@ -1616,6 +1616,7 @@ bool WgpuViewportWindow::initWgpu() {
if (!buildEdgePipeline()) return false;
if (!buildAxisIndicator()) return false;
if (!buildSectionVisualizer()) return false;
if (!buildMarquee()) return false;
if (!buildPickPipeline()) return false;
qInfo() << "wgpu init OK; surface format =" << int(surface_format_);
@@ -2647,6 +2648,318 @@ void WgpuViewportWindow::setPivotIndicatorVisible(bool visible, int hide_after_m
requestUpdate();
}
// -----------------------------------------------------------------------------
// Marquee overlay (drag-rect for box-select)
// -----------------------------------------------------------------------------
//
// 4-segment unit-quad VBO; the vertex shader maps (0..1)² unit coords to NDC
// via the per-frame uniform (rect.min, rect.max in NDC, both in [-1, 1]
// with +y up). Reuses thick_line_clip / fs_main from the shared header so
// the rect has analytical AA and reads cleanly against any background.
static const char* MARQUEE_WGSL = THICK_LINE_HELPERS_WGSL R"WGSL(
struct MarqueeUniforms {
rect_min: vec2<f32>,
rect_max: vec2<f32>,
color: vec4<f32>,
viewport_size: vec2<f32>,
line_width_px: f32,
fill_alpha: f32, // multiplier on color.a for the translucent fill pass
};
@group(0) @binding(0) var<uniform> u: MarqueeUniforms;
// Outline (thick line + AA).
@vertex
fn vs_main(@location(0) start_uv: vec2<f32>,
@location(1) end_uv: vec2<f32>,
@location(2) t: f32,
@location(3) side: f32) -> VsOut {
let p_start = vec4<f32>(mix(u.rect_min, u.rect_max, start_uv), 0.0, 1.0);
let p_end = vec4<f32>(mix(u.rect_min, u.rect_max, end_uv), 0.0, 1.0);
var out: VsOut;
out.clip_pos = thick_line_clip(p_start, p_end, t, side,
u.viewport_size, u.line_width_px);
out.color = u.color;
out.side_t = side;
return out;
}
// Fill (flat translucent quad). Drawn before the outline so the outline
// renders on top with full opacity.
struct VsFillOut {
@builtin(position) clip_pos: vec4<f32>,
};
@vertex
fn vs_fill(@location(0) pos_uv: vec2<f32>) -> VsFillOut {
var out: VsFillOut;
let p = mix(u.rect_min, u.rect_max, pos_uv);
out.clip_pos = vec4<f32>(p, 0.0, 1.0);
return out;
}
@fragment
fn fs_fill() -> @location(0) vec4<f32> {
return vec4<f32>(u.color.xyz, u.color.w * u.fill_alpha);
}
)WGSL";
bool WgpuViewportWindow::buildMarquee() {
// VBO: 4 line segments (top/right/bottom/left of the unit quad),
// each expanded into a 6-vertex thick-line. Per vertex (6 floats =
// 24 bytes): start_uv(vec2) + end_uv(vec2) + t(f32) + side(f32).
struct Seg { std::array<float, 2> s, e; };
static const Seg segs[] = {
{ {0, 0}, {1, 0} }, // top
{ {1, 0}, {1, 1} }, // right
{ {1, 1}, {0, 1} }, // bottom
{ {0, 1}, {0, 0} }, // left
};
std::vector<float> verts;
verts.reserve(std::size(segs) * 6 * 6);
auto push_v = [&](const Seg& s, float t, float side) {
verts.insert(verts.end(), { s.s[0], s.s[1], s.e[0], s.e[1], t, side });
};
for (const auto& s : segs) {
push_v(s, 0.f, -1.f); push_v(s, 0.f, +1.f); push_v(s, 1.f, -1.f);
push_v(s, 1.f, -1.f); push_v(s, 0.f, +1.f); push_v(s, 1.f, +1.f);
}
{
WGPUBufferDescriptor bdesc = {};
bdesc.usage = WGPUBufferUsage_Vertex | WGPUBufferUsage_CopyDst;
bdesc.size = verts.size() * sizeof(float);
bdesc.label = svFromCStr("ifcviewer-wgpu.marquee_vbo");
marquee_vertex_buffer_ = wgpuDeviceCreateBuffer(device_, &bdesc);
wgpuQueueWriteBuffer(queue_, marquee_vertex_buffer_, 0,
verts.data(), verts.size() * sizeof(float));
}
// Fill VBO: 6 verts of a unit-quad triangle list.
static const float fill_verts[] = {
0, 0, 1, 0, 1, 1,
0, 0, 1, 1, 0, 1,
};
{
WGPUBufferDescriptor bdesc = {};
bdesc.usage = WGPUBufferUsage_Vertex | WGPUBufferUsage_CopyDst;
bdesc.size = sizeof(fill_verts);
bdesc.label = svFromCStr("ifcviewer-wgpu.marquee_fill_vbo");
marquee_fill_vertex_buffer_ = wgpuDeviceCreateBuffer(device_, &bdesc);
wgpuQueueWriteBuffer(queue_, marquee_fill_vertex_buffer_, 0,
fill_verts, sizeof(fill_verts));
}
{
WGPUBufferDescriptor bdesc = {};
bdesc.usage = WGPUBufferUsage_Uniform | WGPUBufferUsage_CopyDst;
bdesc.size = 64; // vec2 + vec2 + vec4 + vec2 + f32 + f32 = 48 B + pad
bdesc.label = svFromCStr("ifcviewer-wgpu.marquee_uniforms");
marquee_uniform_buffer_ = wgpuDeviceCreateBuffer(device_, &bdesc);
}
{
WGPUBindGroupLayoutEntry entry = {};
entry.binding = 0;
entry.visibility = WGPUShaderStage_Vertex | WGPUShaderStage_Fragment;
entry.buffer.type = WGPUBufferBindingType_Uniform;
entry.buffer.hasDynamicOffset = 0;
entry.buffer.minBindingSize = 48;
WGPUBindGroupLayoutDescriptor bgl_desc = {};
bgl_desc.entryCount = 1;
bgl_desc.entries = &entry;
bgl_desc.label = svFromCStr("ifcviewer-wgpu.marquee_bgl");
marquee_bgl_ = wgpuDeviceCreateBindGroupLayout(device_, &bgl_desc);
}
{
WGPUPipelineLayoutDescriptor pl_desc = {};
pl_desc.bindGroupLayoutCount = 1;
pl_desc.bindGroupLayouts = &marquee_bgl_;
pl_desc.label = svFromCStr("ifcviewer-wgpu.marquee_pipeline_layout");
marquee_pipeline_layout_ = wgpuDeviceCreatePipelineLayout(device_, &pl_desc);
}
{
WGPUBindGroupEntry entry = {};
entry.binding = 0;
entry.buffer = marquee_uniform_buffer_;
entry.offset = 0;
entry.size = 64;
WGPUBindGroupDescriptor bg_desc = {};
bg_desc.layout = marquee_bgl_;
bg_desc.entryCount = 1;
bg_desc.entries = &entry;
bg_desc.label = svFromCStr("ifcviewer-wgpu.marquee_bind_group");
marquee_bind_group_ = wgpuDeviceCreateBindGroup(device_, &bg_desc);
}
{
WGPUShaderSourceWGSL wgsl_src = {};
wgsl_src.chain.sType = WGPUSType_ShaderSourceWGSL;
wgsl_src.code = svFromCStr(MARQUEE_WGSL);
WGPUShaderModuleDescriptor sm_desc = {};
sm_desc.nextInChain = &wgsl_src.chain;
sm_desc.label = svFromCStr("ifcviewer-wgpu.marquee_wgsl");
marquee_shader_module_ = wgpuDeviceCreateShaderModule(device_, &sm_desc);
}
// Vertex layout: start_uv(vec2) + end_uv(vec2) + t(f32) + side(f32),
// stride 24.
WGPUVertexAttribute attribs[4] = {};
attribs[0].format = WGPUVertexFormat_Float32x2; attribs[0].offset = 0; attribs[0].shaderLocation = 0;
attribs[1].format = WGPUVertexFormat_Float32x2; attribs[1].offset = 8; attribs[1].shaderLocation = 1;
attribs[2].format = WGPUVertexFormat_Float32; attribs[2].offset = 16; attribs[2].shaderLocation = 2;
attribs[3].format = WGPUVertexFormat_Float32; attribs[3].offset = 20; attribs[3].shaderLocation = 3;
WGPUVertexBufferLayout vbl = {};
vbl.arrayStride = 24;
vbl.stepMode = WGPUVertexStepMode_Vertex;
vbl.attributeCount = 4;
vbl.attributes = attribs;
WGPUBlendState blend = {};
blend.color.srcFactor = WGPUBlendFactor_SrcAlpha;
blend.color.dstFactor = WGPUBlendFactor_OneMinusSrcAlpha;
blend.color.operation = WGPUBlendOperation_Add;
blend.alpha.srcFactor = WGPUBlendFactor_One;
blend.alpha.dstFactor = WGPUBlendFactor_OneMinusSrcAlpha;
blend.alpha.operation = WGPUBlendOperation_Add;
WGPUColorTargetState ct = {};
ct.format = surface_format_;
ct.blend = &blend;
ct.writeMask = WGPUColorWriteMask_All;
WGPUFragmentState frag = {};
frag.module = marquee_shader_module_;
frag.entryPoint = svFromCStr("fs_main");
frag.targetCount = 1;
frag.targets = &ct;
WGPURenderPipelineDescriptor rp_desc = {};
rp_desc.layout = marquee_pipeline_layout_;
rp_desc.label = svFromCStr("ifcviewer-wgpu.marquee_pipeline");
rp_desc.vertex.module = marquee_shader_module_;
rp_desc.vertex.entryPoint = svFromCStr("vs_main");
rp_desc.vertex.bufferCount = 1;
rp_desc.vertex.buffers = &vbl;
rp_desc.fragment = &frag;
rp_desc.primitive.topology = WGPUPrimitiveTopology_TriangleList;
rp_desc.primitive.cullMode = WGPUCullMode_None;
rp_desc.multisample.count = 1;
rp_desc.multisample.mask = 0xFFFFFFFFu;
marquee_pipeline_ = wgpuDeviceCreateRenderPipeline(device_, &rp_desc);
// ---- Fill pipeline (translucent quad, vs_fill / fs_fill) ----
WGPUVertexAttribute fill_attribs[1] = {};
fill_attribs[0].format = WGPUVertexFormat_Float32x2; // pos_uv
fill_attribs[0].offset = 0;
fill_attribs[0].shaderLocation = 0;
WGPUVertexBufferLayout fill_vbl = {};
fill_vbl.arrayStride = 8;
fill_vbl.stepMode = WGPUVertexStepMode_Vertex;
fill_vbl.attributeCount = 1;
fill_vbl.attributes = fill_attribs;
WGPUFragmentState fill_frag = {};
fill_frag.module = marquee_shader_module_;
fill_frag.entryPoint = svFromCStr("fs_fill");
fill_frag.targetCount = 1;
fill_frag.targets = &ct;
WGPURenderPipelineDescriptor fill_rp_desc = rp_desc;
fill_rp_desc.label = svFromCStr("ifcviewer-wgpu.marquee_fill_pipeline");
fill_rp_desc.vertex.entryPoint = svFromCStr("vs_fill");
fill_rp_desc.vertex.bufferCount = 1;
fill_rp_desc.vertex.buffers = &fill_vbl;
fill_rp_desc.fragment = &fill_frag;
marquee_fill_pipeline_ = wgpuDeviceCreateRenderPipeline(device_, &fill_rp_desc);
return marquee_pipeline_ != nullptr && marquee_fill_pipeline_ != nullptr;
}
void WgpuViewportWindow::encodeMarquee(WGPUCommandEncoder enc,
WGPUTextureView surface_view) {
if (!marquee_pipeline_ || !surface_view) return;
if (!box_select_active_) return;
if (configured_w_ <= 0 || configured_h_ <= 0) return;
// Logical-pixel rect → NDC. Qt mouse y goes top→bottom; NDC y goes
// bottom→top, hence the (1 - 2y/h) flip.
const float w = float(configured_w_);
const float h = float(configured_h_);
const float dpr = float(std::max(1, int(devicePixelRatio())));
const float lx0 = float(std::min(box_select_start_pos_.x(),
box_select_current_pos_.x())) * dpr;
const float ly0 = float(std::min(box_select_start_pos_.y(),
box_select_current_pos_.y())) * dpr;
const float lx1 = float(std::max(box_select_start_pos_.x(),
box_select_current_pos_.x())) * dpr;
const float ly1 = float(std::max(box_select_start_pos_.y(),
box_select_current_pos_.y())) * dpr;
if (lx1 <= lx0 || ly1 <= ly0) return;
const float nx0 = (lx0 / w) * 2.0f - 1.0f;
const float nx1 = (lx1 / w) * 2.0f - 1.0f;
// y: pixel 0 is top, so NDC y = 1 - 2*py/h
const float ny_top = 1.0f - 2.0f * ly0 / h;
const float ny_bottom = 1.0f - 2.0f * ly1 / h;
// The vs shader expects rect_max y > rect_min y in NDC; the rect_uv
// y = 0 maps to rect_min y. Top corner gets uv.y = 0, bottom gets
// uv.y = 1. So rect_min.y = ny_top, rect_max.y = ny_bottom.
// Bonsai decorator_color_special (axis +Z blue): 0.157, 0.565, 1.000.
// Outline alpha 0.95; fill_alpha (multiplied onto that) gives ~0.15
// alpha in the fill so the underlying scene reads through.
float uniforms[16] = {};
uniforms[0] = nx0; uniforms[1] = ny_top; // rect_min @ 0
uniforms[2] = nx1; uniforms[3] = ny_bottom; // rect_max @ 8
uniforms[4] = 0.157f; uniforms[5] = 0.565f; // color.rg @ 16
uniforms[6] = 1.000f; uniforms[7] = 0.95f; // color.ba
uniforms[8] = w; uniforms[9] = h; // viewport_size @ 32
uniforms[10] = 3.0f * dpr; // line_width_px @ 40
uniforms[11] = 0.20f; // fill_alpha @ 44
wgpuQueueWriteBuffer(queue_, marquee_uniform_buffer_, 0,
uniforms, 12 * sizeof(float));
WGPURenderPassColorAttachment color = {};
color.view = surface_view;
color.loadOp = WGPULoadOp_Load;
color.storeOp = WGPUStoreOp_Store;
color.clearValue = { 0, 0, 0, 1 };
color.depthSlice = WGPU_DEPTH_SLICE_UNDEFINED;
WGPURenderPassDescriptor pass_desc = {};
pass_desc.colorAttachmentCount = 1;
pass_desc.colorAttachments = &color;
pass_desc.label = svFromCStr("ifcviewer-wgpu.marquee_pass");
WGPURenderPassEncoder pass = wgpuCommandEncoderBeginRenderPass(enc, &pass_desc);
wgpuRenderPassEncoderSetBindGroup(pass, 0, marquee_bind_group_, 0, nullptr);
// Pass 1: translucent fill (underneath the outline).
wgpuRenderPassEncoderSetPipeline(pass, marquee_fill_pipeline_);
wgpuRenderPassEncoderSetVertexBuffer(pass, 0, marquee_fill_vertex_buffer_,
0, WGPU_WHOLE_SIZE);
wgpuRenderPassEncoderDraw(pass, 6, 1, 0, 0);
// Pass 2: thick-line outline.
wgpuRenderPassEncoderSetPipeline(pass, marquee_pipeline_);
wgpuRenderPassEncoderSetVertexBuffer(pass, 0, marquee_vertex_buffer_, 0,
WGPU_WHOLE_SIZE);
wgpuRenderPassEncoderDraw(pass, 24, 1, 0, 0);
wgpuRenderPassEncoderEnd(pass);
wgpuRenderPassEncoderRelease(pass);
}
void WgpuViewportWindow::releaseMarquee() {
if (marquee_bind_group_) { wgpuBindGroupRelease(marquee_bind_group_); marquee_bind_group_ = nullptr; }
if (marquee_pipeline_) { wgpuRenderPipelineRelease(marquee_pipeline_); marquee_pipeline_ = nullptr; }
if (marquee_fill_pipeline_) { wgpuRenderPipelineRelease(marquee_fill_pipeline_); marquee_fill_pipeline_ = nullptr; }
if (marquee_shader_module_) { wgpuShaderModuleRelease(marquee_shader_module_); marquee_shader_module_ = nullptr; }
if (marquee_pipeline_layout_) { wgpuPipelineLayoutRelease(marquee_pipeline_layout_); marquee_pipeline_layout_ = nullptr; }
if (marquee_bgl_) { wgpuBindGroupLayoutRelease(marquee_bgl_); marquee_bgl_ = nullptr; }
if (marquee_uniform_buffer_) { wgpuBufferRelease(marquee_uniform_buffer_); marquee_uniform_buffer_ = nullptr; }
if (marquee_vertex_buffer_) { wgpuBufferRelease(marquee_vertex_buffer_); marquee_vertex_buffer_ = nullptr; }
if (marquee_fill_vertex_buffer_) { wgpuBufferRelease(marquee_fill_vertex_buffer_); marquee_fill_vertex_buffer_ = nullptr; }
if (box_pick_staging_buffer_) { wgpuBufferRelease(box_pick_staging_buffer_); box_pick_staging_buffer_ = nullptr; }
box_pick_staging_capacity_ = 0;
}
// -----------------------------------------------------------------------------
// Section plane visualisation
// -----------------------------------------------------------------------------
@@ -3334,6 +3647,150 @@ static bool rayAABBHit(const QVector3D& origin, const QVector3D& dir,
return true;
}
std::vector<uint32_t> WgpuViewportWindow::picksInRect(int x, int y, int w, int h) {
std::vector<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;
// Clip to framebuffer.
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;
ensurePickAttachments(configured_w_, configured_h_);
if (!pick_color_view_ || !pick_depth_view_) return out;
// Padded bytes-per-row for the rect region. R32UInt = 4 B/texel.
const uint64_t unpadded_bpr = uint64_t(w) * 4;
const uint64_t padded_bpr = (unpadded_bpr + WGPU_BYTES_PER_ROW_ALIGN - 1)
/ WGPU_BYTES_PER_ROW_ALIGN
* WGPU_BYTES_PER_ROW_ALIGN;
const uint64_t needed_bytes = padded_bpr * uint64_t(h);
if (needed_bytes > box_pick_staging_capacity_) {
if (box_pick_staging_buffer_) {
wgpuBufferRelease(box_pick_staging_buffer_);
box_pick_staging_buffer_ = nullptr;
}
// 2× grow heuristic — rectangle picks are rare so the slight
// overshoot on the first grow doesn't matter.
const uint64_t cap = std::max<uint64_t>(needed_bytes * 2, 64 * 1024);
WGPUBufferDescriptor sb = {};
sb.size = cap;
sb.usage = WGPUBufferUsage_CopyDst | WGPUBufferUsage_MapRead;
sb.label = svFromCStr("ifcviewer-wgpu.box_pick_staging");
box_pick_staging_buffer_ = wgpuDeviceCreateBuffer(device_, &sb);
box_pick_staging_capacity_ = cap;
}
if (!box_pick_staging_buffer_) return out;
WGPUCommandEncoder enc = wgpuDeviceCreateCommandEncoder(device_, nullptr);
// Same pick pass setup as pickObjectAt, but with two color targets
// (R32UInt object_id + RGBA16F normal — we discard the normal here).
WGPURenderPassColorAttachment color[2] = {};
color[0].view = pick_color_view_;
color[0].loadOp = WGPULoadOp_Clear;
color[0].storeOp = WGPUStoreOp_Store;
color[0].clearValue = { 0, 0, 0, 0 };
color[0].depthSlice = WGPU_DEPTH_SLICE_UNDEFINED;
color[1].view = pick_normal_view_;
color[1].loadOp = WGPULoadOp_Clear;
color[1].storeOp = WGPUStoreOp_Store;
color[1].clearValue = { 0.5, 0.5, 0.5, 0 };
color[1].depthSlice = WGPU_DEPTH_SLICE_UNDEFINED;
WGPURenderPassDepthStencilAttachment depth = {};
depth.view = pick_depth_view_;
depth.depthLoadOp = WGPULoadOp_Clear;
depth.depthStoreOp = WGPUStoreOp_Store;
depth.depthClearValue = 1.0f;
depth.stencilLoadOp = WGPULoadOp_Undefined;
depth.stencilStoreOp = WGPUStoreOp_Undefined;
depth.stencilReadOnly = true;
WGPURenderPassDescriptor pass_desc = {};
pass_desc.colorAttachmentCount = 2;
pass_desc.colorAttachments = color;
pass_desc.depthStencilAttachment = &depth;
pass_desc.label = svFromCStr("ifcviewer-wgpu.box_pick_pass");
WGPURenderPassEncoder pass = wgpuCommandEncoderBeginRenderPass(enc, &pass_desc);
wgpuRenderPassEncoderSetPipeline(pass, pick_pipeline_);
wgpuRenderPassEncoderSetBindGroup(pass, 0, frame_bind_group_, 0, nullptr);
for (const auto& [mid, 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);
// Copy the rect region of the color attachment to the staging buffer.
// Color formats allow arbitrary subrect copies (unlike Depth32Float).
WGPUTexelCopyTextureInfo src = {};
src.texture = pick_color_texture_;
src.aspect = WGPUTextureAspect_All;
src.origin.x = uint32_t(x);
src.origin.y = uint32_t(y);
WGPUTexelCopyBufferInfo dst = {};
dst.buffer = box_pick_staging_buffer_;
dst.layout.bytesPerRow = uint32_t(padded_bpr);
dst.layout.rowsPerImage = uint32_t(h);
WGPUExtent3D extent = {};
extent.width = uint32_t(w);
extent.height = uint32_t(h);
extent.depthOrArrayLayers = 1;
wgpuCommandEncoderCopyTextureToBuffer(enc, &src, &dst, &extent);
WGPUCommandBuffer cmd = wgpuCommandEncoderFinish(enc, nullptr);
wgpuQueueSubmit(queue_, 1, &cmd);
wgpuCommandBufferRelease(cmd);
wgpuCommandEncoderRelease(enc);
struct MapReq { bool done = false; bool ok = false; };
MapReq req;
WGPUBufferMapCallbackInfo mcb = {};
mcb.mode = WGPUCallbackMode_AllowProcessEvents;
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) wgpuInstanceProcessEvents(instance_);
if (!req.ok) return out;
const uint8_t* mapped = static_cast<const uint8_t*>(
wgpuBufferGetConstMappedRange(box_pick_staging_buffer_, 0, needed_bytes));
std::unordered_set<uint32_t> seen;
if (mapped) {
for (int row = 0; row < h; ++row) {
const uint32_t* line = reinterpret_cast<const uint32_t*>(
mapped + size_t(row) * size_t(padded_bpr));
for (int col = 0; col < w; ++col) {
const uint32_t id = line[col];
if (id != 0) seen.insert(id);
}
}
}
wgpuBufferUnmap(box_pick_staging_buffer_);
out.reserve(seen.size());
for (uint32_t id : seen) out.push_back(id);
return out;
}
bool WgpuViewportWindow::pickSurfaceAt(int x_pixels, int y_pixels,
uint32_t& object_id_out,
QVector3D& world_pos_out,
@@ -4581,6 +5038,11 @@ void WgpuViewportWindow::render() {
// surface, so the laplacian can't darken its lines or its background.
encodeCornerAxisGizmo(enc, view);
// Marquee box-select drag rect (visible only while a drag is active).
// Drawn on the resolved surface so the rect outline isn't affected by
// the edge silhouette pass.
encodeMarquee(enc, view);
// ---- HiZ: resolve MSAA depth → small single-sample → ping-pong slot
int hiz_submitted_slot = -1;
if (hiz_enabled_) {
@@ -6557,6 +7019,18 @@ void WgpuViewportWindow::mousePressEvent(QMouseEvent* event) {
&& (mods & Qt::KeyboardModifierMask) == pan_mods_) {
nav_drag_kind_ = NavDrag::Pan;
setPivotIndicatorVisible(true);
} else if (event->button() == Qt::LeftButton
&& !section_tool_active_
&& nav_drag_kind_ == NavDrag::Inactive) {
// Arm marquee box-select. Plain / Shift / Ctrl LMB without a tool
// intercepting the click; if the cursor never moves past the
// threshold this stays armed-only and the release falls through
// to single-pick.
box_select_armed_ = true;
box_select_active_ = false;
box_select_start_pos_ = nav_press_pos_;
box_select_current_pos_ = nav_press_pos_;
box_select_press_mods_ = mods;
}
}
@@ -6567,6 +7041,46 @@ void WgpuViewportWindow::mouseReleaseEvent(QMouseEvent* event) {
nav_active_button_ = Qt::NoButton;
return;
}
// Marquee finalisation: only commit when the drag actually became
// active (cursor moved past threshold). Press-time mods decide the
// set op so a mid-drag Shift release doesn't flip the behaviour.
if (box_select_armed_ && event->button() == Qt::LeftButton) {
const bool was_active = box_select_active_;
box_select_armed_ = false;
box_select_active_ = false;
if (was_active) {
const float dpr = float(devicePixelRatio());
const int x0 = int(std::min(box_select_start_pos_.x(),
box_select_current_pos_.x()) * dpr);
const int y0 = int(std::min(box_select_start_pos_.y(),
box_select_current_pos_.y()) * dpr);
const int x1 = int(std::max(box_select_start_pos_.x(),
box_select_current_pos_.x()) * dpr);
const int y1 = int(std::max(box_select_start_pos_.y(),
box_select_current_pos_.y()) * dpr);
const auto ids = picksInRect(x0, y0, x1 - x0, y1 - y0);
const auto mods = box_select_press_mods_;
if (mods & Qt::ShiftModifier) {
for (uint32_t id : ids) selection_.add(id);
qInfo().noquote().nospace()
<< "[wgpu marquee] +add " << ids.size() << " object_ids";
} else if (mods & Qt::ControlModifier) {
for (uint32_t id : ids) selection_.remove(id);
qInfo().noquote().nospace()
<< "[wgpu marquee] -remove " << ids.size() << " object_ids";
} else {
selection_.clear();
for (uint32_t id : ids) selection_.add(id);
qInfo().noquote().nospace()
<< "[wgpu marquee] replace " << ids.size() << " object_ids";
}
nav_active_button_ = Qt::NoButton;
nav_drag_kind_ = NavDrag::Inactive;
requestUpdate();
return;
}
// armed but not active → fall through to single-click pick below.
}
if (event->button() == nav_active_button_) {
// LMB-click without drag → pick the object under the cursor and
// route through the selection state. Shift = add, Ctrl = remove,
@@ -6689,6 +7203,23 @@ void WgpuViewportWindow::mouseMoveEvent(QMouseEvent* event) {
return;
}
// Marquee box-select: track the current cursor and promote to active
// once the press has moved past the manhattan threshold. Active
// marquee triggers requestUpdate every frame the cursor moves so the
// rect re-renders.
if (box_select_armed_) {
const QPoint pos = event->position().toPoint();
box_select_current_pos_ = pos;
if (!box_select_active_) {
if ((pos - box_select_start_pos_).manhattanLength()
>= kBoxSelectThresholdPx) {
box_select_active_ = true;
}
}
if (box_select_active_) requestUpdate();
return;
}
// Fly-mode mouse-look: turn the camera in place (eye stays put).
// The orbit fields (camera_target_/distance/yaw/pitch) are still our
// single source of truth — but to interpret yaw/pitch as the camera's
@@ -6972,6 +7503,7 @@ void WgpuViewportWindow::shutdown() {
releaseHizResources();
releaseEdgeResources();
releaseSectionVisualizer();
releaseMarquee();
releasePickResources();
if (frame_bind_group_) { wgpuBindGroupRelease(frame_bind_group_); frame_bind_group_ = nullptr; }
+38
View File
@@ -35,6 +35,7 @@
#include <cstdint>
#include <deque>
#include <unordered_map>
#include <unordered_set>
#include "SidecarCache.h"
#include "WgpuBufferPool.h"
@@ -266,6 +267,11 @@ private:
QVector3D& world_pos_out,
QVector3D& world_normal_out,
float* aabb_radius_out = nullptr);
// Rectangle pick: render the pick pass, copy the rect region of the
// R32UInt color attachment, and return every unique non-zero
// object_id covered. `rect` is in physical pixels (post-DPR), already
// clipped to the framebuffer by the caller.
std::vector<uint32_t> picksInRect(int x, int y, int w, int h);
// Section-cutting tool. Mirrors the GL ViewportWindow API:
// K toggle (sectionToolActive / toggleSectionTool)
@@ -461,6 +467,22 @@ private:
WGPURenderPipeline axis_pivot_pipeline_ = nullptr;
WGPURenderPipeline axis_pivot_xray_pipeline_ = nullptr;
WGPURenderPipeline axis_corner_pipeline_ = nullptr;
// Marquee overlay (drag-rect for box-select). Renders on the resolved
// surface after the corner gizmo. Uses a 4-segment unit-quad VBO; the
// shader maps unit coords to NDC via per-frame uniform (rect min/max
// in NDC + colour + viewport + line width).
WGPUShaderModule marquee_shader_module_ = nullptr;
WGPUBindGroupLayout marquee_bgl_ = nullptr;
WGPUPipelineLayout marquee_pipeline_layout_ = nullptr;
WGPURenderPipeline marquee_pipeline_ = nullptr; // outline (thick line + AA)
WGPURenderPipeline marquee_fill_pipeline_ = nullptr; // translucent quad fill
WGPUBuffer marquee_vertex_buffer_ = nullptr; // outline geometry
WGPUBuffer marquee_fill_vertex_buffer_ = nullptr; // 6-vert quad
WGPUBuffer marquee_uniform_buffer_ = nullptr;
WGPUBindGroup marquee_bind_group_ = nullptr;
bool buildMarquee();
void encodeMarquee(WGPUCommandEncoder enc, WGPUTextureView surface_view);
void releaseMarquee();
WGPUBuffer axis_vertex_buffer_ = nullptr; // 6 × 24 B
WGPUBuffer axis_uniform_buffer_ = nullptr; // 3 × 256 B slots
WGPUBindGroup axis_bind_group_ = nullptr; // dynamic-offset
@@ -500,6 +522,22 @@ private:
};
std::vector<SectionPlane> section_planes_;
bool section_tool_active_ = false;
// Marquee box-select. Armed on LMB press (when no other tool consumes
// the click), becomes active after the cursor moves past
// kBoxSelectThresholdPx — until then a release still routes through
// the single-pick path. Press-time modifiers decide the set op at
// release: plain → replace, Shift → add, Ctrl → remove.
bool box_select_armed_ = false;
bool box_select_active_ = false;
QPoint box_select_start_pos_; // logical px
QPoint box_select_current_pos_; // logical px
Qt::KeyboardModifiers box_select_press_mods_ = Qt::NoModifier;
static constexpr int kBoxSelectThresholdPx = 5;
// R32UInt staging for the rect-pick. Sized to the largest rect we've
// seen so far (padded to 256 bpr), regrown if a bigger rect arrives.
WGPUBuffer box_pick_staging_buffer_ = nullptr;
uint64_t box_pick_staging_capacity_ = 0;
// Drag-to-move state for the arrow gizmo. While `section_drag_active_`
// is true, mouseMoveEvent calls updateSectionDrag instead of letting
// the press fall through to the orbit/pan handlers.