diff --git a/src/ifcviewer-web/CMakeLists.txt b/src/ifcviewer-web/CMakeLists.txt index 56d4b92aa6..21aa7dbb25 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','_ifcv_set_selection_outline_c','_ifcv_selection_outline_is_on_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_set_background_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 2c71b7fd4d..1fe1e4f7df 100644 --- a/src/ifcviewer-web/main_web.cpp +++ b/src/ifcviewer-web/main_web.cpp @@ -635,6 +635,15 @@ extern "C" EMSCRIPTEN_KEEPALIVE void ifcv_set_ortho_c(int on) { if (bool(on) != g_app->core.projectionOrtho()) g_app->core.toggleProjection(); } +// Background colour, RGBA in [0..1]. Alpha 0 clears the canvas to nothing, so +// whatever the host page has stacked behind it shows through. See +// ViewportCore::setBackgroundColor. +extern "C" EMSCRIPTEN_KEEPALIVE void ifcv_set_background_c(float r, float g, + float b, float a) { + if (!g_app || !g_app->ready) return; + g_app->core.setBackgroundColor(r, g, b, a); +} + // Mouse navigation scheme: "blender" | "rhino" | "revit" | "web". The preset // only rewrites the button/modifier table classifyPress reads, so unlike the // rest of the scripting API it does not need the GPU app to be live — a host diff --git a/src/ifcviewer-web/tests/background.spec.mjs b/src/ifcviewer-web/tests/background.spec.mjs new file mode 100644 index 0000000000..53c339d9f3 --- /dev/null +++ b/src/ifcviewer-web/tests/background.spec.mjs @@ -0,0 +1,114 @@ +// This file was generated with the assistance of an AI coding tool. +// +// Background colour, and specifically its alpha. The alpha only means +// anything if two separate things are right: the surface has to be +// configured with a premultiplied composite mode, or the channel is +// discarded before it reaches the compositor; and the clear value has to be +// scaled by it, or the viewport adds its background colour on top of the +// layer behind instead of vanishing. Both failures are invisible at the +// default opaque alpha, so nothing else in the suite would catch either. +import { test, expect } from '@playwright/test'; +import zlib from 'node:zlib'; + +// Decode the top-left pixel (RGB) of a PNG buffer. Row 0 pixel 0 is +// filter-agnostic — every PNG predictor references zero neighbours there — +// so this can skip filter handling entirely. +function firstPixelRGB(png) { + let off = 8, colorType = 6; + const idat = []; + while (off + 8 <= png.length) { + const len = png.readUInt32BE(off); + const type = png.toString('ascii', off + 4, off + 8); + const data = png.subarray(off + 8, off + 8 + len); + if (type === 'IHDR') colorType = data[9]; + else if (type === 'IDAT') idat.push(data); + else if (type === 'IEND') break; + off += 12 + len; + } + const raw = zlib.inflateSync(Buffer.concat(idat)); + return [raw[1], raw[2], raw[3]]; // skip the row filter byte +} + +// A 1x1 sample just inside the canvas corner — background, clear of the +// framed model. Screenshotting through the browser rather than reading the +// canvas back in-page is deliberate: a WebGPU swap-chain texture isn't +// persisted for 2D copy, but the browser's own capture path composites the +// GPU layer against what is behind it, which is the whole point here. +async function cornerPixel(page) { + const box = await page.locator('#viewer-canvas').boundingBox(); + const png = await page.screenshot({ + clip: { x: Math.round(box.x + 6), y: Math.round(box.y + 6), width: 1, height: 1 }, + }); + return firstPixelRGB(png); +} + +function watchGpuErrors(page) { + const errors = []; + page.on('console', (msg) => { + if (/Uncaptured WebGPU error|is invalid|Not enough memory left/i.test(msg.text())) + errors.push(msg.text()); + }); + page.on('pageerror', (e) => errors.push('pageerror: ' + e.message)); + return errors; +} + +async function bootViewer(page) { + await page.goto('/scripting.html'); + await page.waitForFunction(() => !!(window.viewer && window.viewer.isLive()), + null, { timeout: 30_000 }); + await page.waitForTimeout(1200); +} + +test('an opaque background colour reaches the clear', async ({ page }) => { + const gpuErrors = watchGpuErrors(page); + await bootViewer(page); + + await page.evaluate(() => window.viewer.setBackground('#c81e1e')); + await page.waitForTimeout(600); + + const [r, g, b] = await cornerPixel(page); + expect(r, `expected the red clear, got ${r},${g},${b}`).toBeGreaterThan(150); + expect(g, `expected the red clear, got ${r},${g},${b}`).toBeLessThan(80); + expect(b, `expected the red clear, got ${r},${g},${b}`).toBeLessThan(80); + + expect(gpuErrors, gpuErrors.join('\n')).toEqual([]); +}); + +test('alpha 0 clears to nothing and the layer behind shows through', async ({ page }) => { + const gpuErrors = watchGpuErrors(page); + await bootViewer(page); + + // #viewer-box is the element the canvas sits in front of — where a host + // page would stack a second view. Paint it a colour neither the scene nor + // the palette uses, so a match can only mean the canvas showed through. + await page.evaluate(() => { + document.getElementById('viewer-box').style.background = '#ff00ff'; + }); + await page.waitForTimeout(300); + + const before = await cornerPixel(page); + expect( + Math.max(...before), + `the corner is already bright before the alpha changed (${before}) — it is not ` + + `sampling background, so the assertion below would prove nothing`, + ).toBeLessThan(90); + + // White at zero alpha, not transparent black: black would pass even if the + // clear forgot to scale by alpha, since scaling black changes nothing. + // White separates the two — premultiplied it becomes (0,0,0,0) and the + // magenta behind survives intact, unscaled it is added on top and washes + // the corner out to white. + await page.evaluate(() => window.viewer.setBackground('#ffffff00')); + await page.waitForTimeout(600); + + const [r, g, b] = await cornerPixel(page); + const got = `got ${r},${g},${b}`; + const why = `expected the magenta behind the canvas. Washed-out/white means the ` + + `clear was not premultiplied, or the surface fell back to an opaque ` + + `composite mode and held alpha at 1. ${got}`; + expect(r, why).toBeGreaterThan(200); + expect(g, why).toBeLessThan(60); + expect(b, why).toBeGreaterThan(200); + + expect(gpuErrors, gpuErrors.join('\n')).toEqual([]); +}); diff --git a/src/ifcviewer-web/web/ifcviewer.js b/src/ifcviewer-web/web/ifcviewer.js index 11b37c677f..a088c42f8f 100644 --- a/src/ifcviewer-web/web/ifcviewer.js +++ b/src/ifcviewer-web/web/ifcviewer.js @@ -320,6 +320,40 @@ if (c.ortho !== undefined) Module._ifcv_set_ortho_c(c.ortho ? 1 : 0); }, + // ---- Background ------------------------------------------------------ + + // Clear colour, as '#rgb' / '#rrggbb' / '#rrggbbaa' or {r, g, b, a} with + // 0-255 channels. Alpha defaults to opaque. + // + // Unlike setColor, an alpha of 0 here is meaningful rather than the "no + // override" sentinel: it clears the canvas to nothing, letting whatever + // is behind it in the DOM show through. Stack anything under the canvas + // — another 3D view, a map, ordinary page content — and the model draws + // on top of it. There is no depth interaction: the layer behind is + // strictly behind, so it reads as a backdrop and cannot occlude the + // model. Requires a platform that composites the canvas with alpha; + // where it does not, the colour still applies and the alpha is ignored. + setBackground: function (color) { + let r, g, b, a = 255; + if (typeof color === 'string') { + let hex = color.replace(/^#/, ''); + if (hex.length === 3) hex = hex.split('').map(function (c) { return c + c; }).join(''); + if (hex.length !== 6 && hex.length !== 8) { + throw new Error('setBackground: expected #rgb, #rrggbb or #rrggbbaa, got ' + color); + } + r = parseInt(hex.slice(0, 2), 16); + g = parseInt(hex.slice(2, 4), 16); + b = parseInt(hex.slice(4, 6), 16); + if (hex.length === 8) a = parseInt(hex.slice(6, 8), 16); + } else if (color && typeof color === 'object') { + r = color.r | 0; g = color.g | 0; b = color.b | 0; + if (color.a !== undefined) a = color.a | 0; + } else { + throw new Error('setBackground: expected a colour, got ' + color); + } + Module._ifcv_set_background_c(r / 255, g / 255, b / 255, a / 255); + }, + // ---- Navigation ------------------------------------------------------ // Which mouse buttons orbit / pan / select, as one of NAV_PRESETS: diff --git a/src/ifcviewer/ViewportCore.cpp b/src/ifcviewer/ViewportCore.cpp index c84d63f0d4..eb425b1298 100644 --- a/src/ifcviewer/ViewportCore.cpp +++ b/src/ifcviewer/ViewportCore.cpp @@ -489,6 +489,13 @@ void ViewportCore::setBackfaceCulling(bool enabled) { host_->requestFrame(); } +void ViewportCore::setBackgroundColor(float r, float g, float b, float a) { + Eigen::Vector4f next{r, g, b, a}; + if (background_color_ == next) return; + background_color_ = next; + host_->requestFrame(); +} + bool ViewportCore::frameSelection() { if (selection_.count() == 0) return false; float lo[3] = { std::numeric_limits::infinity(), @@ -6869,6 +6876,22 @@ void ViewportCore::configureSurface(int width_px, int height_px) { case WGPUPresentMode_Fifo: pm_name = "fifo"; break; default: break; } + // Premultiplied is what lets setBackgroundColor's alpha reach the + // compositor, so a clear below alpha 1 shows through to whatever the + // viewport is stacked over. Not every surface advertises it, so pick it + // only when offered and fall back to Auto — which composites opaquely and + // discards the alpha channel — otherwise. The two are indistinguishable + // at alpha 1, the default and the only value a caller that never touches + // the background will see, so the fallback costs nothing there. + auto supportsAlphaMode = [&](WGPUCompositeAlphaMode mode) { + for (std::size_t i = 0; i < caps.alphaModeCount; ++i) { + if (caps.alphaModes[i] == mode) return true; + } + return false; + }; + const bool premultiplied = + supportsAlphaMode(WGPUCompositeAlphaMode_Premultiplied); + wgpuSurfaceCapabilitiesFreeMembers(caps); cfg.presentMode = pm; if (!surface_configured_) { @@ -6886,7 +6909,14 @@ void ViewportCore::configureSurface(int width_px, int height_px) { } Log::info() << "[wgpu] present mode = " << pm_name << note; } - cfg.alphaMode = WGPUCompositeAlphaMode_Auto; + cfg.alphaMode = premultiplied ? WGPUCompositeAlphaMode_Premultiplied + : WGPUCompositeAlphaMode_Auto; + surface_premultiplied_ = premultiplied; + if (!surface_configured_) { + Log::info() << "[wgpu] composite alpha = " + << (premultiplied ? "premultiplied (background alpha honoured)" + : "auto (opaque -- background alpha ignored)"); + } wgpuSurfaceConfigure(surface_, &cfg); configured_w_ = width_px; @@ -7292,11 +7322,18 @@ void ViewportCore::render() { color.resolveTarget = view; color.loadOp = WGPULoadOp_Clear; color.storeOp = WGPUStoreOp_Store; + // A premultiplied surface expects colour already scaled by alpha, so the + // clear fades toward transparent instead of tinting what shows through: + // leaving it unscaled would have the compositor add the background colour + // on top of the layer behind. When the surface could only be configured + // opaque the alpha is discarded anyway, and scaling would darken the + // colour for nothing — so hold alpha at 1 and write it straight. + const float bg_a = surface_premultiplied_ ? background_color_[3] : 1.0f; color.clearValue = { - srgbToLinear(background_color_[0]), - srgbToLinear(background_color_[1]), - srgbToLinear(background_color_[2]), - 1.0, + srgbToLinear(background_color_[0]) * bg_a, + srgbToLinear(background_color_[1]) * bg_a, + srgbToLinear(background_color_[2]) * bg_a, + bg_a, }; color.depthSlice = WGPU_DEPTH_SLICE_UNDEFINED; diff --git a/src/ifcviewer/ViewportCore.h b/src/ifcviewer/ViewportCore.h index 9e651f358f..3c07d3d448 100644 --- a/src/ifcviewer/ViewportCore.h +++ b/src/ifcviewer/ViewportCore.h @@ -248,6 +248,20 @@ public: void setBackfaceCulling(bool enabled); bool backfaceCulling() const { return backface_culling_; } + // Background colour, RGBA in [0..1]. An alpha below 1 makes the viewport + // composite over whatever is behind it rather than painting a colour of + // its own: at alpha 0 it clears to nothing, so the model appears to sit + // on top of the layer behind — another canvas, a second 3D view, plain + // page content, anything the host stacks there. There is no depth + // interaction; the layer behind is strictly behind. + // + // Honouring the alpha needs a premultiplied surface, which configureSurface + // requests when the platform advertises it. Where it does not, the alpha is + // ignored and the clear stays opaque. At alpha 1 — the default, and every + // caller that leaves the background alone — the two are identical. + void setBackgroundColor(float r, float g, float b, float a); + const Eigen::Vector4f& backgroundColor() const { return background_color_; } + // Frame the current selection: union the selected objects' world AABBs and // fit the camera to them (same 1.30 padding as the desktop "F" hotkey). // No-op with an empty selection or no resolvable AABBs; returns whether it @@ -1504,6 +1518,11 @@ private: // an sRGB-to-linear conversion on top so the on-screen colour // matches the hex value passed via setBackgroundColor. Eigen::Vector4f background_color_ = {0.125f, 0.137f, 0.161f, 1.0f}; + + // Whether the surface was configured with a premultiplied alpha mode, and + // so whether background_color_'s alpha reaches the compositor at all. + // Decided per configureSurface against the surface's advertised modes. + bool surface_premultiplied_ = false; }; #endif // VIEWPORTCORE_H diff --git a/src/ifcviewer/ViewportWindow.cpp b/src/ifcviewer/ViewportWindow.cpp index 53a08625b3..3dbba6a350 100644 --- a/src/ifcviewer/ViewportWindow.cpp +++ b/src/ifcviewer/ViewportWindow.cpp @@ -233,7 +233,6 @@ ViewportWindow::ViewportWindow(QWindow* parent) camera_fov_y_deg_(core_.camera_fov_y_deg_), camera_near_ (core_.camera_near_), camera_far_ (core_.camera_far_), - background_color_(core_.background_color_), frame_uniform_buffer_(core_.frame_uniform_buffer_), frame_bind_group_ (core_.frame_bind_group_), selection_flags_buffer_ (core_.selection_flags_buffer_), @@ -465,7 +464,13 @@ void ViewportWindow::saveScreenshotRgba8(const std::string& path, // QImage takes a stride argument so it doesn't try to read past the // last row — wgpu's staging buffer was BGRA8 padded; core already // packed the rows tightly into rgba. - QImage img(rgba, w, h, w * 4, QImage::Format_RGBA8888); + // + // Premultiplied, matching what the render pass leaves in the buffer: the + // main blend accumulates alpha as One/OneMinusSrcAlpha, so colour there is + // already scaled by coverage. Tagging it straight would wash out a + // screenshot taken over a translucent background; at the default opaque + // alpha the two formats describe the same bytes. + QImage img(rgba, w, h, w * 4, QImage::Format_RGBA8888_Premultiplied); const QString qpath = QString::fromStdString(path); if (img.save(qpath, "PNG")) { Log::info() << "[wgpu] saved screenshot: " @@ -476,8 +481,7 @@ void ViewportWindow::saveScreenshotRgba8(const std::string& path, } void ViewportWindow::setBackgroundColor(float r, float g, float b, float a) { - background_color_ = {r, g, b, a}; - if (isExposed()) requestUpdate(); + core_.setBackgroundColor(r, g, b, a); } // ----------------------------------------------------------------------------- diff --git a/src/ifcviewer/ViewportWindow.h b/src/ifcviewer/ViewportWindow.h index 6d544e277b..9038fb1f83 100644 --- a/src/ifcviewer/ViewportWindow.h +++ b/src/ifcviewer/ViewportWindow.h @@ -760,8 +760,6 @@ private: uint32_t& hiz_reject_count_; std::atomic& hiz_trace_budget_; - Eigen::Vector4f& background_color_; - // Camera state aliases (storage in core_). float (&camera_target_)[3]; float& camera_distance_;