diff --git a/src/ifcviewer-web/CMakeLists.txt b/src/ifcviewer-web/CMakeLists.txt index 13678a7ac0..56d4b92aa6 100644 --- a/src/ifcviewer-web/CMakeLists.txt +++ b/src/ifcviewer-web/CMakeLists.txt @@ -114,7 +114,7 @@ target_link_options(IfcViewerWeb PRIVATE # EMSCRIPTEN_KEEPALIVE alone keeps the symbols in the binary but doesn't # add them to Module. ccall lets the host page (web/ifcviewer.js) pass a JS string (the ?model # URL) to load_sidecar_from_url_c without manual heap marshalling. - "-sEXPORTED_FUNCTIONS=['_main','_malloc','_free','_raf_tick_c','_load_sidecar_from_source_c','_clear_scene_c','_ifcv_on_range_done','_ifcv_chunks_resident_c','_ifcv_chunks_total_c','_ifcv_model_count_c','_ifcv_model_resident_c','_ifcv_model_total_c','_ifcv_bytes_total_c','_ifcv_bytes_needed_c','_ifcv_bytes_loaded_c','_view_all_c','_frame_selection_c','_toggle_projection_c','_projection_is_ortho_c','_standard_view_c','_toggle_fly_c','_fly_is_active_c','_hide_selected_c','_isolate_selected_c','_show_all_c','_hide_all_c','_toggle_xray_c','_xray_is_active_c','_toggle_section_c','_clear_section_c','_section_is_active_c','_ifcv_get_camera_c','_ifcv_set_camera_c','_ifcv_set_ortho_c','_ifcv_set_nav_preset_c','_ifcv_get_selection_c','_ifcv_get_active_object_c','_ifcv_apply_selection_c','_ifcv_set_visible_c','_ifcv_get_hidden_c','_ifcv_set_color_c','_ifcv_clear_colors_c','_ifcv_request_objects_c']" + "-sEXPORTED_FUNCTIONS=['_main','_malloc','_free','_raf_tick_c','_load_sidecar_from_source_c','_clear_scene_c','_ifcv_on_range_done','_ifcv_chunks_resident_c','_ifcv_chunks_total_c','_ifcv_model_count_c','_ifcv_model_resident_c','_ifcv_model_total_c','_ifcv_bytes_total_c','_ifcv_bytes_needed_c','_ifcv_bytes_loaded_c','_view_all_c','_frame_selection_c','_toggle_projection_c','_projection_is_ortho_c','_standard_view_c','_toggle_fly_c','_fly_is_active_c','_hide_selected_c','_isolate_selected_c','_show_all_c','_hide_all_c','_toggle_xray_c','_xray_is_active_c','_toggle_section_c','_clear_section_c','_section_is_active_c','_ifcv_get_camera_c','_ifcv_set_camera_c','_ifcv_set_ortho_c','_ifcv_set_nav_preset_c','_ifcv_get_selection_c','_ifcv_get_active_object_c','_ifcv_apply_selection_c','_ifcv_set_visible_c','_ifcv_get_hidden_c','_ifcv_set_color_c','_ifcv_clear_colors_c','_ifcv_request_objects_c','_ifcv_set_selection_outline_c','_ifcv_selection_outline_is_on_c']" # ccall: the host page (web/ifcviewer.js) passes the ?model URL string to load_sidecar_from_url_c, # and the nav-preset name to ifcv_set_nav_preset_c. # HEAPU8: lets tooling/tests read the wasm heap size (e.g. to verify a large diff --git a/src/ifcviewer-web/main_web.cpp b/src/ifcviewer-web/main_web.cpp index 99cdd9c0af..2c71b7fd4d 100644 --- a/src/ifcviewer-web/main_web.cpp +++ b/src/ifcviewer-web/main_web.cpp @@ -592,6 +592,14 @@ extern "C" EMSCRIPTEN_KEEPALIVE void show_all_c() { if (g_app && g_app-> extern "C" EMSCRIPTEN_KEEPALIVE void hide_all_c() { if (g_app && g_app->ready) g_app->core.hideAll(); } extern "C" EMSCRIPTEN_KEEPALIVE void toggle_xray_c() { if (g_app && g_app->ready) g_app->core.toggleXray(); } extern "C" EMSCRIPTEN_KEEPALIVE int xray_is_active_c() { return (g_app && g_app->ready && g_app->core.xrayActive()) ? 1 : 0; } +extern "C" EMSCRIPTEN_KEEPALIVE void ifcv_set_selection_outline_c(int on) { + if (!g_app || !g_app->ready) return; + g_app->core.setSelectionOutlineEnabled(on != 0); + g_app->host.requestFrame(); +} +extern "C" EMSCRIPTEN_KEEPALIVE int ifcv_selection_outline_is_on_c() { + return (g_app && g_app->ready && g_app->core.selectionOutlineEnabled()) ? 1 : 0; +} // =========================================================================== // Scripting API (web/ifcviewer.js wraps these into the IfcViewer object) @@ -808,6 +816,7 @@ int main(int /*argc*/, char** /*argv*/) { g_app->core.buildHizPipeline(); g_app->core.buildEdgePipeline(); g_app->core.buildPickPipeline(); + g_app->core.buildSelectionOutlinePipelines(); // Load the embedded sample sidecar (mounted into MEMFS via // --embed-file in CMakeLists.txt). The sample stays on the diff --git a/src/ifcviewer-web/tests/outline.spec.mjs b/src/ifcviewer-web/tests/outline.spec.mjs new file mode 100644 index 0000000000..b02fc3b7ed --- /dev/null +++ b/src/ifcviewer-web/tests/outline.spec.mjs @@ -0,0 +1,45 @@ +// This file was generated with the assistance of an AI coding tool. +// +// Selection silhouette outline. The renderer's blue selection tint says +// nothing about an object that is already blue, so the halo is the cue that +// has to survive that case: this paints the whole sample the exact palette +// blue that collides, selects one element, and checks the canvas changes. +import { test, expect } from '@playwright/test'; + +const shot = (page) => page.locator('#viewer-canvas').screenshot(); + +test('halo reads on an object painted the selection tint colour', async ({ page }) => { + const gpuErrors = []; + page.on('console', (msg) => { + if (/Uncaptured WebGPU error|is invalid|Not enough memory left/i.test(msg.text())) + gpuErrors.push(msg.text()); + }); + page.on('pageerror', (e) => gpuErrors.push('pageerror: ' + e.message)); + + await page.goto('/scripting.html'); + await page.waitForFunction(() => !!(window.viewer && window.viewer.isLive()), + null, { timeout: 30_000 }); + await page.waitForTimeout(1500); + + const n = await page.evaluate(async () => { + const v = window.viewer; + const objs = await v.getObjects(); + v.setColor(objs, '#3987e5'); + v.setSelection([objs[0].objectId]); + return objs.length; + }); + expect(n).toBeGreaterThan(0); + await page.waitForTimeout(600); + const withOutline = await shot(page); + + await page.evaluate(() => window.viewer.setSelectionOutline(false)); + await page.waitForTimeout(600); + const withoutOutline = await shot(page); + + expect( + Buffer.compare(withOutline, withoutOutline), + 'the outline toggle changed nothing — halo never drew', + ).not.toBe(0); + + expect(gpuErrors, gpuErrors.join('\n')).toEqual([]); +}); diff --git a/src/ifcviewer-web/web/ifcviewer.js b/src/ifcviewer-web/web/ifcviewer.js index fb1d6c8cdc..11b37c677f 100644 --- a/src/ifcviewer-web/web/ifcviewer.js +++ b/src/ifcviewer-web/web/ifcviewer.js @@ -413,6 +413,17 @@ hideSelected: function () { Module._hide_selected_c(); }, isolateSelected: function () { Module._isolate_selected_c(); }, + // The white-on-dark halo drawn around selected objects (on by default). + // The renderer also tints the selection blue, which says nothing when + // the object is already blue — hence a cue that does not depend on the + // object's colour. Turn it off to get the tint alone. + setSelectionOutline: function (on) { + Module._ifcv_set_selection_outline_c(on ? 1 : 0); + }, + selectionOutlineEnabled: function () { + return Module._ifcv_selection_outline_is_on_c() !== 0; + }, + // ---- Colour ---------------------------------------------------------- // Paint objects a flat colour, replacing whatever the model baked in. diff --git a/src/ifcviewer/ViewportCore.cpp b/src/ifcviewer/ViewportCore.cpp index 6e54a414a3..c84d63f0d4 100644 --- a/src/ifcviewer/ViewportCore.cpp +++ b/src/ifcviewer/ViewportCore.cpp @@ -993,6 +993,19 @@ fn fs_main(in: VsOut) -> @location(0) vec4 { return vec4(srgbToLinear(color), alpha_out); } +// Selection silhouette mask. Same vertex pulling as vs_main, but the only +// output is coverage: 1 where a selected object is drawn, nothing anywhere +// else. The pass shares the main depth buffer read-only, so the mask is the +// selection AS VISIBLE — an occluded object contributes nothing and gets no +// halo. encodeSelectionOutlinePass dilates this into the outline. +@fragment +fn fs_mask(in: VsOut) -> @location(0) f32 { + if (is_section_clipped(in.world_pos)) { discard; } + if (in.object_id >= arrayLength(&sel_flags)) { discard; } + if ((sel_flags[in.object_id] & 1u) == 0u) { discard; } + return 1.0; +} + // --------------------------- Pick pipeline --------------------------------- // Same vertex pulling as vs_main, but VsOutPick carries only the object_id // (flat-interpolated). Fragment writes the object_id to an R32UInt target. @@ -4826,6 +4839,475 @@ void ViewportCore::releaseEdgeResources() { if (edge_bgl_) { wgpuBindGroupLayoutRelease(edge_bgl_); edge_bgl_ = nullptr; } } +// =========================================================================== +// Selection silhouette outline: buildSelectionOutlinePipelines + +// ensureSelectionOutlineTextures + encodeSelectionMaskPass + +// encodeSelectionOutlinePass +// +// A halo drawn just OUTSIDE the selected objects, so the cue does not depend +// on the object's own colour the way the fs_main selection tint does — a blue +// element in a blue-tinted selection is otherwise indistinguishable. +// +// Three steps: a geometry pass writes a coverage mask (fs_mask), then two +// fullscreen passes dilate it. The dilation is separable — a horizontal max +// into an RGBA8 scratch, then a vertical max composited onto the surface — +// because the naive 2D disc is O(r^2) taps per pixel and a 3-physical-pixel +// radius on a HiDPI canvas is already 100+ loads over the whole screen. +// Separable makes it O(r), and the square structuring element it implies is +// invisible at this radius. +// =========================================================================== + +namespace { + +// Scratch format for the horizontal pass. r = max over the inner radius, +// g = max over the outer radius, b = this pixel's own coverage passed +// through so the vertical pass needs only this one texture bound. +constexpr WGPUTextureFormat kSelScratchFormat = WGPUTextureFormat_RGBA8Unorm; + +const char* SEL_OUTLINE_WGSL = R"( +struct OutlineUniforms { + inner_color: vec4, // rgb + alpha of the ring hugging the silhouette + outer_color: vec4, // rgb + alpha of the band beyond it + inner_radius: f32, // physical pixels + outer_radius: f32, // physical pixels, >= inner_radius + _pad0: f32, + _pad1: f32, +}; + +@group(0) @binding(0) var src: texture_2d; +@group(0) @binding(1) var u: OutlineUniforms; + +// Undo the swap chain's implicit linear->sRGB write encoding, exactly as +// the main shader does, so the halo's bytes are the colour we asked for. +fn srgbToLinear(s: vec3) -> vec3 { + let lo = s / 12.92; + let hi = pow((s + 0.055) / 1.055, vec3(2.4)); + return select(hi, lo, s <= vec3(0.04045)); +} + +@vertex +fn vs_main(@builtin(vertex_index) vid: u32) -> @builtin(position) vec4 { + let x = f32((vid << 1u) & 2u) * 2.0 - 1.0; + let y = f32(vid & 2u) * 2.0 - 1.0; + return vec4(x, y, 0.0, 1.0); +} + +// Horizontal half of the dilation. Reads the resolved coverage mask. +@fragment +fn fs_dilate_h(@builtin(position) frag: vec4) -> @location(0) vec4 { + let p = vec2(i32(frag.x), i32(frag.y)); + let max_x = i32(textureDimensions(src).x) - 1; + let ri = i32(u.inner_radius); + let ro = i32(u.outer_radius); + + let here = textureLoad(src, p, 0).r; + var inner = 0.0; + var outer = 0.0; + for (var dx = -ro; dx <= ro; dx = dx + 1) { + let m = textureLoad(src, vec2(clamp(p.x + dx, 0, max_x), p.y), 0).r; + outer = max(outer, m); + if (dx >= -ri && dx <= ri) { inner = max(inner, m); } + } + return vec4(inner, outer, here, 1.0); +} + +// Vertical half, plus the composite. Reads the scratch written above. +@fragment +fn fs_outline(@builtin(position) frag: vec4) -> @location(0) vec4 { + let p = vec2(i32(frag.x), i32(frag.y)); + let max_y = i32(textureDimensions(src).y) - 1; + let ri = i32(u.inner_radius); + let ro = i32(u.outer_radius); + + // Coverage at this pixel. Inside the silhouette there is nothing to + // draw: the halo sits strictly outside, so a selected element's own + // colour is never painted over. + let here = textureLoad(src, p, 0).b; + if (here >= 0.999) { discard; } + + var inner = 0.0; + var outer = 0.0; + for (var dy = -ro; dy <= ro; dy = dy + 1) { + let s = textureLoad(src, vec2(p.x, clamp(p.y + dy, 0, max_y)), 0); + outer = max(outer, s.g); + if (dy >= -ri && dy <= ri) { inner = max(inner, s.r); } + } + + // Two concentric rings, written as differences so they never overlap: + // `in_ring` is the dilation minus the shape, `out_ring` is the wider + // dilation minus the narrower one. + let in_ring = clamp(inner - here, 0.0, 1.0); + let out_ring = clamp(outer - inner, 0.0, 1.0); + + let a_in = in_ring * u.inner_color.a; + let a_out = out_ring * u.outer_color.a * (1.0 - in_ring); + let a = a_in + a_out; + if (a <= 0.004) { discard; } + + // Straight (unpremultiplied) alpha out — the SrcAlpha blend factor + // does the premultiply, which is what the surface's premultiplied + // alpha mode expects to find in the buffer. + let rgb = (srgbToLinear(u.inner_color.rgb) * a_in + + srgbToLinear(u.outer_color.rgb) * a_out) / a; + return vec4(rgb, a); +} +)"; + +} // namespace + +bool ViewportCore::buildSelectionOutlinePipelines() { + // ---- Mask pass. Reuses the main shader module + pipeline layout, so it + // vertex-pulls identically and sees the same sel_flags binding. Depth is + // the main pass's, bound read-only: LessEqual against already-written + // scene depth keeps only the fragments that actually survived. + { + WGPUColorTargetState target = {}; + target.format = WGPUTextureFormat_R8Unorm; + target.writeMask = WGPUColorWriteMask_All; + + WGPUFragmentState frag = {}; + frag.module = main_shader_module_; + frag.entryPoint = svFromCStr("fs_mask"); + frag.targetCount = 1; + frag.targets = ⌖ + + WGPUDepthStencilState depth = {}; + depth.format = WGPUTextureFormat_Depth32Float; + depth.depthWriteEnabled = WGPUOptionalBool_False; + depth.depthCompare = WGPUCompareFunction_LessEqual; + depth.stencilFront.compare = WGPUCompareFunction_Always; + depth.stencilBack.compare = WGPUCompareFunction_Always; + + WGPURenderPipelineDescriptor rp_desc = {}; + rp_desc.layout = pipeline_layout_; + rp_desc.label = svFromCStr("ifcviewer-wgpu.sel_mask_pipeline"); + rp_desc.vertex.module = main_shader_module_; + rp_desc.vertex.entryPoint = svFromCStr("vs_main"); + rp_desc.vertex.bufferCount = 0; + rp_desc.fragment = &frag; + rp_desc.depthStencil = &depth; + rp_desc.primitive.topology = WGPUPrimitiveTopology_TriangleList; + // No cull: an open shell (a wall face, a plate) would otherwise + // punch holes in its own silhouette when seen from behind. + rp_desc.primitive.cullMode = WGPUCullMode_None; + rp_desc.multisample.count = kViewportSampleCount; + rp_desc.multisample.mask = 0xFFFFFFFFu; + + sel_mask_pipeline_ = wgpuDeviceCreateRenderPipeline(device_, &rp_desc); + if (!sel_mask_pipeline_) { + Log::warn() << "wgpu selection mask pipeline creation failed"; + return false; + } + } + + // ---- Shared fullscreen resources. One BGL for both dilation passes: + // each binds a different source texture through the same shape. + WGPUBindGroupLayoutEntry entries[2] = {}; + entries[0].binding = 0; + entries[0].visibility = WGPUShaderStage_Fragment; + entries[0].texture.sampleType = WGPUTextureSampleType_Float; + entries[0].texture.viewDimension = WGPUTextureViewDimension_2D; + entries[1].binding = 1; + entries[1].visibility = WGPUShaderStage_Fragment; + entries[1].buffer.type = WGPUBufferBindingType_Uniform; + entries[1].buffer.minBindingSize = sizeof(SelOutlineUniforms); + + WGPUBindGroupLayoutDescriptor bgl_desc = {}; + bgl_desc.entryCount = 2; + bgl_desc.entries = entries; + bgl_desc.label = svFromCStr("ifcviewer-wgpu.sel_outline_bgl"); + sel_outline_bgl_ = wgpuDeviceCreateBindGroupLayout(device_, &bgl_desc); + + WGPUPipelineLayoutDescriptor pl_desc = {}; + pl_desc.bindGroupLayoutCount = 1; + pl_desc.bindGroupLayouts = &sel_outline_bgl_; + pl_desc.label = svFromCStr("ifcviewer-wgpu.sel_outline_pipeline_layout"); + sel_outline_pipeline_layout_ = wgpuDeviceCreatePipelineLayout(device_, &pl_desc); + + WGPUShaderSourceWGSL wgsl_src = {}; + wgsl_src.chain.sType = WGPUSType_ShaderSourceWGSL; + wgsl_src.code = svFromCStr(SEL_OUTLINE_WGSL); + WGPUShaderModuleDescriptor sm_desc = {}; + sm_desc.nextInChain = &wgsl_src.chain; + sm_desc.label = svFromCStr("ifcviewer-wgpu.sel_outline_wgsl"); + sel_outline_shader_module_ = wgpuDeviceCreateShaderModule(device_, &sm_desc); + + WGPUBufferDescriptor ub = {}; + ub.usage = WGPUBufferUsage_Uniform | WGPUBufferUsage_CopyDst; + ub.size = sizeof(SelOutlineUniforms); + ub.label = svFromCStr("ifcviewer-wgpu.sel_outline_uniforms"); + sel_outline_uniform_buffer_ = wgpuDeviceCreateBuffer(device_, &ub); + + // ---- Horizontal dilation into the scratch target. Opaque write. + { + WGPUColorTargetState target = {}; + target.format = kSelScratchFormat; + target.writeMask = WGPUColorWriteMask_All; + + WGPUFragmentState frag = {}; + frag.module = sel_outline_shader_module_; + frag.entryPoint = svFromCStr("fs_dilate_h"); + frag.targetCount = 1; + frag.targets = ⌖ + + WGPURenderPipelineDescriptor rp_desc = {}; + rp_desc.layout = sel_outline_pipeline_layout_; + rp_desc.label = svFromCStr("ifcviewer-wgpu.sel_dilate_h_pipeline"); + rp_desc.vertex.module = sel_outline_shader_module_; + rp_desc.vertex.entryPoint = svFromCStr("vs_main"); + rp_desc.vertex.bufferCount = 0; + rp_desc.fragment = &frag; + rp_desc.depthStencil = nullptr; + rp_desc.primitive.topology = WGPUPrimitiveTopology_TriangleList; + rp_desc.primitive.cullMode = WGPUCullMode_None; + rp_desc.multisample.count = 1; + rp_desc.multisample.mask = 0xFFFFFFFFu; + + sel_dilate_h_pipeline_ = wgpuDeviceCreateRenderPipeline(device_, &rp_desc); + if (!sel_dilate_h_pipeline_) { + Log::warn() << "wgpu selection dilate pipeline creation failed"; + return false; + } + } + + // ---- Vertical dilation + composite onto the resolved surface. + { + 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 target = {}; + target.format = surface_view_format_; + target.blend = &blend; + target.writeMask = WGPUColorWriteMask_All; + + WGPUFragmentState frag = {}; + frag.module = sel_outline_shader_module_; + frag.entryPoint = svFromCStr("fs_outline"); + frag.targetCount = 1; + frag.targets = ⌖ + + WGPURenderPipelineDescriptor rp_desc = {}; + rp_desc.layout = sel_outline_pipeline_layout_; + rp_desc.label = svFromCStr("ifcviewer-wgpu.sel_outline_pipeline"); + rp_desc.vertex.module = sel_outline_shader_module_; + rp_desc.vertex.entryPoint = svFromCStr("vs_main"); + rp_desc.vertex.bufferCount = 0; + rp_desc.fragment = &frag; + rp_desc.depthStencil = nullptr; + rp_desc.primitive.topology = WGPUPrimitiveTopology_TriangleList; + rp_desc.primitive.cullMode = WGPUCullMode_None; + rp_desc.multisample.count = 1; + rp_desc.multisample.mask = 0xFFFFFFFFu; + + sel_outline_pipeline_ = wgpuDeviceCreateRenderPipeline(device_, &rp_desc); + if (!sel_outline_pipeline_) { + Log::warn() << "wgpu selection outline pipeline creation failed"; + return false; + } + } + return true; +} + +void ViewportCore::ensureSelectionOutlineTextures(int w, int h) { + if (w == sel_mask_w_ && h == sel_mask_h_ && sel_mask_view_) return; + releaseSelectionOutlineTextures(); + + WGPUTextureDescriptor desc = {}; + desc.dimension = WGPUTextureDimension_2D; + desc.size.width = std::uint32_t(w); + desc.size.height = std::uint32_t(h); + desc.size.depthOrArrayLayers = 1; + desc.mipLevelCount = 1; + + // Multisampled coverage target, matching the main pass so it can share + // the depth attachment; resolved down to the single-sample mask the + // dilation reads. The resolve is what gives the halo the same edge + // antialiasing as the geometry it traces. + desc.usage = WGPUTextureUsage_RenderAttachment; + desc.format = WGPUTextureFormat_R8Unorm; + desc.sampleCount = kViewportSampleCount; + desc.label = svFromCStr("ifcviewer-wgpu.sel_mask_msaa"); + sel_mask_msaa_texture_ = wgpuDeviceCreateTexture(device_, &desc); + sel_mask_msaa_view_ = wgpuTextureCreateView(sel_mask_msaa_texture_, nullptr); + + desc.usage = WGPUTextureUsage_RenderAttachment | WGPUTextureUsage_TextureBinding; + desc.sampleCount = 1; + desc.label = svFromCStr("ifcviewer-wgpu.sel_mask"); + sel_mask_texture_ = wgpuDeviceCreateTexture(device_, &desc); + sel_mask_view_ = wgpuTextureCreateView(sel_mask_texture_, nullptr); + + desc.format = kSelScratchFormat; + desc.label = svFromCStr("ifcviewer-wgpu.sel_scratch"); + sel_scratch_texture_ = wgpuDeviceCreateTexture(device_, &desc); + sel_scratch_view_ = wgpuTextureCreateView(sel_scratch_texture_, nullptr); + + sel_mask_w_ = w; + sel_mask_h_ = h; + + // The bind groups name the views we just replaced. + if (sel_dilate_bind_group_) { + wgpuBindGroupRelease(sel_dilate_bind_group_); + sel_dilate_bind_group_ = nullptr; + } + if (sel_outline_bind_group_) { + wgpuBindGroupRelease(sel_outline_bind_group_); + sel_outline_bind_group_ = nullptr; + } +} + +void ViewportCore::releaseSelectionOutlineTextures() { + if (sel_scratch_view_) { wgpuTextureViewRelease(sel_scratch_view_); sel_scratch_view_ = nullptr; } + if (sel_scratch_texture_) { wgpuTextureRelease(sel_scratch_texture_); sel_scratch_texture_ = nullptr; } + if (sel_mask_view_) { wgpuTextureViewRelease(sel_mask_view_); sel_mask_view_ = nullptr; } + if (sel_mask_texture_) { wgpuTextureRelease(sel_mask_texture_); sel_mask_texture_ = nullptr; } + if (sel_mask_msaa_view_) { wgpuTextureViewRelease(sel_mask_msaa_view_); sel_mask_msaa_view_ = nullptr; } + if (sel_mask_msaa_texture_) { wgpuTextureRelease(sel_mask_msaa_texture_); sel_mask_msaa_texture_ = nullptr; } + sel_mask_w_ = sel_mask_h_ = 0; +} + +bool ViewportCore::selectionOutlineActive() const { + return selection_outline_enabled_ && selection_.count() > 0 + && sel_mask_pipeline_ && sel_outline_pipeline_ && sel_mask_view_; +} + +void ViewportCore::encodeSelectionMaskPass(WGPUCommandEncoder enc) { + if (!selectionOutlineActive() || !depth_view_ || !frame_bind_group_) return; + + WGPURenderPassColorAttachment color = {}; + color.view = sel_mask_msaa_view_; + color.resolveTarget = sel_mask_view_; + color.loadOp = WGPULoadOp_Clear; + // Only the resolve is ever read, so the multisampled samples can go. + color.storeOp = WGPUStoreOp_Discard; + color.clearValue = {0.0, 0.0, 0.0, 0.0}; + color.depthSlice = WGPU_DEPTH_SLICE_UNDEFINED; + + // Read-only depth: the scene's own z, already written by the main pass. + // WebGPU requires the load/store ops be left undefined when a depth + // attachment is read-only, which the zero-init here does. + WGPURenderPassDepthStencilAttachment depth = {}; + depth.view = depth_view_; + depth.depthReadOnly = true; + depth.stencilReadOnly = true; + + WGPURenderPassDescriptor pass_desc = {}; + pass_desc.colorAttachmentCount = 1; + pass_desc.colorAttachments = &color; + pass_desc.depthStencilAttachment = &depth; + pass_desc.label = svFromCStr("ifcviewer-wgpu.sel_mask_pass"); + + WGPURenderPassEncoder pass = wgpuCommandEncoderBeginRenderPass(enc, &pass_desc); + wgpuRenderPassEncoderSetPipeline(pass, sel_mask_pipeline_); + wgpuRenderPassEncoderSetBindGroup(pass, 0, frame_bind_group_, 0, nullptr); + + // Same draw stream as the main pass, opaque and transparent together — + // a selected element that happens to be translucent still gets a halo. + 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); +} + +void ViewportCore::encodeSelectionOutlinePass(WGPUCommandEncoder enc, + WGPUTextureView surface_view, + int dpr) { + if (!selectionOutlineActive() || !surface_view || !sel_dilate_h_pipeline_) return; + + // Ring widths in LOGICAL pixels, scaled here so the halo looks the same + // on a HiDPI canvas as it does on a 1x one. + const float scale = float(std::max(1, dpr)); + SelOutlineUniforms u = {}; + u.inner_color[0] = 1.0f; u.inner_color[1] = 1.0f; + u.inner_color[2] = 1.0f; u.inner_color[3] = 1.0f; + u.outer_color[0] = 0.04f; u.outer_color[1] = 0.04f; + u.outer_color[2] = 0.04f; u.outer_color[3] = 0.85f; + u.inner_radius = 2.0f * scale; + u.outer_radius = 3.0f * scale; + wgpuQueueWriteBuffer(queue_, sel_outline_uniform_buffer_, 0, &u, sizeof(u)); + + if (!sel_dilate_bind_group_) { + WGPUBindGroupEntry e[2] = {}; + e[0].binding = 0; + e[0].textureView = sel_mask_view_; + e[1].binding = 1; + e[1].buffer = sel_outline_uniform_buffer_; + e[1].size = sizeof(SelOutlineUniforms); + WGPUBindGroupDescriptor bg = {}; + bg.layout = sel_outline_bgl_; + bg.entryCount = 2; + bg.entries = e; + bg.label = svFromCStr("ifcviewer-wgpu.sel_dilate_bind_group"); + sel_dilate_bind_group_ = wgpuDeviceCreateBindGroup(device_, &bg); + } + if (!sel_outline_bind_group_) { + WGPUBindGroupEntry e[2] = {}; + e[0].binding = 0; + e[0].textureView = sel_scratch_view_; + e[1].binding = 1; + e[1].buffer = sel_outline_uniform_buffer_; + e[1].size = sizeof(SelOutlineUniforms); + WGPUBindGroupDescriptor bg = {}; + bg.layout = sel_outline_bgl_; + bg.entryCount = 2; + bg.entries = e; + bg.label = svFromCStr("ifcviewer-wgpu.sel_outline_bind_group"); + sel_outline_bind_group_ = wgpuDeviceCreateBindGroup(device_, &bg); + } + + { + WGPURenderPassColorAttachment color = {}; + color.view = sel_scratch_view_; + color.loadOp = WGPULoadOp_Clear; + color.storeOp = WGPUStoreOp_Store; + color.clearValue = {0.0, 0.0, 0.0, 1.0}; + color.depthSlice = WGPU_DEPTH_SLICE_UNDEFINED; + + WGPURenderPassDescriptor pass_desc = {}; + pass_desc.colorAttachmentCount = 1; + pass_desc.colorAttachments = &color; + pass_desc.label = svFromCStr("ifcviewer-wgpu.sel_dilate_h_pass"); + + WGPURenderPassEncoder pass = wgpuCommandEncoderBeginRenderPass(enc, &pass_desc); + wgpuRenderPassEncoderSetPipeline(pass, sel_dilate_h_pipeline_); + wgpuRenderPassEncoderSetBindGroup(pass, 0, sel_dilate_bind_group_, 0, nullptr); + wgpuRenderPassEncoderDraw(pass, 3, 1, 0, 0); + wgpuRenderPassEncoderEnd(pass); + wgpuRenderPassEncoderRelease(pass); + } + + { + WGPURenderPassColorAttachment color = {}; + color.view = surface_view; + color.loadOp = WGPULoadOp_Load; + color.storeOp = WGPUStoreOp_Store; + color.depthSlice = WGPU_DEPTH_SLICE_UNDEFINED; + + WGPURenderPassDescriptor pass_desc = {}; + pass_desc.colorAttachmentCount = 1; + pass_desc.colorAttachments = &color; + pass_desc.label = svFromCStr("ifcviewer-wgpu.sel_outline_pass"); + + WGPURenderPassEncoder pass = wgpuCommandEncoderBeginRenderPass(enc, &pass_desc); + wgpuRenderPassEncoderSetPipeline(pass, sel_outline_pipeline_); + wgpuRenderPassEncoderSetBindGroup(pass, 0, sel_outline_bind_group_, 0, nullptr); + wgpuRenderPassEncoderDraw(pass, 3, 1, 0, 0); + wgpuRenderPassEncoderEnd(pass); + wgpuRenderPassEncoderRelease(pass); + } +} + // =========================================================================== // Pick + raycast (#84-t) // =========================================================================== @@ -6413,6 +6895,7 @@ void ViewportCore::configureSurface(int width_px, int height_px) { ensureDepthTexture(width_px, height_px); ensureMsaaColorTexture(width_px, height_px); ensureHizTextures(width_px, height_px); + ensureSelectionOutlineTextures(width_px, height_px); // depth_view_ was just replaced; force the HiZ + edge bind groups // to rebuild against the new view on next encode. if (hiz_bind_group_) { @@ -6897,9 +7380,16 @@ void ViewportCore::render() { wgpuRenderPassEncoderEnd(pass); wgpuRenderPassEncoderRelease(pass); + // Selection coverage, while the main pass's depth is still current. The + // halo itself composites AFTER the edge pass, so the edge multiply does + // not darken it. + encodeSelectionMaskPass(enc); + // Edge silhouette + HiZ resolve, before the surface-targeted overlays. if (edges_enabled_) encodeEdgePass(enc, view); + encodeSelectionOutlinePass(enc, view, dpr_int); + int hiz_submitted_slot = -1; if (hiz_enabled_) hiz_submitted_slot = encodeHizResolve(enc); diff --git a/src/ifcviewer/ViewportCore.h b/src/ifcviewer/ViewportCore.h index 72e39c4209..9e651f358f 100644 --- a/src/ifcviewer/ViewportCore.h +++ b/src/ifcviewer/ViewportCore.h @@ -703,6 +703,36 @@ public: // Called from shutdown() before the device dies. void releaseEdgeResources(); + // ---- Selection silhouette outline ------------------------------------- + // + // A halo drawn just outside the selected objects. The fs_main selection + // tint mixes toward blue, which says nothing on an object that is already + // blue; the halo is a fixed colour against the BACKGROUND instead, so it + // reads whatever the element is painted. + // + // Build the mask + dilation pipelines. Run after buildPipelines(), which + // owns the shader module and pipeline layout the mask pass reuses. + bool buildSelectionOutlinePipelines(); + + // (Re)allocate the coverage mask + dilation scratch to the surface size. + // Idempotent when dimensions match. + void ensureSelectionOutlineTextures(int w, int h); + void releaseSelectionOutlineTextures(); + + // Coverage pass: draws the selection into the mask, depth-tested against + // the main pass's z so only visible surface contributes. Must be encoded + // after the main pass and before encodeSelectionOutlinePass. + void encodeSelectionMaskPass(WGPUCommandEncoder enc); + + // Dilate the mask and composite the halo onto the resolved surface. + // `dpr` scales the ring widths so they hold their apparent size on a + // HiDPI canvas. No-op when nothing is selected. + void encodeSelectionOutlinePass(WGPUCommandEncoder enc, + WGPUTextureView surface_view, int dpr); + + void setSelectionOutlineEnabled(bool on) { selection_outline_enabled_ = on; } + bool selectionOutlineEnabled() const { return selection_outline_enabled_; } + // ---- Pick + raycast (#84-t) ------------------------------------------- // // Build the pick pipeline. Reuses the main shader module's @@ -1102,6 +1132,39 @@ private: WGPUBindGroup edge_bind_group_ = nullptr; bool edges_enabled_ = true; + // Selection silhouette outline. The mask is rendered multisampled so it + // can share the main depth attachment, then resolved; the scratch holds + // the horizontal half of the separable dilation. + struct SelOutlineUniforms { + float inner_color[4]; + float outer_color[4]; + float inner_radius; // physical px + float outer_radius; // physical px + float _pad0; + float _pad1; + }; + WGPURenderPipeline sel_mask_pipeline_ = nullptr; + WGPUTexture sel_mask_msaa_texture_ = nullptr; + WGPUTextureView sel_mask_msaa_view_ = nullptr; + WGPUTexture sel_mask_texture_ = nullptr; + WGPUTextureView sel_mask_view_ = nullptr; + WGPUTexture sel_scratch_texture_ = nullptr; + WGPUTextureView sel_scratch_view_ = nullptr; + int sel_mask_w_ = 0; + int sel_mask_h_ = 0; + WGPUShaderModule sel_outline_shader_module_ = nullptr; + WGPUBindGroupLayout sel_outline_bgl_ = nullptr; + WGPUPipelineLayout sel_outline_pipeline_layout_ = nullptr; + WGPUBuffer sel_outline_uniform_buffer_ = nullptr; + WGPURenderPipeline sel_dilate_h_pipeline_ = nullptr; + WGPUBindGroup sel_dilate_bind_group_ = nullptr; + WGPURenderPipeline sel_outline_pipeline_ = nullptr; + WGPUBindGroup sel_outline_bind_group_ = nullptr; + bool selection_outline_enabled_ = true; + + // True when there is something to outline and the resources are live. + bool selectionOutlineActive() const; + // Pick pass. Reuses pipeline_layout_ — same set of bindings as the // main pass since the pick fragment also vertex-pulls instance data. WGPURenderPipeline pick_pipeline_ = nullptr;